Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d539ff96e5 | ||
|
|
ca5cd65c85 | ||
|
|
a9e1e86fdc | ||
|
|
76e33a91bb | ||
|
|
ee185b98bc | ||
|
|
18b2d0efe6 | ||
|
|
a19633e81f | ||
|
|
c8b6f2d536 | ||
|
|
17300170cc | ||
|
|
e41c13917d | ||
|
|
ae7df31d91 | ||
|
|
bf3bcc8a84 | ||
|
|
86dd36312e | ||
|
|
15ff686542 | ||
|
|
b78c4a4dd4 | ||
|
|
f396e8f235 | ||
|
|
46c9827e8a | ||
|
|
0fc977f19f | ||
|
|
084f629b5b | ||
|
|
bc63ded583 | ||
|
|
cfaeb6ee15 | ||
|
|
76bcce8c54 | ||
|
|
88ed3da6cc | ||
|
|
7c69e0de08 | ||
|
|
fbc4f6e398 | ||
|
|
d42ca4f030 | ||
|
|
335922fecc | ||
|
|
bede2e34fe | ||
|
|
da58a004fe | ||
|
|
77b283cb78 | ||
|
|
6284747339 | ||
|
|
14ad6e241b | ||
|
|
06971ff65c | ||
|
|
18107ce07e | ||
|
|
a0b0c0ed81 | ||
|
|
a2c630a92b | ||
|
|
09634eb851 | ||
|
|
90ab7781ad | ||
|
|
88d04253ca | ||
|
|
08005c5c07 | ||
|
|
026f816ddb | ||
|
|
d31b6f545f | ||
|
|
39f551c914 | ||
|
|
6d25ecdc11 | ||
|
|
37987b2779 | ||
|
|
67a7bace4e | ||
|
|
b48cc23d6e | ||
|
|
c1899229a0 | ||
|
|
84a99a3636 | ||
|
|
84bdf2593c | ||
|
|
c527f20a88 | ||
|
|
9f495021f0 | ||
|
|
33aa7b807c | ||
|
|
2c56d82a81 | ||
|
|
0fc2115973 | ||
|
|
2c1df75d19 | ||
|
|
aefd36c4b1 | ||
|
|
25b9c31c9b | ||
|
|
d68a84f7f9 | ||
|
|
4fa191e9b6 | ||
|
|
c073d03ffb | ||
|
|
730f129404 | ||
|
|
f95f3d0104 | ||
|
|
e70e82c8c0 | ||
|
|
23cc0db1fa | ||
|
|
657f952a2c | ||
|
|
1f256cbb68 | ||
|
|
92fbdb692c | ||
|
|
b2fbcb318d | ||
|
|
44721b579c | ||
|
|
889d00007d | ||
|
|
6f53592041 | ||
|
|
709939f60b | ||
|
|
5b7dd493d4 | ||
|
|
02fe492dbf | ||
|
|
1bc2c88e2f | ||
|
|
863a6d8fd4 | ||
|
|
b252717f6b | ||
|
|
92697b11c5 | ||
|
|
b409c57296 |
@@ -24,6 +24,12 @@ cargo test -p trx-core
|
|||||||
./target/release/trx-server --print-config > trx-server.toml
|
./target/release/trx-server --print-config > trx-server.toml
|
||||||
./target/release/trx-client --print-config > trx-client.toml
|
./target/release/trx-client --print-config > trx-client.toml
|
||||||
|
|
||||||
|
# Validate a config without starting anything (reports every problem)
|
||||||
|
./target/release/trx-server --check-config --config trx-rs.toml
|
||||||
|
|
||||||
|
# Regenerate trx-rs.toml.example after changing a config struct
|
||||||
|
cargo run -p trx-config --example generate_example
|
||||||
|
|
||||||
# Run server
|
# Run server
|
||||||
./target/release/trx-server --config trx-server.toml
|
./target/release/trx-server --config trx-server.toml
|
||||||
# or via CLI args:
|
# or via CLI args:
|
||||||
@@ -41,7 +47,8 @@ This is a Cargo workspace. All crates live under `src/`:
|
|||||||
src/
|
src/
|
||||||
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
|
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
|
||||||
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
|
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
|
||||||
trx-app/ # Shared application helpers (config paths, logging init)
|
trx-app/ # Shared application helpers (logging init, name normalization)
|
||||||
|
trx-config/ # Client + server config structs, loader, validators (~2,500 LOC)
|
||||||
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
|
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
|
||||||
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
|
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
|
||||||
trx-backend/ # Backend abstraction trait + factory + dummy
|
trx-backend/ # Backend abstraction trait + factory + dummy
|
||||||
@@ -49,6 +56,7 @@ src/
|
|||||||
trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT
|
trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT
|
||||||
trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC)
|
trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC)
|
||||||
trx-client/ # Client binary: remote connection, frontend spawning (~1,500 LOC)
|
trx-client/ # Client binary: remote connection, frontend spawning (~1,500 LOC)
|
||||||
|
trx-logbook/ # Station log: QSO record, ADIF reader/writer, append-only store
|
||||||
trx-frontend/ # Frontend trait (FrontendSpawner), runtime context
|
trx-frontend/ # Frontend trait (FrontendSpawner), runtime context
|
||||||
trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth
|
trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth
|
||||||
trx-frontend-http-json/ # JSON-over-TCP control frontend
|
trx-frontend-http-json/ # JSON-over-TCP control frontend
|
||||||
|
|||||||
Generated
+59
-4
@@ -2412,6 +2412,16 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_ignored"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.149"
|
version = "1.0.149"
|
||||||
@@ -3031,10 +3041,6 @@ dependencies = [
|
|||||||
name = "trx-app"
|
name = "trx-app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
|
||||||
"serde",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"toml",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
@@ -3115,6 +3121,7 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
"trx-app",
|
"trx-app",
|
||||||
|
"trx-config",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
"trx-frontend",
|
"trx-frontend",
|
||||||
"trx-frontend-http",
|
"trx-frontend-http",
|
||||||
@@ -3124,6 +3131,23 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trx-config"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"dirs",
|
||||||
|
"serde",
|
||||||
|
"serde_ignored",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"toml",
|
||||||
|
"toml_edit 0.22.27",
|
||||||
|
"tracing",
|
||||||
|
"trx-core",
|
||||||
|
"trx-decode-log",
|
||||||
|
"trx-reporting",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-configurator"
|
name = "trx-configurator"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3132,13 +3156,16 @@ dependencies = [
|
|||||||
"dialoguer",
|
"dialoguer",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio-serial",
|
"tokio-serial",
|
||||||
|
"toml",
|
||||||
"toml_edit 0.22.27",
|
"toml_edit 0.22.27",
|
||||||
|
"trx-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-core"
|
name = "trx-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"flate2",
|
"flate2",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3191,6 +3218,7 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"brotli 7.0.0",
|
"brotli 7.0.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -3199,11 +3227,13 @@ dependencies = [
|
|||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
"trx-frontend",
|
"trx-frontend",
|
||||||
|
"trx-logbook",
|
||||||
"trx-protocol",
|
"trx-protocol",
|
||||||
"ts-rs",
|
"ts-rs",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -3242,6 +3272,19 @@ dependencies = [
|
|||||||
"rustfft",
|
"rustfft",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trx-logbook"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"dirs",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-protocol"
|
name = "trx-protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3294,12 +3337,14 @@ dependencies = [
|
|||||||
"trx-app",
|
"trx-app",
|
||||||
"trx-aprs",
|
"trx-aprs",
|
||||||
"trx-backend",
|
"trx-backend",
|
||||||
|
"trx-config",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
"trx-cw",
|
"trx-cw",
|
||||||
"trx-decode-log",
|
"trx-decode-log",
|
||||||
"trx-ftx",
|
"trx-ftx",
|
||||||
"trx-protocol",
|
"trx-protocol",
|
||||||
"trx-reporting",
|
"trx-reporting",
|
||||||
|
"trx-sstv",
|
||||||
"trx-vdes",
|
"trx-vdes",
|
||||||
"trx-wefax",
|
"trx-wefax",
|
||||||
"trx-wspr",
|
"trx-wspr",
|
||||||
@@ -3307,6 +3352,16 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trx-sstv"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"png",
|
||||||
|
"tracing",
|
||||||
|
"trx-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-vdes"
|
name = "trx-vdes"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ members = [
|
|||||||
"src/decoders/trx-ftx",
|
"src/decoders/trx-ftx",
|
||||||
"src/decoders/trx-rds",
|
"src/decoders/trx-rds",
|
||||||
"src/decoders/trx-vdes",
|
"src/decoders/trx-vdes",
|
||||||
|
"src/decoders/trx-sstv",
|
||||||
"src/decoders/trx-wefax",
|
"src/decoders/trx-wefax",
|
||||||
"src/decoders/trx-wspr",
|
"src/decoders/trx-wspr",
|
||||||
"src/trx-core",
|
"src/trx-core",
|
||||||
"src/trx-protocol",
|
"src/trx-protocol",
|
||||||
"src/trx-app",
|
"src/trx-app",
|
||||||
|
"src/trx-config",
|
||||||
"src/trx-reporting",
|
"src/trx-reporting",
|
||||||
"src/trx-server",
|
"src/trx-server",
|
||||||
"src/trx-server/trx-backend",
|
"src/trx-server/trx-backend",
|
||||||
@@ -24,6 +26,7 @@ members = [
|
|||||||
"src/trx-server/trx-backend/trx-backend-ft450d",
|
"src/trx-server/trx-backend/trx-backend-ft450d",
|
||||||
"src/trx-server/trx-backend/trx-backend-soapysdr",
|
"src/trx-server/trx-backend/trx-backend-soapysdr",
|
||||||
"src/trx-client",
|
"src/trx-client",
|
||||||
|
"src/trx-client/trx-logbook",
|
||||||
"src/trx-client/trx-frontend",
|
"src/trx-client/trx-frontend",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-http",
|
"src/trx-client/trx-frontend/trx-frontend-http",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-http-json",
|
"src/trx-client/trx-frontend/trx-frontend-http-json",
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
APRS symbol set (https://github.com/hessu/aprs-symbols)
|
||||||
|
=========================================================
|
||||||
|
|
||||||
|
Verbatim copy of the upstream COPYRIGHT.md, retrieved 2026-08-03 from
|
||||||
|
https://github.com/hessu/aprs-symbols. The set has no single SPDX license:
|
||||||
|
individual symbols carry different terms, summarized below. Attribution
|
||||||
|
requirement from the upstream README: "If you use this symbol set, please
|
||||||
|
provide a pointer to the source (http://github.com/hessu/aprs-symbols/)."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
Copyright and licensing information
|
||||||
|
======================================
|
||||||
|
|
||||||
|
This is a collection of vectorized symbols for use on the APRS system.
|
||||||
|
|
||||||
|
The copyright status of this collection is a bit complicated, since the
|
||||||
|
symbols come from various sources, each having different copyright owners.
|
||||||
|
|
||||||
|
Most of the vectorized symbols are loosely based on the low-resolution
|
||||||
|
"standard" bitmap symbol set as distributed by Stephen Smith, WA8LMF. That
|
||||||
|
set is used by most APRS software around the world. The low resolution of
|
||||||
|
those symbols does not allow direct vector conversion, so I've drawn new
|
||||||
|
symbols in a similar layout. The vector versions try to mimic the original
|
||||||
|
appearance and colours, with the intention of keeping the set recognizable
|
||||||
|
and familiar to existing users. In some cases the vector versions are
|
||||||
|
probably similar enough to the originals, so that they cannot be considered
|
||||||
|
"original work" by myself. In some of these cases, the originals are
|
||||||
|
probably also mimicking someone else's design.
|
||||||
|
|
||||||
|
The original symbols do not come with any information on their licensing.
|
||||||
|
They've been distributed with a lot of APRS software over time, but I don't
|
||||||
|
know who designed which symbol originally. Most likely all of them are
|
||||||
|
drawn by one of:
|
||||||
|
|
||||||
|
* Roger Barker, G4IDE, "original set provided with UI-View" (SK)
|
||||||
|
* Steve Dimse, KH4G, "U.S. customary set"
|
||||||
|
* Stephen Smith, WA8LMF
|
||||||
|
|
||||||
|
The Adobe Illustrator (.ai) file contains a copy of the original bitmaps
|
||||||
|
as a hidden layer, just for reference.
|
||||||
|
|
||||||
|
Some symbols I obtained from other sources, such as Wikipedia. In those
|
||||||
|
cases I picked SVG versions which allow commercial reuse (source known, and
|
||||||
|
the work is placed on public domain, or with a CC license which allows
|
||||||
|
adaptation and commercial reuse).
|
||||||
|
|
||||||
|
Some symbols are vectorized versions of product or brand logos. The
|
||||||
|
copyright of those is owned by the respective companies (Apple, Microsoft,
|
||||||
|
Kenwood), and each of those may have some opinions on how the logos are
|
||||||
|
used. Please check for yourself if you can use them or not.
|
||||||
|
|
||||||
|
In the list below I try to summarize the licensing status for each symbol.
|
||||||
|
|
||||||
|
|
||||||
|
Shorthand notation for common licensing status
|
||||||
|
-------------------------------------------------
|
||||||
|
|
||||||
|
* *VEC-OH7LZB* - Vectorized by OH7LZB, based on original APRS symbol set
|
||||||
|
* Source of original bitmap: http://wa8lmf.net/aprs/APRS_symbols.htm
|
||||||
|
* Original designer of individual symbol unknown at this time, but one of:
|
||||||
|
* Roger Barker, G4IDE
|
||||||
|
* Steve Dimse, KH4G
|
||||||
|
* Stephen Smith, WA8LMF
|
||||||
|
* Vectorized versions are designed to look similar
|
||||||
|
* Licensing: Unknown
|
||||||
|
* *OH7LZB* - Original vector design by Heikki Hannikainen, OH7LZB
|
||||||
|
* Different enough (by author's opinion) to make it a new original work,
|
||||||
|
instead of a copy of the old symbol
|
||||||
|
* License: CC BY-SA 2.0
|
||||||
|
* https://creativecommons.org/licenses/by-sa/2.0/
|
||||||
|
|
||||||
|
|
||||||
|
Primary table
|
||||||
|
----------------
|
||||||
|
|
||||||
|
* /! - Police station
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /# - Digipeater / Green star with D in middle
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /$ - Telephone
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /% - DX cluster
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /& - HF gateway
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /' - Small aircraft
|
||||||
|
* https://openclipart.org/detail/27182/topdown-airplane-view
|
||||||
|
* Author: Wirelizard (Brian Burger)
|
||||||
|
* With color and some other small tuning added by OH7LZB
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /( - Mobile satellite station
|
||||||
|
* OH7LZB
|
||||||
|
* /) - Wheelchair, handicapped
|
||||||
|
* PD wheelchair symbol
|
||||||
|
* Vectorized from bitmap by OH7LZB
|
||||||
|
* /* - Snowmobile
|
||||||
|
* https://openclipart.org/detail/15849/snowmobile
|
||||||
|
* Author: Mystica (https://openclipart.org/user-detail/mystica)
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /+ - Red Cross
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /, - Boy Scouts
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /- - House
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /. - Red X
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* // - Red dot
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /0 to /9 - Numbered circles
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Fire
|
||||||
|
* http://commons.wikimedia.org/wiki/File:FireIcon.svg
|
||||||
|
* Author: Piotr Jaworski
|
||||||
|
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
|
||||||
|
* Tent
|
||||||
|
* https://openclipart.org/detail/174933/green-tent-by-stamps-174933
|
||||||
|
* Author: stamps
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* Motorcycle
|
||||||
|
* http://commons.wikimedia.org/wiki/File:MUTCD_W8-15P.svg
|
||||||
|
* This file is in the public domain because it comes from the Manual on
|
||||||
|
Uniform Traffic Control Devices, sign number W8-15P, which states
|
||||||
|
specifically on page I-1 that: Any traffic control device design or
|
||||||
|
application provision contained in this Manual shall be considered to
|
||||||
|
be in the public domain. Traffic control devices contained in this
|
||||||
|
Manual shall not be protected by a patent, trademark, or copyright,
|
||||||
|
except for the Interstate Shield and any other items owned by FHWA.
|
||||||
|
* Colour version by OH7LZB
|
||||||
|
* /= - Railroad engine
|
||||||
|
* http://commons.wikimedia.org/wiki/File:Icon_train.svg
|
||||||
|
* Author: http://en.wikipedia.org/wiki/User:Richtom80
|
||||||
|
* CC-BY-SA-2.5,2.0,1.0
|
||||||
|
* /> - Car
|
||||||
|
* OH7LZB
|
||||||
|
* /? - File server
|
||||||
|
* https://openclipart.org/detail/163717/file-server-by-lyte
|
||||||
|
* Author: lyte
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /@ - Hurricane predicted path
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /A - Aid station
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Mail (BBS)
|
||||||
|
* https://openclipart.org/detail/29268/yellow-mail-by-rg1024-29268
|
||||||
|
* Author: rg1024
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /C - Canoe
|
||||||
|
* https://openclipart.org/detail/179047/red-canoe-by-rambo-tribble-179047
|
||||||
|
* https://openclipart.org/detail/179041/canoe-paddle-by-rambo-tribble-179041
|
||||||
|
* Author: Rambo Tribble
|
||||||
|
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
|
||||||
|
* /E - Eyeball
|
||||||
|
* http://commons.wikimedia.org/wiki/File:Blue_eye.svg
|
||||||
|
* PD: "This file is from the Open Clip Art Library, which released it explicitly into the public domain"
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /F - Tractor
|
||||||
|
* https://openclipart.org/detail/191654/farm-tractor-by-tmjbeary-191654
|
||||||
|
* Author: tmjbeary
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /G - Grid square, 3 by 3
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /H - Hotel
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /I - TCP/IP
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /K - School
|
||||||
|
* OH7LZB
|
||||||
|
* /L - PC user
|
||||||
|
* OH7LZB
|
||||||
|
* /M - Mac apple
|
||||||
|
* Apple
|
||||||
|
* /N - NTS
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /O - Hot air balloon
|
||||||
|
* OH7LZB
|
||||||
|
* /P - Police
|
||||||
|
* OH7LZB
|
||||||
|
* /R - RV
|
||||||
|
* OH7LZB
|
||||||
|
* /S - Space Shuttle
|
||||||
|
* https://openclipart.org/detail/814/space-shuttle-by-johnny_automatic
|
||||||
|
* PD: Published by the NASA, in "The Brain in Space"
|
||||||
|
* /T - SSTV
|
||||||
|
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
|
||||||
|
* Author: rg1024
|
||||||
|
* Adjusted by OH7LZB
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /U - Bus
|
||||||
|
* OH7LZB
|
||||||
|
* /V - ATV, amateur television
|
||||||
|
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
|
||||||
|
* Author: rg1024
|
||||||
|
* Adjusted by OH7LZB
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /W - Wx, Weather service site
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /X - Helicopter
|
||||||
|
* OH7LZB
|
||||||
|
* /Y - Sailboat
|
||||||
|
* OH7LZB
|
||||||
|
* /Z - Windows flag
|
||||||
|
* Microsoft
|
||||||
|
* /[ - Human
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /\ - DF triangle
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /] - Mailbox, post office, letter
|
||||||
|
* /^ - Large aircraft
|
||||||
|
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
|
||||||
|
* Author: SketchArtist
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /_ - Weather station
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /` - Satellite dish
|
||||||
|
* OH7LZB
|
||||||
|
* /a - Ambulance
|
||||||
|
* OH7LZB
|
||||||
|
* /b - Bicycle
|
||||||
|
* http://commons.wikimedia.org/wiki/File:Bicycle_evolution-numbers.svg
|
||||||
|
* Author: Wikipedia user: Al2
|
||||||
|
* CC BY 3.0
|
||||||
|
* /c - Incident command post
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /d - Fire station
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /e - Horse, equestrian
|
||||||
|
* https://openclipart.org/detail/142627/horse-riding-lesson-by-olku
|
||||||
|
* Author: OlKu
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /f - Fire truck
|
||||||
|
* OH7LZB
|
||||||
|
* /g - Hang glider
|
||||||
|
* OH7LZB
|
||||||
|
* /h - Hospital
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /i - IOTA, islands on the air
|
||||||
|
* http://commons.wikimedia.org/wiki/File:Palm_Island_R.svg
|
||||||
|
* PI
|
||||||
|
* /j - Jeep
|
||||||
|
* OH7LZB
|
||||||
|
* /k - Truck
|
||||||
|
* OH7LZB
|
||||||
|
* /l - Laptop
|
||||||
|
* OH7LZB
|
||||||
|
* /m - Mic-E repeater
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /n - Node, black bulls-eye
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /o - Emergency operations center
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /p - Dog(e)
|
||||||
|
* OH7LZB
|
||||||
|
* /q - Grid square, 2 by 2
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /r - Repeater tower
|
||||||
|
* OH7LZB
|
||||||
|
* /s - Ship, power boat
|
||||||
|
* OH7LZB
|
||||||
|
* /t - Truck stop
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /u - Semi-trailer truck, 18-wheeler
|
||||||
|
* OH7LZB
|
||||||
|
* /v - Van
|
||||||
|
* OH7LZB
|
||||||
|
* /w - Water station
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /x - X / Unix
|
||||||
|
* https://commons.wikimedia.org/wiki/File:X11.svg
|
||||||
|
* PD
|
||||||
|
* /y - House, yagi antenna
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /z - Shelter
|
||||||
|
* VEC-OH7LZB
|
||||||
|
|
||||||
|
|
||||||
|
Secondary table
|
||||||
|
------------------
|
||||||
|
|
||||||
|
* Emergency
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Numbered digipeater / Green star
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Bank
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Numbered gateway / Black diamond
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Crash site
|
||||||
|
* OH7LZB
|
||||||
|
* Cloudy
|
||||||
|
* OH7LZB
|
||||||
|
* MEO
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Snowflake
|
||||||
|
* http://commons.wikimedia.org/wiki/File:Snowflake_01.svg
|
||||||
|
* Author: Wikipedia user: Amada44
|
||||||
|
* Public Domain
|
||||||
|
* Church
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Girl Scout
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Looks slightly like the common USA girl scouts logos. Should be different
|
||||||
|
enough to not infringe on "Girl Scouts of the USA" copyrights.
|
||||||
|
* Home (HF antenna)
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Unknown position
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Destination
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Numbered circle
|
||||||
|
* VEC-OH7LZB
|
||||||
|
|
||||||
|
* Petrol Station
|
||||||
|
* OH7LZB
|
||||||
|
* Hail
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Park
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Gale Flag
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Red car from above
|
||||||
|
* OH7LZB
|
||||||
|
* Info Kiosk
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Hurricane
|
||||||
|
* OH7LZB
|
||||||
|
|
||||||
|
* Numbered white box
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Snow blowing
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Coast Guard
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Drizzle
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Smoke / Chimney
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Freezing rain
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Snow Shwr
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Haze
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Rain Shower
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Lightning
|
||||||
|
* OH7LZB
|
||||||
|
* "Kenwood radio"
|
||||||
|
* Kenwood logo, vectorized
|
||||||
|
* "Lighthouse"
|
||||||
|
* CC BY-SA 2.0
|
||||||
|
* http://wiki.openstreetmap.org/wiki/File:Lighthouse.svg
|
||||||
|
* Nav Buoy
|
||||||
|
* OH7LZB
|
||||||
|
* Rocket
|
||||||
|
* http://www.clker.com/clipart-gglkuglug.html
|
||||||
|
* PD according to clker.com license
|
||||||
|
* Parking
|
||||||
|
* VEC-OH7LZB
|
||||||
|
|
||||||
|
* Earthquake, Restaurant
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Satellite
|
||||||
|
* OH7LZB
|
||||||
|
* Thunderstorm
|
||||||
|
* OH7LZB
|
||||||
|
* Sunny
|
||||||
|
* OH7LZB
|
||||||
|
* VORTAC, Numbered WXS
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Pharmacy Rx
|
||||||
|
* OH7LZB
|
||||||
|
* Wall Cloud
|
||||||
|
* OH7LZB
|
||||||
|
* Numbered plane
|
||||||
|
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
|
||||||
|
* Author: SketchArtist
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* Numbered WX Station
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Rain
|
||||||
|
* Source: http://commons.wikimedia.org/wiki/File:Heavy-rain-shower-transparent.svg
|
||||||
|
* Author: Wikipedia user: Peepo
|
||||||
|
* Public Domain
|
||||||
|
* With modifications by OH7LZB
|
||||||
|
|
||||||
|
* Numbered diamond
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Dust blowing
|
||||||
|
* NA
|
||||||
|
* Numbered civil defence
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* DX spot
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Sleet
|
||||||
|
* NA
|
||||||
|
* Funnel Cloud
|
||||||
|
* NA
|
||||||
|
* Gale
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Store
|
||||||
|
* https://openclipart.org/detail/89299/cart-medium-by-martins.bruvelis
|
||||||
|
* Author: martins.bruvelis
|
||||||
|
* Public Domain
|
||||||
|
* Adjustments by OH7LZB
|
||||||
|
* Numbered black box
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Work zone / Excavator
|
||||||
|
* Based on http://www.clker.com/clipart-292480.html PNG version
|
||||||
|
* Vectorized and colors adjusted by OH7LZB
|
||||||
|
* PD according to clker.com documentation, uploader KURSVEIAL
|
||||||
|
* SUV
|
||||||
|
* OH7LZB
|
||||||
|
* Milepost, Numbered triangle, Circle sm
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Partly cloudy
|
||||||
|
* OH7LZB
|
||||||
|
|
||||||
|
* Restrooms, Numbered boat
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Tornado (also used in Funnel cloud, Skywarn)
|
||||||
|
* https://openclipart.org/detail/104887/tornado-by-laabadon
|
||||||
|
* Author: Laabadon
|
||||||
|
* Public Domain
|
||||||
|
* Numbered truck
|
||||||
|
* OH7LZB
|
||||||
|
* Numbered van
|
||||||
|
* OH7LZB
|
||||||
|
* Flooding
|
||||||
|
* NA
|
||||||
|
* Sky warn, Numbered shelter, fog
|
||||||
|
* VEC-OH7LZB
|
||||||
@@ -93,13 +93,17 @@ The wizard walks you through rig selection, serial port detection, audio
|
|||||||
settings, and frontend options, then writes `trx-server.toml` and
|
settings, and frontend options, then writes `trx-server.toml` and
|
||||||
`trx-client.toml`.
|
`trx-client.toml`.
|
||||||
|
|
||||||
Alternatively, generate example configs and edit them by hand:
|
Alternatively, copy `trx-rs.toml.example` — a commented example covering every
|
||||||
|
setting — and edit it by hand:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./target/release/trx-server --print-config > trx-server.toml
|
cp trx-rs.toml.example trx-rs.toml
|
||||||
./target/release/trx-client --print-config > trx-client.toml
|
./target/release/trx-server --check-config --config trx-rs.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--check-config` reports everything wrong with a config without starting
|
||||||
|
anything. `--print-config` prints the same settings without comments.
|
||||||
|
|
||||||
### 4. Run
|
### 4. Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -107,6 +111,9 @@ Alternatively, generate example configs and edit them by hand:
|
|||||||
./target/release/trx-client --config trx-client.toml
|
./target/release/trx-client --config trx-client.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A single `trx-rs.toml` can configure both: the server reads its `[trx-server]`
|
||||||
|
section and the client reads `[trx-client]`.
|
||||||
|
|
||||||
Open the configured HTTP frontend address in a browser (default `http://localhost:8080`).
|
Open the configured HTTP frontend address in a browser (default `http://localhost:8080`).
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
@@ -144,4 +151,7 @@ a unified set of frontends.
|
|||||||
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
|
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
|
||||||
bundled third-party license files. Bundled third-party components retain their
|
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
|
original licenses: Leaflet is BSD-2-Clause, DSEG is OFL-1.1, and opus-decoder
|
||||||
is MIT.
|
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).
|
||||||
|
|||||||
+16
@@ -56,3 +56,19 @@ SPDX-License-Identifier = "OFL-1.1"
|
|||||||
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/opus-decoder-0.7.11.min.js"]
|
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-FileCopyrightText = "2021-2025 Ethan Halsall"
|
||||||
SPDX-License-Identifier = "MIT"
|
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"
|
||||||
|
|||||||
@@ -322,3 +322,269 @@ trx-configurator
|
|||||||
| `trx-app` | Config types and validation | Yes |
|
| `trx-app` | Config types and validation | Yes |
|
||||||
| `serialport` | Serial port enumeration | Yes (transitive) |
|
| `serialport` | Serial port enumeration | Yes (transitive) |
|
||||||
| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) |
|
| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logbook and Ham Radio Layout
|
||||||
|
|
||||||
|
Two halves of one feature ([#54](https://git.haxx.space/sjg/trx-rs/issues/54)): a station
|
||||||
|
logbook in a panel of its own, and an operator layout that puts a transceiver's controls
|
||||||
|
around it.
|
||||||
|
|
||||||
|
Nothing in the application records a QSO today. The map's "QSO summary" cards describe
|
||||||
|
contacts *between other stations*, reconstructed from decoded traffic; bookmarks are
|
||||||
|
frequencies, not contacts. Neither is the operator's own log.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
| ID | Description |
|
||||||
|
|----|-------------|
|
||||||
|
| REQ-LOG-001 | The system shall record QSOs the operator makes, each holding at minimum callsign, date, time, band, frequency, mode and both signal reports. |
|
||||||
|
| REQ-LOG-002 | When starting a log entry, the system shall pre-fill exactly six fields: frequency, mode, rig name, time, callsign and locator. |
|
||||||
|
| REQ-LOG-003 | The system shall leave every other field of a log entry empty for the operator to fill. |
|
||||||
|
| REQ-LOG-004 | The system shall allow a logged QSO to be edited and deleted. |
|
||||||
|
| REQ-LOG-005 | The system shall survive a crash without losing a QSO that was recorded before it. |
|
||||||
|
| REQ-LOG-006 | The system shall list, search and filter the log by callsign, band, mode and date range. |
|
||||||
|
| REQ-LOG-007 | Where a decoded station is on screen, the system shall offer to start a log entry from it, pre-filled, without logging it unattended. |
|
||||||
|
| REQ-LOG-008 | The system shall show whether a callsign has been worked before, and on which bands. |
|
||||||
|
| REQ-FMT-001 | The system shall export the log as an ADIF 3.1.x `.adi` file. |
|
||||||
|
| REQ-FMT-002 | The system shall import ADIF `.adi` files produced by other logging software, preserving fields it does not itself use. |
|
||||||
|
| REQ-FMT-003 | When importing, the system shall identify QSOs already held and shall not duplicate them. |
|
||||||
|
| REQ-FMT-004 | The system shall export a filtered selection of the log as a Cabrillo 3.0 file for contest submission. |
|
||||||
|
| REQ-LOG-009 | The system shall stamp QSO times from the server's clock in UTC, and shall tell the operator when the browser's clock disagrees with it by more than one second. |
|
||||||
|
| REQ-LAY-001 | The system shall present the logbook in a panel of its own, reachable whatever layout is selected. |
|
||||||
|
| REQ-LAY-002 | The system shall offer a "Ham radio" operator layout presenting the transceiver controls and that panel together. |
|
||||||
|
| REQ-LAY-003 | Where the selected rig cannot transmit, the system shall not offer the ham layout. |
|
||||||
|
|
||||||
|
### A decode is not a QSO
|
||||||
|
|
||||||
|
The decoders are receive-only: FT8, CW, APRS and the rest report what was *heard*. A heard
|
||||||
|
callsign is the beginning of a log entry, not a contact, and the logbook must never write one
|
||||||
|
by itself — REQ-LOG-007 says offer and pre-fill, never auto-log. Digital QSOs made in
|
||||||
|
WSJT-X or similar arrive the way every other logger takes them: through ADIF import.
|
||||||
|
|
||||||
|
### What is pre-filled, and what is not
|
||||||
|
|
||||||
|
Six fields, and no more (REQ-LOG-002, REQ-LOG-003):
|
||||||
|
|
||||||
|
| Field | From | ADIF |
|
||||||
|
|-------|------|------|
|
||||||
|
| Frequency | the selected rig's dial | `FREQ`, with `BAND` derived from it |
|
||||||
|
| Mode | the selected rig | `MODE`, and `SUBMODE` where the mode implies one |
|
||||||
|
| Rig name | the rig's display name | `MY_RIG` |
|
||||||
|
| Time | the server's clock, UTC, when the entry opens | `QSO_DATE`, `TIME_ON` |
|
||||||
|
| Callsign | the decode row or map station the entry was started from, else empty | `CALL` |
|
||||||
|
| Locator | that station's grid, where the decode carried one, else empty | `GRIDSQUARE` |
|
||||||
|
|
||||||
|
Signal reports, power, name, QTH and the rest stay empty. A report in particular is the
|
||||||
|
operator's to give: an FT8 SNR is not what was sent, and pre-filling one would put a number in
|
||||||
|
the log that nobody exchanged.
|
||||||
|
|
||||||
|
The station's own callsign and locator are not pre-filled per entry either — they are station
|
||||||
|
identity, taken from configuration when the QSO is written (`STATION_CALLSIGN`, `OPERATOR`,
|
||||||
|
`MY_GRIDSQUARE`), and shown once at the top of the panel rather than typed into every row.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
#### New crate: `trx-logbook`
|
||||||
|
|
||||||
|
```
|
||||||
|
src/trx-client/
|
||||||
|
trx-logbook/
|
||||||
|
src/
|
||||||
|
lib.rs # LogbookHandle: open, add, edit, delete, query, import, export
|
||||||
|
qso.rs # Qso: the record, its ADIF field mapping, band/mode helpers
|
||||||
|
adif.rs # ADI reader and writer (pure Rust, no new dependencies)
|
||||||
|
store.rs # Append-only JSON Lines file, in-memory index, compaction
|
||||||
|
dedupe.rs # Worked-before and import-collision rules
|
||||||
|
```
|
||||||
|
|
||||||
|
A library crate under `src/trx-client/`, beside `trx-frontend`, consumed by
|
||||||
|
`trx-frontend-http`. The log belongs to the station rather than to a rig or a frontend, so it
|
||||||
|
sits where any frontend can reach it.
|
||||||
|
|
||||||
|
#### Storage: append-only, not a rewritten blob
|
||||||
|
|
||||||
|
Bookmarks use `PickleDb` with `AutoDump`, which rewrites the whole file on every write. That
|
||||||
|
is right for a few dozen bookmarks and wrong for a log: a station with 40 000 QSOs would
|
||||||
|
rewrite several megabytes to log one contact, and lose the lot if the power went during the
|
||||||
|
dump.
|
||||||
|
|
||||||
|
The log is instead a JSON Lines file — the shape `trx-decode-log` already uses — appended one
|
||||||
|
record per write and read into an in-memory index at startup. An edit or a delete appends a new revision of that record's id; the load
|
||||||
|
keeps the last one, and a compaction pass rewrites the file when superseded records exceed a
|
||||||
|
threshold. Appending is O(1) and atomic per line, so a crash costs at most the line being
|
||||||
|
written.
|
||||||
|
|
||||||
|
#### Two formats, for the two things a log is asked for
|
||||||
|
|
||||||
|
The file formats were left open ("pick a well-known ham format"), so: **ADIF for interchange,
|
||||||
|
Cabrillo for contest submission.** Both are implemented in-repo, in the way this project
|
||||||
|
already implements its decoders, and neither adds a dependency.
|
||||||
|
|
||||||
|
**ADIF has to stay.** It is not one option among several — it is the only thing the ecosystem
|
||||||
|
reads. LoTW, eQSL, Club Log, QRZ.com and every other logger take ADIF and nothing else, so a
|
||||||
|
log that cannot write `.adi` cannot be uploaded, confirmed, or moved to another program. That
|
||||||
|
is a one-way door, and the interoperability is most of the point of keeping a log at all.
|
||||||
|
Nothing on disk is ADI regardless: the store is JSON Lines, and ADIF is what comes out of an
|
||||||
|
export.
|
||||||
|
|
||||||
|
ADI is a tagged text format — `<FIELD:length>value`, records ended by `<EOR>`, a header ended
|
||||||
|
by `<EOH>`, everything outside a tag ignored — small enough to implement exactly. The reader
|
||||||
|
must be lenient in the ways real files are irregular (lowercase tags, CRLF, missing header,
|
||||||
|
unknown fields, type indicators) and the writer strict. Unknown fields are carried through
|
||||||
|
import to export unchanged, so a round trip through trx-rs does not quietly strip what another
|
||||||
|
logger wrote. ADX, the XML serialisation of the same data model, is out of scope: it is part
|
||||||
|
of the standard but almost nothing reads it.
|
||||||
|
|
||||||
|
**Cabrillo is the second format, because ADIF cannot do its job.** Contest logs are submitted
|
||||||
|
to sponsors in Cabrillo 3.0 and are rejected in anything else — a header of `CALLSIGN:`,
|
||||||
|
`CONTEST:`, `CATEGORY-*` and `CLAIMED-SCORE:` lines, then one fixed-column `QSO:` line per
|
||||||
|
contact carrying frequency in kHz, a mode code (`CW`, `PH`, `FM`, `RY`, `DG`), the UTC date and
|
||||||
|
time, and both stations' calls, reports and exchanges. It is export-only and drops everything
|
||||||
|
outside the contest's exchange, which is why it complements ADIF rather than replacing it.
|
||||||
|
It arrives with the contest exchange fields in phase 5, since without a serial or a zone to
|
||||||
|
put in the exchange there is nothing for it to write.
|
||||||
|
|
||||||
|
#### Integration points
|
||||||
|
|
||||||
|
| Source | What it gives the log | How |
|
||||||
|
|--------|----------------------|-----|
|
||||||
|
| `RigState` | `FREQ`, `BAND`, `MODE`/`SUBMODE`, and the rig id a QSO was made on | watch channel already in the frontend context |
|
||||||
|
| Client config `general.callsign` | `STATION_CALLSIGN`, and the default `OPERATOR` | already surfaced as `owner_callsign` in frontend meta |
|
||||||
|
| The QSO's own rig, and its position | `MY_GRIDSQUARE` | per-rig latitude and longitude already carried in the rig list |
|
||||||
|
| Server clock | `QSO_DATE`, `TIME_ON` in UTC | new `GET /api/logbook/now`, which also feeds the browser-clock check |
|
||||||
|
| Decoder panels and map | a pre-filled entry: callsign, grid, and the report to offer | existing decode history; no new plumbing |
|
||||||
|
| `bandForHz` | `BAND` from a frequency | exists in `map-core.ts`; move to a shared module |
|
||||||
|
|
||||||
|
#### HTTP API
|
||||||
|
|
||||||
|
Under `/api/logbook`, as the recorder's endpoints are, because `/logbook` itself is the page:
|
||||||
|
the `/bookmarks` API and the bookmarks page already share a path, and whichever is registered
|
||||||
|
first wins.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `GET` | `/api/logbook` | Query: filters, paging |
|
||||||
|
| `POST` | `/api/logbook` | Add a QSO |
|
||||||
|
| `PUT` | `/api/logbook/{id}` | Edit |
|
||||||
|
| `DELETE` | `/api/logbook/{id}` | Delete |
|
||||||
|
| `GET` | `/api/logbook/export.adi` | ADIF export, honouring the current filter |
|
||||||
|
| `GET` | `/api/logbook/export.cbr` | Cabrillo export of a contest selection |
|
||||||
|
| `POST` | `/api/logbook/import` | Import, answering with counts: added, duplicate, rejected |
|
||||||
|
| `GET` | `/api/logbook/worked/{call}` | Worked-before: bands and modes |
|
||||||
|
| `GET` | `/api/logbook/statistics` | Contacts, stations and confirmations, per band |
|
||||||
|
| `GET` | `/api/logbook/now` | The server's UTC clock, for checking the browser's |
|
||||||
|
| `GET` | `/api/logbook/prefill` | The six fields an entry opens with |
|
||||||
|
|
||||||
|
Writes require the 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
|
||||||
|
|
||||||
|
All five are implemented.
|
||||||
|
|
||||||
|
| Phase | Lands |
|
||||||
|
|-------|-------|
|
||||||
|
| 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers |
|
||||||
|
| 2 | Store, dedupe, and the HTTP API behind the control role |
|
||||||
|
| 3 | Logbook tab: entry, table, filters, import, export |
|
||||||
|
| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before |
|
||||||
|
| 5 | Contest exchange fields and Cabrillo export; QSL and LoTW/eQSL fields; per-band worked/confirmed statistics |
|
||||||
|
|
||||||
|
Two decisions phase 5 settled. The Cabrillo header cannot be derived from a log — how many
|
||||||
|
operators, how much power, what the score is claimed to be — so it comes from the operator,
|
||||||
|
with `SINGLE-OP`, `LOW`, `ALL` and `MIXED` behind it. And a confirmation counts from whichever
|
||||||
|
bureau answered: an award wants one card or one electronic match, not one of each, so a log
|
||||||
|
that counted them separately would tell the operator they were short of what they have.
|
||||||
|
|
||||||
|
### Decisions
|
||||||
|
|
||||||
|
**One station log, not one per rig.** Awards and uploads are per station callsign — DXCC, WAS
|
||||||
|
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.
|
||||||
+307
-22
@@ -17,30 +17,61 @@ frontends.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Both `trx-server` and `trx-client` use TOML configuration files. Use
|
Both `trx-server` and `trx-client` read TOML. The server takes its settings
|
||||||
`--print-config` to generate a fully commented example.
|
from the `[trx-server]` section and the client from `[trx-client]`, so one
|
||||||
|
`trx-rs.toml` can configure both — or each may live in its own file with the
|
||||||
|
section header left off.
|
||||||
|
|
||||||
|
`trx-rs.toml.example` in the repository root is a complete, commented example
|
||||||
|
generated from the config definitions themselves. `--print-config` prints the
|
||||||
|
same settings without the comments.
|
||||||
|
|
||||||
### File Locations
|
### File Locations
|
||||||
|
|
||||||
**trx-server** lookup order:
|
Both binaries use the same lookup order:
|
||||||
1. `--config <FILE>`
|
|
||||||
2. `./trx-server.toml`
|
|
||||||
3. `~/.trx-server.toml`
|
|
||||||
4. `~/.config/trx-rs/server.toml`
|
|
||||||
5. `/etc/trx-rs/server.toml`
|
|
||||||
|
|
||||||
**trx-client** lookup order:
|
|
||||||
1. `--config <FILE>`
|
1. `--config <FILE>`
|
||||||
2. `./trx-client.toml`
|
2. `./trx-rs.toml`
|
||||||
3. `~/.config/trx-rs/client.toml`
|
3. `~/.config/trx-rs/trx-rs.toml`
|
||||||
4. `/etc/trx-rs/client.toml`
|
4. `/etc/trx-rs/trx-rs.toml`
|
||||||
|
|
||||||
CLI arguments override config file values.
|
CLI arguments override config file values.
|
||||||
|
|
||||||
### Environment Variables
|
### Checking a Config
|
||||||
|
|
||||||
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by
|
`--check-config` loads the file, reports every problem it finds — unknown keys,
|
||||||
both server and client.
|
invalid values, listeners fighting over a port — and exits without starting
|
||||||
|
anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
trx-server --check-config --config trx-rs.toml
|
||||||
|
trx-client --check-config --config trx-rs.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Unknown keys are warnings by default, so a config written for a newer version
|
||||||
|
still runs on an older binary. `--strict-config` makes them fatal.
|
||||||
|
|
||||||
|
`trx-configurator --check <FILE>` runs the same checks.
|
||||||
|
|
||||||
|
### Environment Variables and Secrets
|
||||||
|
|
||||||
|
Any string in the config may reference an environment variable as `${VAR}`;
|
||||||
|
an unset variable is an error rather than an empty value.
|
||||||
|
|
||||||
|
Credentials can be kept out of the config entirely by pointing at a file
|
||||||
|
instead. Every secret has a `*_file` sibling — set one or the other, never
|
||||||
|
both:
|
||||||
|
|
||||||
|
| Inline key | File key | Contents |
|
||||||
|
|------------|----------|----------|
|
||||||
|
| `[listen.auth].tokens` | `tokens_file` | one token per line |
|
||||||
|
| `[[remotes]].auth.token` | `token_file` | the token |
|
||||||
|
| `[frontends.http.auth].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
|
### Server Options
|
||||||
|
|
||||||
@@ -96,6 +127,7 @@ CLI arguments override config file values.
|
|||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
|
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
|
||||||
|
| `tokens_file` | string | — | Read tokens from this file, one per line |
|
||||||
|
|
||||||
#### `[audio]`
|
#### `[audio]`
|
||||||
|
|
||||||
@@ -121,6 +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 |
|
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
|
||||||
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
|
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
|
||||||
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
|
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
|
||||||
|
| `spectrum_fft_size` | usize | `1024` | Spectrum FFT bins; power of two, 128–8192 |
|
||||||
|
| `spectrum_interval_ms` | u64 | `50` | How often a spectrum frame is pushed to subscribed clients |
|
||||||
|
|
||||||
|
Spectrum is the largest thing on the client connection. On a slow or
|
||||||
|
high-latency link, halving `spectrum_fft_size` halves the bytes per frame (at
|
||||||
|
half the frequency resolution) and raising `spectrum_interval_ms` sends fewer of
|
||||||
|
them; see [Spectrum over a slow link](#spectrum-over-a-slow-link).
|
||||||
|
|
||||||
#### `[sdr.gain]`
|
#### `[sdr.gain]`
|
||||||
|
|
||||||
@@ -197,6 +236,29 @@ Notes:
|
|||||||
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
|
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
|
||||||
`%MM%`, `%DD%` (UTC).
|
`%MM%`, `%DD%` (UTC).
|
||||||
|
|
||||||
|
#### `[decoders]`
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `enabled` | string[] | all decoders | Decoders to run for this rig |
|
||||||
|
| `output_dir` | string | `"$XDG_CACHE_HOME/trx-rs"` | Base directory for decoders that write images |
|
||||||
|
|
||||||
|
Valid decoder names: `aprs`, `aprs_hf`, `ais`, `cw`, `ft2`, `ft4`, `ft8`,
|
||||||
|
`lrpt`, `sstv`, `vdes`, `wefax`, `wspr` — the same names `[[sdr.channels]]`
|
||||||
|
uses. An unrecognised name is a config error.
|
||||||
|
|
||||||
|
Every decoder runs by default, which costs real CPU on a small machine. On a
|
||||||
|
station that only works digital modes, listing just what you use is worth it:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[decoders]
|
||||||
|
enabled = ["ft8", "ft4", "wspr"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`sstv`, `wefax` and `lrpt` write images into a subdirectory of `output_dir`
|
||||||
|
named after the decoder. `ais` and `vdes` additionally require an SDR channel
|
||||||
|
configured to feed them.
|
||||||
|
|
||||||
#### Multi-Rig Configuration
|
#### Multi-Rig Configuration
|
||||||
|
|
||||||
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
|
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
|
||||||
@@ -240,12 +302,32 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `url` | string | — | Server address (e.g. `localhost:4530`) |
|
| `url` | string | — | Server address (e.g. `localhost:4530`) |
|
||||||
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
||||||
|
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
|
||||||
|
|
||||||
#### `[remote.auth]`
|
#### `[remote.auth]`
|
||||||
|
|
||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `token` | string | — | Auth token (must not be empty if set) |
|
| `token` | string | — | Auth token (must not be empty if set) |
|
||||||
|
| `token_file` | string | — | Read the token from this file instead |
|
||||||
|
|
||||||
|
#### `[[remotes]]`
|
||||||
|
|
||||||
|
Preferred over the single `[remote]` section: one entry per rig, each mapping a
|
||||||
|
short name to a server and an optional server-side rig id.
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `name` | string | — | Short name used everywhere in the client |
|
||||||
|
| `url` | string | — | Server address (`host:port`) |
|
||||||
|
| `rig_id` | string | — | Rig id on a multi-rig server |
|
||||||
|
| `auth.token` | string | — | Auth token |
|
||||||
|
| `auth.token_file` | string | — | Read the token from this file instead |
|
||||||
|
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
||||||
|
|
||||||
|
The `name` is the key used by `default_rig_name`, `rigctl.rig_ports`,
|
||||||
|
`audio.rig_urls`, `audio.rig_ports` and `decode_history_retention_min_by_rig`.
|
||||||
|
A name in any of those maps that no remote answers to is a config error.
|
||||||
|
|
||||||
#### `[frontends.http]`
|
#### `[frontends.http]`
|
||||||
|
|
||||||
@@ -254,6 +336,31 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
| `enabled` | bool | `true` | Enable web UI |
|
| `enabled` | bool | `true` | Enable web UI |
|
||||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||||
| `port` | u16 | `8080` | Bind port |
|
| `port` | u16 | `8080` | Bind port |
|
||||||
|
| `default_rig_name` | string | — | Remote selected on startup |
|
||||||
|
| `initial_map_zoom` | u8 | `10` | Starting zoom for the APRS map |
|
||||||
|
| `show_sdr_gain_control` | bool | `true` | Expose the RF gain control |
|
||||||
|
| `bandplan_enabled` | bool | `true` | Show the bandplan strip |
|
||||||
|
| `bandplan_region` | string | `"iaru_r1"` | `iaru_r1`, `iaru_r2`, or `iaru_r3` |
|
||||||
|
| `decode_history_retention_min` | u64 | `1440` | Decode history retention |
|
||||||
|
| `decode_history_retention_min_by_rig` | table | `{}` | Per-remote retention override |
|
||||||
|
| `spectrum_coverage_margin_hz` | u32 | `50000` | Centre-retune guard margin |
|
||||||
|
| `spectrum_usable_span_ratio` | f32 | `0.92` | Usable fraction of the sampled span |
|
||||||
|
|
||||||
|
#### `[frontends.http.auth]`
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `enabled` | bool | `false` | 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]`
|
#### `[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 |
|
| `enabled` | bool | `false` | Enable Hamlib rigctl |
|
||||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||||
| `port` | u16 | `4532` | Bind port |
|
| `rig_ports` | table | `{}` | Remote name → local port; one listener each |
|
||||||
|
|
||||||
|
One listener is started per `rig_ports` entry, each routing to its rig, so
|
||||||
|
`rig_ports` must name at least one remote when the frontend is enabled. The
|
||||||
|
older single `port` key and `--rigctl-port` are ignored.
|
||||||
|
|
||||||
#### `[frontends.http_json]`
|
#### `[frontends.http_json]`
|
||||||
|
|
||||||
@@ -271,13 +382,17 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||||
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
|
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
|
||||||
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
|
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
|
||||||
|
| `auth.tokens_file` | string | — | Read tokens from this file, one per line |
|
||||||
|
|
||||||
#### `[frontends.audio]`
|
#### `[frontends.audio]`
|
||||||
|
|
||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `enabled` | bool | `true` | Enable audio client |
|
| `enabled` | bool | `true` | Enable audio client |
|
||||||
| `server_port` | u16 | `4531` | Server audio port |
|
| `server_url` | string | — | Audio endpoint for every remote |
|
||||||
|
| `rig_urls` | table | `{}` | Remote name → audio URL (wins over `server_url`) |
|
||||||
|
| `server_port` | u16 | `4531` | Fallback port when no URL is configured |
|
||||||
|
| `rig_ports` | table | `{}` | Remote name → port; superseded by `rig_urls` |
|
||||||
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
|
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
|
||||||
| `bridge.rx_output_device` | string | — | Local playback device |
|
| `bridge.rx_output_device` | string | — | Local playback device |
|
||||||
| `bridge.tx_input_device` | string | — | Local capture device |
|
| `bridge.tx_input_device` | string | — | Local capture device |
|
||||||
@@ -287,16 +402,186 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
|
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
|
||||||
loopback on Linux, BlackHole on macOS).
|
loopback on Linux, BlackHole on macOS).
|
||||||
|
|
||||||
|
### Spectrum over a slow link
|
||||||
|
|
||||||
|
Spectrum dominates the server↔client connection: everything else is a few
|
||||||
|
hundred bytes, a frame is a few kilobytes. Three things govern what it costs.
|
||||||
|
|
||||||
|
**Frames are pushed, not polled.** The client subscribes and the server sends
|
||||||
|
frames at `[sdr].spectrum_interval_ms`. Polling cost a round trip per frame, so
|
||||||
|
the rate was capped at 1/RTT — on a 200 ms link you could not exceed 5 frames a
|
||||||
|
second however often the client asked. Clients fall back to polling
|
||||||
|
automatically against a server too old to stream.
|
||||||
|
|
||||||
|
**Bins travel as whole dBFS.** They are base64-encoded `i8` on the wire, about
|
||||||
|
an eighth of the JSON array of floats they used to be, at the resolution the
|
||||||
|
display draws anyway.
|
||||||
|
|
||||||
|
**Both ends have a rate, and the slower one wins.** The server pushes no faster
|
||||||
|
than `[sdr].spectrum_interval_ms`; the client asks for no more than
|
||||||
|
`[[remotes]].spectrum_interval_ms`.
|
||||||
|
|
||||||
|
For a link that struggles, start here:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[trx-server.sdr]
|
||||||
|
spectrum_fft_size = 512 # half the bins, half the bytes
|
||||||
|
spectrum_interval_ms = 200 # 5 frames/s instead of 20
|
||||||
|
|
||||||
|
[[trx-client.remotes]]
|
||||||
|
name = "remote-site"
|
||||||
|
url = "radio.example.com:4530"
|
||||||
|
spectrum_interval_ms = 200
|
||||||
|
```
|
||||||
|
|
||||||
|
That is roughly 0.7 KB per frame at 5 frames/s — about 3.5 KB/s, against
|
||||||
|
roughly 200 KB/s for 1024 float bins at 20 frames/s.
|
||||||
|
|
||||||
### CLI Override Summary
|
### CLI Override Summary
|
||||||
|
|
||||||
**trx-server:**
|
**trx-server:**
|
||||||
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
|
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`,
|
||||||
`--port`. SDR options are file-only.
|
`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only.
|
||||||
|
|
||||||
**trx-client:**
|
**trx-client:**
|
||||||
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
|
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`,
|
||||||
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
|
`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`,
|
||||||
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
|
`--http-port`, `--rigctl-listen`, `--http-json-listen`, `--http-json-port`,
|
||||||
|
`--callsign`.
|
||||||
|
|
||||||
|
`--listen` on the server overrides the bind address of both the control
|
||||||
|
listener and every rig's audio listener.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multiple Rigs in the Web UI
|
||||||
|
|
||||||
|
A client connected to several rigs decodes all of them at once, whichever one
|
||||||
|
is on screen. The rig picker in the header decides which rig the UI is about,
|
||||||
|
and each page answers that differently:
|
||||||
|
|
||||||
|
| Page | Shows |
|
||||||
|
|------|-------|
|
||||||
|
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
|
||||||
|
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
|
||||||
|
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. HF APRS is a source of its own there, filtered apart from VHF APRS. |
|
||||||
|
| Statistics | The whole station, including the per-rig comparison. |
|
||||||
|
|
||||||
|
Switching rigs repaints the radio and digital modes pages for the rig now
|
||||||
|
selected. Nothing is lost by switching: the traffic other rigs heard is still
|
||||||
|
held, and switching back brings it up again. The one exception is the CW pane,
|
||||||
|
which is a single running stream of copied text rather than a list of frames,
|
||||||
|
so it starts empty on the rig you switch to.
|
||||||
|
|
||||||
|
Each browser tab keeps its own selection, so two tabs can watch two rigs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logbook
|
||||||
|
|
||||||
|
The **Logbook** tab keeps the station's contacts and speaks ADIF, so a log can
|
||||||
|
be uploaded to LoTW, eQSL, Club Log or QRZ, or moved to another logger.
|
||||||
|
|
||||||
|
An entry opens pre-filled with six fields and no more: the frequency, mode and
|
||||||
|
name of the rig you are on, the time from the server's clock, and — when the
|
||||||
|
entry was started from a decode or a map station — that station's callsign and
|
||||||
|
locator. Signal reports, name, comment and the rest are yours to fill in; a
|
||||||
|
report in particular is never guessed, because an FT8 SNR is not what was sent.
|
||||||
|
|
||||||
|
Your own callsign and locator are not per-contact fields. They are station
|
||||||
|
identity: the callsign comes from `[trx-client.general].callsign`, the locator
|
||||||
|
from the rig's position, and both are shown once at the top of the panel. The
|
||||||
|
operator defaults to the station callsign and can be changed for the session,
|
||||||
|
which is what a multi-operator station needs.
|
||||||
|
|
||||||
|
**Times are the server's**, in UTC — the server is the machine at the radio. If
|
||||||
|
the browser's clock disagrees by more than a second the entry says so rather
|
||||||
|
than logging a time you did not expect.
|
||||||
|
|
||||||
|
**A decode is not a contact.** The decoders are receive-only, so a **Log**
|
||||||
|
button on an FT8 or APRS row opens an entry with what was heard in it and logs
|
||||||
|
nothing by itself. Digital QSOs made in WSJT-X come in through Import ADIF, the
|
||||||
|
way every other logger takes them.
|
||||||
|
|
||||||
|
| Action | What it does |
|
||||||
|
|--------|--------------|
|
||||||
|
| Log contact | Writes the entry and opens a fresh one |
|
||||||
|
| Export ADIF | Downloads the log — filtered, if a filter is set |
|
||||||
|
| Import ADIF | Reads a file, skipping contacts already held and reporting what could not be read |
|
||||||
|
| Worked before | Shows, as you type a callsign, which bands and modes it has been worked on |
|
||||||
|
|
||||||
|
Two contacts are treated as the same when the callsign, band and mode match and
|
||||||
|
the times are within two minutes: two loggers rarely stamp a QSO to the same
|
||||||
|
minute, one recording when it started and the other when it was typed.
|
||||||
|
|
||||||
|
The log is a JSON Lines file, appended one contact at a time so a crash costs at
|
||||||
|
most the contact being written. It lives in your data directory by default;
|
||||||
|
`[trx-client.logbook].path` moves it, for a station that keeps its log on a
|
||||||
|
backed-up volume.
|
||||||
|
|
||||||
|
### Contests
|
||||||
|
|
||||||
|
The **Contest exchange** block on the entry holds the contest's name and the
|
||||||
|
serials — sent and received. Both stay between contacts, because they belong to
|
||||||
|
the session rather than to the contact just logged, and the serial sent counts
|
||||||
|
on by itself so it is not retyped forty times an hour. An exchange that is a
|
||||||
|
zone, a section or a name rather than a number is kept as written.
|
||||||
|
|
||||||
|
**Contest entry (Cabrillo)** exports the entry sponsors accept. Only the
|
||||||
|
contacts of the contest named there are included. The header — operator
|
||||||
|
category, power, claimed score — cannot be worked out from a log, so it is
|
||||||
|
yours to fill in; the defaults are single operator, low power, all bands, mixed
|
||||||
|
mode.
|
||||||
|
|
||||||
|
### Confirmations
|
||||||
|
|
||||||
|
The **QSL** column shows a tick when the other station has confirmed, and the
|
||||||
|
**Confirm** button on a row records a card that has arrived. A confirmation
|
||||||
|
counts from wherever it came: a paper card, LoTW or eQSL. An award wants one of
|
||||||
|
them, not all three, so the log does not ask for all three.
|
||||||
|
|
||||||
|
**Bands worked** counts, per band, the contacts made, the distinct stations
|
||||||
|
worked, and how many of those contacts are confirmed.
|
||||||
|
|
||||||
|
### Ham radio layout
|
||||||
|
|
||||||
|
The **Ham radio** operator layout opens on the logbook with the transceiver
|
||||||
|
controls around it — the arrangement for working the bands, where logging the
|
||||||
|
contact is the task and the radio is the instrument. It is offered only where
|
||||||
|
the selected rig can transmit; a receiver has no contacts to log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tune Links
|
||||||
|
|
||||||
|
Every page of the web UI carries what the radio is doing in its address, so the
|
||||||
|
URL in the address bar is always a link someone else can open:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://receiver.example:8080/?rig=sdr&f=14074000&mode=USB&bw=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Meaning |
|
||||||
|
|-----------|---------|
|
||||||
|
| `f` | Frequency. Hz by default; `7074k` and `14.074M` also work. |
|
||||||
|
| `mode` | Demodulation mode, e.g. `USB`, `CW`, `WFM`. |
|
||||||
|
| `bw` | Filter bandwidth in Hz. Ignored by rigs without filter control. |
|
||||||
|
| `rig` | Rig to select first, by id, on a multi-rig client. |
|
||||||
|
|
||||||
|
Opening such a link selects the rig, sets the mode, tunes, and applies the
|
||||||
|
bandwidth, in that order — a mode change carries its own default bandwidth, so
|
||||||
|
an explicit `bw` is applied last. Anything the rig cannot do (an unknown mode,
|
||||||
|
a frequency outside its range) is reported and the rest of the link still
|
||||||
|
applies. All four parameters are optional.
|
||||||
|
|
||||||
|
The link button in the top bar copies the current link to the clipboard. The
|
||||||
|
address bar itself is updated as you tune, using `replaceState`, so sweeping
|
||||||
|
the dial does not fill the browser's history.
|
||||||
|
|
||||||
|
Applying a link changes the radio, so it needs the `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,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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,9 +9,5 @@ edition = "2021"
|
|||||||
license = "GPL-2.0-or-later"
|
license = "GPL-2.0-or-later"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
|
||||||
toml = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
dirs = "6"
|
|
||||||
thiserror = "2"
|
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum ConfigError {
|
|
||||||
#[error("Failed to read config file {0}: {1}")]
|
|
||||||
ReadError(PathBuf, String),
|
|
||||||
|
|
||||||
#[error("Failed to parse config file {0}: {1}")]
|
|
||||||
ParseError(PathBuf, String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the default search paths for `trx-rs.toml`
|
|
||||||
/// (current directory → XDG config → /etc).
|
|
||||||
fn config_search_paths() -> Vec<PathBuf> {
|
|
||||||
let mut paths = vec![PathBuf::from("trx-rs.toml")];
|
|
||||||
if let Some(config_dir) = dirs::config_dir() {
|
|
||||||
paths.push(config_dir.join("trx-rs").join("trx-rs.toml"));
|
|
||||||
}
|
|
||||||
paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml"));
|
|
||||||
paths
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract and deserialize a named section from a TOML file.
|
|
||||||
///
|
|
||||||
/// Returns `Ok(Some(cfg))` when the section is present and parses cleanly,
|
|
||||||
/// `Ok(None)` when the section is absent, or `Err` on I/O / parse failure.
|
|
||||||
fn load_section_from_file<T: DeserializeOwned>(
|
|
||||||
path: &Path,
|
|
||||||
key: &str,
|
|
||||||
) -> Result<Option<T>, ConfigError> {
|
|
||||||
let content = std::fs::read_to_string(path)
|
|
||||||
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
|
|
||||||
|
|
||||||
let table: toml::Table = toml::from_str(&content)
|
|
||||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
|
|
||||||
|
|
||||||
let Some(section) = table.get(key) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Re-serialize the section then parse as T so all serde defaults apply.
|
|
||||||
let section_toml = toml::to_string(section)
|
|
||||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
|
|
||||||
let cfg = toml::from_str::<T>(§ion_toml)
|
|
||||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
|
|
||||||
Ok(Some(cfg))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trait for loading configuration from a `trx-rs.toml` section.
|
|
||||||
pub trait ConfigFile: Sized + Default + DeserializeOwned {
|
|
||||||
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
|
|
||||||
fn section_key() -> &'static str;
|
|
||||||
|
|
||||||
/// Load the section from a specific file path.
|
|
||||||
///
|
|
||||||
/// Returns an error if the file cannot be read, is not valid TOML, or
|
|
||||||
/// does not contain the expected `[<section_key>]` header.
|
|
||||||
fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
|
|
||||||
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
|
|
||||||
ConfigError::ParseError(
|
|
||||||
path.to_path_buf(),
|
|
||||||
format!("missing [{}] section", Self::section_key()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
|
|
||||||
/// the first file that contains the expected section.
|
|
||||||
///
|
|
||||||
/// Returns `(config, path_where_found)` or `(Default::default(), None)`
|
|
||||||
/// when no config file is found.
|
|
||||||
fn load_from_default_paths() -> Result<(Self, Option<PathBuf>), ConfigError> {
|
|
||||||
for path in config_search_paths() {
|
|
||||||
if path.exists() {
|
|
||||||
if let Some(cfg) = load_section_from_file::<Self>(&path, Self::section_key())? {
|
|
||||||
return Ok((cfg, Some(path)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok((Self::default(), None))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
pub mod config;
|
//! Shared application helpers.
|
||||||
|
//!
|
||||||
|
//! Configuration types and their loader live in the `trx-config` crate.
|
||||||
|
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod shared_config;
|
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
pub use config::{ConfigError, ConfigFile};
|
|
||||||
pub use logging::init_logging;
|
pub use logging::init_logging;
|
||||||
pub use shared_config::{validate_log_level, validate_tokens};
|
|
||||||
pub use util::normalize_name;
|
pub use util::normalize_name;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ uuid = { workspace = true }
|
|||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
opus = "0.3"
|
opus = "0.3"
|
||||||
trx-app = { path = "../trx-app" }
|
trx-app = { path = "../trx-app" }
|
||||||
|
trx-config = { path = "../trx-config" }
|
||||||
trx-core = { path = "../trx-core" }
|
trx-core = { path = "../trx-core" }
|
||||||
trx-protocol = { path = "../trx-protocol" }
|
trx-protocol = { path = "../trx-protocol" }
|
||||||
trx-frontend = { path = "trx-frontend" }
|
trx-frontend = { path = "trx-frontend" }
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ use trx_core::audio::{
|
|||||||
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
||||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
||||||
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
||||||
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO,
|
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_SSTV_DECODE,
|
||||||
AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED,
|
AUDIO_MSG_SSTV_PROGRESS, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
|
||||||
AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB,
|
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
|
||||||
AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS,
|
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
|
||||||
AUDIO_MSG_WSPR_DECODE,
|
AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
||||||
};
|
};
|
||||||
use trx_core::decode::DecodedMessage;
|
use trx_core::decode::DecodedMessage;
|
||||||
use trx_frontend::VChanAudioCmd;
|
use trx_frontend::VChanAudioCmd;
|
||||||
@@ -533,7 +533,9 @@ async fn handle_single_rig_connection(
|
|||||||
| AUDIO_MSG_LRPT_IMAGE
|
| AUDIO_MSG_LRPT_IMAGE
|
||||||
| AUDIO_MSG_LRPT_PROGRESS
|
| AUDIO_MSG_LRPT_PROGRESS
|
||||||
| AUDIO_MSG_WEFAX_DECODE
|
| AUDIO_MSG_WEFAX_DECODE
|
||||||
| AUDIO_MSG_WEFAX_PROGRESS,
|
| AUDIO_MSG_WEFAX_PROGRESS
|
||||||
|
| AUDIO_MSG_SSTV_DECODE
|
||||||
|
| AUDIO_MSG_SSTV_PROGRESS,
|
||||||
payload,
|
payload,
|
||||||
)) => {
|
)) => {
|
||||||
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
||||||
|
|||||||
+5
-1214
File diff suppressed because it is too large
Load Diff
+142
-8
@@ -20,6 +20,7 @@ use tokio::task::JoinHandle;
|
|||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use trx_app::{init_logging, normalize_name};
|
use trx_app::{init_logging, normalize_name};
|
||||||
|
use trx_config::ConfigFile;
|
||||||
use trx_core::audio::AudioStreamInfo;
|
use trx_core::audio::AudioStreamInfo;
|
||||||
|
|
||||||
use trx_core::decode::DecodedMessage;
|
use trx_core::decode::DecodedMessage;
|
||||||
@@ -50,6 +51,12 @@ struct Cli {
|
|||||||
/// Print example configuration and exit
|
/// Print example configuration and exit
|
||||||
#[arg(long = "print-config")]
|
#[arg(long = "print-config")]
|
||||||
print_config: bool,
|
print_config: bool,
|
||||||
|
/// Treat unknown configuration keys as a fatal error
|
||||||
|
#[arg(long = "strict-config")]
|
||||||
|
strict_config: bool,
|
||||||
|
/// Validate the configuration and exit without starting anything
|
||||||
|
#[arg(long = "check-config")]
|
||||||
|
check_config: bool,
|
||||||
/// Remote server URL (host:port)
|
/// Remote server URL (host:port)
|
||||||
#[arg(short = 'u', long = "url")]
|
#[arg(short = 'u', long = "url")]
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
@@ -74,7 +81,7 @@ struct Cli {
|
|||||||
/// rigctl frontend listen address
|
/// rigctl frontend listen address
|
||||||
#[arg(long = "rigctl-listen")]
|
#[arg(long = "rigctl-listen")]
|
||||||
rigctl_listen: Option<IpAddr>,
|
rigctl_listen: Option<IpAddr>,
|
||||||
/// rigctl frontend listen port
|
/// Deprecated: ignored, use [frontends.rigctl].rig_ports
|
||||||
#[arg(long = "rigctl-port")]
|
#[arg(long = "rigctl-port")]
|
||||||
rigctl_port: Option<u16>,
|
rigctl_port: Option<u16>,
|
||||||
/// JSON TCP frontend listen address
|
/// JSON TCP frontend listen address
|
||||||
@@ -109,6 +116,65 @@ async fn main() -> DynResult<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `--check-config`: report everything wrong with the configuration and exit.
|
||||||
|
///
|
||||||
|
/// Unlike startup, this reports every problem it finds rather than stopping at
|
||||||
|
/// the first, so a config can be fixed in one pass. The file is checked as
|
||||||
|
/// written, without CLI overrides.
|
||||||
|
fn check_config(loaded: &trx_config::ConfigLoad<ClientConfig>) -> DynResult<()> {
|
||||||
|
match &loaded.path {
|
||||||
|
Some(path) => println!("{}", path.display()),
|
||||||
|
None => println!("(no config file found; checking built-in defaults)"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
|
||||||
|
warnings.extend(ClientConfig::deprecations(&loaded.present_keys));
|
||||||
|
|
||||||
|
let mut cfg = loaded.config.clone();
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) {
|
||||||
|
errors.push(e);
|
||||||
|
}
|
||||||
|
let cfg = &cfg;
|
||||||
|
let remotes = cfg.resolved_remotes();
|
||||||
|
if remotes.is_empty() {
|
||||||
|
warnings.push(
|
||||||
|
"no remotes configured; --url will be required at startup (add [[remotes]] entries)"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
errors.extend(cfg.validate_all());
|
||||||
|
if !remotes.is_empty() {
|
||||||
|
errors.extend(cfg.validate_resolved_all(&remotes));
|
||||||
|
}
|
||||||
|
|
||||||
|
for w in &warnings {
|
||||||
|
println!(" warning: {}", w);
|
||||||
|
}
|
||||||
|
for e in &errors {
|
||||||
|
println!(" error: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.is_empty() {
|
||||||
|
println!(
|
||||||
|
" OK: {} remote(s) configured: {}",
|
||||||
|
remotes.len(),
|
||||||
|
remotes
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
if !warnings.is_empty() {
|
||||||
|
println!(" {} warning(s)", warnings.len());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Holds the state needed after async initialization completes.
|
/// Holds the state needed after async initialization completes.
|
||||||
struct AppState {
|
struct AppState {
|
||||||
shutdown_tx: watch::Sender<bool>,
|
shutdown_tx: watch::Sender<bool>,
|
||||||
@@ -135,20 +201,44 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (cfg, config_path) = if let Some(ref path) = cli.config {
|
let loaded = if let Some(ref path) = cli.config {
|
||||||
let cfg = ClientConfig::load_from_file(path)?;
|
ClientConfig::load_from_file(path)?
|
||||||
(cfg, Some(path.clone()))
|
|
||||||
} else {
|
} else {
|
||||||
ClientConfig::load_from_default_paths()?
|
ClientConfig::load_from_default_paths()?
|
||||||
};
|
};
|
||||||
cfg.validate()
|
let config_path = loaded.path.clone();
|
||||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
|
||||||
|
|
||||||
init_logging(cfg.general.log_level.as_deref());
|
if cli.check_config {
|
||||||
|
match check_config(&loaded) {
|
||||||
|
Ok(()) => std::process::exit(0),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging comes up before any config complaint so the warnings are visible.
|
||||||
|
init_logging(loaded.config.general.log_level.as_deref());
|
||||||
|
|
||||||
if let Some(ref path) = config_path {
|
if let Some(ref path) = config_path {
|
||||||
info!("Loaded configuration from {}", path.display());
|
info!("Loaded configuration from {}", path.display());
|
||||||
}
|
}
|
||||||
|
loaded.report_unknown_keys(cli.strict_config)?;
|
||||||
|
loaded.report_deprecations();
|
||||||
|
if cli.rigctl_port.is_some() {
|
||||||
|
tracing::warn!(
|
||||||
|
"--rigctl-port is ignored; give each rig its own listener via \
|
||||||
|
[frontends.rigctl].rig_ports"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cfg = loaded.config;
|
||||||
|
// Secrets configured as *_file are read before validation, so everything
|
||||||
|
// downstream sees resolved values.
|
||||||
|
cfg.resolve_secrets(config_path.as_deref())?;
|
||||||
|
cfg.validate()
|
||||||
|
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||||
|
|
||||||
frontend_runtime.http_auth.tokens = cfg
|
frontend_runtime.http_auth.tokens = cfg
|
||||||
.frontends
|
.frontends
|
||||||
@@ -189,6 +279,10 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
.http
|
.http
|
||||||
.decode_history_retention_min_by_rig
|
.decode_history_retention_min_by_rig
|
||||||
.clone();
|
.clone();
|
||||||
|
frontend_runtime.http_ui.logbook_path = cfg.logbook.path.clone();
|
||||||
|
// The callsign the station is on the air with, which every contact is
|
||||||
|
// logged under and which the operator defaults to.
|
||||||
|
frontend_runtime.http_ui.station_callsign = cfg.general.callsign.clone();
|
||||||
|
|
||||||
// Resolve remote entries: CLI --url > [[remotes]] > legacy [remote] > error
|
// Resolve remote entries: CLI --url > [[remotes]] > legacy [remote] > error
|
||||||
let resolved_remotes: Vec<RemoteEntry> = if let Some(ref url) = cli.url {
|
let resolved_remotes: Vec<RemoteEntry> = if let Some(ref url) = cli.url {
|
||||||
@@ -198,10 +292,14 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
|
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
|
||||||
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
|
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
|
||||||
vec![RemoteEntry {
|
vec![RemoteEntry {
|
||||||
|
spectrum_interval_ms: cfg.remote.spectrum_interval_ms,
|
||||||
name,
|
name,
|
||||||
url: url.clone(),
|
url: url.clone(),
|
||||||
rig_id,
|
rig_id,
|
||||||
auth: config::RemoteAuthConfig { token },
|
auth: config::RemoteAuthConfig {
|
||||||
|
token,
|
||||||
|
token_file: None,
|
||||||
|
},
|
||||||
poll_interval_ms,
|
poll_interval_ms,
|
||||||
}]
|
}]
|
||||||
} else {
|
} else {
|
||||||
@@ -262,6 +360,30 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
.http_json_listen
|
.http_json_listen
|
||||||
.unwrap_or(cfg.frontends.http_json.listen);
|
.unwrap_or(cfg.frontends.http_json.listen);
|
||||||
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
|
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
|
||||||
|
|
||||||
|
// Fold the CLI overrides back into the config so validation and the
|
||||||
|
// frontends agree on what is about to be bound.
|
||||||
|
cfg.frontends.http.enabled = frontends.iter().any(|f| f == "http");
|
||||||
|
cfg.frontends.rigctl.enabled = frontends.iter().any(|f| f == "rigctl");
|
||||||
|
cfg.frontends.http_json.enabled = frontends.iter().any(|f| f == "httpjson");
|
||||||
|
cfg.frontends.http.listen = http_listen;
|
||||||
|
cfg.frontends.http.port = http_port;
|
||||||
|
cfg.frontends.rigctl.listen = rigctl_listen;
|
||||||
|
cfg.frontends.http_json.listen = http_json_listen;
|
||||||
|
cfg.frontends.http_json.port = http_json_port;
|
||||||
|
|
||||||
|
// Second validation phase: the per-rig maps are keyed by remote short name,
|
||||||
|
// so they can only be checked once the remote list is final.
|
||||||
|
if cli.url.is_none() {
|
||||||
|
cfg.validate_resolved(&resolved_remotes)
|
||||||
|
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||||
|
} else {
|
||||||
|
// --url replaces the configured remotes outright, so only the socket
|
||||||
|
// checks still apply.
|
||||||
|
trx_config::shared::check_socket_conflicts(&cfg.bound_sockets())
|
||||||
|
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
let callsign = cli
|
let callsign = cli
|
||||||
.callsign
|
.callsign
|
||||||
.clone()
|
.clone()
|
||||||
@@ -366,6 +488,13 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
.map(|e| e.poll_interval_ms)
|
.map(|e| e.poll_interval_ms)
|
||||||
.min()
|
.min()
|
||||||
.unwrap_or(750);
|
.unwrap_or(750);
|
||||||
|
// Entries sharing a server share its connections, so the most frequent
|
||||||
|
// request wins: whoever wants spectrum fastest sets the rate.
|
||||||
|
let spectrum_interval = entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.spectrum_interval_ms)
|
||||||
|
.min()
|
||||||
|
.unwrap_or_else(|| remote_client::DEFAULT_SPECTRUM_INTERVAL.as_millis() as u64);
|
||||||
|
|
||||||
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
@@ -379,6 +508,8 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
|
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
|
||||||
rig_states: frontend_runtime.routing.rig_states.clone(),
|
rig_states: frontend_runtime.routing.rig_states.clone(),
|
||||||
poll_interval: Duration::from_millis(poll_interval),
|
poll_interval: Duration::from_millis(poll_interval),
|
||||||
|
spectrum_interval: Duration::from_millis(spectrum_interval),
|
||||||
|
spectrum_stream_unsupported: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
spectrum: frontend_runtime.spectrum.sender.clone(),
|
spectrum: frontend_runtime.spectrum.sender.clone(),
|
||||||
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
|
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
|
||||||
server_connected: frontend_runtime.routing.server_connected.clone(),
|
server_connected: frontend_runtime.routing.server_connected.clone(),
|
||||||
@@ -527,6 +658,9 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
DecodedMessage::LrptProgress(_) => {}
|
DecodedMessage::LrptProgress(_) => {}
|
||||||
DecodedMessage::Wefax(_) => {}
|
DecodedMessage::Wefax(_) => {}
|
||||||
DecodedMessage::WefaxProgress(_) => {}
|
DecodedMessage::WefaxProgress(_) => {}
|
||||||
|
// Pictures replay from their own history, not this one.
|
||||||
|
DecodedMessage::Sstv(_) => {}
|
||||||
|
DecodedMessage::SstvProgress(_) => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+275
-103
@@ -20,35 +20,22 @@ use trx_core::{RigError, RigResult};
|
|||||||
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
|
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
|
||||||
use trx_protocol::rig_command_to_client;
|
use trx_protocol::rig_command_to_client;
|
||||||
use trx_protocol::types::RigEntry;
|
use trx_protocol::types::RigEntry;
|
||||||
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate};
|
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, SpectrumFrame};
|
||||||
|
|
||||||
|
// Endpoint parsing lives in `trx-config` so config validation and the
|
||||||
|
// connection code agree on what a URL means.
|
||||||
|
pub use trx_config::url::{parse_audio_url, parse_remote_url, RemoteEndpoint};
|
||||||
|
|
||||||
const DEFAULT_REMOTE_PORT: u16 = 4530;
|
|
||||||
const DEFAULT_AUDIO_PORT: u16 = 4531;
|
|
||||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const IO_TIMEOUT: Duration = Duration::from_secs(15);
|
const IO_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
|
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
|
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
|
||||||
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
|
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
// Default spectrum cadence when a config does not specify one. Both the push
|
||||||
pub struct RemoteEndpoint {
|
// stream and the poll fallback run at the configured rate; see
|
||||||
pub host: String,
|
// `[[remotes]].spectrum_interval_ms`.
|
||||||
pub port: u16,
|
pub const DEFAULT_SPECTRUM_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
}
|
|
||||||
|
|
||||||
impl RemoteEndpoint {
|
|
||||||
pub fn connect_addr(&self) -> String {
|
|
||||||
if self.host.contains(':') && !self.host.starts_with('[') {
|
|
||||||
format!("[{}]:{}", self.host, self.port)
|
|
||||||
} else {
|
|
||||||
format!("{}:{}", self.host, self.port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep remote spectrum reasonably responsive without returning to the old
|
|
||||||
// timeout churn caused by a much tighter request cadence.
|
|
||||||
const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RemoteClientConfig {
|
pub struct RemoteClientConfig {
|
||||||
@@ -57,6 +44,12 @@ pub struct RemoteClientConfig {
|
|||||||
pub selected_rig_id: Arc<Mutex<Option<String>>>,
|
pub selected_rig_id: Arc<Mutex<Option<String>>>,
|
||||||
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
||||||
pub poll_interval: Duration,
|
pub poll_interval: Duration,
|
||||||
|
/// How often spectrum frames are wanted. Drives the poll fallback and is
|
||||||
|
/// the rate the client asks the server to push at.
|
||||||
|
pub spectrum_interval: Duration,
|
||||||
|
/// Set once a server has rejected `SubscribeSpectrum`, so later
|
||||||
|
/// connections to it go straight to polling instead of asking again.
|
||||||
|
pub spectrum_stream_unsupported: Arc<AtomicBool>,
|
||||||
/// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe.
|
/// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe.
|
||||||
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
|
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
|
||||||
/// Shared flag: `true` while a TCP connection to trx-server is active.
|
/// Shared flag: `true` while a TCP connection to trx-server is active.
|
||||||
@@ -505,6 +498,100 @@ async fn send_get_sat_passes_on(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What ended a spectrum stream attempt.
|
||||||
|
enum SpectrumStreamOutcome {
|
||||||
|
/// The stream ran and is over; the connection is spent.
|
||||||
|
Finished,
|
||||||
|
/// The server rejected the subscription. The connection is still usable,
|
||||||
|
/// so the caller can poll on it.
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to the server's spectrum push for one rig and publish frames as
|
||||||
|
/// they arrive.
|
||||||
|
///
|
||||||
|
/// The server answers either with frames or, when it is too old to know the
|
||||||
|
/// command, with an error response — which leaves the connection usable for
|
||||||
|
/// polling, so falling back costs no reconnect.
|
||||||
|
async fn run_spectrum_stream(
|
||||||
|
config: &RemoteClientConfig,
|
||||||
|
writer: &mut (impl AsyncWriteExt + Unpin),
|
||||||
|
reader: &mut (impl AsyncBufRead + Unpin),
|
||||||
|
short_name: &str,
|
||||||
|
shutdown_rx: &mut watch::Receiver<bool>,
|
||||||
|
) -> RigResult<SpectrumStreamOutcome> {
|
||||||
|
let envelope = build_envelope(
|
||||||
|
config,
|
||||||
|
ClientCommand::SubscribeSpectrum,
|
||||||
|
Some(short_name.to_string()),
|
||||||
|
);
|
||||||
|
let mut payload = serde_json::to_string(&envelope)
|
||||||
|
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
|
||||||
|
payload.push('\n');
|
||||||
|
time::timeout(SPECTRUM_IO_TIMEOUT, writer.write_all(payload.as_bytes()))
|
||||||
|
.await
|
||||||
|
.map_err(|_| RigError::communication("spectrum subscribe write timed out".to_string()))?
|
||||||
|
.map_err(|e| RigError::communication(format!("spectrum subscribe write failed: {e}")))?;
|
||||||
|
time::timeout(SPECTRUM_IO_TIMEOUT, writer.flush())
|
||||||
|
.await
|
||||||
|
.map_err(|_| RigError::communication("spectrum subscribe flush timed out".to_string()))?
|
||||||
|
.map_err(|e| RigError::communication(format!("spectrum subscribe flush failed: {e}")))?;
|
||||||
|
|
||||||
|
// Re-check what the UI wants: switching rigs has to end this stream so the
|
||||||
|
// connection can be rebuilt for the new one.
|
||||||
|
let mut supervisor = time::interval(Duration::from_millis(500));
|
||||||
|
supervisor.tick().await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
changed = shutdown_rx.changed() => {
|
||||||
|
match changed {
|
||||||
|
Ok(()) if *shutdown_rx.borrow() => return Ok(SpectrumStreamOutcome::Finished),
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(_) => return Ok(SpectrumStreamOutcome::Finished),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = supervisor.tick() => {
|
||||||
|
let wanted = active_spectrum_rig_ids(config);
|
||||||
|
if wanted.len() != 1 || wanted[0] != short_name {
|
||||||
|
return Ok(SpectrumStreamOutcome::Finished);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line = read_limited_line(reader, MAX_JSON_LINE_BYTES) => {
|
||||||
|
let line = line
|
||||||
|
.map_err(|e| RigError::communication(format!("spectrum read failed: {e}")))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
RigError::communication("spectrum connection closed".to_string())
|
||||||
|
})?;
|
||||||
|
let trimmed = line.trim_end();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match serde_json::from_str::<SpectrumFrame>(trimmed) {
|
||||||
|
Ok(frame) => publish_spectrum_frame(config, short_name, frame),
|
||||||
|
// Anything that is not a frame means the server would rather
|
||||||
|
// answer than stream: an older build rejecting the command.
|
||||||
|
Err(_) => return Ok(SpectrumStreamOutcome::Unsupported),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish one pushed frame to the per-rig and selected-rig watch channels.
|
||||||
|
fn publish_spectrum_frame(config: &RemoteClientConfig, short_name: &str, frame: SpectrumFrame) {
|
||||||
|
if let Ok(map) = config.rig_spectrums.read() {
|
||||||
|
if let Some(tx) = map.get(short_name) {
|
||||||
|
tx.send_modify(|s| s.set(Some(frame.spectrum.clone()), frame.vchan_rds.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selected_rig_id(config).as_deref() == Some(short_name) {
|
||||||
|
config
|
||||||
|
.spectrum
|
||||||
|
.send_modify(|s| s.set(Some(frame.spectrum), frame.vchan_rds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_spectrum_connection(
|
async fn handle_spectrum_connection(
|
||||||
config: &RemoteClientConfig,
|
config: &RemoteClientConfig,
|
||||||
stream: TcpStream,
|
stream: TcpStream,
|
||||||
@@ -512,7 +599,33 @@ async fn handle_spectrum_connection(
|
|||||||
) -> RigResult<()> {
|
) -> RigResult<()> {
|
||||||
let (reader, mut writer) = stream.into_split();
|
let (reader, mut writer) = stream.into_split();
|
||||||
let mut reader = BufReader::new(reader);
|
let mut reader = BufReader::new(reader);
|
||||||
let mut interval = time::interval(SPECTRUM_POLL_INTERVAL);
|
|
||||||
|
// Prefer the push stream: polling costs a round trip per frame, so on a
|
||||||
|
// high-latency link the frame rate is 1/RTT no matter what interval is
|
||||||
|
// configured. It only works for one rig per connection, and only against
|
||||||
|
// a server new enough to understand the command.
|
||||||
|
let streamable = active_spectrum_rig_ids(config);
|
||||||
|
if streamable.len() == 1 && !config.spectrum_stream_unsupported.load(Ordering::Relaxed) {
|
||||||
|
match run_spectrum_stream(
|
||||||
|
config,
|
||||||
|
&mut writer,
|
||||||
|
&mut reader,
|
||||||
|
&streamable[0],
|
||||||
|
shutdown_rx,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
SpectrumStreamOutcome::Finished => return Ok(()),
|
||||||
|
SpectrumStreamOutcome::Unsupported => {
|
||||||
|
info!("Server does not support spectrum streaming; falling back to polling");
|
||||||
|
config
|
||||||
|
.spectrum_stream_unsupported
|
||||||
|
.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut interval = time::interval(config.spectrum_interval);
|
||||||
// Cache the token outside the poll loop to avoid cloning it every 50ms.
|
// Cache the token outside the poll loop to avoid cloning it every 50ms.
|
||||||
let cached_token = config.token.clone();
|
let cached_token = config.token.clone();
|
||||||
|
|
||||||
@@ -1195,92 +1308,13 @@ async fn read_limited_line<R: AsyncBufRead + Unpin>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
|
|
||||||
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
|
|
||||||
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
|
|
||||||
let trimmed = url.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Err(format!("{kind} url is empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let addr = trimmed
|
|
||||||
.strip_prefix("tcp://")
|
|
||||||
.or_else(|| trimmed.strip_prefix("http-json://"))
|
|
||||||
.or_else(|| trimmed.strip_prefix("audio://"))
|
|
||||||
.unwrap_or(trimmed);
|
|
||||||
|
|
||||||
parse_host_port(addr, default_port, kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
|
|
||||||
if let Some(rest) = input.strip_prefix('[') {
|
|
||||||
let closing = rest
|
|
||||||
.find(']')
|
|
||||||
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
|
|
||||||
let host = &rest[..closing];
|
|
||||||
let remainder = &rest[closing + 1..];
|
|
||||||
if host.is_empty() {
|
|
||||||
return Err(format!("invalid {kind} url: host is empty"));
|
|
||||||
}
|
|
||||||
let port = if remainder.is_empty() {
|
|
||||||
default_port
|
|
||||||
} else if let Some(port_str) = remainder.strip_prefix(':') {
|
|
||||||
parse_port(port_str, kind)?
|
|
||||||
} else {
|
|
||||||
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
|
|
||||||
};
|
|
||||||
return Ok(RemoteEndpoint {
|
|
||||||
host: host.to_string(),
|
|
||||||
port,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if input.contains(':') {
|
|
||||||
if input.matches(':').count() > 1 {
|
|
||||||
return Err(format!(
|
|
||||||
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let (host, port_str) = input
|
|
||||||
.rsplit_once(':')
|
|
||||||
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
|
|
||||||
if host.is_empty() {
|
|
||||||
return Err(format!("invalid {kind} url: host is empty"));
|
|
||||||
}
|
|
||||||
return Ok(RemoteEndpoint {
|
|
||||||
host: host.to_string(),
|
|
||||||
port: parse_port(port_str, kind)?,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(RemoteEndpoint {
|
|
||||||
host: input.to_string(),
|
|
||||||
port: default_port,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
|
|
||||||
let port: u16 = port_str
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
|
|
||||||
if port == 0 {
|
|
||||||
return Err(format!("invalid {kind} port: 0"));
|
|
||||||
}
|
|
||||||
Ok(port)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
|
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
|
||||||
use super::{
|
use super::{
|
||||||
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
|
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
|
||||||
|
DEFAULT_SPECTRUM_INTERVAL,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
@@ -1475,6 +1509,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(100),
|
poll_interval: Duration::from_millis(100),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1520,6 +1556,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1535,6 +1573,130 @@ mod tests {
|
|||||||
assert_eq!(envelope.rig_id.as_deref(), Some("sdr"));
|
assert_eq!(envelope.rig_id.as_deref(), Some("sdr"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stream_test_config(spectrum_tx: watch::Sender<SharedSpectrum>) -> super::RemoteClientConfig {
|
||||||
|
super::RemoteClientConfig {
|
||||||
|
addr: "127.0.0.1:4530".to_string(),
|
||||||
|
token: None,
|
||||||
|
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
||||||
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
|
spectrum: Arc::new(spectrum_tx),
|
||||||
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
rig_states: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
rig_id_to_short_name: HashMap::new(),
|
||||||
|
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
sat_passes: Arc::new(RwLock::new(None)),
|
||||||
|
rig_meters: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pushed frame reaches the watch channel the UI reads, with no request
|
||||||
|
/// from the client beyond the initial subscribe.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spectrum_stream_publishes_pushed_frames() {
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
|
||||||
|
let (spectrum_tx, mut spectrum_rx) = watch::channel(SharedSpectrum::default());
|
||||||
|
let config = stream_test_config(spectrum_tx);
|
||||||
|
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
|
||||||
|
|
||||||
|
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
|
||||||
|
let (client_read, mut client_write) = tokio::io::split(client_io);
|
||||||
|
let mut client_read = BufReader::new(client_read);
|
||||||
|
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
super::run_spectrum_stream(
|
||||||
|
&config,
|
||||||
|
&mut client_write,
|
||||||
|
&mut client_read,
|
||||||
|
"sdr",
|
||||||
|
&mut shutdown_rx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
// The server sees the subscribe, then pushes without being asked.
|
||||||
|
let mut server = BufReader::new(&mut server_io);
|
||||||
|
let mut subscribe = String::new();
|
||||||
|
server.read_line(&mut subscribe).await.expect("subscribe");
|
||||||
|
assert!(
|
||||||
|
subscribe.contains("subscribe_spectrum"),
|
||||||
|
"unexpected command: {subscribe}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let frame = trx_protocol::SpectrumFrame {
|
||||||
|
rig_id: "sdr".to_string(),
|
||||||
|
spectrum: trx_core::rig::state::SpectrumData {
|
||||||
|
bins: vec![-70.0, -30.0],
|
||||||
|
center_hz: 14_200_000,
|
||||||
|
sample_rate: 1_920_000,
|
||||||
|
rds: None,
|
||||||
|
},
|
||||||
|
vchan_rds: None,
|
||||||
|
};
|
||||||
|
let mut line = serde_json::to_string(&frame).unwrap();
|
||||||
|
line.push('\n');
|
||||||
|
server_io.write_all(line.as_bytes()).await.expect("push");
|
||||||
|
server_io.flush().await.expect("flush");
|
||||||
|
|
||||||
|
spectrum_rx.changed().await.expect("spectrum published");
|
||||||
|
let published = spectrum_rx.borrow().clone();
|
||||||
|
let spectrum = published.frame.expect("spectrum present");
|
||||||
|
assert_eq!(spectrum.center_hz, 14_200_000);
|
||||||
|
assert_eq!(spectrum.bins, vec![-70.0, -30.0]);
|
||||||
|
|
||||||
|
let _ = shutdown_tx.send(true);
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An older server answers the unknown command with an error instead of
|
||||||
|
/// frames. That has to read as "poll instead", not as a dead connection.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spectrum_stream_falls_back_when_unsupported() {
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
|
||||||
|
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
||||||
|
let config = stream_test_config(spectrum_tx);
|
||||||
|
let (_shutdown_tx, mut shutdown_rx) = watch::channel(false);
|
||||||
|
|
||||||
|
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
|
||||||
|
let (client_read, mut client_write) = tokio::io::split(client_io);
|
||||||
|
let mut client_read = BufReader::new(client_read);
|
||||||
|
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
super::run_spectrum_stream(
|
||||||
|
&config,
|
||||||
|
&mut client_write,
|
||||||
|
&mut client_read,
|
||||||
|
"sdr",
|
||||||
|
&mut shutdown_rx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut server = BufReader::new(&mut server_io);
|
||||||
|
let mut subscribe = String::new();
|
||||||
|
server.read_line(&mut subscribe).await.expect("subscribe");
|
||||||
|
|
||||||
|
server_io
|
||||||
|
.write_all(
|
||||||
|
b"{\"success\":false,\"state\":null,\"error\":\"Invalid JSON: unknown variant\"}\n",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("error response");
|
||||||
|
server_io.flush().await.expect("flush");
|
||||||
|
|
||||||
|
let outcome = task.await.expect("join").expect("stream result");
|
||||||
|
assert!(
|
||||||
|
matches!(outcome, super::SpectrumStreamOutcome::Unsupported),
|
||||||
|
"an error response should fall back to polling"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_envelope_translates_short_name_to_server_rig_id() {
|
fn build_envelope_translates_short_name_to_server_rig_id() {
|
||||||
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
||||||
@@ -1548,6 +1710,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
|
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1580,6 +1744,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1604,6 +1770,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1644,6 +1812,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: known_rigs.clone(),
|
known_rigs: known_rigs.clone(),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1717,6 +1887,8 @@ mod tests {
|
|||||||
selected_rig_id,
|
selected_rig_id,
|
||||||
known_rigs,
|
known_rigs,
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use trx_core::audio::AudioStreamInfo;
|
use trx_core::audio::AudioStreamInfo;
|
||||||
use trx_core::decode::{
|
use trx_core::decode::{
|
||||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
|
||||||
WsprMessage,
|
WefaxMessage, WsprMessage,
|
||||||
};
|
};
|
||||||
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
||||||
use trx_core::{DynResult, RigRequest, RigState};
|
use trx_core::{DynResult, RigRequest, RigState};
|
||||||
@@ -233,6 +233,7 @@ pub struct DecodeHistoryContext {
|
|||||||
pub ft2: DecodeHistory<Ft8Message>,
|
pub ft2: DecodeHistory<Ft8Message>,
|
||||||
pub wspr: DecodeHistory<WsprMessage>,
|
pub wspr: DecodeHistory<WsprMessage>,
|
||||||
pub wefax: DecodeHistory<WefaxMessage>,
|
pub wefax: DecodeHistory<WefaxMessage>,
|
||||||
|
pub sstv: DecodeHistory<SstvMessage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DecodeHistoryContext {
|
impl Default for DecodeHistoryContext {
|
||||||
@@ -248,6 +249,7 @@ impl Default for DecodeHistoryContext {
|
|||||||
ft2: Arc::new(Mutex::new(VecDeque::new())),
|
ft2: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
wspr: Arc::new(Mutex::new(VecDeque::new())),
|
wspr: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
wefax: Arc::new(Mutex::new(VecDeque::new())),
|
wefax: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
|
sstv: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +292,10 @@ pub struct HttpUiConfig {
|
|||||||
pub bandplan_region: String,
|
pub bandplan_region: String,
|
||||||
pub decode_history_retention_min: u64,
|
pub decode_history_retention_min: u64,
|
||||||
pub decode_history_retention_min_by_rig: HashMap<String, u64>,
|
pub decode_history_retention_min_by_rig: HashMap<String, u64>,
|
||||||
|
/// Where the station log is kept; `None` takes the default.
|
||||||
|
pub logbook_path: Option<String>,
|
||||||
|
/// The callsign contacts are logged under, and the operator by default.
|
||||||
|
pub station_callsign: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HttpUiConfig {
|
impl Default for HttpUiConfig {
|
||||||
@@ -303,6 +309,8 @@ impl Default for HttpUiConfig {
|
|||||||
bandplan_region: "iaru_r1".to_string(),
|
bandplan_region: "iaru_r1".to_string(),
|
||||||
decode_history_retention_min: 24 * 60,
|
decode_history_retention_min: 24 * 60,
|
||||||
decode_history_retention_min_by_rig: HashMap::new(),
|
decode_history_retention_min_by_rig: HashMap::new(),
|
||||||
|
logbook_path: None,
|
||||||
|
station_callsign: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ build = "build.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
trx-core = { path = "../../../trx-core" }
|
trx-core = { path = "../../../trx-core" }
|
||||||
trx-frontend = { path = ".." }
|
trx-frontend = { path = ".." }
|
||||||
|
trx-logbook = { path = "../../trx-logbook" }
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
trx-protocol = { path = "../../../../src/trx-protocol" }
|
trx-protocol = { path = "../../../../src/trx-protocol" }
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
@@ -30,3 +32,6 @@ pickledb = "0.5"
|
|||||||
dirs = "6"
|
dirs = "6"
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
ts-rs = "12.0.1"
|
ts-rs = "12.0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -140,18 +144,22 @@ function aisLatestByVessel(messages) {
|
|||||||
}
|
}
|
||||||
return Array.from(byMmsi.values());
|
return Array.from(byMmsi.values());
|
||||||
}
|
}
|
||||||
|
function aisRigMessages() {
|
||||||
|
return forActiveRig(aisMessageHistory);
|
||||||
|
}
|
||||||
function updateAisSummary() {
|
function updateAisSummary() {
|
||||||
const plan = currentAisChannelPlan();
|
const plan = currentAisChannelPlan();
|
||||||
if (aisChannelSummaryEl) {
|
if (aisChannelSummaryEl) {
|
||||||
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||||
}
|
}
|
||||||
const vessels = aisLatestByVessel(aisMessageHistory);
|
const rigMessages = aisRigMessages();
|
||||||
|
const vessels = aisLatestByVessel(rigMessages);
|
||||||
if (aisVesselCountEl) {
|
if (aisVesselCountEl) {
|
||||||
const count = vessels.length;
|
const count = vessels.length;
|
||||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (aisLatestSeenEl) {
|
if (aisLatestSeenEl) {
|
||||||
const latest = aisMessageHistory[0];
|
const latest = rigMessages[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aisLatestSeenEl.textContent = "No traffic yet";
|
aisLatestSeenEl.textContent = "No traffic yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -160,8 +168,18 @@ function updateAisSummary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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) {
|
function renderAisRow(msg) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("details");
|
||||||
row.className = "ais-message";
|
row.className = "ais-message";
|
||||||
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
|
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@@ -174,7 +192,8 @@ function renderAisRow(msg) {
|
|||||||
const motion = aisMotionText(msg);
|
const motion = aisMotionText(msg);
|
||||||
const route = aisRouteText(msg);
|
const route = aisRouteText(msg);
|
||||||
const distance = aisDistanceText(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>` : "";
|
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 = [
|
row.dataset.filterText = [
|
||||||
name,
|
name,
|
||||||
msg.mmsi,
|
msg.mmsi,
|
||||||
@@ -185,7 +204,16 @@ function renderAisRow(msg) {
|
|||||||
msg.destination,
|
msg.destination,
|
||||||
aisTypeLabel(msg.message_type)
|
aisTypeLabel(msg.message_type)
|
||||||
].filter(Boolean).join(" ").toUpperCase();
|
].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}">${escapeAisHtml(channel.label)}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span></div><div class="ais-row-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` + (route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") + `<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span></div><div class="ais-row-detail">` + (motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) + (distance ? `<span>${escapeAisHtml(distance)}</span>` : "") + (pos ? `<span>${pos}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span></div>`;
|
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);
|
applyAisFilterToRow(row);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -202,7 +230,9 @@ function updateAisBar() {
|
|||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
const recent = aisMessageHistory.filter(
|
||||||
|
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)
|
||||||
|
);
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
if (!isAis || messages.length === 0) {
|
if (!isAis || messages.length === 0) {
|
||||||
aisBarOverlay.style.display = "none";
|
aisBarOverlay.style.display = "none";
|
||||||
@@ -247,7 +277,7 @@ function renderAisHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of aisMessageHistory) {
|
for (const message of aisRigMessages()) {
|
||||||
fragment.appendChild(renderAisRow(message));
|
fragment.appendChild(renderAisRow(message));
|
||||||
}
|
}
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
@@ -265,9 +295,11 @@ function addAisMessage(msg) {
|
|||||||
pruneAisMessageHistory();
|
pruneAisMessageHistory();
|
||||||
scheduleAisBarUpdate();
|
scheduleAisBarUpdate();
|
||||||
scheduleAisHistoryRender();
|
scheduleAisHistoryRender();
|
||||||
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
|
plotAisMessage(msg);
|
||||||
aisWindow.aisMapAddVessel(msg);
|
}
|
||||||
}
|
function plotAisMessage(msg) {
|
||||||
|
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
|
||||||
|
aisWindow.aisMapAddVessel(msg);
|
||||||
}
|
}
|
||||||
function normalizeServerAisMessage(msg) {
|
function normalizeServerAisMessage(msg) {
|
||||||
return {
|
return {
|
||||||
@@ -277,10 +309,10 @@ function normalizeServerAisMessage(msg) {
|
|||||||
}
|
}
|
||||||
function onServerAisBatch(messages) {
|
function onServerAisBatch(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerAisMessage(msg);
|
const next = normalizeServerAisMessage(msg);
|
||||||
|
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -288,9 +320,7 @@ function onServerAisBatch(messages) {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit"
|
second: "2-digit"
|
||||||
});
|
});
|
||||||
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
|
plotAisMessage(next);
|
||||||
aisWindow.aisMapAddVessel(next);
|
|
||||||
}
|
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -322,8 +352,9 @@ if (aisFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function onServerAis(msg) {
|
function onServerAis(msg) {
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
const message = normalizeServerAisMessage(msg);
|
||||||
addAisMessage(normalizeServerAisMessage(msg));
|
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
||||||
|
addAisMessage(message);
|
||||||
}
|
}
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
window.trxPluginRuntime.registerDecoder({
|
||||||
@@ -332,5 +363,13 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerAisBatch,
|
onBatch: onServerAisBatch,
|
||||||
restore: onServerAisBatch,
|
restore: onServerAisBatch,
|
||||||
reset: resetAisHistoryView,
|
reset: resetAisHistoryView,
|
||||||
prune: pruneAisHistoryView
|
prune: pruneAisHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
updateAisBar();
|
||||||
|
renderAisHistory();
|
||||||
|
},
|
||||||
|
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsHexBytes,
|
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
collapseAprsDuplicates,
|
collapseAprsDuplicates,
|
||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsInfo,
|
renderAprsPacketRow
|
||||||
renderLocalAprsSymbol
|
} from "./chunk-REYSUJQ4.js";
|
||||||
} from "./chunk-M2I6DH4X.js";
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -85,20 +86,25 @@ function aprsFilterMatch(pkt) {
|
|||||||
].filter(Boolean).join(" ").toUpperCase();
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
return haystack.includes(aprsFilterText);
|
return haystack.includes(aprsFilterText);
|
||||||
}
|
}
|
||||||
|
function aprsRigPackets() {
|
||||||
|
return forActiveRig(aprsPacketHistory);
|
||||||
|
}
|
||||||
function aprsVisiblePackets() {
|
function aprsVisiblePackets() {
|
||||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
const rigPackets = aprsRigPackets();
|
||||||
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
|
||||||
return packets.filter(aprsFilterMatch);
|
return packets.filter(aprsFilterMatch);
|
||||||
}
|
}
|
||||||
function updateAprsSummary() {
|
function updateAprsSummary() {
|
||||||
|
const rigPackets = aprsRigPackets();
|
||||||
const visible = aprsVisiblePackets();
|
const visible = aprsVisiblePackets();
|
||||||
if (aprsTotalCountEl) {
|
if (aprsTotalCountEl) {
|
||||||
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
}
|
}
|
||||||
if (aprsVisibleCountEl) {
|
if (aprsVisibleCountEl) {
|
||||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (aprsLatestSeenEl) {
|
if (aprsLatestSeenEl) {
|
||||||
const latest = aprsPacketHistory[0];
|
const latest = rigPackets[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aprsLatestSeenEl.textContent = "No packets yet";
|
aprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -114,51 +120,30 @@ function updateAprsChipState() {
|
|||||||
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
|
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
|
||||||
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||||
}
|
}
|
||||||
function renderAprsRow(pkt, isFresh) {
|
async function copyAprsCoords(text) {
|
||||||
const row = document.createElement("div");
|
try {
|
||||||
row.className = "aprs-packet";
|
const clipboard = Reflect.get(navigator, "clipboard");
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
if (!clipboard) return;
|
||||||
if (isFresh) row.classList.add("aprs-packet-new");
|
await clipboard.writeText(text);
|
||||||
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
showAprsHint("Coordinates copied", 1200);
|
||||||
const age = aprsAgeText(pkt._tsMs);
|
} catch {
|
||||||
const category = aprsPacketCategory(pkt);
|
showAprsHint("Copy failed", 1500);
|
||||||
const categoryLabel = aprsCategoryLabel(category);
|
|
||||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
|
||||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
|
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
|
|
||||||
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">${escapeAprsHtml(pkt.srcCall ?? "")}</span><span>>${escapeAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeAprsHtml(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">${escapeAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(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">${escapeAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
|
||||||
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
|
||||||
el.addEventListener("click", (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
const raw = el.dataset.aprsMap ?? "";
|
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
|
||||||
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
aprsWindow.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const copyBtn = row.querySelector("[data-aprs-copy]");
|
|
||||||
if (copyBtn) {
|
|
||||||
copyBtn.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
const raw = copyBtn.dataset.aprsCopy ?? "";
|
|
||||||
try {
|
|
||||||
const clipboard = Reflect.get(navigator, "clipboard");
|
|
||||||
if (clipboard) {
|
|
||||||
await clipboard.writeText(raw);
|
|
||||||
showAprsHint("Coordinates copied", 1200);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
showAprsHint("Copy failed", 1500);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return row;
|
}
|
||||||
|
function renderAprsRow(pkt, isFresh) {
|
||||||
|
return renderAprsPacketRow(pkt, {
|
||||||
|
fresh: isFresh,
|
||||||
|
distance: aprsDistanceText(pkt),
|
||||||
|
onMap: (lat, lon) => {
|
||||||
|
aprsWindow.navigateToAprsMap?.(lat, lon);
|
||||||
|
},
|
||||||
|
onCopy: (text) => {
|
||||||
|
void copyAprsCoords(text);
|
||||||
|
},
|
||||||
|
onLog: (call, gridsquare) => {
|
||||||
|
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? void 0, decoder: "aprs" });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function renderAprsHistory() {
|
function renderAprsHistory() {
|
||||||
pruneAprsPacketHistory();
|
pruneAprsPacketHistory();
|
||||||
@@ -180,7 +165,9 @@ function updateAprsBar() {
|
|||||||
if (!aprsBarOverlay) return;
|
if (!aprsBarOverlay) return;
|
||||||
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
const okFrames = aprsPacketHistory.filter(
|
||||||
|
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id)
|
||||||
|
);
|
||||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
@@ -223,15 +210,17 @@ function pruneAprsHistoryView() {
|
|||||||
updateAprsBar();
|
updateAprsBar();
|
||||||
renderAprsHistory();
|
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) {
|
function addAprsPacket(pkt) {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
aprsPacketHistory.unshift(pkt);
|
aprsPacketHistory.unshift(pkt);
|
||||||
pruneAprsPacketHistory();
|
pruneAprsPacketHistory();
|
||||||
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
|
plotAprsPacket(pkt);
|
||||||
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
|
||||||
}
|
|
||||||
if (pkt.crcOk) scheduleAprsBarUpdate();
|
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||||
scheduleAprsHistoryRender();
|
scheduleAprsHistoryRender();
|
||||||
}
|
}
|
||||||
@@ -240,17 +229,15 @@ function normalizeServerAprsPacket(pkt) {
|
|||||||
}
|
}
|
||||||
function onServerAprsBatch(packets) {
|
function onServerAprsBatch(packets) {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
let hasCrcOk = false;
|
let hasCrcOk = false;
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerAprsPacket(pkt);
|
const next = normalizeServerAprsPacket(pkt);
|
||||||
|
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
|
plotAprsPacket(next);
|
||||||
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
|
|
||||||
}
|
|
||||||
if (next.crcOk) hasCrcOk = true;
|
if (next.crcOk) hasCrcOk = true;
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
@@ -304,8 +291,9 @@ if (aprsFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function onServerAprs(pkt) {
|
function onServerAprs(pkt) {
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
const packet = normalizeServerAprsPacket(pkt);
|
||||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
|
||||||
|
addAprsPacket(packet);
|
||||||
}
|
}
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
window.trxPluginRuntime.registerDecoder({
|
||||||
@@ -314,5 +302,13 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerAprsBatch,
|
onBatch: onServerAprsBatch,
|
||||||
restore: onServerAprsBatch,
|
restore: onServerAprsBatch,
|
||||||
reset: resetAprsHistoryView,
|
reset: resetAprsHistoryView,
|
||||||
prune: pruneAprsHistoryView
|
prune: pruneAprsHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
updateAprsBar();
|
||||||
|
renderAprsHistory();
|
||||||
|
},
|
||||||
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+116
-47
@@ -19,9 +19,11 @@ var bgdWindow = window;
|
|||||||
let bookmarkList = [];
|
let bookmarkList = [];
|
||||||
let statusInterval = null;
|
let statusInterval = null;
|
||||||
let bgdDirty = false;
|
let bgdDirty = false;
|
||||||
|
let statusByBookmark = /* @__PURE__ */ new Map();
|
||||||
|
let lastStatus = null;
|
||||||
function initBackgroundDecode(rigId, role) {
|
function initBackgroundDecode(rigId, role) {
|
||||||
backgroundDecodeRole = role;
|
backgroundDecodeRole = role;
|
||||||
currentRigId = rigId || null;
|
currentRigId = rigId || hostState.lastActiveRigId || null;
|
||||||
if (currentRigId) loadBackgroundDecode();
|
if (currentRigId) loadBackgroundDecode();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
}
|
}
|
||||||
@@ -108,7 +110,7 @@ var bgdWindow = window;
|
|||||||
}
|
}
|
||||||
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||||
renderBookmarkChecklist();
|
renderBookmarkChecklist();
|
||||||
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
const isControl = isControlRole();
|
||||||
const panel = document.getElementById("background-decode-panel");
|
const panel = document.getElementById("background-decode-panel");
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
|
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
|
||||||
@@ -119,6 +121,10 @@ var bgdWindow = window;
|
|||||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||||
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||||
|
syncSaveButton();
|
||||||
|
}
|
||||||
|
function currentFilterText() {
|
||||||
|
return document.getElementById("bgd-bookmark-filter")?.value ?? "";
|
||||||
}
|
}
|
||||||
function renderBookmarkChecklist(filterText = "") {
|
function renderBookmarkChecklist(filterText = "") {
|
||||||
const container = document.getElementById("bgd-bookmark-checklist");
|
const container = document.getElementById("bgd-bookmark-checklist");
|
||||||
@@ -134,20 +140,49 @@ var bgdWindow = window;
|
|||||||
return text.indexOf(filter) >= 0;
|
return text.indexOf(filter) >= 0;
|
||||||
}) : all;
|
}) : all;
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
container.innerHTML = '<div class="bgd-checklist-empty">' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "</div>";
|
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
|
||||||
|
renderSelectionSummary();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
filtered.forEach(function(bookmark) {
|
filtered.forEach(function(bookmark) {
|
||||||
const row = document.createElement("label");
|
const row = document.createElement("label");
|
||||||
row.className = "bgd-checklist-row";
|
row.className = "bgd-checklist-row";
|
||||||
|
row.dataset.bmId = bookmark.id;
|
||||||
const decoders = bookmarkDecoderKinds(bookmark);
|
const decoders = bookmarkDecoderKinds(bookmark);
|
||||||
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
const selected = selectedIds.has(bookmark.id);
|
||||||
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>";
|
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) {
|
row.querySelector("input")?.addEventListener("change", function(e) {
|
||||||
onChecklistToggle(bookmark.id, e.currentTarget.checked);
|
onChecklistToggle(bookmark.id, e.currentTarget.checked);
|
||||||
});
|
});
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
|
renderSelectionSummary();
|
||||||
|
}
|
||||||
|
function emptyListText(supportedCount) {
|
||||||
|
if (supportedCount > 0) return "No bookmark matches that filter.";
|
||||||
|
if (bookmarkList.length > 0) {
|
||||||
|
return "None of your bookmarks name a decoder that can run in the background. Give one a decoder on the Bookmarks tab to list it here.";
|
||||||
|
}
|
||||||
|
return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
|
||||||
|
}
|
||||||
|
function stateBadgeHtml(bookmarkId, selected) {
|
||||||
|
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
|
||||||
|
const entry = statusByBookmark.get(bookmarkId);
|
||||||
|
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
|
||||||
|
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '"><span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
|
||||||
|
}
|
||||||
|
function renderSelectionSummary() {
|
||||||
|
const el = document.getElementById("bgd-selection-summary");
|
||||||
|
if (!el) return;
|
||||||
|
const selected = currentConfig?.bookmark_ids.length ?? 0;
|
||||||
|
if (selected === 0) {
|
||||||
|
el.textContent = "Nothing selected — background decoding is idle.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
|
||||||
|
const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
|
||||||
|
el.textContent = currentConfig?.enabled ? `${noun}, ${String(active)} decoding now.` : `${noun}. Switch Enabled on to start decoding them.`;
|
||||||
}
|
}
|
||||||
function onChecklistToggle(bookmarkId, checked) {
|
function onChecklistToggle(bookmarkId, checked) {
|
||||||
if (!currentConfig) {
|
if (!currentConfig) {
|
||||||
@@ -182,7 +217,7 @@ var bgdWindow = window;
|
|||||||
}).catch(function(err) {
|
}).catch(function(err) {
|
||||||
showToast(`Save failed: ${errorMessage(err)}`, true);
|
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||||
}).finally(function() {
|
}).finally(function() {
|
||||||
if (btn) btn.disabled = false;
|
syncSaveButton();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function resetBackgroundDecode() {
|
async function resetBackgroundDecode() {
|
||||||
@@ -210,59 +245,83 @@ var bgdWindow = window;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function renderStatus(status) {
|
function renderStatus(status) {
|
||||||
const card = document.getElementById("background-decode-status-card");
|
lastStatus = status;
|
||||||
if (!card) return;
|
statusByBookmark = new Map(
|
||||||
const entries = status.entries ?? [];
|
(status.entries ?? []).filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0).map((entry) => [entry.bookmark_id, entry])
|
||||||
if (!entries.length) {
|
);
|
||||||
card.textContent = "No background decode bookmarks configured.";
|
renderSpanSummary();
|
||||||
|
renderBookmarkChecklist(currentFilterText());
|
||||||
|
}
|
||||||
|
function renderSpanSummary() {
|
||||||
|
const el = document.getElementById("bgd-span-summary");
|
||||||
|
if (!el) return;
|
||||||
|
const status = lastStatus;
|
||||||
|
if (!status) {
|
||||||
|
el.textContent = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const summary = [];
|
if (!status.active_rig) {
|
||||||
if (status.active_rig) {
|
el.textContent = "This rig is not the one playing audio.";
|
||||||
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
el.dataset.tone = "warn";
|
||||||
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
return;
|
||||||
} else {
|
}
|
||||||
summary.push("This rig is not currently selected for audio.");
|
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.";
|
||||||
}
|
}
|
||||||
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
|
||||||
html += '<div class="bgd-status-list">';
|
|
||||||
entries.forEach(function(entry) {
|
|
||||||
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
|
||||||
const parts = [];
|
|
||||||
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
|
||||||
if (entry.mode) parts.push(entry.mode);
|
|
||||||
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
|
||||||
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
|
||||||
}
|
|
||||||
html += '<div class="bgd-status-row"><div><div class="bgd-status-name">' + escHtml(name) + '</div><div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div></div><div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '"><svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' + escHtml(prettyState(entry.state)) + "</div></div>";
|
|
||||||
});
|
|
||||||
html += "</div>";
|
|
||||||
card.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
function prettyState(state) {
|
function prettyState(state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "active":
|
case "active":
|
||||||
return "✓ Active";
|
return "Decoding";
|
||||||
case "out_of_span":
|
case "out_of_span":
|
||||||
return "△ Out of span";
|
return "Out of span";
|
||||||
case "waiting_for_spectrum":
|
case "waiting_for_spectrum":
|
||||||
return "△ Waiting";
|
return "Waiting for spectrum";
|
||||||
case "waiting_for_user":
|
case "waiting_for_user":
|
||||||
return "△ No user";
|
return "Nobody listening";
|
||||||
case "missing_bookmark":
|
case "missing_bookmark":
|
||||||
return "✗ Missing";
|
return "Bookmark gone";
|
||||||
case "no_supported_decoders":
|
case "no_supported_decoders":
|
||||||
return "✗ Unsupported";
|
return "No decoder";
|
||||||
case "disabled":
|
case "disabled":
|
||||||
return "△ Disabled";
|
return "Off";
|
||||||
case "handled_by_scheduler":
|
case "handled_by_scheduler":
|
||||||
return "△ Scheduler";
|
|
||||||
case "scheduler_has_control":
|
case "scheduler_has_control":
|
||||||
return "△ Scheduler";
|
return "Scheduler has it";
|
||||||
case "handled_by_virtual_channel":
|
case "handled_by_virtual_channel":
|
||||||
return "△ VChan";
|
return "On a channel";
|
||||||
|
case "pending":
|
||||||
|
return "Not saved";
|
||||||
default:
|
default:
|
||||||
return "△ Inactive";
|
return "Idle";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function setCheckbox(id, value) {
|
function setCheckbox(id, value) {
|
||||||
@@ -285,13 +344,21 @@ var bgdWindow = window;
|
|||||||
function markBgdDirty() {
|
function markBgdDirty() {
|
||||||
if (bgdDirty) return;
|
if (bgdDirty) return;
|
||||||
bgdDirty = true;
|
bgdDirty = true;
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
syncSaveButton();
|
||||||
if (btn) btn.classList.add("sch-dirty");
|
|
||||||
}
|
}
|
||||||
function clearBgdDirty() {
|
function clearBgdDirty() {
|
||||||
bgdDirty = false;
|
bgdDirty = false;
|
||||||
|
syncSaveButton();
|
||||||
|
}
|
||||||
|
function syncSaveButton() {
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
if (btn) btn.classList.remove("sch-dirty");
|
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) {
|
function showToast(msg, isError) {
|
||||||
const el = document.getElementById("background-decode-toast");
|
const el = document.getElementById("background-decode-toast");
|
||||||
@@ -311,7 +378,7 @@ var bgdWindow = window;
|
|||||||
return bm.id;
|
return bm.id;
|
||||||
});
|
});
|
||||||
currentConfig.bookmark_ids = ids;
|
currentConfig.bookmark_ids = ids;
|
||||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
renderBookmarkChecklist(currentFilterText());
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
function deselectAllBookmarks() {
|
function deselectAllBookmarks() {
|
||||||
@@ -319,7 +386,7 @@ var bgdWindow = window;
|
|||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
currentConfig.bookmark_ids = [];
|
currentConfig.bookmark_ids = [];
|
||||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
renderBookmarkChecklist(currentFilterText());
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
function wireBackgroundDecodeEvents() {
|
function wireBackgroundDecodeEvents() {
|
||||||
@@ -334,7 +401,9 @@ var bgdWindow = window;
|
|||||||
if (enabledCb && !enabledCb._wired) {
|
if (enabledCb && !enabledCb._wired) {
|
||||||
enabledCb._wired = true;
|
enabledCb._wired = true;
|
||||||
enabledCb.addEventListener("change", function() {
|
enabledCb.addEventListener("change", function() {
|
||||||
|
if (currentConfig) currentConfig.enabled = enabledCb.checked;
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
|
renderBookmarkChecklist(currentFilterText());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ function bmApply(bm) {
|
|||||||
const modeEl = document.getElementById("mode");
|
const modeEl = document.getElementById("mode");
|
||||||
if (modeEl) {
|
if (modeEl) {
|
||||||
modeEl.value = (bm.mode || "").toUpperCase();
|
modeEl.value = (bm.mode || "").toUpperCase();
|
||||||
|
hostCore.syncModePicker();
|
||||||
}
|
}
|
||||||
if (bm.bandwidth_hz) {
|
if (bm.bandwidth_hz) {
|
||||||
hostState.currentBandwidthHz = bm.bandwidth_hz;
|
hostState.currentBandwidthHz = bm.bandwidth_hz;
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
// src/plugins/aprs-shared.ts
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
function renderLocalAprsSymbol(packet, escapeHtml) {
|
|
||||||
if (!packet.symbolTable || !packet.symbolCode) return "";
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
aprsPacketCategory,
|
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsAgeText,
|
|
||||||
collapseAprsDuplicates,
|
|
||||||
aprsHexBytes,
|
|
||||||
renderAprsInfo,
|
|
||||||
renderLocalAprsSymbol,
|
|
||||||
normalizeAprsPacket
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
// src/plugins/aprs-shared.ts
|
||||||
|
function escapeAprsHtml(value) {
|
||||||
|
return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
|
function aprsPacketCategory(packet) {
|
||||||
|
const type = (packet.type ?? "").toLowerCase();
|
||||||
|
const info = (packet.info ?? "").toLowerCase();
|
||||||
|
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
|
||||||
|
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||||
|
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||||
|
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
function aprsCategoryLabel(category) {
|
||||||
|
switch (category) {
|
||||||
|
case "position":
|
||||||
|
return "Position";
|
||||||
|
case "message":
|
||||||
|
return "Message";
|
||||||
|
case "weather":
|
||||||
|
return "Weather";
|
||||||
|
case "telemetry":
|
||||||
|
return "Telemetry";
|
||||||
|
default:
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function aprsAgeText(timestampMs) {
|
||||||
|
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
|
||||||
|
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${String(seconds)}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${String(minutes)}m ago`;
|
||||||
|
return `${String(Math.round(minutes / 60))}h ago`;
|
||||||
|
}
|
||||||
|
function aprsPacketSignature(packet) {
|
||||||
|
return [
|
||||||
|
packet.srcCall ?? "",
|
||||||
|
packet.destCall ?? "",
|
||||||
|
packet.path ?? "",
|
||||||
|
packet.info ?? "",
|
||||||
|
packet.type ?? "",
|
||||||
|
packet.lat?.toFixed(4) ?? "",
|
||||||
|
packet.lon?.toFixed(4) ?? ""
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
function collapseAprsDuplicates(packets) {
|
||||||
|
const seen = /* @__PURE__ */ new Set();
|
||||||
|
return packets.filter((packet) => {
|
||||||
|
const signature = aprsPacketSignature(packet);
|
||||||
|
if (seen.has(signature)) return false;
|
||||||
|
seen.add(signature);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function aprsHexBytes(bytes) {
|
||||||
|
if (!bytes?.length) return "--";
|
||||||
|
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||||
|
}
|
||||||
|
function renderAprsInfo(packet) {
|
||||||
|
if (packet.info_bytes?.length) {
|
||||||
|
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
|
||||||
|
}
|
||||||
|
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
|
||||||
|
}
|
||||||
|
function renderAprsByte(byte) {
|
||||||
|
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||||
|
}
|
||||||
|
function renderAprsCharacter(character) {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||||
|
}
|
||||||
|
function escapeAprsCharacter(character) {
|
||||||
|
if (character === "<") return "<";
|
||||||
|
if (character === ">") return ">";
|
||||||
|
if (character === "&") return "&";
|
||||||
|
if (character === '"') return """;
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
var APRS_SPRITE_COLUMNS = 16;
|
||||||
|
var APRS_SPRITE_CELL_PX = 24;
|
||||||
|
var APRS_SPRITE_FIRST_CODE = 33;
|
||||||
|
var APRS_SPRITE_LAST_CODE = 126;
|
||||||
|
function aprsSpriteOffset(code) {
|
||||||
|
if (code.length !== 1) return null;
|
||||||
|
const point = code.charCodeAt(0);
|
||||||
|
if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
|
||||||
|
const index = point - APRS_SPRITE_FIRST_CODE;
|
||||||
|
const column = index % APRS_SPRITE_COLUMNS;
|
||||||
|
const row = Math.floor(index / APRS_SPRITE_COLUMNS);
|
||||||
|
return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
|
||||||
|
}
|
||||||
|
function aprsSymbolSprite(symbolTable, symbolCode) {
|
||||||
|
if (!symbolTable || !symbolCode) return null;
|
||||||
|
const symbolOffset = aprsSpriteOffset(symbolCode);
|
||||||
|
if (!symbolOffset) return null;
|
||||||
|
if (symbolTable === "/") {
|
||||||
|
return {
|
||||||
|
className: "aprs-symbol-primary",
|
||||||
|
backgroundPosition: symbolOffset,
|
||||||
|
label: `Primary APRS symbol ${symbolTable}${symbolCode}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (symbolTable === "\\") {
|
||||||
|
return {
|
||||||
|
className: "aprs-symbol-alternate",
|
||||||
|
backgroundPosition: symbolOffset,
|
||||||
|
label: `Alternate APRS symbol ${symbolTable}${symbolCode}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const overlayOffset = aprsSpriteOffset(symbolTable);
|
||||||
|
if (!overlayOffset) return null;
|
||||||
|
return {
|
||||||
|
className: "aprs-symbol-overlaid",
|
||||||
|
backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
|
||||||
|
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function renderAprsSymbolSlot(packet, escapeHtml) {
|
||||||
|
return renderLocalAprsSymbol(packet, escapeHtml) || '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
|
||||||
|
}
|
||||||
|
function renderLocalAprsSymbol(packet, escapeHtml) {
|
||||||
|
if (!packet.symbolTable || !packet.symbolCode) return "";
|
||||||
|
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
|
||||||
|
if (sprite) {
|
||||||
|
return `<span class="aprs-symbol ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
|
||||||
|
}
|
||||||
|
const symbol = escapeHtml(packet.symbolCode);
|
||||||
|
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
|
||||||
|
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
||||||
|
}
|
||||||
|
function normalizeAprsPacket(packet, receiver) {
|
||||||
|
return {
|
||||||
|
rig_id: packet.rig_id || null,
|
||||||
|
receiver,
|
||||||
|
srcCall: packet.src_call ?? "",
|
||||||
|
destCall: packet.dest_call ?? "",
|
||||||
|
path: packet.path ?? "",
|
||||||
|
info: packet.info ?? "",
|
||||||
|
info_bytes: packet.info_bytes ?? [],
|
||||||
|
type: packet.packet_type ?? "",
|
||||||
|
crcOk: packet.crc_ok ?? false,
|
||||||
|
ts_ms: packet.ts_ms ?? null,
|
||||||
|
lat: packet.lat ?? null,
|
||||||
|
lon: packet.lon ?? null,
|
||||||
|
symbolTable: packet.symbol_table ?? null,
|
||||||
|
symbolCode: packet.symbol_code ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function fahrenheitToCelsius(fahrenheit) {
|
||||||
|
return Math.round((fahrenheit - 32) * 5 / 9 * 10) / 10;
|
||||||
|
}
|
||||||
|
function summarizeAprsWeather(info) {
|
||||||
|
const parts = [];
|
||||||
|
const temperature = /t(-?\d{2,3})/.exec(info);
|
||||||
|
if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`);
|
||||||
|
const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info);
|
||||||
|
if (wind) {
|
||||||
|
const gust = /g(\d{3})/.exec(info);
|
||||||
|
const knots = Number(wind[2]);
|
||||||
|
parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`);
|
||||||
|
}
|
||||||
|
const humidity = /h(\d{2})/.exec(info);
|
||||||
|
if (humidity) {
|
||||||
|
const value = Number(humidity[1]);
|
||||||
|
parts.push(`${value === 0 ? 100 : value}% RH`);
|
||||||
|
}
|
||||||
|
const pressure = /b(\d{5})/.exec(info);
|
||||||
|
if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`);
|
||||||
|
const rain = /r(\d{3})/.exec(info);
|
||||||
|
if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`);
|
||||||
|
return parts.length ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
function summarizeAprsTelemetry(info) {
|
||||||
|
const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim());
|
||||||
|
if (!match?.[2]) return null;
|
||||||
|
const channels = match[2].split(",").filter((value) => value.length > 0);
|
||||||
|
const bits = match[3] ? ` · bits ${match[3]}` : "";
|
||||||
|
return `#${match[1]} · ${channels.join(" ")}${bits}`;
|
||||||
|
}
|
||||||
|
function summarizeAprsMessage(info) {
|
||||||
|
const match = /^:([^:]{9}):(.*)$/.exec(info);
|
||||||
|
if (!match?.[1] || match[2] == null) return null;
|
||||||
|
const addressee = match[1].trim();
|
||||||
|
const text = match[2].replace(/\{\d+\s*$/, "").trim();
|
||||||
|
return `→ ${addressee}: ${text}`;
|
||||||
|
}
|
||||||
|
function summarizeAprsCourseSpeed(info) {
|
||||||
|
const match = /(\d{3})\/(\d{3})/.exec(info);
|
||||||
|
if (!match) return null;
|
||||||
|
const knots = Number(match[2]);
|
||||||
|
if (knots === 0) return null;
|
||||||
|
return `${Number(match[1])}° ${knots} kt`;
|
||||||
|
}
|
||||||
|
function summarizeAprsPayload(packet) {
|
||||||
|
const info = packet.info ?? "";
|
||||||
|
if (!info) return null;
|
||||||
|
const category = aprsPacketCategory(packet);
|
||||||
|
if (category === "message") return summarizeAprsMessage(info);
|
||||||
|
if (category === "weather") return summarizeAprsWeather(info);
|
||||||
|
if (category === "telemetry") return summarizeAprsTelemetry(info);
|
||||||
|
if (category === "position") {
|
||||||
|
const parts = [];
|
||||||
|
if (packet.lat != null && packet.lon != null) {
|
||||||
|
parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`);
|
||||||
|
}
|
||||||
|
const courseSpeed = summarizeAprsCourseSpeed(info);
|
||||||
|
if (courseSpeed) parts.push(courseSpeed);
|
||||||
|
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
|
||||||
|
if (comment && comment.length <= 60) parts.push(comment);
|
||||||
|
return parts.length ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
const text = info.replace(/^[>;<?]/, "").trim();
|
||||||
|
return text.length ? text : null;
|
||||||
|
}
|
||||||
|
function maidenheadForLatLon(lat, lon) {
|
||||||
|
const adjustedLon = lon + 180;
|
||||||
|
const adjustedLat = lat + 90;
|
||||||
|
const field = String.fromCharCode(65 + Math.floor(adjustedLon / 20)) + String.fromCharCode(65 + Math.floor(adjustedLat / 10));
|
||||||
|
const square = String(Math.floor(adjustedLon % 20 / 2)) + String(Math.floor(adjustedLat % 10));
|
||||||
|
const subLon = adjustedLon % 2 * 60 / 5;
|
||||||
|
const subLat = adjustedLat % 1 * 60 / 2.5;
|
||||||
|
const sub = String.fromCharCode(97 + Math.floor(subLon)) + String.fromCharCode(97 + Math.floor(subLat));
|
||||||
|
return `${field}${square}${sub}`.toUpperCase();
|
||||||
|
}
|
||||||
|
function renderAprsPacketRow(packet, options = {}) {
|
||||||
|
const row = document.createElement("details");
|
||||||
|
row.className = "aprs-packet";
|
||||||
|
if (!packet.crcOk) row.classList.add("aprs-packet-crc");
|
||||||
|
if (options.fresh) row.classList.add("aprs-packet-new");
|
||||||
|
const time = packet._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const category = aprsPacketCategory(packet);
|
||||||
|
const summary = summarizeAprsPayload(packet);
|
||||||
|
const hasPosition = packet.lat != null && packet.lon != null;
|
||||||
|
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
|
||||||
|
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>>${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` + (options.onLog && packet.srcCall ? '<button class="aprs-inline-btn" type="button" data-aprs-log="1">Log</button>' : "") + `</div></div>`;
|
||||||
|
row.querySelectorAll("[data-aprs-map]").forEach((element) => {
|
||||||
|
element.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number);
|
||||||
|
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const logButton = row.querySelector("[data-aprs-log]");
|
||||||
|
if (logButton) {
|
||||||
|
logButton.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const grid = packet.lat != null && packet.lon != null ? maidenheadForLatLon(packet.lat, packet.lon) : null;
|
||||||
|
options.onLog?.(packet.srcCall ?? "", grid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const copyButton = row.querySelector("[data-aprs-copy]");
|
||||||
|
if (copyButton) {
|
||||||
|
copyButton.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
aprsPacketCategory,
|
||||||
|
aprsAgeText,
|
||||||
|
collapseAprsDuplicates,
|
||||||
|
aprsSymbolSprite,
|
||||||
|
normalizeAprsPacket,
|
||||||
|
renderAprsPacketRow
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/active-rig.ts
|
||||||
|
function isActiveRigDecode(rigId) {
|
||||||
|
const activeRigId = hostState.lastActiveRigId;
|
||||||
|
if (!activeRigId || !rigId) return true;
|
||||||
|
return rigId === activeRigId;
|
||||||
|
}
|
||||||
|
function forActiveRig(items) {
|
||||||
|
return items.filter((item) => isActiveRigDecode(item.rig_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
isActiveRigDecode,
|
||||||
|
forActiveRig
|
||||||
|
};
|
||||||
+51
-12
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -148,14 +152,33 @@ function initializeFtxDecoder(config) {
|
|||||||
second: "2-digit"
|
second: "2-digit"
|
||||||
});
|
});
|
||||||
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
||||||
|
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||||
|
if (station) {
|
||||||
|
const log = document.createElement("button");
|
||||||
|
log.type = "button";
|
||||||
|
log.className = "ft8-log-btn";
|
||||||
|
log.textContent = "Log";
|
||||||
|
log.title = `Start a log entry for ${station}`;
|
||||||
|
log.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const details = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
|
bridge.logContact?.({
|
||||||
|
call: station,
|
||||||
|
gridsquare: details.find((detail) => detail.station === station)?.grid,
|
||||||
|
decoder: id
|
||||||
|
});
|
||||||
|
});
|
||||||
|
row.appendChild(log);
|
||||||
|
}
|
||||||
return row;
|
return row;
|
||||||
};
|
};
|
||||||
const render = () => {
|
const render = () => {
|
||||||
prune();
|
prune();
|
||||||
if (!messagesElement) return;
|
if (!messagesElement) return;
|
||||||
|
const rigMessages = forActiveRig(history);
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const message of history) {
|
for (const message of rigMessages) {
|
||||||
if (count >= 200) break;
|
if (count >= 200) break;
|
||||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||||
fragment.appendChild(renderRow(message));
|
fragment.appendChild(renderRow(message));
|
||||||
@@ -163,20 +186,26 @@ function initializeFtxDecoder(config) {
|
|||||||
}
|
}
|
||||||
messagesElement.replaceChildren(fragment);
|
messagesElement.replaceChildren(fragment);
|
||||||
};
|
};
|
||||||
const normalize = (message) => {
|
const plotLocator = (message) => {
|
||||||
const raw = message.message ?? "";
|
const raw = message.message ?? "";
|
||||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
||||||
|
if (grids.length === 0) return;
|
||||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
if (grids.length > 0) {
|
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
...message,
|
||||||
...message,
|
freq_hz: frequency ?? message.freq_hz,
|
||||||
freq_hz: frequency ?? message.freq_hz,
|
locator_details: locatorDetails
|
||||||
locator_details: locatorDetails
|
});
|
||||||
});
|
};
|
||||||
}
|
const normalize = (message) => {
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
plotLocator(message);
|
||||||
return {
|
return {
|
||||||
|
// The rig that heard it, kept so the mini view can tell a decode of the
|
||||||
|
// rig on screen from one a background rig made on another band.
|
||||||
|
rig_id: message.rig_id ?? null,
|
||||||
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||||
ts_ms: message.ts_ms,
|
ts_ms: message.ts_ms,
|
||||||
snr_db: message.snr_db,
|
snr_db: message.snr_db,
|
||||||
@@ -188,7 +217,9 @@ function initializeFtxDecoder(config) {
|
|||||||
};
|
};
|
||||||
const receiveBatch = (messages) => {
|
const receiveBatch = (messages) => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
if (status) status.textContent = "Receiving";
|
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
|
||||||
|
status.textContent = "Receiving";
|
||||||
|
}
|
||||||
history = messages.map(normalize).reverse().concat(history);
|
history = messages.map(normalize).reverse().concat(history);
|
||||||
prune();
|
prune();
|
||||||
bridge.setFt8FamilyBarDecoder?.(id);
|
bridge.setFt8FamilyBarDecoder?.(id);
|
||||||
@@ -202,7 +233,7 @@ function initializeFtxDecoder(config) {
|
|||||||
bridge.clearMapMarkersByType?.(id);
|
bridge.clearMapMarkersByType?.(id);
|
||||||
};
|
};
|
||||||
const barFrames = () => {
|
const barFrames = () => {
|
||||||
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
|
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5 && isActiveRigDecode(message.rig_id)).slice(0, 8);
|
||||||
let html = "";
|
let html = "";
|
||||||
for (const message of recent) {
|
for (const message of recent) {
|
||||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||||
@@ -226,7 +257,15 @@ function initializeFtxDecoder(config) {
|
|||||||
prune();
|
prune();
|
||||||
render();
|
render();
|
||||||
},
|
},
|
||||||
reset
|
reset,
|
||||||
|
rerender: () => {
|
||||||
|
bridge.updateFt8Bar?.();
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const message of [...history].reverse()) plotLocator(message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||||
const updatePeriod = () => {
|
const updatePeriod = () => {
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import {
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -25,7 +28,7 @@ var CW_BAR_LINE_GAP_MS = 5e3;
|
|||||||
var cwLastAppendTime = 0;
|
var cwLastAppendTime = 0;
|
||||||
var cwTonePickerRaf = null;
|
var cwTonePickerRaf = null;
|
||||||
var cwBarHistory = [];
|
var cwBarHistory = [];
|
||||||
var cwBarCurrentLine = null;
|
var cwBarCurrentLines = /* @__PURE__ */ new Map();
|
||||||
var cwBarDismissedAtMs = 0;
|
var cwBarDismissedAtMs = 0;
|
||||||
var cwAutoLocalOverride = null;
|
var cwAutoLocalOverride = null;
|
||||||
function escapeCwHtml(input) {
|
function escapeCwHtml(input) {
|
||||||
@@ -50,20 +53,22 @@ cwWindow.applyCwAutoUiFromServer = function(enabled) {
|
|||||||
if (cwAutoLocalOverride !== null) return;
|
if (cwAutoLocalOverride !== null) return;
|
||||||
applyCwAutoUi(enabled);
|
applyCwAutoUi(enabled);
|
||||||
};
|
};
|
||||||
function cwBarFlushCurrentLine() {
|
function cwBarFlushCurrentLine(key) {
|
||||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
const line = cwBarCurrentLines.get(key);
|
||||||
cwBarHistory.unshift(cwBarCurrentLine);
|
cwBarCurrentLines.delete(key);
|
||||||
|
if (line?.text.trim()) {
|
||||||
|
cwBarHistory.unshift(line);
|
||||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
}
|
}
|
||||||
cwBarCurrentLine = null;
|
|
||||||
}
|
}
|
||||||
function updateCwBar() {
|
function updateCwBar() {
|
||||||
if (!cwBarOverlay) return;
|
if (!cwBarOverlay) return;
|
||||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
const isCw = mode === "CW" || mode === "CWR";
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
|
||||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
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);
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
@@ -308,7 +313,7 @@ function resetCwHistoryView() {
|
|||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
cwLastAppendTime = 0;
|
cwLastAppendTime = 0;
|
||||||
cwBarHistory = [];
|
cwBarHistory = [];
|
||||||
cwBarCurrentLine = null;
|
cwBarCurrentLines.clear();
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
}
|
}
|
||||||
@@ -324,8 +329,9 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
function onServerCw(evt) {
|
function onServerCw(evt) {
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
|
||||||
if (evt.text && cwOutputEl) {
|
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
|
||||||
|
if (evt.text && cwOutputEl && forSelectedRig) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
||||||
const line = document.createElement("div");
|
const line = document.createElement("div");
|
||||||
@@ -346,25 +352,29 @@ function onServerCw(evt) {
|
|||||||
}
|
}
|
||||||
if (evt.text) {
|
if (evt.text) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const rigId = evt.rig_id ?? null;
|
||||||
|
const key = rigId ?? "";
|
||||||
if (evt.text === "\n") {
|
if (evt.text === "\n") {
|
||||||
cwBarFlushCurrentLine();
|
cwBarFlushCurrentLine(key);
|
||||||
} else {
|
} else {
|
||||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
let line = cwBarCurrentLines.get(key);
|
||||||
cwBarFlushCurrentLine();
|
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||||
|
cwBarFlushCurrentLine(key);
|
||||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||||
|
cwBarCurrentLines.set(key, line);
|
||||||
}
|
}
|
||||||
cwBarCurrentLine.text += evt.text;
|
line.text += evt.text;
|
||||||
cwBarCurrentLine.lastMs = now;
|
line.lastMs = now;
|
||||||
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
|
||||||
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
|
||||||
}
|
}
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
}
|
}
|
||||||
if (cwSignalIndicator) {
|
if (cwSignalIndicator && forSelectedRig) {
|
||||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||||
}
|
}
|
||||||
if (!cwAutoInput || cwAutoInput.checked) {
|
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
|
||||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||||
}
|
}
|
||||||
@@ -389,7 +399,14 @@ cwWindow.trxPluginRuntime.registerDecoder({
|
|||||||
id: "cw",
|
id: "cw",
|
||||||
onMessage: onServerCw,
|
onMessage: onServerCw,
|
||||||
restore: restoreCwHistory,
|
restore: restoreCwHistory,
|
||||||
reset: resetCwHistoryView
|
reset: resetCwHistoryView,
|
||||||
|
// The copied text of a rig that is no longer on screen cannot be unpicked
|
||||||
|
// from the pane, so the switch starts the new rig's stream from empty.
|
||||||
|
rerender: () => {
|
||||||
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
|
cwLastAppendTime = 0;
|
||||||
|
updateCwBar();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
// src/decode-history-worker.ts
|
// src/decode-history-worker.ts
|
||||||
var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||||
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"];
|
||||||
var workerScope = self;
|
var workerScope = self;
|
||||||
function decodeCborUint(view, bytes, state, additional) {
|
function decodeCborUint(view, bytes, state, additional) {
|
||||||
const offset = state.offset;
|
const offset = state.offset;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
initializeFtxDecoder
|
initializeFtxDecoder
|
||||||
} from "./chunk-O2Y7YEVQ.js";
|
} from "./chunk-WU5EWJX4.js";
|
||||||
|
import "./chunk-S57W63QN.js";
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
// src/plugins/ft2.ts
|
// src/plugins/ft2.ts
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
initializeFtxDecoder
|
initializeFtxDecoder
|
||||||
} from "./chunk-O2Y7YEVQ.js";
|
} from "./chunk-WU5EWJX4.js";
|
||||||
|
import "./chunk-S57W63QN.js";
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
// src/plugins/ft4.ts
|
// src/plugins/ft4.ts
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import {
|
|||||||
initializeFt8FamilyBar,
|
initializeFt8FamilyBar,
|
||||||
initializeFtxDecoder,
|
initializeFtxDecoder,
|
||||||
installFtxCompatibilityHelpers
|
installFtxCompatibilityHelpers
|
||||||
} from "./chunk-O2Y7YEVQ.js";
|
} from "./chunk-WU5EWJX4.js";
|
||||||
|
import "./chunk-S57W63QN.js";
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
// src/plugins/ft8.ts
|
// src/plugins/ft8.ts
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsHexBytes,
|
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
collapseAprsDuplicates,
|
collapseAprsDuplicates,
|
||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsInfo,
|
renderAprsPacketRow
|
||||||
renderLocalAprsSymbol
|
} from "./chunk-REYSUJQ4.js";
|
||||||
} from "./chunk-M2I6DH4X.js";
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
|
|
||||||
// src/plugins/hf-aprs.ts
|
// src/plugins/hf-aprs.ts
|
||||||
var hfAprsWindow = window;
|
var hfAprsWindow = window;
|
||||||
var escapeHfAprsHtml = (input) => hostCore.escapeMapHtml(input);
|
|
||||||
var hfAprsStatus = document.getElementById("hf-aprs-status");
|
var hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||||
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
||||||
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
||||||
@@ -71,21 +71,26 @@ function hfAprsFilterMatch(pkt) {
|
|||||||
].filter(Boolean).join(" ").toUpperCase();
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
return haystack.includes(hfAprsFilterText);
|
return haystack.includes(hfAprsFilterText);
|
||||||
}
|
}
|
||||||
|
function hfAprsRigPackets() {
|
||||||
|
return forActiveRig(hfAprsPacketHistory);
|
||||||
|
}
|
||||||
function hfAprsVisiblePackets() {
|
function hfAprsVisiblePackets() {
|
||||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
const rigPackets = hfAprsRigPackets();
|
||||||
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
|
||||||
return packets.filter(hfAprsFilterMatch);
|
return packets.filter(hfAprsFilterMatch);
|
||||||
}
|
}
|
||||||
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||||
function updateHfAprsSummary() {
|
function updateHfAprsSummary() {
|
||||||
|
const rigPackets = hfAprsRigPackets();
|
||||||
const visible = hfAprsVisiblePackets();
|
const visible = hfAprsVisiblePackets();
|
||||||
if (hfAprsTotalCountEl) {
|
if (hfAprsTotalCountEl) {
|
||||||
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
}
|
}
|
||||||
if (hfAprsVisibleCountEl) {
|
if (hfAprsVisibleCountEl) {
|
||||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (hfAprsLatestSeenEl) {
|
if (hfAprsLatestSeenEl) {
|
||||||
const latest = hfAprsPacketHistory[0];
|
const latest = rigPackets[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -102,51 +107,27 @@ function updateHfAprsChipState() {
|
|||||||
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
||||||
}
|
}
|
||||||
function renderHfAprsRow(pkt, isFresh) {
|
function renderHfAprsRow(pkt, isFresh) {
|
||||||
const row = document.createElement("div");
|
return renderAprsPacketRow(pkt, {
|
||||||
row.className = "aprs-packet";
|
fresh: isFresh,
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
badge: "HF",
|
||||||
if (isFresh) row.classList.add("aprs-packet-new");
|
distance: hfAprsDistanceText(pkt),
|
||||||
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
onMap: (lat, lon) => {
|
||||||
const age = aprsAgeText(pkt._tsMs);
|
hfAprsWindow.navigateToAprsMap?.(lat, lon);
|
||||||
const category = aprsPacketCategory(pkt);
|
},
|
||||||
const categoryLabel = aprsCategoryLabel(category);
|
onCopy: (text) => {
|
||||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
void copyHfAprsCoords(text);
|
||||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeHfAprsHtml(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>';
|
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
|
|
||||||
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">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span><span>>${escapeHfAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeHfAprsHtml(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">${escapeHfAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(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">${escapeHfAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
|
||||||
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
|
||||||
el.addEventListener("click", (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
const raw = el.dataset.aprsMap ?? "";
|
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
|
||||||
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
hfAprsWindow.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
const copyBtn = row.querySelector("[data-aprs-copy]");
|
}
|
||||||
if (copyBtn) {
|
async function copyHfAprsCoords(text) {
|
||||||
copyBtn.addEventListener("click", () => {
|
try {
|
||||||
void (async () => {
|
const clipboard = Reflect.get(navigator, "clipboard");
|
||||||
const raw = copyBtn.dataset.aprsCopy ?? "";
|
if (!clipboard) return;
|
||||||
try {
|
await clipboard.writeText(text);
|
||||||
const clipboard = Reflect.get(navigator, "clipboard");
|
hostCore.showHint("Coordinates copied", 1200);
|
||||||
if (clipboard) {
|
} catch {
|
||||||
await clipboard.writeText(raw);
|
hostCore.showHint("Copy failed", 1500);
|
||||||
hostCore.showHint("Coordinates copied", 1200);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
hostCore.showHint("Copy failed", 1500);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return row;
|
|
||||||
}
|
}
|
||||||
function renderHfAprsHistory() {
|
function renderHfAprsHistory() {
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
@@ -168,17 +149,32 @@ function resetHfAprsHistoryView() {
|
|||||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
hfAprsPacketHistory = [];
|
hfAprsPacketHistory = [];
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
|
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
|
||||||
}
|
}
|
||||||
function pruneHfAprsHistoryView() {
|
function pruneHfAprsHistoryView() {
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
}
|
}
|
||||||
|
function plotHfAprsPacket(pkt) {
|
||||||
|
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
|
||||||
|
hfAprsWindow.aprsMapAddStation(
|
||||||
|
pkt.srcCall ?? "",
|
||||||
|
pkt.lat,
|
||||||
|
pkt.lon,
|
||||||
|
pkt.info ?? "",
|
||||||
|
pkt.symbolTable,
|
||||||
|
pkt.symbolCode,
|
||||||
|
pkt,
|
||||||
|
"hf_aprs"
|
||||||
|
);
|
||||||
|
}
|
||||||
function addHfAprsPacket(pkt) {
|
function addHfAprsPacket(pkt) {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
hfAprsPacketHistory.unshift(pkt);
|
hfAprsPacketHistory.unshift(pkt);
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
|
plotHfAprsPacket(pkt);
|
||||||
scheduleHfAprsHistoryRender();
|
scheduleHfAprsHistoryRender();
|
||||||
}
|
}
|
||||||
function normalizeServerHfAprsPacket(pkt) {
|
function normalizeServerHfAprsPacket(pkt) {
|
||||||
@@ -186,13 +182,14 @@ function normalizeServerHfAprsPacket(pkt) {
|
|||||||
}
|
}
|
||||||
function onServerHfAprsBatch(packets) {
|
function onServerHfAprsBatch(packets) {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerHfAprsPacket(pkt);
|
const next = normalizeServerHfAprsPacket(pkt);
|
||||||
|
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
plotHfAprsPacket(next);
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -255,8 +252,9 @@ if (hfAprsFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function onServerHfAprs(pkt) {
|
function onServerHfAprs(pkt) {
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
const packet = normalizeServerHfAprsPacket(pkt);
|
||||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||||
|
addHfAprsPacket(packet);
|
||||||
}
|
}
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
window.trxPluginRuntime.registerDecoder({
|
||||||
@@ -265,5 +263,10 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerHfAprsBatch,
|
onBatch: onServerHfAprsBatch,
|
||||||
restore: onServerHfAprsBatch,
|
restore: onServerHfAprsBatch,
|
||||||
reset: resetHfAprsHistoryView,
|
reset: resetHfAprsHistoryView,
|
||||||
prune: pruneHfAprsHistoryView
|
prune: pruneHfAprsHistoryView,
|
||||||
|
rerender: renderHfAprsHistory,
|
||||||
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import {
|
||||||
|
hostCore,
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/logbook.ts
|
||||||
|
var bridge = window;
|
||||||
|
var el = (id) => document.getElementById(id);
|
||||||
|
var form = el("log-entry-form");
|
||||||
|
var callInput = el("log-call");
|
||||||
|
var freqInput = el("log-freq");
|
||||||
|
var modeInput = el("log-mode");
|
||||||
|
var rstSentInput = el("log-rst-sent");
|
||||||
|
var rstRcvdInput = el("log-rst-rcvd");
|
||||||
|
var gridInput = el("log-grid");
|
||||||
|
var nameInput = el("log-name");
|
||||||
|
var commentInput = el("log-comment");
|
||||||
|
var operatorInput = el("log-operator");
|
||||||
|
var rowsBody = el("log-rows");
|
||||||
|
var summaryEl = el("log-summary");
|
||||||
|
var workedEl = el("log-worked-before");
|
||||||
|
var clockEl = el("log-clock");
|
||||||
|
var stationCallEl = el("log-station-callsign");
|
||||||
|
var stationRigEl = el("log-station-rig-name");
|
||||||
|
var stationGridEl = el("log-station-grid");
|
||||||
|
var filterCall = el("log-filter-call");
|
||||||
|
var filterBand = el("log-filter-band");
|
||||||
|
var filterMode = el("log-filter-mode");
|
||||||
|
var importBtn = el("log-import-btn");
|
||||||
|
var importFile = el("log-import-file");
|
||||||
|
var exportLink = el("log-export-btn");
|
||||||
|
var clearBtn = el("log-clear-btn");
|
||||||
|
var saveBtn = el("log-save-btn");
|
||||||
|
var contestIdInput = el("log-contest-id");
|
||||||
|
var stxInput = el("log-stx");
|
||||||
|
var srxInput = el("log-srx");
|
||||||
|
var statisticsRows = el("log-statistics-rows");
|
||||||
|
var cabrilloContest = el("log-cbr-contest");
|
||||||
|
var cabrilloCallsign = el("log-cbr-callsign");
|
||||||
|
var cabrilloOperator = el("log-cbr-operator");
|
||||||
|
var cabrilloPower = el("log-cbr-power");
|
||||||
|
var cabrilloScore = el("log-cbr-score");
|
||||||
|
var cabrilloExport = el("log-cbr-export");
|
||||||
|
var entryStartedAt = null;
|
||||||
|
var entryRigId = null;
|
||||||
|
var entryRigName = null;
|
||||||
|
var entryGrid = null;
|
||||||
|
var qsos = [];
|
||||||
|
var workedRequest = 0;
|
||||||
|
function notify(message, kind) {
|
||||||
|
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0);
|
||||||
|
else hostCore.showHint(message, 2e3);
|
||||||
|
}
|
||||||
|
async function getJson(path) {
|
||||||
|
const response = await fetch(path);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
function formatFreq(hz) {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "";
|
||||||
|
return String(Number((hz / 1e6).toFixed(6)));
|
||||||
|
}
|
||||||
|
function parseFreq(text) {
|
||||||
|
const value = Number(text.trim().replace(/\s+/g, "").replace(",", "."));
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return null;
|
||||||
|
return value < 1e5 ? Math.round(value * 1e6) : Math.round(value);
|
||||||
|
}
|
||||||
|
function numberOrNull(text) {
|
||||||
|
const trimmed = (text ?? "").trim();
|
||||||
|
if (!trimmed || !/^\d+$/.test(trimmed)) return null;
|
||||||
|
const value = Number(trimmed);
|
||||||
|
return Number.isSafeInteger(value) ? value : null;
|
||||||
|
}
|
||||||
|
function utcDate(iso) {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
function utcTime(iso) {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19);
|
||||||
|
}
|
||||||
|
async function openEntry(seed = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (hostState.lastActiveRigId) params.set("remote", hostState.lastActiveRigId);
|
||||||
|
if (seed.decoder) params.set("decoder", seed.decoder);
|
||||||
|
if (seed.call) params.set("call", seed.call);
|
||||||
|
if (seed.gridsquare) params.set("gridsquare", seed.gridsquare);
|
||||||
|
try {
|
||||||
|
const prefill = await getJson(`/api/logbook/prefill?${params.toString()}`);
|
||||||
|
applyPrefill(prefill);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("logbook prefill failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function applyPrefill(prefill) {
|
||||||
|
entryStartedAt = prefill.started_at;
|
||||||
|
entryRigId = prefill.rig_id;
|
||||||
|
entryRigName = prefill.my_rig;
|
||||||
|
if (freqInput) freqInput.value = formatFreq(prefill.freq_hz);
|
||||||
|
if (modeInput) modeInput.value = prefill.submode ?? prefill.mode;
|
||||||
|
if (callInput && prefill.call) callInput.value = prefill.call;
|
||||||
|
if (gridInput && prefill.gridsquare) gridInput.value = prefill.gridsquare;
|
||||||
|
if (stationRigEl) stationRigEl.textContent = prefill.my_rig ?? "no rig";
|
||||||
|
showClock(prefill);
|
||||||
|
updateWorkedBefore();
|
||||||
|
}
|
||||||
|
function showClock(prefill) {
|
||||||
|
if (!clockEl) return;
|
||||||
|
const time = utcTime(prefill.started_at);
|
||||||
|
const drift = Math.abs(Date.now() - prefill.epoch_ms);
|
||||||
|
clockEl.textContent = drift > 1e3 ? `${time}Z · your clock is ${(drift / 1e3).toFixed(0)}s out` : `${time}Z`;
|
||||||
|
clockEl.classList.toggle("is-adrift", drift > 1e3);
|
||||||
|
}
|
||||||
|
function resetEntry() {
|
||||||
|
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) {
|
||||||
|
if (input) input.value = "";
|
||||||
|
}
|
||||||
|
if (workedEl) workedEl.textContent = "";
|
||||||
|
void openEntry();
|
||||||
|
}
|
||||||
|
async function submitEntry(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const call = callInput?.value.trim() ?? "";
|
||||||
|
if (!call) {
|
||||||
|
notify("A contact needs a callsign", "error");
|
||||||
|
callInput?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const freqHz = parseFreq(freqInput?.value ?? "");
|
||||||
|
if (freqHz == null) {
|
||||||
|
notify("A contact needs a frequency", "error");
|
||||||
|
freqInput?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const typedMode = (modeInput?.value ?? "").trim().toUpperCase();
|
||||||
|
const isSideband = typedMode === "USB" || typedMode === "LSB";
|
||||||
|
const body = {
|
||||||
|
// The server stamps the time; this is the one it gave when the entry
|
||||||
|
// opened, so a contact logged five minutes later keeps the time it started.
|
||||||
|
started_at: entryStartedAt,
|
||||||
|
call,
|
||||||
|
freq_hz: freqHz,
|
||||||
|
mode: isSideband ? "SSB" : typedMode,
|
||||||
|
submode: isSideband ? typedMode : null,
|
||||||
|
rst_sent: rstSentInput?.value ?? null,
|
||||||
|
rst_rcvd: rstRcvdInput?.value ?? null,
|
||||||
|
gridsquare: gridInput?.value ?? null,
|
||||||
|
name: nameInput?.value ?? null,
|
||||||
|
comment: commentInput?.value ?? null,
|
||||||
|
contest_id: contestIdInput?.value ?? null,
|
||||||
|
// The exchange is a number when it is a serial and a word when it is a
|
||||||
|
// zone or a section; both are kept, and the log writes whichever it has.
|
||||||
|
stx: numberOrNull(stxInput?.value),
|
||||||
|
stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null,
|
||||||
|
srx: numberOrNull(srxInput?.value),
|
||||||
|
srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null,
|
||||||
|
station_callsign: stationCallEl?.textContent?.trim() ?? null,
|
||||||
|
operator: operatorInput?.value ?? null,
|
||||||
|
my_gridsquare: entryGrid,
|
||||||
|
my_rig: entryRigName,
|
||||||
|
rig_id: entryRigId
|
||||||
|
};
|
||||||
|
if (saveBtn) saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/logbook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
|
||||||
|
}
|
||||||
|
notify(`${call} logged`);
|
||||||
|
const sent = numberOrNull(stxInput?.value);
|
||||||
|
resetEntry();
|
||||||
|
if (stxInput && sent != null) stxInput.value = String(sent + 1);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error) {
|
||||||
|
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
} finally {
|
||||||
|
if (saveBtn) saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateWorkedBefore() {
|
||||||
|
if (!workedEl) return;
|
||||||
|
const call = callInput?.value.trim() ?? "";
|
||||||
|
if (call.length < 3) {
|
||||||
|
workedEl.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const request = ++workedRequest;
|
||||||
|
void getJson(
|
||||||
|
`/api/logbook/worked/${encodeURIComponent(call)}`
|
||||||
|
).then((answer) => {
|
||||||
|
if (request !== workedRequest || !workedEl) return;
|
||||||
|
if (answer.worked.length === 0) {
|
||||||
|
workedEl.textContent = "Not worked before";
|
||||||
|
workedEl.classList.remove("is-worked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const where = answer.worked.map((entry) => `${entry.band} ${entry.mode}`).join(", ");
|
||||||
|
workedEl.textContent = `Worked before: ${where}`;
|
||||||
|
workedEl.classList.add("is-worked");
|
||||||
|
}).catch(() => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function currentQuery() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const call = filterCall?.value.trim();
|
||||||
|
if (call) params.set("call", call);
|
||||||
|
if (filterBand?.value) params.set("band", filterBand.value);
|
||||||
|
if (filterMode?.value) params.set("mode", filterMode.value);
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
async function refreshLog() {
|
||||||
|
try {
|
||||||
|
const query = currentQuery();
|
||||||
|
const answer = await getJson(
|
||||||
|
`/api/logbook${query ? `?${query}` : ""}`
|
||||||
|
);
|
||||||
|
qsos = answer.qsos;
|
||||||
|
renderRows();
|
||||||
|
renderFilterOptions();
|
||||||
|
if (summaryEl) {
|
||||||
|
summaryEl.textContent = query ? `${String(qsos.length)} of ${String(answer.total)} contacts` : `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
|
||||||
|
await refreshStatistics();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("logbook read failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function refreshStatistics() {
|
||||||
|
if (!statisticsRows) return;
|
||||||
|
try {
|
||||||
|
const answer = await getJson("/api/logbook/statistics");
|
||||||
|
if (answer.bands.length === 0) {
|
||||||
|
statisticsRows.innerHTML = '<tr><td colspan="4" class="log-empty">Nothing worked yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const band of answer.bands) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
for (const value of [band.band, band.contacts, band.stations, band.confirmed]) {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.textContent = String(value);
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
fragment.appendChild(row);
|
||||||
|
}
|
||||||
|
statisticsRows.replaceChildren(fragment);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("logbook statistics failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function syncCabrilloLink() {
|
||||||
|
if (!cabrilloExport) return;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const contest = cabrilloContest?.value.trim();
|
||||||
|
if (contest) {
|
||||||
|
params.set("contest", contest);
|
||||||
|
}
|
||||||
|
const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? "");
|
||||||
|
if (callsign) params.set("callsign", callsign);
|
||||||
|
if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value);
|
||||||
|
if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value);
|
||||||
|
const score = numberOrNull(cabrilloScore?.value);
|
||||||
|
if (score != null) params.set("claimed_score", String(score));
|
||||||
|
const operator = operatorInput?.value.trim();
|
||||||
|
if (operator) params.set("operators", operator);
|
||||||
|
cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`;
|
||||||
|
}
|
||||||
|
function renderRows() {
|
||||||
|
if (!rowsBody) return;
|
||||||
|
if (qsos.length === 0) {
|
||||||
|
rowsBody.innerHTML = '<tr><td colspan="10" class="log-empty">No contacts yet. Work someone and log them here, or bring a log in with Import ADIF.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const qso of qsos) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
row.dataset.qsoId = qso.id;
|
||||||
|
const cells = [
|
||||||
|
utcDate(qso.started_at),
|
||||||
|
utcTime(qso.started_at),
|
||||||
|
qso.call,
|
||||||
|
qso.band ?? "",
|
||||||
|
qso.submode ?? qso.mode,
|
||||||
|
qso.rst_sent ?? "",
|
||||||
|
qso.rst_rcvd ?? "",
|
||||||
|
qso.gridsquare ?? "",
|
||||||
|
qso.my_rig ?? "",
|
||||||
|
qso.confirmed ? "✓" : ""
|
||||||
|
];
|
||||||
|
for (const [index, value] of cells.entries()) {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.textContent = value ?? "";
|
||||||
|
if (index === 2) cell.className = "log-cell-call";
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
const actions = document.createElement("td");
|
||||||
|
const confirm = document.createElement("button");
|
||||||
|
confirm.type = "button";
|
||||||
|
confirm.className = "log-row-btn";
|
||||||
|
confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm";
|
||||||
|
confirm.title = qso.confirmed ? "Mark this contact as not confirmed" : "Mark this contact confirmed by QSL";
|
||||||
|
confirm.addEventListener("click", () => {
|
||||||
|
void setConfirmed(qso, !qso.confirmed);
|
||||||
|
});
|
||||||
|
actions.appendChild(confirm);
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.type = "button";
|
||||||
|
remove.className = "log-row-btn";
|
||||||
|
remove.textContent = "Delete";
|
||||||
|
remove.setAttribute("aria-label", `Delete the contact with ${qso.call}`);
|
||||||
|
remove.addEventListener("click", () => {
|
||||||
|
void deleteQso(qso);
|
||||||
|
});
|
||||||
|
actions.appendChild(remove);
|
||||||
|
row.appendChild(actions);
|
||||||
|
fragment.appendChild(row);
|
||||||
|
}
|
||||||
|
rowsBody.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
function renderFilterOptions() {
|
||||||
|
for (const [select, values] of [
|
||||||
|
[filterBand, [...new Set(qsos.map((q) => q.band).filter((b) => !!b))]],
|
||||||
|
[filterMode, [...new Set(qsos.map((q) => q.submode ?? q.mode).filter(Boolean))]]
|
||||||
|
]) {
|
||||||
|
if (!select) continue;
|
||||||
|
const chosen = select.value;
|
||||||
|
const known = new Set([...select.options].map((option) => option.value));
|
||||||
|
for (const value of [...values].sort()) {
|
||||||
|
if (known.has(value)) continue;
|
||||||
|
select.add(new Option(value, value));
|
||||||
|
}
|
||||||
|
select.value = chosen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function setConfirmed(qso, confirmed) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...qso,
|
||||||
|
// A card is a card: this is the paper one, and an electronic
|
||||||
|
// confirmation the log already holds is left where it is.
|
||||||
|
qsl_rcvd: confirmed ? "Y" : "N"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error) {
|
||||||
|
notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function deleteQso(qso) {
|
||||||
|
const confirmed = await bridge.trxUi.confirm({
|
||||||
|
title: "Delete this contact?",
|
||||||
|
message: `${qso.call} on ${qso.band ?? formatFreq(qso.freq_hz)} will be removed from the log.`,
|
||||||
|
confirmLabel: "Delete"
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { method: "DELETE" });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error) {
|
||||||
|
notify(`Could not delete: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function importAdif(file) {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/logbook/import", { method: "POST", body: await file.arrayBuffer() });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
const outcome = await response.json();
|
||||||
|
const parts = [`${String(outcome.added)} added`];
|
||||||
|
if (outcome.duplicate > 0) parts.push(`${String(outcome.duplicate)} already held`);
|
||||||
|
if (outcome.rejected.length > 0) parts.push(`${String(outcome.rejected.length)} not readable`);
|
||||||
|
notify(parts.join(", "));
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error) {
|
||||||
|
notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderStation() {
|
||||||
|
const callsign = hostState.ownerCallsign ?? "";
|
||||||
|
if (stationCallEl) stationCallEl.textContent = callsign || "no callsign set";
|
||||||
|
if (operatorInput && !operatorInput.value) operatorInput.value = callsign;
|
||||||
|
if (stationGridEl) {
|
||||||
|
const grid = hostState.serverLat != null && hostState.serverLon != null ? hostCore.latLonToMaidenhead(hostState.serverLat, hostState.serverLon) : "";
|
||||||
|
entryGrid = grid || null;
|
||||||
|
stationGridEl.textContent = grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form?.addEventListener("submit", (event) => {
|
||||||
|
void submitEntry(event);
|
||||||
|
});
|
||||||
|
clearBtn?.addEventListener("click", resetEntry);
|
||||||
|
callInput?.addEventListener("input", updateWorkedBefore);
|
||||||
|
for (const control of [filterCall, filterBand, filterMode]) {
|
||||||
|
control?.addEventListener("input", () => {
|
||||||
|
void refreshLog();
|
||||||
|
});
|
||||||
|
control?.addEventListener("change", () => {
|
||||||
|
void refreshLog();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) {
|
||||||
|
control?.addEventListener("input", syncCabrilloLink);
|
||||||
|
control?.addEventListener("change", syncCabrilloLink);
|
||||||
|
}
|
||||||
|
importBtn?.addEventListener("click", () => {
|
||||||
|
importFile?.click();
|
||||||
|
});
|
||||||
|
importFile?.addEventListener("change", () => {
|
||||||
|
const file = importFile.files?.[0];
|
||||||
|
if (file) void importAdif(file);
|
||||||
|
importFile.value = "";
|
||||||
|
});
|
||||||
|
bridge.logContact = (seed) => {
|
||||||
|
bridge.navigateToTab?.("logbook");
|
||||||
|
void openEntry(seed).then(() => callInput?.focus());
|
||||||
|
};
|
||||||
|
renderStation();
|
||||||
|
if (cabrilloCallsign && !cabrilloCallsign.value) {
|
||||||
|
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
|
||||||
|
}
|
||||||
|
syncCabrilloLink();
|
||||||
|
void openEntry();
|
||||||
|
void refreshLog();
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
aprsSymbolSprite
|
||||||
|
} from "./chunk-REYSUJQ4.js";
|
||||||
|
|
||||||
// src/map-core.ts
|
// src/map-core.ts
|
||||||
function mapEl(id) {
|
function mapEl(id) {
|
||||||
const element = document.querySelector(`#${CSS.escape(id)}`);
|
const element = document.querySelector(`#${CSS.escape(id)}`);
|
||||||
@@ -56,8 +60,9 @@ var mapWindow = window;
|
|||||||
const decodeContactPaths = /* @__PURE__ */ new Map();
|
const decodeContactPaths = /* @__PURE__ */ new Map();
|
||||||
let selectedMapQsoKey = null;
|
let selectedMapQsoKey = null;
|
||||||
const mapMarkers = /* @__PURE__ */ new Set();
|
const mapMarkers = /* @__PURE__ */ new Set();
|
||||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||||
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||||
|
const MAP_FILTER_ALL_KEY = "__all";
|
||||||
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
|
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
|
||||||
let mapSearchFilter = "";
|
let mapSearchFilter = "";
|
||||||
let mapRigFilter = "";
|
let mapRigFilter = "";
|
||||||
@@ -123,6 +128,12 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
function aprsEntrySource(entry) {
|
||||||
|
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||||
|
}
|
||||||
|
function aprsStationKey(call, source) {
|
||||||
|
return source === "aprs" ? call : `${source}:${call}`;
|
||||||
|
}
|
||||||
function refreshAprsTrack(call, entry) {
|
function refreshAprsTrack(call, entry) {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
||||||
@@ -144,7 +155,7 @@ var mapWindow = window;
|
|||||||
lineJoin: "round",
|
lineJoin: "round",
|
||||||
interactive: false
|
interactive: false
|
||||||
});
|
});
|
||||||
track.__trxType = "aprs";
|
track.__trxType = aprsEntrySource(entry);
|
||||||
track._aprsCall = call;
|
track._aprsCall = call;
|
||||||
entry.track = track;
|
entry.track = track;
|
||||||
}
|
}
|
||||||
@@ -376,6 +387,7 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
function mapSourceLabel(type) {
|
function mapSourceLabel(type) {
|
||||||
if (type === "bookmark") return "Bookmarks";
|
if (type === "bookmark") return "Bookmarks";
|
||||||
|
if (type === "hf_aprs") return "HF APRS";
|
||||||
return String(type || "").toUpperCase();
|
return String(type || "").toUpperCase();
|
||||||
}
|
}
|
||||||
function locatorFilterColor(type) {
|
function locatorFilterColor(type) {
|
||||||
@@ -391,6 +403,7 @@ var mapWindow = window;
|
|||||||
if (type === "vdes") return "#a78bfa";
|
if (type === "vdes") return "#a78bfa";
|
||||||
if (type === "sat") return "#f59e0b";
|
if (type === "sat") return "#f59e0b";
|
||||||
if (type === "aprs") return "#00d17f";
|
if (type === "aprs") return "#00d17f";
|
||||||
|
if (type === "hf_aprs") return "#fb7185";
|
||||||
return locatorFilterColor(type);
|
return locatorFilterColor(type);
|
||||||
}
|
}
|
||||||
function bandForHz(hz) {
|
function bandForHz(hz) {
|
||||||
@@ -834,38 +847,36 @@ var mapWindow = window;
|
|||||||
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
|
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let helperText = "";
|
const noun = kind === "band" ? "bands" : "sources";
|
||||||
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : [];
|
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : [];
|
||||||
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
|
const showingAll = kind === "source" ? sourceKeys.every((k) => !mapFilter[k]) : !(selectedSet instanceof Set) || selectedSet.size === 0;
|
||||||
if (kind === "source") {
|
const allChip = document.createElement("button");
|
||||||
if (noneSelected) {
|
allChip.type = "button";
|
||||||
helperText = "All sources visible — click to filter";
|
allChip.className = "map-locator-chip map-locator-chip-all";
|
||||||
}
|
if (showingAll) allChip.classList.add("is-active");
|
||||||
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
|
allChip.dataset.filterKind = kind;
|
||||||
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
|
allChip.dataset.filterKey = MAP_FILTER_ALL_KEY;
|
||||||
}
|
allChip.setAttribute("aria-pressed", showingAll ? "true" : "false");
|
||||||
|
allChip.title = showingAll ? `All ${noun} shown` : `Show all ${noun}`;
|
||||||
|
allChip.innerHTML = `<span class="map-locator-chip-text">All</span>`;
|
||||||
|
container.appendChild(allChip);
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.type = "button";
|
btn.type = "button";
|
||||||
btn.className = "map-locator-chip";
|
btn.className = "map-locator-chip";
|
||||||
const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key);
|
const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key);
|
||||||
if (kind === "source" && noneSelected) {
|
if (showingAll) {
|
||||||
btn.classList.add("is-default");
|
btn.classList.add("is-default");
|
||||||
} else if (!isActive) {
|
} else if (!isActive) {
|
||||||
btn.classList.add("is-inactive");
|
btn.classList.add("is-inactive");
|
||||||
}
|
}
|
||||||
|
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
|
||||||
btn.dataset.filterKind = kind;
|
btn.dataset.filterKind = kind;
|
||||||
btn.dataset.filterKey = item.key;
|
btn.dataset.filterKey = item.key;
|
||||||
btn.style.setProperty("--chip-color", item.color);
|
btn.style.setProperty("--chip-color", item.color);
|
||||||
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
|
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
}
|
}
|
||||||
if (helperText) {
|
|
||||||
const hint = document.createElement("span");
|
|
||||||
hint.className = "map-locator-empty";
|
|
||||||
hint.textContent = helperText;
|
|
||||||
container.appendChild(hint);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function renderMapLocatorPhaseRow(container, phase) {
|
function renderMapLocatorPhaseRow(container, phase) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -928,10 +939,8 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const entry of stationMarkers.values()) {
|
for (const entry of stationMarkers.values()) {
|
||||||
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
|
if (!entry?.visibleInHistoryWindow) continue;
|
||||||
availableSources.add("aprs");
|
availableSources.add(aprsEntrySource(entry));
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const bandMap = /* @__PURE__ */ new Map();
|
const bandMap = /* @__PURE__ */ new Map();
|
||||||
for (const entry of locatorMarkers.values()) {
|
for (const entry of locatorMarkers.values()) {
|
||||||
@@ -963,7 +972,7 @@ var mapWindow = window;
|
|||||||
for (const key of Array.from(mapLocatorFilter.bands)) {
|
for (const key of Array.from(mapLocatorFilter.bands)) {
|
||||||
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
||||||
}
|
}
|
||||||
const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
|
const sourceItems = ["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
|
||||||
key,
|
key,
|
||||||
label: mapSourceLabel(key),
|
label: mapSourceLabel(key),
|
||||||
color: mapSourceColor(key),
|
color: mapSourceColor(key),
|
||||||
@@ -973,11 +982,10 @@ var mapWindow = window;
|
|||||||
renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems);
|
renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems);
|
||||||
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
|
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
|
||||||
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
|
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
|
||||||
|
choiceLabelEl.textContent = "Show";
|
||||||
if (mapLocatorFilter.phase === "band") {
|
if (mapLocatorFilter.phase === "band") {
|
||||||
choiceLabelEl.textContent = "Visible Bands";
|
|
||||||
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
|
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
|
||||||
} else {
|
} else {
|
||||||
choiceLabelEl.textContent = "Visible Sources";
|
|
||||||
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
|
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
|
||||||
}
|
}
|
||||||
syncLocatorMarkerStyles();
|
syncLocatorMarkerStyles();
|
||||||
@@ -1026,9 +1034,10 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
return parts.join(" ").toLowerCase();
|
return parts.join(" ").toLowerCase();
|
||||||
}
|
}
|
||||||
if (type === "aprs") {
|
if (type === "aprs" || type === "hf_aprs") {
|
||||||
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
|
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||||
const entry = stationMarkers.get(call);
|
const entry = stationMarkers.get(key);
|
||||||
|
const call = entry?.call ?? key;
|
||||||
const info = entry?.info ? String(entry.info) : "";
|
const info = entry?.info ? String(entry.info) : "";
|
||||||
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
||||||
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
||||||
@@ -1198,9 +1207,10 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
mapWindow.clearMapMarkersByType = function(type) {
|
mapWindow.clearMapMarkersByType = function(type) {
|
||||||
if (type === "aprs") {
|
if (type === "aprs" || type === "hf_aprs") {
|
||||||
selectedAprsTrackCall = null;
|
selectedAprsTrackCall = null;
|
||||||
stationMarkers.forEach((entry) => {
|
stationMarkers.forEach((entry, key) => {
|
||||||
|
if (aprsEntrySource(entry) !== type) return;
|
||||||
if (entry && entry.marker) {
|
if (entry && entry.marker) {
|
||||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.marker);
|
mapMarkers.delete(entry.marker);
|
||||||
@@ -1209,8 +1219,8 @@ var mapWindow = window;
|
|||||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.track);
|
mapMarkers.delete(entry.track);
|
||||||
}
|
}
|
||||||
|
stationMarkers.delete(key);
|
||||||
});
|
});
|
||||||
stationMarkers.clear();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === "ais") {
|
if (type === "ais") {
|
||||||
@@ -1331,7 +1341,8 @@ var mapWindow = window;
|
|||||||
function applyMapOverlayPanelVisibility() {
|
function applyMapOverlayPanelVisibility() {
|
||||||
const panel = document.querySelector("#map-stage .map-overlay-panel");
|
const panel = document.querySelector("#map-stage .map-overlay-panel");
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.classList.toggle("is-hidden", !mapOverlayPanelVisible);
|
panel.classList.toggle("filters-hidden", !mapOverlayPanelVisible);
|
||||||
|
panel.querySelector(".map-overlay-filters")?.classList.toggle("is-hidden", !mapOverlayPanelVisible);
|
||||||
}
|
}
|
||||||
function updateMapOverlayToggleButton() {
|
function updateMapOverlayToggleButton() {
|
||||||
const btn = mapEl("map-overlay-toggle-btn");
|
const btn = mapEl("map-overlay-toggle-btn");
|
||||||
@@ -1445,13 +1456,15 @@ var mapWindow = window;
|
|||||||
if (!ll) return;
|
if (!ll) return;
|
||||||
const entry = stationMarkers.get(marker._aprsCall);
|
const entry = stationMarkers.get(marker._aprsCall);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
const source = aprsEntrySource(entry);
|
||||||
|
const call = entry.call ?? String(marker._aprsCall);
|
||||||
|
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||||
refreshAprsTrack(String(marker._aprsCall), entry);
|
refreshAprsTrack(String(marker._aprsCall), entry);
|
||||||
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
|
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
|
||||||
entry.track.addTo(aprsMap);
|
entry.track.addTo(aprsMap);
|
||||||
}
|
}
|
||||||
selectedAprsTrackCall = String(marker._aprsCall);
|
selectedAprsTrackCall = String(marker._aprsCall);
|
||||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
|
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (marker._aisMmsi) {
|
if (marker._aisMmsi) {
|
||||||
@@ -1528,7 +1541,13 @@ var mapWindow = window;
|
|||||||
const kind = String(chip.dataset.filterKind || "");
|
const kind = String(chip.dataset.filterKind || "");
|
||||||
const key = String(chip.dataset.filterKey || "");
|
const key = String(chip.dataset.filterKey || "");
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
|
if (key === MAP_FILTER_ALL_KEY) {
|
||||||
|
if (kind === "source") {
|
||||||
|
for (const srcKey of Object.keys(DEFAULT_MAP_SOURCE_FILTER)) mapFilter[srcKey] = false;
|
||||||
|
} else {
|
||||||
|
mapLocatorFilter.bands.clear();
|
||||||
|
}
|
||||||
|
} else if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
|
||||||
const sourceKey = key;
|
const sourceKey = key;
|
||||||
mapFilter[sourceKey] = !mapFilter[sourceKey];
|
mapFilter[sourceKey] = !mapFilter[sourceKey];
|
||||||
const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER);
|
const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER);
|
||||||
@@ -1643,42 +1662,29 @@ var mapWindow = window;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mapRect = mapContainer.getBoundingClientRect();
|
const mapRect = mapContainer.getBoundingClientRect();
|
||||||
const width = mapContainer.clientWidth || mapRect.width;
|
|
||||||
const footer = document.querySelector(".footer");
|
const footer = document.querySelector(".footer");
|
||||||
let bottom = mapIsFullscreen() && stage ? stage.getBoundingClientRect().bottom : window.innerHeight;
|
let bottom = window.innerHeight;
|
||||||
if (!mapIsFullscreen() && footer) {
|
if (footer) {
|
||||||
const fr = footer.getBoundingClientRect();
|
const fr = footer.getBoundingClientRect();
|
||||||
if (fr.top > mapRect.top + 50) bottom = fr.top;
|
if (fr.top > mapRect.top + 50) bottom = Math.min(fr.top, bottom);
|
||||||
}
|
}
|
||||||
const available = Math.max(0, Math.floor(bottom - mapRect.top - 8));
|
const target = Math.max(0, Math.floor(bottom - mapRect.top - 8));
|
||||||
const widthDriven = width > 0 ? Math.floor(width / 1.55) : available;
|
|
||||||
const viewportCap = mapIsFullscreen() ? Math.floor(window.innerHeight * 0.9) : Math.floor(window.innerHeight * 0.75);
|
|
||||||
const minHeight = Math.min(260, available);
|
|
||||||
const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven));
|
|
||||||
mapContainer.style.height = `${target}px`;
|
mapContainer.style.height = `${target}px`;
|
||||||
if (aprsMap) aprsMap.invalidateSize();
|
if (aprsMap) aprsMap.invalidateSize();
|
||||||
}
|
}
|
||||||
function aprsSymbolIcon(symbolTable, symbolCode) {
|
function aprsSymbolIcon(symbolTable, symbolCode) {
|
||||||
if (!symbolTable || !symbolCode) return null;
|
if (!symbolTable || !symbolCode) return null;
|
||||||
const table = symbolTable === "/" ? "primary" : "alternate";
|
const sprite = aprsSymbolSprite(symbolTable, symbolCode);
|
||||||
|
const html = sprite ? `<div class="aprs-symbol aprs-symbol-marker ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeMapHtml(sprite.label)}" aria-label="${escapeMapHtml(sprite.label)}"></div>` : `<div class="aprs-symbol aprs-symbol-marker aprs-symbol-local" title="${symbolTable === "/" ? "primary" : "alternate"} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`;
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: "",
|
className: "",
|
||||||
html: `<div class="aprs-symbol-local" title="${table} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`,
|
html,
|
||||||
iconSize: [24, 24],
|
iconSize: [24, 24],
|
||||||
iconAnchor: [12, 12],
|
iconAnchor: [12, 12],
|
||||||
popupAnchor: [0, -12]
|
popupAnchor: [0, -12]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
mapWindow.navigateToAprsMap = function(lat, lon) {
|
function focusMapPosition(lat, lon) {
|
||||||
T._activeTab = "map";
|
|
||||||
document.querySelectorAll(".tab-bar .tab").forEach((t) => {
|
|
||||||
t.classList.remove("active");
|
|
||||||
});
|
|
||||||
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
|
|
||||||
if (mapTabBtn) mapTabBtn.classList.add("active");
|
|
||||||
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
|
|
||||||
const mapPanel = mapEl("tab-map");
|
|
||||||
if (mapPanel) mapPanel.style.display = "";
|
|
||||||
initAprsMap();
|
initAprsMap();
|
||||||
sizeAprsMapToViewport();
|
sizeAprsMapToViewport();
|
||||||
if (aprsMap) {
|
if (aprsMap) {
|
||||||
@@ -1690,19 +1696,10 @@ var mapWindow = window;
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
mapWindow.navigateToMapLocator = function(grid, preferredType = null) {
|
function focusMapLocator(grid, preferredType = null) {
|
||||||
const normalizedGrid = String(grid || "").trim().toUpperCase();
|
const normalizedGrid = String(grid || "").trim().toUpperCase();
|
||||||
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false;
|
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false;
|
||||||
T._activeTab = "map";
|
|
||||||
document.querySelectorAll(".tab-bar .tab").forEach((t) => {
|
|
||||||
t.classList.remove("active");
|
|
||||||
});
|
|
||||||
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
|
|
||||||
if (mapTabBtn) mapTabBtn.classList.add("active");
|
|
||||||
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
|
|
||||||
const mapPanel = mapEl("tab-map");
|
|
||||||
if (mapPanel) mapPanel.style.display = "";
|
|
||||||
initAprsMap();
|
initAprsMap();
|
||||||
sizeAprsMapToViewport();
|
sizeAprsMapToViewport();
|
||||||
if (!aprsMap) return false;
|
if (!aprsMap) return false;
|
||||||
@@ -1747,7 +1744,7 @@ var mapWindow = window;
|
|||||||
requestAnimationFrame(focusMarker);
|
requestAnimationFrame(focusMarker);
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
};
|
}
|
||||||
function buildReceiverPopupHtml(rigIds) {
|
function buildReceiverPopupHtml(rigIds) {
|
||||||
const call = T.serverCallsign || T.ownerCallsign || "Receiver";
|
const call = T.serverCallsign || T.ownerCallsign || "Receiver";
|
||||||
let meta = "";
|
let meta = "";
|
||||||
@@ -1861,28 +1858,33 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
function _aprsAddMarkerToMap(call, entry) {
|
function _aprsAddMarkerToMap(key, entry) {
|
||||||
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
||||||
refreshAprsTrack(call, entry);
|
refreshAprsTrack(key, entry);
|
||||||
|
const source = aprsEntrySource(entry);
|
||||||
|
const call = entry.call ?? key;
|
||||||
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
||||||
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
||||||
|
const color = mapSourceColor(source);
|
||||||
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
|
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
|
||||||
radius: 6,
|
radius: 6,
|
||||||
color: "#00d17f",
|
color,
|
||||||
fillColor: "#00d17f",
|
fillColor: color,
|
||||||
fillOpacity: 0.8
|
fillOpacity: 0.8
|
||||||
}).addTo(aprsMap).bindPopup(popupContent);
|
}).addTo(aprsMap).bindPopup(popupContent);
|
||||||
marker.__trxType = "aprs";
|
marker.__trxType = source;
|
||||||
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
|
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
|
||||||
marker._aprsCall = call;
|
marker._aprsCall = key;
|
||||||
entry.marker = marker;
|
entry.marker = marker;
|
||||||
mapMarkers.add(marker);
|
mapMarkers.add(marker);
|
||||||
}
|
}
|
||||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
|
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
|
||||||
const nextPoint = [lat, lon];
|
const nextPoint = [lat, lon];
|
||||||
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
||||||
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
||||||
const existing = stationMarkers.get(call);
|
const stationSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||||
|
const key = aprsStationKey(call, stationSource);
|
||||||
|
const existing = stationMarkers.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.pkt = pkt;
|
existing.pkt = pkt;
|
||||||
existing.lat = lat;
|
existing.lat = lat;
|
||||||
@@ -1901,7 +1903,8 @@ var mapWindow = window;
|
|||||||
} else if (prevPoint) {
|
} else if (prevPoint) {
|
||||||
prevPoint.tsMs = tsMs;
|
prevPoint.tsMs = tsMs;
|
||||||
}
|
}
|
||||||
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
|
existing.call = call;
|
||||||
|
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
|
||||||
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
||||||
existing.marker.setLatLng([lat, lon]);
|
existing.marker.setLatLng([lat, lon]);
|
||||||
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
||||||
@@ -1912,7 +1915,8 @@ var mapWindow = window;
|
|||||||
track: null,
|
track: null,
|
||||||
trackHistory: [{ lat, lon, tsMs }],
|
trackHistory: [{ lat, lon, tsMs }],
|
||||||
trackPoints: [nextPoint],
|
trackPoints: [nextPoint],
|
||||||
type: "aprs",
|
type: stationSource,
|
||||||
|
call,
|
||||||
pkt,
|
pkt,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
@@ -1921,9 +1925,9 @@ var mapWindow = window;
|
|||||||
symbolCode,
|
symbolCode,
|
||||||
rigIds: new Set(msgRigId ? [msgRigId] : [])
|
rigIds: new Set(msgRigId ? [msgRigId] : [])
|
||||||
};
|
};
|
||||||
stationMarkers.set(call, entry);
|
stationMarkers.set(key, entry);
|
||||||
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
|
||||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
|
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
|
||||||
if (aprsMap) scheduleDecodeMapMaintenance();
|
if (aprsMap) scheduleDecodeMapMaintenance();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2165,14 +2169,16 @@ var mapWindow = window;
|
|||||||
function updateMapContactPathsToggle() {
|
function updateMapContactPathsToggle() {
|
||||||
const btn = mapEl("map-contact-paths-toggle");
|
const btn = mapEl("map-contact-paths-toggle");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
|
|
||||||
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
|
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
|
||||||
|
btn.setAttribute("aria-pressed", mapDecodeContactPathsEnabled ? "true" : "false");
|
||||||
|
btn.title = mapDecodeContactPathsEnabled ? "Directed decode paths are drawn when the target locator is known" : "Directed decode paths are hidden";
|
||||||
}
|
}
|
||||||
function updateMapP2pPathsToggle() {
|
function updateMapP2pPathsToggle() {
|
||||||
const btn = mapEl("map-p2p-paths-toggle");
|
const btn = mapEl("map-p2p-paths-toggle");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
|
|
||||||
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
|
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
|
||||||
|
btn.setAttribute("aria-pressed", mapP2pRadioPathsEnabled ? "true" : "false");
|
||||||
|
btn.title = mapP2pRadioPathsEnabled ? "TRX paths are drawn from a station popup" : "TRX paths are hidden";
|
||||||
}
|
}
|
||||||
function scheduleDecodeMapMaintenance() {
|
function scheduleDecodeMapMaintenance() {
|
||||||
if (C.decodeHistoryMapRenderingDeferred()) {
|
if (C.decodeHistoryMapRenderingDeferred()) {
|
||||||
@@ -2332,7 +2338,7 @@ var mapWindow = window;
|
|||||||
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
|
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
|
||||||
syncDecodeContactPathVisibility();
|
syncDecodeContactPathVisibility();
|
||||||
if (selectedMapQsoKey && entry.sourceGrid) {
|
if (selectedMapQsoKey && entry.sourceGrid) {
|
||||||
mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType);
|
focusMapLocator(entry.sourceGrid, entry.sourceType);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const head = document.createElement("div");
|
const head = document.createElement("div");
|
||||||
@@ -2438,7 +2444,7 @@ var mapWindow = window;
|
|||||||
card.className = "map-qso-card";
|
card.className = "map-qso-card";
|
||||||
if (entry.grid) {
|
if (entry.grid) {
|
||||||
card.addEventListener("click", () => {
|
card.addEventListener("click", () => {
|
||||||
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
|
focusMapLocator(entry.grid ?? "", entry.sourceType);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const head = document.createElement("div");
|
const head = document.createElement("div");
|
||||||
@@ -2544,7 +2550,7 @@ var mapWindow = window;
|
|||||||
card.className = "map-qso-card";
|
card.className = "map-qso-card";
|
||||||
if (entry.grid) {
|
if (entry.grid) {
|
||||||
card.addEventListener("click", () => {
|
card.addEventListener("click", () => {
|
||||||
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
|
focusMapLocator(entry.grid ?? "", entry.sourceType);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const head = document.createElement("div");
|
const head = document.createElement("div");
|
||||||
@@ -3049,6 +3055,8 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
modules.map = {
|
modules.map = {
|
||||||
initAprsMap,
|
initAprsMap,
|
||||||
|
focusMapPosition,
|
||||||
|
focusMapLocator,
|
||||||
sizeAprsMapToViewport,
|
sizeAprsMapToViewport,
|
||||||
syncAprsReceiverMarker,
|
syncAprsReceiverMarker,
|
||||||
updateMapRigFilter,
|
updateMapRigFilter,
|
||||||
@@ -3109,5 +3117,6 @@ var mapWindow = window;
|
|||||||
bandForHz,
|
bandForHz,
|
||||||
reverseGeocodeLocation
|
reverseGeocodeLocation
|
||||||
};
|
};
|
||||||
|
window.trxPluginRuntime.syncMapAll();
|
||||||
autoInitIfVisible();
|
autoInitIfVisible();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ var satDom = {
|
|||||||
status: document.getElementById("sat-status"),
|
status: document.getElementById("sat-status"),
|
||||||
liveView: document.getElementById("sat-live-view"),
|
liveView: document.getElementById("sat-live-view"),
|
||||||
historyView: document.getElementById("sat-history-view"),
|
historyView: document.getElementById("sat-history-view"),
|
||||||
predictionsView: document.getElementById("sat-predictions-view"),
|
|
||||||
liveLatest: document.getElementById("sat-live-latest"),
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
historyList: document.getElementById("sat-history-list"),
|
historyList: document.getElementById("sat-history-list"),
|
||||||
historyCount: document.getElementById("sat-history-count"),
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
@@ -17,29 +16,12 @@ var satDom = {
|
|||||||
typeFilter: document.getElementById("sat-type-filter"),
|
typeFilter: document.getElementById("sat-type-filter"),
|
||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
viewHistoryBtn: document.getElementById("sat-view-history")
|
||||||
viewPredBtn: document.getElementById("sat-view-predictions"),
|
|
||||||
predFilter: document.getElementById("sat-pred-filter"),
|
|
||||||
predMinEl: document.getElementById("sat-pred-min-el"),
|
|
||||||
predCategory: document.getElementById("sat-pred-category"),
|
|
||||||
predCurrentList: document.getElementById("sat-pred-current-list"),
|
|
||||||
predUpcomingList: document.getElementById("sat-pred-list"),
|
|
||||||
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
|
||||||
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
|
||||||
predStatus: document.getElementById("sat-pred-status")
|
|
||||||
};
|
};
|
||||||
var satImageHistory = [];
|
var satImageHistory = [];
|
||||||
var SAT_MAX_IMAGES = 100;
|
var SAT_MAX_IMAGES = 100;
|
||||||
var SAT_PRED_PAGE_SIZE = 50;
|
|
||||||
var satPredShowAll = false;
|
|
||||||
var satFilterText = "";
|
var satFilterText = "";
|
||||||
var satActiveView = "live";
|
var satActiveView = "live";
|
||||||
var satPredData = [];
|
|
||||||
var satPredFilterText = "";
|
|
||||||
var satPredMinEl = 0;
|
|
||||||
var satPredCategory = "all";
|
|
||||||
var satPredSatCount = 0;
|
|
||||||
var satPredCountdownTimer = null;
|
|
||||||
function scheduleSatUi(key, job) {
|
function scheduleSatUi(key, job) {
|
||||||
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
||||||
satWindow.trxScheduleUiFrameJob(key, job);
|
satWindow.trxScheduleUiFrameJob(key, job);
|
||||||
@@ -48,37 +30,19 @@ function scheduleSatUi(key, job) {
|
|||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
function switchSatView(view) {
|
function switchSatView(view) {
|
||||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
|
||||||
satActiveView = view;
|
satActiveView = view;
|
||||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
|
||||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
if (view === "history") renderSatHistoryTable();
|
||||||
if (leavingPredictions) clearPredictionDom();
|
|
||||||
if (view === "history") {
|
|
||||||
renderSatHistoryTable();
|
|
||||||
} else if (view === "predictions") {
|
|
||||||
satPredShowAll = false;
|
|
||||||
void loadSatPredictions();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function clearPredictionDom() {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
}
|
|
||||||
satWindow.clearSatPredictionDom = clearPredictionDom;
|
|
||||||
satDom.viewLiveBtn?.addEventListener("click", () => {
|
satDom.viewLiveBtn?.addEventListener("click", () => {
|
||||||
switchSatView("live");
|
switchSatView("live");
|
||||||
});
|
});
|
||||||
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
||||||
switchSatView("history");
|
switchSatView("history");
|
||||||
});
|
});
|
||||||
satDom.viewPredBtn?.addEventListener("click", () => {
|
|
||||||
switchSatView("predictions");
|
|
||||||
});
|
|
||||||
var lastSatLrptOn = null;
|
var lastSatLrptOn = null;
|
||||||
satWindow.updateSatLiveState = function(update) {
|
satWindow.updateSatLiveState = function(update) {
|
||||||
if (!satDom.lrptState) return;
|
if (!satDom.lrptState) return;
|
||||||
@@ -275,200 +239,6 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click",
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
function azToCardinal(deg) {
|
|
||||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
|
||||||
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
|
||||||
}
|
|
||||||
function formatPredTime(ms) {
|
|
||||||
const d = new Date(ms);
|
|
||||||
const now = /* @__PURE__ */ new Date();
|
|
||||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
||||||
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
|
||||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
|
||||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
|
||||||
return `${day}${hh}:${mm}`;
|
|
||||||
}
|
|
||||||
function formatPredDuration(s) {
|
|
||||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
function formatCountdown(ms) {
|
|
||||||
const totalSec = Math.max(0, Math.floor(ms / 1e3));
|
|
||||||
const m = Math.floor(totalSec / 60);
|
|
||||||
const s = totalSec % 60;
|
|
||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
function elevationClass(deg) {
|
|
||||||
if (deg >= 45) return "sat-pred-el-high";
|
|
||||||
if (deg >= 10) return "sat-pred-el-mid";
|
|
||||||
return "sat-pred-el-low";
|
|
||||||
}
|
|
||||||
function stopCountdownTimer() {
|
|
||||||
if (satPredCountdownTimer) {
|
|
||||||
clearInterval(satPredCountdownTimer);
|
|
||||||
satPredCountdownTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function startCountdownTimer(container) {
|
|
||||||
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
|
|
||||||
if (countdownEls.length === 0) return;
|
|
||||||
satPredCountdownTimer = setInterval(() => {
|
|
||||||
if (satActiveView !== "predictions") {
|
|
||||||
stopCountdownTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const n = Date.now();
|
|
||||||
let anyActive = false;
|
|
||||||
for (const el of countdownEls) {
|
|
||||||
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
|
||||||
const rem = los - n;
|
|
||||||
if (rem > 0) {
|
|
||||||
el.textContent = formatCountdown(rem);
|
|
||||||
anyActive = true;
|
|
||||||
} else {
|
|
||||||
el.textContent = "0:00";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!anyActive) {
|
|
||||||
stopCountdownTimer();
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
}, 1e3);
|
|
||||||
}
|
|
||||||
function buildCurrentPassRow(pass, now) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row-current";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
const remaining = Math.max(0, pass.los_ms - now);
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
function buildUpcomingPassRow(pass) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
|
||||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
function getFilteredPredictions() {
|
|
||||||
let items = satPredData;
|
|
||||||
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
|
||||||
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
|
||||||
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
function applyPredFilters() {
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
var satPredictionFilter = satDom.predFilter;
|
|
||||||
satPredictionFilter?.addEventListener("input", () => {
|
|
||||||
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
var satPredictionMinElevation = satDom.predMinEl;
|
|
||||||
satPredictionMinElevation?.addEventListener("change", () => {
|
|
||||||
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
var satPredictionCategory = satDom.predCategory;
|
|
||||||
satPredictionCategory?.addEventListener("change", () => {
|
|
||||||
satPredCategory = satPredictionCategory.value;
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
function renderSatPredictions(passes, error) {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (error) {
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Array.isArray(passes) || passes.length === 0) {
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
|
||||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
|
||||||
if (satDom.predCurrentList) {
|
|
||||||
if (current.length === 0) {
|
|
||||||
satDom.predCurrentList.innerHTML = "";
|
|
||||||
} else {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
|
||||||
satDom.predCurrentList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
|
||||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
|
||||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
|
||||||
if (satDom.predUpcomingList) {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
|
||||||
if (hiddenCount > 0) {
|
|
||||||
const moreRow = document.createElement("div");
|
|
||||||
moreRow.className = "sat-pred-row";
|
|
||||||
moreRow.style.cursor = "pointer";
|
|
||||||
moreRow.style.textAlign = "center";
|
|
||||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
|
|
||||||
moreRow.addEventListener("click", () => {
|
|
||||||
satPredShowAll = true;
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
});
|
|
||||||
frag.appendChild(moreRow);
|
|
||||||
}
|
|
||||||
satDom.predUpcomingList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
if (satDom.predStatus) {
|
|
||||||
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
|
||||||
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
|
|
||||||
satDom.predStatus.textContent = text;
|
|
||||||
}
|
|
||||||
if (current.length > 0 && satActiveView === "predictions") {
|
|
||||||
startCountdownTimer(satDom.predCurrentList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function loadSatPredictions() {
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…";
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/sat_passes");
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
satPredSatCount = data.satellite_count || 0;
|
|
||||||
if (data.error) {
|
|
||||||
satPredData = [];
|
|
||||||
renderSatPredictions([], data.error);
|
|
||||||
} else {
|
|
||||||
satPredData = data.passes || [];
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
satWindow.satShowOnMap = function(south, west, north, east) {
|
satWindow.satShowOnMap = function(south, west, north, east) {
|
||||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||||
satWindow.enableMapSourceFilter("sat");
|
satWindow.enableMapSourceFilter("sat");
|
||||||
|
|||||||
+228
@@ -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();
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
});
|
||||||
@@ -286,7 +286,10 @@ function vchanSyncModeDisplay() {
|
|||||||
if (!modeEl) return;
|
if (!modeEl) return;
|
||||||
if (vchanIsOnVirtual()) {
|
if (vchanIsOnVirtual()) {
|
||||||
const ch = vchanActiveChannel();
|
const ch = vchanActiveChannel();
|
||||||
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
|
if (ch && ch.mode) {
|
||||||
|
modeEl.value = ch.mode.toUpperCase();
|
||||||
|
hostCore.syncModePicker();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const modeUpper = (modeEl.value || "").toUpperCase();
|
const modeUpper = (modeEl.value || "").toUpperCase();
|
||||||
if (typeof hostState.lastModeName === "string") {
|
if (typeof hostState.lastModeName === "string") {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -60,17 +64,21 @@ function vdesHexPreview(rawBytes) {
|
|||||||
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
||||||
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
||||||
}
|
}
|
||||||
|
function vdesRigMessages() {
|
||||||
|
return forActiveRig(vdesMessageHistory);
|
||||||
|
}
|
||||||
function updateVdesSummary() {
|
function updateVdesSummary() {
|
||||||
pruneVdesMessageHistory();
|
pruneVdesMessageHistory();
|
||||||
if (vdesChannelSummaryEl) {
|
if (vdesChannelSummaryEl) {
|
||||||
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||||
}
|
}
|
||||||
|
const rigMessages = vdesRigMessages();
|
||||||
if (vdesFrameCountEl) {
|
if (vdesFrameCountEl) {
|
||||||
const count = vdesMessageHistory.length;
|
const count = rigMessages.length;
|
||||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (vdesLatestSeenEl) {
|
if (vdesLatestSeenEl) {
|
||||||
const latest = vdesMessageHistory[0];
|
const latest = rigMessages[0];
|
||||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +145,7 @@ function updateVdesBar() {
|
|||||||
updateVdesSummary();
|
updateVdesSummary();
|
||||||
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
||||||
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)).slice(0, 6);
|
||||||
if (!isVdes || messages.length === 0) {
|
if (!isVdes || messages.length === 0) {
|
||||||
vdesBarOverlay.style.display = "none";
|
vdesBarOverlay.style.display = "none";
|
||||||
vdesBarOverlay.innerHTML = "";
|
vdesBarOverlay.innerHTML = "";
|
||||||
@@ -182,7 +190,7 @@ function renderVdesHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of vdesMessageHistory) {
|
for (const message of vdesRigMessages()) {
|
||||||
fragment.appendChild(renderVdesRow(message));
|
fragment.appendChild(renderVdesRow(message));
|
||||||
}
|
}
|
||||||
vdesMessagesEl.replaceChildren(fragment);
|
vdesMessagesEl.replaceChildren(fragment);
|
||||||
@@ -209,10 +217,10 @@ function normalizeServerVdesMessage(msg) {
|
|||||||
}
|
}
|
||||||
function onServerVdesBatch(messages) {
|
function onServerVdesBatch(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -220,9 +228,7 @@ function onServerVdesBatch(messages) {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit"
|
second: "2-digit"
|
||||||
});
|
});
|
||||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
plotVdesMessage(next);
|
||||||
vdesWindow.vdesMapAddPoint(next);
|
|
||||||
}
|
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -248,13 +254,15 @@ if (vdesFilterInput) {
|
|||||||
renderVdesHistory();
|
renderVdesHistory();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function plotVdesMessage(msg) {
|
||||||
|
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
|
||||||
|
vdesWindow.vdesMapAddPoint(msg);
|
||||||
|
}
|
||||||
function onServerVdes(msg) {
|
function onServerVdes(msg) {
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||||
addVdesMessage(next);
|
addVdesMessage(next);
|
||||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
plotVdesMessage(next);
|
||||||
vdesWindow.vdesMapAddPoint(next);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function pruneVdesHistoryView() {
|
function pruneVdesHistoryView() {
|
||||||
pruneVdesMessageHistory();
|
pruneVdesMessageHistory();
|
||||||
@@ -268,5 +276,13 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerVdesBatch,
|
onBatch: onServerVdesBatch,
|
||||||
restore: onServerVdesBatch,
|
restore: onServerVdesBatch,
|
||||||
reset: resetVdesHistoryView,
|
reset: resetVdesHistoryView,
|
||||||
prune: pruneVdesHistoryView
|
prune: pruneVdesHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
},
|
||||||
|
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -102,13 +106,17 @@ function paintLine(lineBytes) {
|
|||||||
wefaxLiveCtx.putImageData(imgData, 0, y);
|
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||||
wefaxLiveLineCount++;
|
wefaxLiveLineCount++;
|
||||||
}
|
}
|
||||||
|
function wefaxRigImages() {
|
||||||
|
return forActiveRig(wefaxImageHistory);
|
||||||
|
}
|
||||||
function renderWefaxLatestCard() {
|
function renderWefaxLatestCard() {
|
||||||
if (!wefaxDom.liveLatest) return;
|
if (!wefaxDom.liveLatest) return;
|
||||||
if (wefaxImageHistory.length === 0) {
|
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>';
|
wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const img = wefaxImageHistory[0];
|
const img = rigImages[0];
|
||||||
if (!img) return;
|
if (!img) return;
|
||||||
const ts = img._ts || "--";
|
const ts = img._ts || "--";
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
@@ -129,7 +137,7 @@ function renderWefaxLatestCard() {
|
|||||||
wefaxDom.liveLatest.innerHTML = html;
|
wefaxDom.liveLatest.innerHTML = html;
|
||||||
}
|
}
|
||||||
function getWefaxFilteredHistory() {
|
function getWefaxFilteredHistory() {
|
||||||
let items = wefaxImageHistory;
|
let items = wefaxRigImages();
|
||||||
if (wefaxFilterText) {
|
if (wefaxFilterText) {
|
||||||
items = items.filter(function(i) {
|
items = items.filter(function(i) {
|
||||||
const haystack = [
|
const haystack = [
|
||||||
@@ -173,7 +181,7 @@ function renderWefaxHistoryTable() {
|
|||||||
}
|
}
|
||||||
wefaxDom.historyList.replaceChildren(fragment);
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
if (wefaxDom.historyCount) {
|
if (wefaxDom.historyCount) {
|
||||||
const total = wefaxImageHistory.length;
|
const total = wefaxRigImages().length;
|
||||||
const shown = items.length;
|
const shown = items.length;
|
||||||
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
|
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
|
||||||
}
|
}
|
||||||
@@ -208,6 +216,7 @@ function addWefaxImage(msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onServerWefaxProgress(msg) {
|
function onServerWefaxProgress(msg) {
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
if (msg.state && !msg.line_data) {
|
if (msg.state && !msg.line_data) {
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
wefaxDom.status.textContent = msg.state;
|
wefaxDom.status.textContent = msg.state;
|
||||||
@@ -234,6 +243,7 @@ function onServerWefaxProgress(msg) {
|
|||||||
}
|
}
|
||||||
function onServerWefax(msg) {
|
function onServerWefax(msg) {
|
||||||
addWefaxImage(msg);
|
addWefaxImage(msg);
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
|
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
|
||||||
@@ -328,7 +338,11 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onMessage: onServerWefax,
|
onMessage: onServerWefax,
|
||||||
restore: restoreWefaxHistory,
|
restore: restoreWefaxHistory,
|
||||||
prune: pruneWefaxHistoryView,
|
prune: pruneWefaxHistoryView,
|
||||||
reset: resetWefaxHistoryView
|
reset: resetWefaxHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "wefax_progress",
|
id: "wefax_progress",
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -64,10 +68,10 @@ function renderWsprRow(msg) {
|
|||||||
function renderWsprHistory() {
|
function renderWsprHistory() {
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
if (!wsprMessagesEl) return;
|
if (!wsprMessagesEl) return;
|
||||||
|
const rigMessages = forActiveRig(wsprMessageHistory);
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
for (const message of rigMessages) {
|
||||||
const message = wsprMessageHistory[i];
|
fragment.appendChild(renderWsprRow(message));
|
||||||
if (message) fragment.appendChild(renderWsprRow(message));
|
|
||||||
}
|
}
|
||||||
wsprMessagesEl.replaceChildren(fragment);
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
}
|
}
|
||||||
@@ -90,6 +94,8 @@ function normalizeServerWsprMessage(msg) {
|
|||||||
station,
|
station,
|
||||||
rfHz,
|
rfHz,
|
||||||
history: {
|
history: {
|
||||||
|
rig_id: msg.rig_id ?? null,
|
||||||
|
_rfHz: rfHz,
|
||||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||||
ts_ms: msg.ts_ms,
|
ts_ms: msg.ts_ms,
|
||||||
snr_db: msg.snr_db,
|
snr_db: msg.snr_db,
|
||||||
@@ -101,16 +107,11 @@ function normalizeServerWsprMessage(msg) {
|
|||||||
}
|
}
|
||||||
function onServerWsprBatch(messages) {
|
function onServerWsprBatch(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
plotWsprLocator(msg);
|
||||||
...msg,
|
|
||||||
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
normalized.push(next.history);
|
normalized.push(next.history);
|
||||||
}
|
}
|
||||||
@@ -250,15 +251,19 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
function onServerWspr(msg) {
|
function plotWsprLocator(msg) {
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
|
||||||
...msg,
|
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
...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);
|
addWsprMessage(next.history);
|
||||||
}
|
}
|
||||||
wsprWindow.trxPluginRuntime.registerDecoder({
|
wsprWindow.trxPluginRuntime.registerDecoder({
|
||||||
@@ -267,5 +272,10 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerWsprBatch,
|
onBatch: onServerWsprBatch,
|
||||||
restore: onServerWsprBatch,
|
restore: onServerWsprBatch,
|
||||||
prune: pruneWsprHistoryView,
|
prune: pruneWsprHistoryView,
|
||||||
reset: resetWsprHistoryView
|
reset: resetWsprHistoryView,
|
||||||
|
rerender: renderWsprHistory,
|
||||||
|
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||||
<symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol>
|
<symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol>
|
||||||
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
|
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
|
||||||
<symbol id="icon-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-logbook" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 2h8a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M5.5 2v12"/><path d="M7.5 5.5h3M7.5 8h3M7.5 10.5h2"/></symbol>
|
||||||
|
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
|
||||||
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
|
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
|
||||||
|
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
|
||||||
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
|
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
|
||||||
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
|
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
|
||||||
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
|
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
|
||||||
@@ -52,14 +54,25 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
||||||
<span class="tab-label">Bookmarks</span>
|
<span class="tab-label">Bookmarks</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" data-tab="digital-modes">
|
<button class="tab" data-tab="logbook">
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-signal"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-logbook"/></svg>
|
||||||
|
<span class="tab-label">Logbook</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="digital-modes" aria-label="Digital modes">
|
||||||
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
|
||||||
<span class="tab-label">Digital modes</span>
|
<span class="tab-label">Digital modes</span>
|
||||||
|
<!-- 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>
|
||||||
<button class="tab" data-tab="map">
|
<button class="tab" data-tab="map">
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
||||||
<span class="tab-label">Map</span>
|
<span class="tab-label">Map</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="tab" data-tab="satellites">
|
||||||
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-satellite"/></svg>
|
||||||
|
<span class="tab-label">Satellites</span>
|
||||||
|
</button>
|
||||||
<button class="tab" data-tab="statistics">
|
<button class="tab" data-tab="statistics">
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
||||||
<span class="tab-label">Statistics</span>
|
<span class="tab-label">Statistics</span>
|
||||||
@@ -99,6 +112,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<option value="phosphor">Phosphor</option>
|
<option value="phosphor">Phosphor</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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="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>
|
<button id="header-auth-btn" class="header-bar-btn" type="button" style="display:none;" aria-label="Login or Logout">Login</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,6 +158,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></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" aria-describedby="spectrum-text-summary"></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>
|
<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-zoom-indicator" aria-hidden="true"></div>
|
||||||
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></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-db-axis" aria-hidden="true"></div>
|
||||||
@@ -156,13 +176,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-size-grip" title="Drag to resize spectrum height" aria-label="Resize spectrum height"></div>
|
||||||
<div id="spectrum-controls">
|
<div id="spectrum-controls">
|
||||||
<div id="spectrum-bw-row">
|
<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>
|
<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" type="button">Set</button>
|
<button id="spectrum-bw-set-btn" class="spectrum-btn" type="button">Set</button>
|
||||||
<button id="spectrum-bw-auto-btn" type="button">Auto BW</button>
|
<button id="spectrum-bw-auto-btn" class="spectrum-btn" type="button">Auto BW</button>
|
||||||
<button id="spectrum-bw-sweet-btn" type="button">Sweet-spot</button>
|
<button id="spectrum-bw-sweet-btn" class="spectrum-btn" type="button">Sweet-spot</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="spectrum-controls-spacer" aria-hidden="true"></div>
|
||||||
<div id="spectrum-level-row">
|
<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">
|
<select id="overview-peak-hold" class="status-input">
|
||||||
<option value="0">Off</option>
|
<option value="0">Off</option>
|
||||||
<option value="500">0.5 s</option>
|
<option value="500">0.5 s</option>
|
||||||
@@ -175,10 +196,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<option value="60000">60 s</option>
|
<option value="60000">60 s</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label id="spectrum-floor-label">Floor <input type="number" id="spectrum-floor-input" value="-115" step="5" /> dB</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">Range <input type="number" id="spectrum-range-input" value="90" step="10" min="10" /> dB</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" type="button">Auto</button>
|
<button id="spectrum-auto-btn" class="spectrum-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-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>
|
</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>
|
<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>
|
||||||
@@ -205,38 +226,27 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="label"><span>Signal strength</span></div>
|
<div class="label"><span>Signal strength</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="freq-field frequency-col">
|
<div class="freq-field frequency-col">
|
||||||
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" />
|
<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 class="label" id="freq-label"><span>Frequency</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
|
<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="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" />
|
<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 class="label" id="center-freq-label"><span>Center Frequency</span></div>
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="full-row controls-tray-shell">
|
<div class="full-row controls-tray-shell">
|
||||||
<div class="controls-tray-scroll">
|
<div class="controls-tray-scroll">
|
||||||
<div class="controls-tray">
|
<div class="controls-tray">
|
||||||
<div class="controls-row full-row">
|
<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="label"><span>Mode</span></div>
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<select class="status-input" id="mode" aria-label="Demodulation 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>
|
</div>
|
||||||
<div class="controls-col controls-col-center">
|
<div class="controls-col controls-col-center">
|
||||||
@@ -248,6 +258,35 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<button id="jog-up" type="button" class="jog-btn">+</button>
|
<button id="jog-up" type="button" class="jog-btn">+</button>
|
||||||
</div>
|
</div>
|
||||||
</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="controls-col controls-col-wfm label-below-col" id="wfm-controls-col" style="display:none;">
|
||||||
<div class="inline wfm-controls-inline">
|
<div class="inline wfm-controls-inline">
|
||||||
<label class="wfm-control">
|
<label class="wfm-control">
|
||||||
@@ -305,14 +344,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
<div class="label"><span>SAM</span></div>
|
<div class="label"><span>SAM</span></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>
|
||||||
<div class="full-row label-below-row" id="vfo-row">
|
<div class="full-row label-below-row" id="vfo-row">
|
||||||
<div class="label"><span>VFO</span></div>
|
<div class="label"><span>VFO</span></div>
|
||||||
@@ -362,32 +393,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="vchan-picker" id="vchan-picker"></div>
|
<div class="vchan-picker" id="vchan-picker"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
</details>
|
|
||||||
<div class="full-row label-below-row">
|
<div class="full-row label-below-row">
|
||||||
<div class="label"><span>Signal</span></div>
|
<div class="label"><span>Signal</span></div>
|
||||||
<div class="signal" style="gap: 1rem;">
|
<div class="signal" style="gap: 1rem;">
|
||||||
@@ -425,13 +430,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
|
<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="rx-audio-btn" type="button">Play Audio</button>
|
||||||
<button id="tx-audio-btn" type="button">Transmit 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>
|
<span class="audio-group audio-volume-group">
|
||||||
<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 class="audio-group-label">Volume</span>
|
||||||
<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>
|
<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>
|
||||||
<div id="audio-level">
|
<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>
|
||||||
<div id="audio-level-fill"></div>
|
</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>
|
||||||
|
</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>
|
||||||
<small id="audio-status" style="min-width: 60px;">Off</small>
|
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -442,6 +485,192 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tab-logbook" class="tab-panel" style="display:none;">
|
||||||
|
<div id="logbook-panel" class="log-panel">
|
||||||
|
<!-- Station: who the contacts are logged under, and from where. Shown
|
||||||
|
once rather than typed into every entry. -->
|
||||||
|
<div class="log-station">
|
||||||
|
<div class="log-station-identity">
|
||||||
|
<span class="log-station-call" id="log-station-callsign">--</span>
|
||||||
|
<label class="log-station-operator">
|
||||||
|
<span>Operator</span>
|
||||||
|
<input type="text" id="log-operator" class="status-input" autocomplete="off"
|
||||||
|
spellcheck="false" aria-label="Operator callsign" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="log-station-rig">
|
||||||
|
<span id="log-station-rig-name">--</span>
|
||||||
|
<span id="log-station-grid" class="log-station-grid"></span>
|
||||||
|
<span id="log-clock" class="log-clock" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entry: opens pre-filled with the six fields the radio and the clock
|
||||||
|
can answer for. Everything else is the operator's. -->
|
||||||
|
<form id="log-entry-form" class="log-entry" autocomplete="off">
|
||||||
|
<div class="log-entry-grid">
|
||||||
|
<label class="log-field log-field-call">
|
||||||
|
<span>Callsign</span>
|
||||||
|
<input type="text" id="log-call" class="status-input" required
|
||||||
|
spellcheck="false" aria-describedby="log-worked-before" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Frequency</span>
|
||||||
|
<input type="text" id="log-freq" class="status-input" inputmode="decimal" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>Mode</span>
|
||||||
|
<input type="text" id="log-mode" class="status-input" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>RST sent</span>
|
||||||
|
<input type="text" id="log-rst-sent" class="status-input" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>RST rcvd</span>
|
||||||
|
<input type="text" id="log-rst-rcvd" class="status-input" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>Locator</span>
|
||||||
|
<input type="text" id="log-grid" class="status-input" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Name</span>
|
||||||
|
<input type="text" id="log-name" class="status-input" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Comment</span>
|
||||||
|
<input type="text" id="log-comment" class="status-input" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<details class="log-contest" id="log-contest-block">
|
||||||
|
<summary>Contest exchange</summary>
|
||||||
|
<div class="log-entry-grid">
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Contest</span>
|
||||||
|
<input type="text" id="log-contest-id" class="status-input" spellcheck="false"
|
||||||
|
placeholder="CQ-WW-SSB" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>Serial sent</span>
|
||||||
|
<input type="text" id="log-stx" class="status-input" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>Received</span>
|
||||||
|
<input type="text" id="log-srx" class="status-input" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="log-entry-actions">
|
||||||
|
<span id="log-worked-before" class="log-worked" aria-live="polite"></span>
|
||||||
|
<span class="log-entry-buttons">
|
||||||
|
<button type="button" id="log-clear-btn" class="log-secondary-btn">Clear</button>
|
||||||
|
<button type="submit" id="log-save-btn" class="log-primary-btn">Log contact</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- The log itself. -->
|
||||||
|
<div class="log-list-toolbar">
|
||||||
|
<input type="text" id="log-filter-call" class="status-input" placeholder="Callsign…"
|
||||||
|
aria-label="Filter by callsign" />
|
||||||
|
<select id="log-filter-band" class="status-input" aria-label="Filter by band">
|
||||||
|
<option value="">All bands</option>
|
||||||
|
</select>
|
||||||
|
<select id="log-filter-mode" class="status-input" aria-label="Filter by mode">
|
||||||
|
<option value="">All modes</option>
|
||||||
|
</select>
|
||||||
|
<span class="log-toolbar-spacer"></span>
|
||||||
|
<a id="log-export-btn" class="log-secondary-btn" href="/api/logbook/export.adi"
|
||||||
|
download>Export ADIF</a>
|
||||||
|
<button type="button" id="log-import-btn" class="log-secondary-btn">Import ADIF</button>
|
||||||
|
<input type="file" id="log-import-file" accept=".adi,.adif,text/plain" hidden />
|
||||||
|
</div>
|
||||||
|
<div class="log-table-wrap">
|
||||||
|
<table class="log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Date</th>
|
||||||
|
<th scope="col">Time</th>
|
||||||
|
<th scope="col">Callsign</th>
|
||||||
|
<th scope="col">Band</th>
|
||||||
|
<th scope="col">Mode</th>
|
||||||
|
<th scope="col">Sent</th>
|
||||||
|
<th scope="col">Rcvd</th>
|
||||||
|
<th scope="col">Locator</th>
|
||||||
|
<th scope="col">Rig</th>
|
||||||
|
<th scope="col">QSL</th>
|
||||||
|
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="log-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="log-summary" class="log-summary" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<details class="log-report" id="log-statistics-block">
|
||||||
|
<summary>Bands worked</summary>
|
||||||
|
<div class="log-table-wrap">
|
||||||
|
<table class="log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Band</th>
|
||||||
|
<th scope="col">Contacts</th>
|
||||||
|
<th scope="col">Stations</th>
|
||||||
|
<th scope="col">Confirmed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="log-statistics-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="log-report" id="log-contest-export-block">
|
||||||
|
<summary>Contest entry (Cabrillo)</summary>
|
||||||
|
<p class="log-report-note">
|
||||||
|
Contest logs are submitted in Cabrillo and rejected in anything else.
|
||||||
|
Only the contacts of the contest named here go into the entry.
|
||||||
|
</p>
|
||||||
|
<div class="log-entry-grid">
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Contest</span>
|
||||||
|
<input type="text" id="log-cbr-contest" class="status-input" spellcheck="false"
|
||||||
|
placeholder="CQ-WW-SSB" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Callsign</span>
|
||||||
|
<input type="text" id="log-cbr-callsign" class="status-input" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Operators</span>
|
||||||
|
<select id="log-cbr-operator" class="status-input">
|
||||||
|
<option value="SINGLE-OP">Single operator</option>
|
||||||
|
<option value="MULTI-OP">Multi operator</option>
|
||||||
|
<option value="CHECKLOG">Checklog</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Power</span>
|
||||||
|
<select id="log-cbr-power" class="status-input">
|
||||||
|
<option value="LOW">Low</option>
|
||||||
|
<option value="HIGH">High</option>
|
||||||
|
<option value="QRP">QRP</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>Claimed score</span>
|
||||||
|
<input type="text" id="log-cbr-score" class="status-input" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="log-entry-actions">
|
||||||
|
<span class="log-entry-buttons">
|
||||||
|
<a id="log-cbr-export" class="log-secondary-btn" href="/api/logbook/export.cbr"
|
||||||
|
download>Export Cabrillo</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="tab-bookmarks" class="tab-panel" style="display:none;">
|
<div id="tab-bookmarks" class="tab-panel" style="display:none;">
|
||||||
<div class="bm-toolbar">
|
<div class="bm-toolbar">
|
||||||
<select id="bm-scope-picker" class="status-input" aria-label="Bookmark scope">
|
<select id="bm-scope-picker" class="status-input" aria-label="Bookmark scope">
|
||||||
@@ -555,6 +784,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<button class="sub-tab" data-subtab="rds">RDS</button>
|
<button class="sub-tab" data-subtab="rds">RDS</button>
|
||||||
<button class="sub-tab" data-subtab="sat">SAT</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="wefax">WEFAX</button>
|
||||||
|
<button class="sub-tab" data-subtab="sstv">SSTV</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="subtab-overview" class="sub-tab-panel">
|
<div id="subtab-overview" class="sub-tab-panel">
|
||||||
<div class="plugin-item" data-decoder="ais">
|
<div class="plugin-item" data-decoder="ais">
|
||||||
@@ -617,6 +847,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
|
Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="plugin-item" data-decoder="wefax">
|
||||||
<strong>WEFAX Decoder</strong>
|
<strong>WEFAX Decoder</strong>
|
||||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||||
@@ -624,6 +860,56 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 id="subtab-rds" class="sub-tab-panel" style="display:none;">
|
||||||
<div class="rds-grid">
|
<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>
|
<div class="rds-field"><span class="rds-label">Status</span><span id="rds-status" class="rds-value rds-no-signal">No signal</span></div>
|
||||||
@@ -654,19 +940,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)" />
|
<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>
|
<small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="ais-summary">
|
<div class="aprs-filter-row">
|
||||||
<div class="ais-summary-card">
|
<span class="aprs-counts">
|
||||||
<span class="ais-summary-label">Channels</span>
|
<span id="ais-vessel-count" class="aprs-counts-value">0 vessels</span>
|
||||||
<span id="ais-channel-summary" class="ais-summary-value">A 161.975 MHz · B 162.025 MHz</span>
|
<span id="ais-latest-seen" class="aprs-counts-value">No traffic yet</span>
|
||||||
</div>
|
<span id="ais-channel-summary" class="aprs-counts-value">A 161.975 MHz · B 162.025 MHz</span>
|
||||||
<div class="ais-summary-card">
|
</span>
|
||||||
<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>
|
</div>
|
||||||
<div id="ais-messages"></div>
|
<div id="ais-messages"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -696,20 +975,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<input id="aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
|
<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>
|
<small id="aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||||
</div>
|
</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">
|
<div class="aprs-filter-row">
|
||||||
<button id="aprs-type-all" class="aprs-chip active" type="button">All</button>
|
<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>
|
<button id="aprs-type-position" class="aprs-chip" type="button">Pos</button>
|
||||||
@@ -717,11 +982,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-weather" class="aprs-chip" type="button">Wx</button>
|
||||||
<button id="aprs-type-telemetry" class="aprs-chip" type="button">Tlm</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>
|
<button id="aprs-type-other" class="aprs-chip" type="button">Other</button>
|
||||||
</div>
|
<span class="aprs-filter-sep" aria-hidden="true"></span>
|
||||||
<div class="aprs-filter-row">
|
|
||||||
<button id="aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
|
<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-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
|
||||||
<button id="aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</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>
|
||||||
<div id="aprs-packets"></div>
|
<div id="aprs-packets"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -731,20 +1000,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)" />
|
<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>
|
<small id="hf-aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||||
</div>
|
</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">
|
<div class="aprs-filter-row">
|
||||||
<button id="hf-aprs-type-all" class="aprs-chip active" type="button">All</button>
|
<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>
|
<button id="hf-aprs-type-position" class="aprs-chip" type="button">Pos</button>
|
||||||
@@ -752,11 +1007,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-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-telemetry" class="aprs-chip" type="button">Tlm</button>
|
||||||
<button id="hf-aprs-type-other" class="aprs-chip" type="button">Other</button>
|
<button id="hf-aprs-type-other" class="aprs-chip" type="button">Other</button>
|
||||||
</div>
|
<span class="aprs-filter-sep" aria-hidden="true"></span>
|
||||||
<div class="aprs-filter-row">
|
|
||||||
<button id="hf-aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
|
<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-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>
|
<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>
|
||||||
<div id="hf-aprs-packets"></div>
|
<div id="hf-aprs-packets"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -855,7 +1114,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="sat-view-bar">
|
<div class="sat-view-bar">
|
||||||
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
||||||
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
|
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
|
||||||
<button id="sat-view-predictions" class="sat-view-btn" type="button">Predictions</button>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Live view -->
|
<!-- Live view -->
|
||||||
<div id="sat-live-view">
|
<div id="sat-live-view">
|
||||||
@@ -897,50 +1155,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div id="sat-history-list"></div>
|
<div id="sat-history-list"></div>
|
||||||
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
||||||
</div>
|
</div>
|
||||||
<!-- Predictions view -->
|
|
||||||
<div id="sat-predictions-view" style="display:none;">
|
|
||||||
<div class="ft8-controls">
|
|
||||||
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
|
||||||
<select id="sat-pred-category" class="sat-sort-select">
|
|
||||||
<option value="all">All</option>
|
|
||||||
<option value="weather">Weather</option>
|
|
||||||
<option value="amateur">Ham Radio</option>
|
|
||||||
<option value="other">Other</option>
|
|
||||||
</select>
|
|
||||||
<select id="sat-pred-min-el" class="sat-sort-select">
|
|
||||||
<option value="0">All passes</option>
|
|
||||||
<option value="10">Min 10°</option>
|
|
||||||
<option value="20">Min 20°</option>
|
|
||||||
<option value="45">Min 45°</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<!-- Current passes -->
|
|
||||||
<div id="sat-pred-current-section">
|
|
||||||
<div class="sat-pred-section-title">Currently receivable</div>
|
|
||||||
<div class="sat-pred-header sat-pred-header-current">
|
|
||||||
<span class="sat-pred-col-sat">Satellite</span>
|
|
||||||
<span class="sat-pred-col-el">Max El</span>
|
|
||||||
<span class="sat-pred-col-time">AOS Start</span>
|
|
||||||
<span class="sat-pred-col-time">AOS End</span>
|
|
||||||
<span class="sat-pred-col-countdown">Time left</span>
|
|
||||||
<span class="sat-pred-col-dir">Direction</span>
|
|
||||||
</div>
|
|
||||||
<div id="sat-pred-current-list"></div>
|
|
||||||
</div>
|
|
||||||
<!-- Upcoming passes -->
|
|
||||||
<div id="sat-pred-upcoming-section">
|
|
||||||
<div class="sat-pred-section-title">Upcoming passes</div>
|
|
||||||
<div class="sat-pred-header">
|
|
||||||
<span class="sat-pred-col-time">AOS (UTC)</span>
|
|
||||||
<span class="sat-pred-col-sat">Satellite</span>
|
|
||||||
<span class="sat-pred-col-el">Max El</span>
|
|
||||||
<span class="sat-pred-col-dur">Duration</span>
|
|
||||||
<span class="sat-pred-col-dir">Direction</span>
|
|
||||||
</div>
|
|
||||||
<div id="sat-pred-list"></div>
|
|
||||||
</div>
|
|
||||||
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
|
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
|
||||||
<div class="ft8-controls">
|
<div class="ft8-controls">
|
||||||
@@ -1002,54 +1216,103 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<template id="tmpl-map">
|
<template id="tmpl-map">
|
||||||
<div id="map-stage">
|
<div id="map-stage">
|
||||||
<div class="map-overlay-panel">
|
<div class="map-overlay-panel">
|
||||||
<div class="map-locator-filter-group">
|
<div class="map-overlay-filters">
|
||||||
<span class="map-locator-filter-label">Filter by</span>
|
<div class="map-locator-filter-group">
|
||||||
<div id="map-locator-phase" class="map-locator-phase-row"></div>
|
<span class="map-locator-filter-label">Filter</span>
|
||||||
</div>
|
<div id="map-locator-phase" class="map-locator-phase-row"></div>
|
||||||
<div class="map-locator-filter-group">
|
</div>
|
||||||
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
|
<div class="map-locator-filter-group">
|
||||||
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
|
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
|
||||||
</div>
|
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
|
||||||
<div class="map-locator-filter-group">
|
</div>
|
||||||
<span class="map-locator-filter-label">Rig</span>
|
<div class="map-locator-filter-group">
|
||||||
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
|
<span class="map-locator-filter-label">Rig</span>
|
||||||
<option value="">All</option>
|
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
|
||||||
</select>
|
<option value="">All</option>
|
||||||
</div>
|
</select>
|
||||||
<div class="map-locator-filter-group">
|
</div>
|
||||||
<span class="map-locator-filter-label">Search</span>
|
<div class="map-locator-filter-group">
|
||||||
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
|
<span class="map-locator-filter-label">History</span>
|
||||||
</div>
|
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
|
||||||
<div class="map-locator-filter-group">
|
<option value="15">15 min</option>
|
||||||
<span class="map-locator-filter-label">History</span>
|
<option value="30">30 min</option>
|
||||||
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
|
<option value="60">1 hr</option>
|
||||||
<option value="15">15 min</option>
|
<option value="180">3 hrs</option>
|
||||||
<option value="30">30 min</option>
|
<option value="360">6 hrs</option>
|
||||||
<option value="60">1 hr</option>
|
<option value="720">12 hrs</option>
|
||||||
<option value="180">3 hrs</option>
|
<option value="1440">24 hrs</option>
|
||||||
<option value="360">6 hrs</option>
|
</select>
|
||||||
<option value="720">12 hrs</option>
|
</div>
|
||||||
<option value="1440">24 hrs</option>
|
<div class="map-locator-filter-group">
|
||||||
</select>
|
<span class="map-locator-filter-label">Paths</span>
|
||||||
</div>
|
<div class="map-locator-phase-row">
|
||||||
<div class="map-locator-filter-group">
|
<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>
|
||||||
<span class="map-locator-filter-label">Paths</span>
|
<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>
|
||||||
<div class="map-locator-phase-row">
|
<span class="map-paths-hint">TRX paths on popup, directed decode paths when target locator is known</span>
|
||||||
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn">TRX Paths On</button>
|
</div>
|
||||||
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn">Contact Paths On</button>
|
</div>
|
||||||
<span class="map-locator-empty">TRX paths on popup, directed decode paths when target locator is known</span>
|
<!-- 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>
|
||||||
</div>
|
<div class="map-overlay-actions">
|
||||||
<div class="map-corner-controls">
|
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
|
||||||
<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>
|
||||||
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div>
|
<div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div>
|
||||||
<div id="aprs-map"></div>
|
<div id="aprs-map"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tab-satellites" class="tab-panel" style="display:none;">
|
||||||
|
<h2 class="section-heading">Satellites</h2>
|
||||||
|
<div class="ft8-controls">
|
||||||
|
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
||||||
|
<select id="sat-pred-category" class="sat-sort-select">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="weather">Weather</option>
|
||||||
|
<option value="amateur">Ham Radio</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
<select id="sat-pred-min-el" class="sat-sort-select">
|
||||||
|
<option value="0">All passes</option>
|
||||||
|
<option value="10">Min 10°</option>
|
||||||
|
<option value="20">Min 20°</option>
|
||||||
|
<option value="45">Min 45°</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Current passes -->
|
||||||
|
<div id="sat-pred-current-section">
|
||||||
|
<div class="sat-pred-section-title">Currently receivable</div>
|
||||||
|
<div class="sat-pred-header sat-pred-header-current">
|
||||||
|
<span class="sat-pred-col-sat">Satellite</span>
|
||||||
|
<span class="sat-pred-col-el">Max El</span>
|
||||||
|
<span class="sat-pred-col-time">AOS Start</span>
|
||||||
|
<span class="sat-pred-col-time">AOS End</span>
|
||||||
|
<span class="sat-pred-col-countdown">Time left</span>
|
||||||
|
<span class="sat-pred-col-dir">Direction</span>
|
||||||
|
</div>
|
||||||
|
<div id="sat-pred-current-list"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Upcoming passes -->
|
||||||
|
<div id="sat-pred-upcoming-section">
|
||||||
|
<div class="sat-pred-section-title">Upcoming passes</div>
|
||||||
|
<div class="sat-pred-header">
|
||||||
|
<span class="sat-pred-col-time">AOS (UTC)</span>
|
||||||
|
<span class="sat-pred-col-sat">Satellite</span>
|
||||||
|
<span class="sat-pred-col-el">Max El</span>
|
||||||
|
<span class="sat-pred-col-dur">Duration</span>
|
||||||
|
<span class="sat-pred-col-dir">Direction</span>
|
||||||
|
</div>
|
||||||
|
<div id="sat-pred-list"></div>
|
||||||
|
</div>
|
||||||
|
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
||||||
<h2 class="section-heading">Statistics</h2>
|
<h2 class="section-heading">Statistics</h2>
|
||||||
<template id="tmpl-statistics">
|
<template id="tmpl-statistics">
|
||||||
@@ -1395,30 +1658,31 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
|
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
|
||||||
<div id="background-decode-panel" class="sch-panel">
|
<div id="background-decode-panel" class="sch-panel">
|
||||||
<div class="sch-toast" id="background-decode-toast" role="alert" aria-live="polite" style="display:none;"></div>
|
<div class="sch-toast" id="background-decode-toast" role="alert" aria-live="polite" style="display:none;"></div>
|
||||||
<!-- Now Playing status card (moved to top) -->
|
|
||||||
<div class="now-playing-card">
|
|
||||||
<div id="background-decode-status-card" class="sch-status-card">No background decode bookmarks configured.</div>
|
|
||||||
</div>
|
|
||||||
<div class="sch-section">
|
<div class="sch-section">
|
||||||
<div class="sch-section-title">Configuration</div>
|
<div class="sch-section-title">Background Decode</div>
|
||||||
<div class="sch-row">
|
<p class="bgd-intro">
|
||||||
<label class="sch-label bgd-toggle-wrap">Background decode
|
Decodes saved bookmarks on hidden channels while you work another band.
|
||||||
<span class="bgd-toggle-row">
|
Needs an SDR rig, and a bookmark is only decoded while it falls inside
|
||||||
<input type="checkbox" id="background-decode-enabled" />
|
the span the rig is tuned across.
|
||||||
<span>Enable hidden background decoder channels</span>
|
</p>
|
||||||
</span>
|
<div class="bgd-controls">
|
||||||
|
<label class="bgd-enable">
|
||||||
|
<input type="checkbox" id="background-decode-enabled" />
|
||||||
|
<span>Enabled</span>
|
||||||
</label>
|
</label>
|
||||||
|
<span id="bgd-span-summary" class="bgd-span" aria-live="polite"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sch-row" style="flex-direction:column;gap:0.5rem;">
|
<div class="bgd-list-toolbar">
|
||||||
<label class="sch-label" style="min-width:100%;">Bookmarks
|
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter"
|
||||||
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter" placeholder="Filter bookmarks..." />
|
placeholder="Filter bookmarks…" aria-label="Filter bookmarks" />
|
||||||
</label>
|
|
||||||
<div class="bgd-select-actions">
|
<div class="bgd-select-actions">
|
||||||
<button type="button" id="bgd-select-all-btn" class="bgd-select-btn" aria-label="Select all bookmarks">Select 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="Deselect all bookmarks">Deselect All</button>
|
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Select no bookmarks">None</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="bgd-bookmark-checklist" class="bgd-checklist"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="bgd-bookmark-checklist" class="bgd-checklist" role="group"
|
||||||
|
aria-label="Bookmarks decoded in the background"></div>
|
||||||
|
<div id="bgd-selection-summary" class="bgd-summary" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sch-actions">
|
<div class="sch-actions">
|
||||||
@@ -1594,10 +1858,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<div class="copyright">
|
<div class="footer-meta">
|
||||||
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>
|
<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>
|
||||||
<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>
|
||||||
<div id="conn-lost-overlay" class="decode-history-overlay content-overlay is-hidden" aria-live="assertive" aria-atomic="true">
|
<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">
|
<div class="decode-history-overlay-card">
|
||||||
@@ -1631,11 +1898,12 @@ 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 class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="decode-history-overlay" class="decode-history-overlay is-hidden" aria-live="polite" aria-atomic="true">
|
<div id="decode-history-overlay" class="history-progress is-hidden" role="status" aria-live="polite" aria-atomic="true">
|
||||||
<div class="decode-history-overlay-card">
|
<div class="history-progress-text">
|
||||||
<div id="decode-history-overlay-title" class="decode-history-overlay-title">Loading decode history…</div>
|
<span id="decode-history-overlay-title" class="history-progress-title">Loading decode history…</span>
|
||||||
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
|
<span id="decode-history-overlay-sub" class="history-progress-sub">Preparing recent decodes for the UI</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="history-progress-track"><span id="decode-history-progress-bar" class="history-progress-bar"></span></span>
|
||||||
</div>
|
</div>
|
||||||
<script defer src="/vendor/opus-decoder-0.7.11.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="/vendor/leaflet.js"></script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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 |
@@ -23,14 +23,17 @@ await build({
|
|||||||
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
|
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
|
||||||
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
||||||
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
||||||
|
sstv: path.join(sourceDir, "plugins", "sstv.ts"),
|
||||||
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
||||||
ais: path.join(sourceDir, "plugins", "ais.ts"),
|
ais: path.join(sourceDir, "plugins", "ais.ts"),
|
||||||
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
|
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
|
||||||
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
||||||
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
||||||
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
|
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
|
||||||
|
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
|
||||||
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
||||||
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
||||||
|
logbook: path.join(sourceDir, "plugins", "logbook.ts"),
|
||||||
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
||||||
"map-core": path.join(sourceDir, "map-core.ts"),
|
"map-core": path.join(sourceDir, "map-core.ts"),
|
||||||
screenshot: path.join(sourceDir, "screenshot.ts"),
|
screenshot: path.join(sourceDir, "screenshot.ts"),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"test": "node --test tests/*.test.mjs",
|
||||||
"test:browser": "node tests/browser-smoke.mjs",
|
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs",
|
||||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export type RigRxStatus = { sig: number | null, };
|
|||||||
|
|
||||||
export type RigStatus = { freq: Freq, mode: RigMode, tx_en: boolean, vfo: RigVfo | null, tx: RigTxStatus | null, rx: RigRxStatus | null, lock: boolean | null, };
|
export type RigStatus = { freq: Freq, mode: RigMode, tx_en: boolean, vfo: RigVfo | null, tx: RigTxStatus | null, rx: RigRxStatus | null, lock: boolean | null, };
|
||||||
|
|
||||||
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
|
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, sstv_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||||
|
|
||||||
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
||||||
|
|
||||||
@@ -78,6 +78,10 @@ export type RdsData = { pi?: number | null, program_service?: string | null, rad
|
|||||||
export type SpectrumData = {
|
export type SpectrumData = {
|
||||||
/**
|
/**
|
||||||
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
|
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
|
||||||
|
*
|
||||||
|
* On the wire these are base64-encoded `i8` whole dBFS (see
|
||||||
|
* `spectrum_wire`), which is what the display draws anyway; the TypeScript
|
||||||
|
* type describes the decoded array the browser receives over SSE.
|
||||||
*/
|
*/
|
||||||
bins: Array<number>,
|
bins: Array<number>,
|
||||||
/**
|
/**
|
||||||
@@ -111,7 +115,7 @@ export type RigSnapshot = { info: RigInfo, status: RigStatus, band: string | nul
|
|||||||
/**
|
/**
|
||||||
* Per-virtual-channel RDS snapshots, when available.
|
* Per-virtual-channel RDS snapshots, when available.
|
||||||
*/
|
*/
|
||||||
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
|
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, sstv_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||||
|
|
||||||
export type RigListItem = { remote: string, display_name: string | null, manufacturer: string, model: string, supported_modes: Array<RigMode>, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, };
|
export type RigListItem = { remote: string, display_name: string | null, manufacturer: string, model: string, supported_modes: Array<RigMode>, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, };
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
|||||||
export {};
|
export {};
|
||||||
|
|
||||||
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"] as const;
|
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"] as const;
|
||||||
type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number];
|
type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number];
|
||||||
type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
|
type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
|
||||||
interface DecodeState { offset: number }
|
interface DecodeState { offset: number }
|
||||||
|
|||||||
+66
-1
@@ -3,7 +3,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
export const TAB_ORDER = [
|
export const TAB_ORDER = [
|
||||||
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
|
"main", "bookmarks", "logbook", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type TabName = typeof TAB_ORDER[number];
|
export type TabName = typeof TAB_ORDER[number];
|
||||||
@@ -11,8 +11,10 @@ export type TabName = typeof TAB_ORDER[number];
|
|||||||
export const TAB_PATHS: Readonly<Record<TabName, string>> = {
|
export const TAB_PATHS: Readonly<Record<TabName, string>> = {
|
||||||
main: "/",
|
main: "/",
|
||||||
bookmarks: "/bookmarks",
|
bookmarks: "/bookmarks",
|
||||||
|
logbook: "/logbook",
|
||||||
"digital-modes": "/digital-modes",
|
"digital-modes": "/digital-modes",
|
||||||
map: "/map",
|
map: "/map",
|
||||||
|
satellites: "/satellites",
|
||||||
statistics: "/statistics",
|
statistics: "/statistics",
|
||||||
recorder: "/recorder",
|
recorder: "/recorder",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
@@ -37,3 +39,66 @@ export function updateTabHistory(name: TabName, replace = false): void {
|
|||||||
if (replace) window.history.replaceState({}, "", nextUrl);
|
if (replace) window.history.replaceState({}, "", nextUrl);
|
||||||
else window.history.pushState({}, "", nextUrl);
|
else window.history.pushState({}, "", nextUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a shared link says to listen to: `?rig=sdr&f=14074000&mode=USB&bw=3000`. */
|
||||||
|
export interface TuneLink {
|
||||||
|
rig: string | null;
|
||||||
|
freqHz: number | null;
|
||||||
|
mode: string | null;
|
||||||
|
bandwidthHz: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FREQ_MULTIPLIER: Readonly<Record<string, number>> = { k: 1e3, m: 1e6, g: 1e9 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a frequency written for a person: bare Hz as the canonical form, but
|
||||||
|
* `7074k` and `14.074M` are what someone typing a link by hand reaches for.
|
||||||
|
* Returns Hz, or null for anything that is not a positive frequency.
|
||||||
|
*/
|
||||||
|
export function parseFrequencyParam(raw: string | null | undefined): number | null {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const text = raw.trim().toLowerCase().replace(/hz$/, "").trim();
|
||||||
|
const match = /^(\d+(?:\.\d+)?)\s*([kmg]?)$/.exec(text);
|
||||||
|
if (!match) return null;
|
||||||
|
const value = Number(match[1]) * (FREQ_MULTIPLIER[match[2] ?? ""] ?? 1);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return null;
|
||||||
|
return Math.round(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mode names run from FM to VDES; anything else in the URL is not one. */
|
||||||
|
function parseModeParam(raw: string | null | undefined): string | null {
|
||||||
|
const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
|
||||||
|
return /^[A-Z]{2,4}$/.test(mode) ? mode : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTuneLink(search: string): TuneLink {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const rig = (params.get("rig") || "").trim();
|
||||||
|
return {
|
||||||
|
rig: rig || null,
|
||||||
|
freqHz: parseFrequencyParam(params.get("f")),
|
||||||
|
mode: parseModeParam(params.get("mode")),
|
||||||
|
bandwidthHz: parseFrequencyParam(params.get("bw")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite the tune parameters of a query string, leaving anything else in it
|
||||||
|
* alone — the address bar is the share link, so it has to keep up with the
|
||||||
|
* dial without discarding whatever else a page put there.
|
||||||
|
*/
|
||||||
|
export function tuneLinkSearch(search: string, link: TuneLink): string {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const set = (key: string, value: string | null) => {
|
||||||
|
if (value == null || value === "") params.delete(key);
|
||||||
|
else params.set(key, value);
|
||||||
|
};
|
||||||
|
const hz = (value: number | null) =>
|
||||||
|
(typeof value === "number" && Number.isFinite(value) && value > 0 ? String(Math.round(value)) : null);
|
||||||
|
set("rig", link.rig);
|
||||||
|
set("f", hz(link.freqHz));
|
||||||
|
set("mode", parseModeParam(link.mode));
|
||||||
|
set("bw", hz(link.bandwidthHz));
|
||||||
|
const text = params.toString();
|
||||||
|
return text ? `?${text}` : "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import type * as Leaflet from "leaflet";
|
import type * as Leaflet from "leaflet";
|
||||||
|
import { aprsSymbolSprite } from "./plugins/aprs-shared";
|
||||||
|
import type { PluginRuntimeWindow } from "./plugins/runtime-contract";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
@@ -29,7 +31,7 @@ function mapEl(id: string): MapElement {
|
|||||||
return element as MapElement;
|
return element as MapElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DecoderSource = "ais" | "vdes" | "aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
|
type DecoderSource = "ais" | "vdes" | "aprs" | "hf_aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
|
||||||
interface TrackPoint { lat: number; lon: number; tsMs: number }
|
interface TrackPoint { lat: number; lon: number; tsMs: number }
|
||||||
interface DecodeDetail {
|
interface DecodeDetail {
|
||||||
station?: string | null; source?: string | null; target?: string | null;
|
station?: string | null; source?: string | null; target?: string | null;
|
||||||
@@ -81,6 +83,9 @@ interface MapEntry {
|
|||||||
bookmarks?: Array<Record<string, unknown>>;
|
bookmarks?: Array<Record<string, unknown>>;
|
||||||
bounds?: Leaflet.LatLngBoundsExpression;
|
bounds?: Leaflet.LatLngBoundsExpression;
|
||||||
symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
|
symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
|
||||||
|
/** Callsign as heard. The map key qualifies it with the source, so that a
|
||||||
|
* station worked on both 144 MHz and 30 m keeps one marker per band. */
|
||||||
|
call?: string;
|
||||||
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
|
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
|
||||||
pathKey?: string; sourceGrid?: string; targetGrid?: string;
|
pathKey?: string; sourceGrid?: string; targetGrid?: string;
|
||||||
from?: LatLon; to?: LatLon;
|
from?: LatLon; to?: LatLon;
|
||||||
@@ -169,7 +174,7 @@ interface MapWindow {
|
|||||||
clearMapMarkersByType?(type: DecoderSource): void;
|
clearMapMarkersByType?(type: DecoderSource): void;
|
||||||
navigateToAprsMap?(lat: number, lon: number): void;
|
navigateToAprsMap?(lat: number, lon: number): void;
|
||||||
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
|
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
|
||||||
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage): void;
|
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage, source?: "aprs" | "hf_aprs"): void;
|
||||||
aisMapAddVessel?(message: MapMessage): void;
|
aisMapAddVessel?(message: MapMessage): void;
|
||||||
vdesMapAddPoint?(message: MapMessage): void;
|
vdesMapAddPoint?(message: MapMessage): void;
|
||||||
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
|
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
|
||||||
@@ -226,8 +231,10 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
const decodeContactPaths = new Map<string, MapEntry>();
|
const decodeContactPaths = new Map<string, MapEntry>();
|
||||||
let selectedMapQsoKey: string | null = null;
|
let selectedMapQsoKey: string | null = null;
|
||||||
const mapMarkers = new Set<TrxLayer>();
|
const mapMarkers = new Set<TrxLayer>();
|
||||||
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||||
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
|
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||||
|
/** Chip key that clears a selection rather than naming a band or a source. */
|
||||||
|
const MAP_FILTER_ALL_KEY = "__all";
|
||||||
const mapLocatorFilter: { phase: "band" | "type"; bands: Set<string> } = { phase: "band", bands: new Set() };
|
const mapLocatorFilter: { phase: "band" | "type"; bands: Set<string> } = { phase: "band", bands: new Set() };
|
||||||
let mapSearchFilter = "";
|
let mapSearchFilter = "";
|
||||||
let mapRigFilter = ""; // "" = all rigs
|
let mapRigFilter = ""; // "" = all rigs
|
||||||
@@ -298,6 +305,18 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The source a station marker belongs to, defaulting to VHF APRS for
|
||||||
|
* entries stored before HF APRS had a source of its own. */
|
||||||
|
function aprsEntrySource(entry: MapEntry | null | undefined): DecoderSource {
|
||||||
|
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map key for a station: the callsign alone on VHF, source-qualified on HF,
|
||||||
|
* so one station heard on both bands keeps a marker for each. */
|
||||||
|
function aprsStationKey(call: string, source: DecoderSource): string {
|
||||||
|
return source === "aprs" ? call : `${source}:${call}`;
|
||||||
|
}
|
||||||
|
|
||||||
function refreshAprsTrack(call: string, entry: MapEntry): void {
|
function refreshAprsTrack(call: string, entry: MapEntry): void {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
||||||
@@ -319,7 +338,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
lineJoin: "round",
|
lineJoin: "round",
|
||||||
interactive: false,
|
interactive: false,
|
||||||
}) as unknown as TrxLayer;
|
}) as unknown as TrxLayer;
|
||||||
track.__trxType = "aprs";
|
track.__trxType = aprsEntrySource(entry);
|
||||||
track._aprsCall = call;
|
track._aprsCall = call;
|
||||||
entry.track = track;
|
entry.track = track;
|
||||||
}
|
}
|
||||||
@@ -571,6 +590,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
|
|
||||||
function mapSourceLabel(type: DecoderSource): string {
|
function mapSourceLabel(type: DecoderSource): string {
|
||||||
if (type === "bookmark") return "Bookmarks";
|
if (type === "bookmark") return "Bookmarks";
|
||||||
|
if (type === "hf_aprs") return "HF APRS";
|
||||||
return String(type || "").toUpperCase();
|
return String(type || "").toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,6 +610,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (type === "vdes") return "#a78bfa";
|
if (type === "vdes") return "#a78bfa";
|
||||||
if (type === "sat") return "#f59e0b";
|
if (type === "sat") return "#f59e0b";
|
||||||
if (type === "aprs") return "#00d17f";
|
if (type === "aprs") return "#00d17f";
|
||||||
|
// Far enough from the VHF green to tell the two apart at a glance.
|
||||||
|
if (type === "hf_aprs") return "#fb7185";
|
||||||
return locatorFilterColor(type);
|
return locatorFilterColor(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1077,38 +1099,42 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
|
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let helperText = "";
|
const noun = kind === "band" ? "bands" : "sources";
|
||||||
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[] : [];
|
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[] : [];
|
||||||
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
|
// Selecting nothing selects everything, for both kinds.
|
||||||
if (kind === "source") {
|
const showingAll = kind === "source"
|
||||||
if (noneSelected) {
|
? sourceKeys.every((k) => !mapFilter[k])
|
||||||
helperText = "All sources visible \u2014 click to filter";
|
: !(selectedSet instanceof Set) || selectedSet.size === 0;
|
||||||
}
|
// An "All" chip carries what a sentence of helper text used to say, in a
|
||||||
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
|
// width the bar can afford, and gives the selection somewhere to be undone.
|
||||||
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
|
const allChip = document.createElement("button");
|
||||||
}
|
allChip.type = "button";
|
||||||
|
allChip.className = "map-locator-chip map-locator-chip-all";
|
||||||
|
if (showingAll) allChip.classList.add("is-active");
|
||||||
|
allChip.dataset.filterKind = kind;
|
||||||
|
allChip.dataset.filterKey = MAP_FILTER_ALL_KEY;
|
||||||
|
allChip.setAttribute("aria-pressed", showingAll ? "true" : "false");
|
||||||
|
allChip.title = showingAll ? `All ${noun} shown` : `Show all ${noun}`;
|
||||||
|
allChip.innerHTML = `<span class="map-locator-chip-text">All</span>`;
|
||||||
|
container.appendChild(allChip);
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.type = "button";
|
btn.type = "button";
|
||||||
btn.className = "map-locator-chip";
|
btn.className = "map-locator-chip";
|
||||||
const isActive = kind === "source" ? !!mapFilter[item.key as MapFilterKey] : !!selectedSet?.has(item.key);
|
const isActive = kind === "source" ? !!mapFilter[item.key as MapFilterKey] : !!selectedSet?.has(item.key);
|
||||||
if (kind === "source" && noneSelected) {
|
// Nothing is filtered out yet, so no chip is dimmed as if it were.
|
||||||
|
if (showingAll) {
|
||||||
btn.classList.add("is-default");
|
btn.classList.add("is-default");
|
||||||
} else if (!isActive) {
|
} else if (!isActive) {
|
||||||
btn.classList.add("is-inactive");
|
btn.classList.add("is-inactive");
|
||||||
}
|
}
|
||||||
|
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
|
||||||
btn.dataset.filterKind = kind;
|
btn.dataset.filterKind = kind;
|
||||||
btn.dataset.filterKey = item.key;
|
btn.dataset.filterKey = item.key;
|
||||||
btn.style.setProperty("--chip-color", item.color);
|
btn.style.setProperty("--chip-color", item.color);
|
||||||
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
|
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
}
|
}
|
||||||
if (helperText) {
|
|
||||||
const hint = document.createElement("span");
|
|
||||||
hint.className = "map-locator-empty";
|
|
||||||
hint.textContent = helperText;
|
|
||||||
container.appendChild(hint);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMapLocatorPhaseRow(container: HTMLElement, phase: "band" | "type"): void {
|
function renderMapLocatorPhaseRow(container: HTMLElement, phase: "band" | "type"): void {
|
||||||
@@ -1180,10 +1206,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const entry of stationMarkers.values()) {
|
for (const entry of stationMarkers.values()) {
|
||||||
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
|
if (!entry?.visibleInHistoryWindow) continue;
|
||||||
availableSources.add("aprs");
|
availableSources.add(aprsEntrySource(entry));
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const bandMap = new Map<string, FilterChip>();
|
const bandMap = new Map<string, FilterChip>();
|
||||||
for (const entry of locatorMarkers.values()) {
|
for (const entry of locatorMarkers.values()) {
|
||||||
@@ -1217,7 +1241,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
|
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
|
||||||
.filter((key) => availableSources.has(key))
|
.filter((key) => availableSources.has(key))
|
||||||
.map((key) => ({
|
.map((key) => ({
|
||||||
key,
|
key,
|
||||||
@@ -1232,11 +1256,12 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
|
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
|
||||||
|
|
||||||
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
|
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
|
||||||
|
// The phase buttons next door already name the dimension; "Show" is the
|
||||||
|
// rest of the sentence, and it keeps the bar's labels a uniform width.
|
||||||
|
choiceLabelEl.textContent = "Show";
|
||||||
if (mapLocatorFilter.phase === "band") {
|
if (mapLocatorFilter.phase === "band") {
|
||||||
choiceLabelEl.textContent = "Visible Bands";
|
|
||||||
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
|
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
|
||||||
} else {
|
} else {
|
||||||
choiceLabelEl.textContent = "Visible Sources";
|
|
||||||
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
|
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
|
||||||
}
|
}
|
||||||
syncLocatorMarkerStyles();
|
syncLocatorMarkerStyles();
|
||||||
@@ -1287,9 +1312,10 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
}
|
}
|
||||||
return parts.join(" ").toLowerCase();
|
return parts.join(" ").toLowerCase();
|
||||||
}
|
}
|
||||||
if (type === "aprs") {
|
if (type === "aprs" || type === "hf_aprs") {
|
||||||
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
|
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||||
const entry = stationMarkers.get(call);
|
const entry = stationMarkers.get(key);
|
||||||
|
const call = entry?.call ?? key;
|
||||||
const info = entry?.info ? String(entry.info) : "";
|
const info = entry?.info ? String(entry.info) : "";
|
||||||
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
||||||
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
||||||
@@ -1487,9 +1513,10 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
};
|
};
|
||||||
|
|
||||||
mapWindow.clearMapMarkersByType = function(type) {
|
mapWindow.clearMapMarkersByType = function(type) {
|
||||||
if (type === "aprs") {
|
if (type === "aprs" || type === "hf_aprs") {
|
||||||
selectedAprsTrackCall = null;
|
selectedAprsTrackCall = null;
|
||||||
stationMarkers.forEach((entry) => {
|
stationMarkers.forEach((entry, key) => {
|
||||||
|
if (aprsEntrySource(entry) !== type) return;
|
||||||
if (entry && entry.marker) {
|
if (entry && entry.marker) {
|
||||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.marker);
|
mapMarkers.delete(entry.marker);
|
||||||
@@ -1498,8 +1525,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.track);
|
mapMarkers.delete(entry.track);
|
||||||
}
|
}
|
||||||
|
stationMarkers.delete(key);
|
||||||
});
|
});
|
||||||
stationMarkers.clear();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1635,7 +1662,10 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
function applyMapOverlayPanelVisibility() {
|
function applyMapOverlayPanelVisibility() {
|
||||||
const panel = document.querySelector("#map-stage .map-overlay-panel");
|
const panel = document.querySelector("#map-stage .map-overlay-panel");
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.classList.toggle("is-hidden", !mapOverlayPanelVisible);
|
// Only the filters collapse. The bar itself stays, because it carries the
|
||||||
|
// button that brings them back.
|
||||||
|
panel.classList.toggle("filters-hidden", !mapOverlayPanelVisible);
|
||||||
|
panel.querySelector(".map-overlay-filters")?.classList.toggle("is-hidden", !mapOverlayPanelVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMapOverlayToggleButton() {
|
function updateMapOverlayToggleButton() {
|
||||||
@@ -1769,13 +1799,15 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (!ll) return;
|
if (!ll) return;
|
||||||
const entry = stationMarkers.get(marker._aprsCall);
|
const entry = stationMarkers.get(marker._aprsCall);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
const source = aprsEntrySource(entry);
|
||||||
|
const call = entry.call ?? String(marker._aprsCall);
|
||||||
|
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||||
refreshAprsTrack(String(marker._aprsCall), entry);
|
refreshAprsTrack(String(marker._aprsCall), entry);
|
||||||
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
|
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
|
||||||
entry.track.addTo(aprsMap);
|
entry.track.addTo(aprsMap);
|
||||||
}
|
}
|
||||||
selectedAprsTrackCall = String(marker._aprsCall);
|
selectedAprsTrackCall = String(marker._aprsCall);
|
||||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
|
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1858,7 +1890,14 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
const kind = String(chip.dataset.filterKind || "");
|
const kind = String(chip.dataset.filterKind || "");
|
||||||
const key = String(chip.dataset.filterKey || "");
|
const key = String(chip.dataset.filterKey || "");
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
|
if (key === MAP_FILTER_ALL_KEY) {
|
||||||
|
// Back to no selection at all, which is what shows everything.
|
||||||
|
if (kind === "source") {
|
||||||
|
for (const srcKey of Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[]) mapFilter[srcKey] = false;
|
||||||
|
} else {
|
||||||
|
mapLocatorFilter.bands.clear();
|
||||||
|
}
|
||||||
|
} else if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
|
||||||
// toggle the clicked source; when none are selected everything is shown
|
// toggle the clicked source; when none are selected everything is shown
|
||||||
const sourceKey = key as MapFilterKey;
|
const sourceKey = key as MapFilterKey;
|
||||||
mapFilter[sourceKey] = !mapFilter[sourceKey];
|
mapFilter[sourceKey] = !mapFilter[sourceKey];
|
||||||
@@ -1979,48 +2018,43 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (aprsMap) aprsMap.invalidateSize();
|
if (aprsMap) aprsMap.invalidateSize();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Everything below is the windowed path — the fullscreen branch returned.
|
||||||
|
// The map tab is a whole page, so the stage fills the column down to the
|
||||||
|
// footer. Capping it at a fraction of the viewport, or at a width-derived
|
||||||
|
// aspect ratio, left a dead band under the map that grew with the window
|
||||||
|
// (and on narrow screens made the map barely a third of the page).
|
||||||
const mapRect = mapContainer.getBoundingClientRect();
|
const mapRect = mapContainer.getBoundingClientRect();
|
||||||
const width = mapContainer.clientWidth || mapRect.width;
|
|
||||||
const footer = document.querySelector(".footer");
|
const footer = document.querySelector(".footer");
|
||||||
let bottom = mapIsFullscreen() && stage
|
let bottom = window.innerHeight;
|
||||||
? stage.getBoundingClientRect().bottom
|
if (footer) {
|
||||||
: window.innerHeight;
|
|
||||||
if (!mapIsFullscreen() && footer) {
|
|
||||||
const fr = footer.getBoundingClientRect();
|
const fr = footer.getBoundingClientRect();
|
||||||
if (fr.top > mapRect.top + 50) bottom = fr.top;
|
// Clamped to the viewport: once the column is tall enough to push the
|
||||||
|
// footer below the fold, growing into it would push it further still.
|
||||||
|
if (fr.top > mapRect.top + 50) bottom = Math.min(fr.top, bottom);
|
||||||
}
|
}
|
||||||
const available = Math.max(0, Math.floor(bottom - mapRect.top - 8));
|
const target = Math.max(0, Math.floor(bottom - mapRect.top - 8));
|
||||||
const widthDriven = width > 0 ? Math.floor(width / 1.55) : available;
|
|
||||||
const viewportCap = mapIsFullscreen()
|
|
||||||
? Math.floor(window.innerHeight * 0.9)
|
|
||||||
: Math.floor(window.innerHeight * 0.75);
|
|
||||||
const minHeight = Math.min(260, available);
|
|
||||||
const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven));
|
|
||||||
mapContainer.style.height = `${target}px`;
|
mapContainer.style.height = `${target}px`;
|
||||||
if (aprsMap) aprsMap.invalidateSize();
|
if (aprsMap) aprsMap.invalidateSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
function aprsSymbolIcon(symbolTable: string, symbolCode: string): Leaflet.DivIcon | null {
|
function aprsSymbolIcon(symbolTable: string, symbolCode: string): Leaflet.DivIcon | null {
|
||||||
if (!symbolTable || !symbolCode) return null;
|
if (!symbolTable || !symbolCode) return null;
|
||||||
const table = symbolTable === "/" ? "primary" : "alternate";
|
const sprite = aprsSymbolSprite(symbolTable, symbolCode);
|
||||||
|
const html = sprite
|
||||||
|
? `<div class="aprs-symbol aprs-symbol-marker ${sprite.className}" role="img"` +
|
||||||
|
` style="background-position:${sprite.backgroundPosition}"` +
|
||||||
|
` title="${escapeMapHtml(sprite.label)}" aria-label="${escapeMapHtml(sprite.label)}"></div>`
|
||||||
|
: `<div class="aprs-symbol aprs-symbol-marker aprs-symbol-local" title="${symbolTable === "/" ? "primary" : "alternate"} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`;
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: "",
|
className: "",
|
||||||
html: `<div class="aprs-symbol-local" title="${table} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`,
|
html,
|
||||||
iconSize: [24, 24],
|
iconSize: [24, 24],
|
||||||
iconAnchor: [12, 12],
|
iconAnchor: [12, 12],
|
||||||
popupAnchor: [0, -12]
|
popupAnchor: [0, -12]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
mapWindow.navigateToAprsMap = function(lat, lon) {
|
function focusMapPosition(lat: number, lon: number) {
|
||||||
// Activate the map tab
|
|
||||||
T._activeTab = "map";
|
|
||||||
document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); });
|
|
||||||
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
|
|
||||||
if (mapTabBtn) mapTabBtn.classList.add("active");
|
|
||||||
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => (p.style.display = "none"));
|
|
||||||
const mapPanel = mapEl("tab-map");
|
|
||||||
if (mapPanel) mapPanel.style.display = "";
|
|
||||||
initAprsMap();
|
initAprsMap();
|
||||||
sizeAprsMapToViewport();
|
sizeAprsMapToViewport();
|
||||||
if (aprsMap) {
|
if (aprsMap) {
|
||||||
@@ -2032,20 +2066,12 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
mapWindow.navigateToMapLocator = function(grid, preferredType = null) {
|
function focusMapLocator(grid: string, preferredType: string | null = null) {
|
||||||
const normalizedGrid = String(grid || "").trim().toUpperCase();
|
const normalizedGrid = String(grid || "").trim().toUpperCase();
|
||||||
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false;
|
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false;
|
||||||
|
|
||||||
T._activeTab = "map";
|
|
||||||
document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); });
|
|
||||||
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
|
|
||||||
if (mapTabBtn) mapTabBtn.classList.add("active");
|
|
||||||
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => (p.style.display = "none"));
|
|
||||||
const mapPanel = mapEl("tab-map");
|
|
||||||
if (mapPanel) mapPanel.style.display = "";
|
|
||||||
|
|
||||||
initAprsMap();
|
initAprsMap();
|
||||||
sizeAprsMapToViewport();
|
sizeAprsMapToViewport();
|
||||||
if (!aprsMap) return false;
|
if (!aprsMap) return false;
|
||||||
@@ -2095,7 +2121,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
requestAnimationFrame(focusMarker);
|
requestAnimationFrame(focusMarker);
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2264,28 +2290,33 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _aprsAddMarkerToMap(call: string, entry: MapEntry): void {
|
function _aprsAddMarkerToMap(key: string, entry: MapEntry): void {
|
||||||
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
||||||
refreshAprsTrack(call, entry);
|
refreshAprsTrack(key, entry);
|
||||||
|
const source = aprsEntrySource(entry);
|
||||||
|
const call = entry.call ?? key;
|
||||||
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
||||||
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
||||||
|
const color = mapSourceColor(source);
|
||||||
const marker = (icon
|
const marker = (icon
|
||||||
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
|
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
|
||||||
: L.circleMarker([entry.lat, entry.lon], {
|
: L.circleMarker([entry.lat, entry.lon], {
|
||||||
radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8
|
radius: 6, color, fillColor: color, fillOpacity: 0.8
|
||||||
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
|
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
|
||||||
marker.__trxType = "aprs";
|
marker.__trxType = source;
|
||||||
marker.__trxRigIds = entry.rigIds || new Set();
|
marker.__trxRigIds = entry.rigIds || new Set();
|
||||||
marker._aprsCall = call;
|
marker._aprsCall = key;
|
||||||
entry.marker = marker;
|
entry.marker = marker;
|
||||||
mapMarkers.add(marker);
|
mapMarkers.add(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
|
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
|
||||||
const nextPoint: Leaflet.LatLngTuple = [lat, lon];
|
const nextPoint: Leaflet.LatLngTuple = [lat, lon];
|
||||||
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
||||||
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
||||||
const existing = stationMarkers.get(call);
|
const stationSource: DecoderSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||||
|
const key = aprsStationKey(call, stationSource);
|
||||||
|
const existing = stationMarkers.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.pkt = pkt;
|
existing.pkt = pkt;
|
||||||
existing.lat = lat;
|
existing.lat = lat;
|
||||||
@@ -2304,7 +2335,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
} else if (prevPoint) {
|
} else if (prevPoint) {
|
||||||
prevPoint.tsMs = tsMs;
|
prevPoint.tsMs = tsMs;
|
||||||
}
|
}
|
||||||
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
|
existing.call = call;
|
||||||
|
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
|
||||||
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
||||||
existing.marker.setLatLng([lat, lon]);
|
existing.marker.setLatLng([lat, lon]);
|
||||||
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
||||||
@@ -2315,7 +2347,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
track: null,
|
track: null,
|
||||||
trackHistory: [{ lat, lon, tsMs }],
|
trackHistory: [{ lat, lon, tsMs }],
|
||||||
trackPoints: [nextPoint],
|
trackPoints: [nextPoint],
|
||||||
type: "aprs",
|
type: stationSource,
|
||||||
|
call,
|
||||||
pkt,
|
pkt,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
@@ -2324,9 +2357,9 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
symbolCode,
|
symbolCode,
|
||||||
rigIds: new Set(msgRigId ? [msgRigId] : []),
|
rigIds: new Set(msgRigId ? [msgRigId] : []),
|
||||||
};
|
};
|
||||||
stationMarkers.set(call, entry);
|
stationMarkers.set(key, entry);
|
||||||
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
|
||||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
|
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
|
||||||
if (aprsMap) scheduleDecodeMapMaintenance();
|
if (aprsMap) scheduleDecodeMapMaintenance();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2592,18 +2625,26 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
syncDecodeContactPathVisibility();
|
syncDecodeContactPathVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The buttons light up when they are on, so the label need not repeat it —
|
||||||
|
// an "On"/"Off" suffix on each cost the bar most of a row.
|
||||||
function updateMapContactPathsToggle() {
|
function updateMapContactPathsToggle() {
|
||||||
const btn = mapEl("map-contact-paths-toggle");
|
const btn = mapEl("map-contact-paths-toggle");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
|
|
||||||
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
|
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
|
||||||
|
btn.setAttribute("aria-pressed", mapDecodeContactPathsEnabled ? "true" : "false");
|
||||||
|
btn.title = mapDecodeContactPathsEnabled
|
||||||
|
? "Directed decode paths are drawn when the target locator is known"
|
||||||
|
: "Directed decode paths are hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMapP2pPathsToggle() {
|
function updateMapP2pPathsToggle() {
|
||||||
const btn = mapEl("map-p2p-paths-toggle");
|
const btn = mapEl("map-p2p-paths-toggle");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
|
|
||||||
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
|
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
|
||||||
|
btn.setAttribute("aria-pressed", mapP2pRadioPathsEnabled ? "true" : "false");
|
||||||
|
btn.title = mapP2pRadioPathsEnabled
|
||||||
|
? "TRX paths are drawn from a station popup"
|
||||||
|
: "TRX paths are hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleDecodeMapMaintenance() {
|
function scheduleDecodeMapMaintenance() {
|
||||||
@@ -2806,7 +2847,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
|
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
|
||||||
syncDecodeContactPathVisibility();
|
syncDecodeContactPathVisibility();
|
||||||
if (selectedMapQsoKey && entry.sourceGrid) {
|
if (selectedMapQsoKey && entry.sourceGrid) {
|
||||||
mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType);
|
focusMapLocator(entry.sourceGrid, entry.sourceType);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2933,7 +2974,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
card.className = "map-qso-card";
|
card.className = "map-qso-card";
|
||||||
if (entry.grid) {
|
if (entry.grid) {
|
||||||
card.addEventListener("click", () => {
|
card.addEventListener("click", () => {
|
||||||
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
|
focusMapLocator(entry.grid ?? "", entry.sourceType);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3059,7 +3100,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
card.className = "map-qso-card";
|
card.className = "map-qso-card";
|
||||||
if (entry.grid) {
|
if (entry.grid) {
|
||||||
card.addEventListener("click", () => {
|
card.addEventListener("click", () => {
|
||||||
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
|
focusMapLocator(entry.grid ?? "", entry.sourceType);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3623,6 +3664,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
// Register module API for core to call
|
// Register module API for core to call
|
||||||
modules.map = {
|
modules.map = {
|
||||||
initAprsMap,
|
initAprsMap,
|
||||||
|
focusMapPosition,
|
||||||
|
focusMapLocator,
|
||||||
sizeAprsMapToViewport,
|
sizeAprsMapToViewport,
|
||||||
syncAprsReceiverMarker,
|
syncAprsReceiverMarker,
|
||||||
updateMapRigFilter,
|
updateMapRigFilter,
|
||||||
@@ -3672,6 +3715,18 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
reverseGeocodeLocation,
|
reverseGeocodeLocation,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Everything the decoders already hold goes onto the map now. This module is
|
||||||
|
// lazy -- it arrives when the Map tab is first opened, long after startup
|
||||||
|
// restored the decode history -- and until it does, aprsMapAddStation and
|
||||||
|
// friends are undefined, so every restored position was dropped on the floor.
|
||||||
|
// The map then showed only what arrived live after it loaded, which is why it
|
||||||
|
// took a second reload (with the module cached, and so loaded early enough to
|
||||||
|
// win the race against the history fetch) for the stations to appear.
|
||||||
|
//
|
||||||
|
// The add functions are keyed by callsign/MMSI/point, so replaying costs
|
||||||
|
// nothing on a second call and cannot duplicate a marker.
|
||||||
|
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.syncMapAll();
|
||||||
|
|
||||||
// If the map tab is already visible (direct /map URL), init immediately.
|
// If the map tab is already visible (direct /map URL), init immediately.
|
||||||
autoInitIfVisible();
|
autoInitIfVisible();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -2,14 +2,25 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
|
type PluginGroup =
|
||||||
|
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
|
||||||
|
| "bookmarks" | "logbook" | "recorder" | "settings";
|
||||||
|
|
||||||
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
||||||
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
|
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
||||||
|
// with it. They used to come only with the map group, which left their
|
||||||
|
// sub-tabs empty — decodes queueing in the runtime — until something opened
|
||||||
|
// the Map tab. Their map calls are optional, so map-core stays lazy.
|
||||||
|
"digital-modes": [
|
||||||
|
"/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js",
|
||||||
|
"/sat.js", "/wefax.js", "/sstv.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js",
|
||||||
|
],
|
||||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||||
|
satellites: ["/satellite-predictions.js"],
|
||||||
statistics: ["/map-core.js"],
|
statistics: ["/map-core.js"],
|
||||||
bookmarks: ["/bookmarks.js"],
|
bookmarks: ["/bookmarks.js"],
|
||||||
|
logbook: ["/logbook.js"],
|
||||||
recorder: [],
|
recorder: [],
|
||||||
settings: ["/vchan.js", "/scheduler.js"],
|
settings: ["/vchan.js", "/scheduler.js"],
|
||||||
};
|
};
|
||||||
@@ -37,7 +48,7 @@ async function loadPlugins(group: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadEagerPlugins(): Promise<void> {
|
export async function loadEagerPlugins(): Promise<void> {
|
||||||
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
|
await Promise.all(["digital-modes", "bookmarks", "logbook", "settings"].map(loadPlugins));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPluginsForTab(tab: string): Promise<void> {
|
export async function loadPluginsForTab(tab: string): Promise<void> {
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ const runtime: TrxPluginRuntime = {
|
|||||||
plugin.prune();
|
plugin.prune();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
rerenderAll() { for (const plugin of decoders.values()) plugin.rerender?.(); },
|
||||||
|
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
|
||||||
clearQueued() { queued.clear(); },
|
clearQueued() { queued.clear(); },
|
||||||
hasDecoder: (id) => decoders.has(id),
|
hasDecoder: (id) => decoders.has(id),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import { hostState } from "./host.js";
|
||||||
|
|
||||||
|
// The decode stream is not rig-scoped: every rig a client is connected to sends
|
||||||
|
// its decodes to every browser, each frame naming the rig that heard it (the
|
||||||
|
// client stamps `rig_id` as it leaves the audio connection). The map wants all
|
||||||
|
// of them — it has its own filter, and plotting only one rig would empty it —
|
||||||
|
// but the radio page and the decoder panels describe one rig at a time: the one
|
||||||
|
// the operator selected, whose spectrum is on screen and whose audio is
|
||||||
|
// playing. A frame a background rig copied on another band belongs to neither
|
||||||
|
// picture, so both filter on this.
|
||||||
|
export function isActiveRigDecode(rigId: string | null | undefined): boolean {
|
||||||
|
const activeRigId = hostState.lastActiveRigId;
|
||||||
|
// Before the rig list has arrived, and for a decode that reached the browser
|
||||||
|
// without a rig of its own, there is nothing to disagree with.
|
||||||
|
if (!activeRigId || !rigId) return true;
|
||||||
|
return rigId === activeRigId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The decodes of `items` that the selected rig heard, in their original order. */
|
||||||
|
export function forActiveRig<T extends { rig_id?: string | null | undefined }>(items: T[]): T[] {
|
||||||
|
return items.filter((item) => isActiveRigDecode(item.rig_id));
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { hostCore, hostState } from "./host.js";
|
|||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||||
|
|
||||||
interface AisMessage {
|
interface AisMessage {
|
||||||
@@ -27,6 +28,7 @@ interface AisMessage {
|
|||||||
}
|
}
|
||||||
interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
|
interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
|
||||||
interface AisBridge {
|
interface AisBridge {
|
||||||
|
navigateToAprsMap?: (lat: number, lon: number) => void;
|
||||||
getDecodeHistoryRetentionMs?: () => number;
|
getDecodeHistoryRetentionMs?: () => number;
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
|
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
|
||||||
@@ -189,20 +191,27 @@ function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
|
|||||||
return Array.from(byMmsi.values());
|
return Array.from(byMmsi.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the selected rig heard. The history holds every rig's traffic, for
|
||||||
|
* the map; the panel is about the rig the operator is working. */
|
||||||
|
function aisRigMessages(): AisMessage[] {
|
||||||
|
return forActiveRig(aisMessageHistory);
|
||||||
|
}
|
||||||
|
|
||||||
function updateAisSummary() {
|
function updateAisSummary() {
|
||||||
const plan = currentAisChannelPlan();
|
const plan = currentAisChannelPlan();
|
||||||
if (aisChannelSummaryEl) {
|
if (aisChannelSummaryEl) {
|
||||||
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const vessels = aisLatestByVessel(aisMessageHistory);
|
const rigMessages = aisRigMessages();
|
||||||
|
const vessels = aisLatestByVessel(rigMessages);
|
||||||
if (aisVesselCountEl) {
|
if (aisVesselCountEl) {
|
||||||
const count = vessels.length;
|
const count = vessels.length;
|
||||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aisLatestSeenEl) {
|
if (aisLatestSeenEl) {
|
||||||
const latest = aisMessageHistory[0];
|
const latest = rigMessages[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aisLatestSeenEl.textContent = "No traffic yet";
|
aisLatestSeenEl.textContent = "No traffic yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -212,8 +221,21 @@ function updateAisSummary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the message says, in one line: where the vessel is and what it is
|
||||||
|
* doing, or — for the static reports that carry no fix — where it is going. */
|
||||||
|
function aisSummaryText(msg: AisMessage): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
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: AisMessage): HTMLElement {
|
function renderAisRow(msg: AisMessage): HTMLElement {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("details");
|
||||||
row.className = "ais-message";
|
row.className = "ais-message";
|
||||||
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@@ -227,8 +249,9 @@ function renderAisRow(msg: AisMessage): HTMLElement {
|
|||||||
const route = aisRouteText(msg);
|
const route = aisRouteText(msg);
|
||||||
const distance = aisDistanceText(msg);
|
const distance = aisDistanceText(msg);
|
||||||
const pos = msg.lat != null && msg.lon != null
|
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>`
|
? `<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 = [
|
row.dataset.filterText = [
|
||||||
name,
|
name,
|
||||||
msg.mmsi,
|
msg.mmsi,
|
||||||
@@ -243,23 +266,43 @@ function renderAisRow(msg: AisMessage): HTMLElement {
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
`<div class="ais-row-head">` +
|
`<summary class="decode-line">` +
|
||||||
`<span class="ais-time">${ts}</span>` +
|
`<span class="ais-time">${escapeAisHtml(ts)}</span>` +
|
||||||
`<span class="ais-call">${nameHtml}</span>` +
|
`<span class="ais-call">${nameHtml}</span>` +
|
||||||
`<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
|
|
||||||
`<span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span>` +
|
`<span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span>` +
|
||||||
`</div>` +
|
`<span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span>` +
|
||||||
`<div class="ais-row-meta">` +
|
`<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
|
||||||
`<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
|
(distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") +
|
||||||
(route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") +
|
`</summary>` +
|
||||||
`<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span>` +
|
`<div class="decode-expanded">` +
|
||||||
`</div>` +
|
`<div class="decode-expanded-meta">` +
|
||||||
`<div class="ais-row-detail">` +
|
`<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
|
||||||
(motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
`<span>${escapeAisHtml(channel.freqText)}</span>` +
|
||||||
(distance ? `<span>${escapeAisHtml(distance)}</span>` : "") +
|
(route ? `<span>${escapeAisHtml(route)}</span>` : "") +
|
||||||
(pos ? `<span>${pos}</span>` : "") +
|
(motion ? `<span>${escapeAisHtml(motion)}</span>` : "") +
|
||||||
`<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</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>`;
|
`</div>`;
|
||||||
|
|
||||||
|
row.querySelectorAll<HTMLElement>("[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);
|
applyAisFilterToRow(row);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -279,7 +322,9 @@ function updateAisBar() {
|
|||||||
|
|
||||||
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
|
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
const recent = aisMessageHistory.filter(
|
||||||
|
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id),
|
||||||
|
);
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
if (!isAis || messages.length === 0) {
|
if (!isAis || messages.length === 0) {
|
||||||
aisBarOverlay.style.display = "none";
|
aisBarOverlay.style.display = "none";
|
||||||
@@ -333,7 +378,7 @@ function renderAisHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of aisMessageHistory) {
|
for (const message of aisRigMessages()) {
|
||||||
fragment.appendChild(renderAisRow(message));
|
fragment.appendChild(renderAisRow(message));
|
||||||
}
|
}
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
@@ -354,9 +399,13 @@ function addAisMessage(msg: AisMessage): void {
|
|||||||
scheduleAisBarUpdate();
|
scheduleAisBarUpdate();
|
||||||
scheduleAisHistoryRender();
|
scheduleAisHistoryRender();
|
||||||
|
|
||||||
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
|
plotAisMessage(msg);
|
||||||
aisWindow.aisMapAddVessel(msg);
|
}
|
||||||
}
|
|
||||||
|
/** Hands a positioned message to the map, if the map module is loaded yet. */
|
||||||
|
function plotAisMessage(msg: AisMessage): void {
|
||||||
|
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
|
||||||
|
aisWindow.aisMapAddVessel(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerAisMessage(msg: AisMessage): AisMessage {
|
function normalizeServerAisMessage(msg: AisMessage): AisMessage {
|
||||||
@@ -368,10 +417,11 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage {
|
|||||||
|
|
||||||
function onServerAisBatch(messages: AisMessage[]): void {
|
function onServerAisBatch(messages: AisMessage[]): void {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
|
||||||
const normalized: AisMessage[] = [];
|
const normalized: AisMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerAisMessage(msg);
|
const next = normalizeServerAisMessage(msg);
|
||||||
|
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||||
|
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -379,9 +429,7 @@ function onServerAisBatch(messages: AisMessage[]): void {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
|
plotAisMessage(next);
|
||||||
aisWindow.aisMapAddVessel(next);
|
|
||||||
}
|
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -415,8 +463,9 @@ if (aisFilterInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onServerAis(msg: AisMessage): void {
|
function onServerAis(msg: AisMessage): void {
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
const message = normalizeServerAisMessage(msg);
|
||||||
addAisMessage(normalizeServerAisMessage(msg));
|
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
||||||
|
addAisMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
@@ -427,4 +476,7 @@ updateAisSummary();
|
|||||||
restore: onServerAisBatch,
|
restore: onServerAisBatch,
|
||||||
reset: resetAisHistoryView,
|
reset: resetAisHistoryView,
|
||||||
prune: pruneAisHistoryView,
|
prune: pruneAisHistoryView,
|
||||||
|
rerender: () => { updateAisBar(); renderAisHistory(); },
|
||||||
|
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ export interface AprsPacket {
|
|||||||
symbol_code?: string | null;
|
symbol_code?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeAprsHtml(value: string): string {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
export function aprsPacketCategory(packet: AprsPacket): AprsCategory {
|
export function aprsPacketCategory(packet: AprsPacket): AprsCategory {
|
||||||
const type = (packet.type ?? "").toLowerCase();
|
const type = (packet.type ?? "").toLowerCase();
|
||||||
const info = (packet.info ?? "").toLowerCase();
|
const info = (packet.info ?? "").toLowerCase();
|
||||||
@@ -108,8 +116,87 @@ function escapeAprsCharacter(character: string): string {
|
|||||||
return character;
|
return character;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The vendored sprite sheets (assets/web/vendor/aprs-symbols-24-*.png) are
|
||||||
|
// 16x6 grids of 24px cells covering the printable codes 0x21..0x7E, so a
|
||||||
|
// symbol's cell index is simply `code - 0x21`. The sheet URLs live in CSS so
|
||||||
|
// the retina variants can be picked up by a media query; only the cell offsets
|
||||||
|
// are computed here.
|
||||||
|
const APRS_SPRITE_COLUMNS = 16;
|
||||||
|
const APRS_SPRITE_CELL_PX = 24;
|
||||||
|
const APRS_SPRITE_FIRST_CODE = 0x21;
|
||||||
|
const APRS_SPRITE_LAST_CODE = 0x7e;
|
||||||
|
|
||||||
|
export interface AprsSymbolSprite {
|
||||||
|
/** Sheet modifier class appended to `.aprs-symbol`. */
|
||||||
|
className: string;
|
||||||
|
/** `background-position` covering the overlay layer first, if any. */
|
||||||
|
backgroundPosition: string;
|
||||||
|
/** Human-readable description for the tooltip. */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aprsSpriteOffset(code: string): string | null {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an APRS table/code pair to a sprite cell. A table identifier of `/`
|
||||||
|
* selects the primary sheet and `\` the alternate one; any other character is
|
||||||
|
* an overlay, which draws that character from the overlay sheet on top of the
|
||||||
|
* alternate symbol. Returns null when the pair is outside the sprite sheets,
|
||||||
|
* leaving callers to fall back to the raw character.
|
||||||
|
*/
|
||||||
|
export function aprsSymbolSprite(
|
||||||
|
symbolTable: string | null | undefined,
|
||||||
|
symbolCode: string | null | undefined,
|
||||||
|
): AprsSymbolSprite | null {
|
||||||
|
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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An empty slot of the symbol's size, so a frame without one still lines up
|
||||||
|
* with the frames around it in the list. */
|
||||||
|
export function renderAprsSymbolSlot(packet: AprsPacket, escapeHtml: (value: string) => string): string {
|
||||||
|
return renderLocalAprsSymbol(packet, escapeHtml)
|
||||||
|
|| '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
|
||||||
|
}
|
||||||
|
|
||||||
export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string {
|
export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string {
|
||||||
if (!packet.symbolTable || !packet.symbolCode) return "";
|
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 symbol = escapeHtml(packet.symbolCode);
|
||||||
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
|
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
|
||||||
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
||||||
@@ -133,3 +220,201 @@ export function normalizeAprsPacket(packet: AprsPacket, receiver: unknown): Aprs
|
|||||||
symbolCode: packet.symbol_code ?? null,
|
symbolCode: packet.symbol_code ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Payload summaries ──────────────────────────────────────────────────────
|
||||||
|
// APRS packs its meaning into the information field with a set of one-character
|
||||||
|
// type identifiers and fixed-width encodings (APRS 1.0.1, chapters 6-15). The
|
||||||
|
// list showed that field as it arrives on the air, so reading a weather report
|
||||||
|
// meant decoding "_10090556c220s004g005t077..." by eye. These produce a line
|
||||||
|
// of plain text for the common types and leave the raw field to the expanded
|
||||||
|
// view, which is still the authority when a summary cannot be made.
|
||||||
|
|
||||||
|
/** `t077` → 25.0 °C. APRS carries temperature in whole degrees Fahrenheit. */
|
||||||
|
function fahrenheitToCelsius(fahrenheit: number): number {
|
||||||
|
return Math.round(((fahrenheit - 32) * 5 / 9) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weather report fields: wind, gust, temperature, rain, humidity, pressure. */
|
||||||
|
function summarizeAprsWeather(info: string): string | null {
|
||||||
|
const parts: string[] = [];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `T#005,199,000,255,073,123,01101001` → sequence, five channels, eight bits. */
|
||||||
|
function summarizeAprsTelemetry(info: string): string | null {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `:DEST :text{01` → addressed message text. */
|
||||||
|
function summarizeAprsMessage(info: string): string | null {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Course/speed appended to a position, as `088/036`. */
|
||||||
|
function summarizeAprsCourseSpeed(info: string): string | null {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whatever a frame is worth saying in one line, or null to fall back to raw. */
|
||||||
|
export function summarizeAprsPayload(packet: AprsPacket): string | null {
|
||||||
|
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: string[] = [];
|
||||||
|
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);
|
||||||
|
// Whatever the station wrote after the position report.
|
||||||
|
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
|
||||||
|
if (comment && comment.length <= 60) parts.push(comment);
|
||||||
|
return parts.length ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
// Status and anything else: the text it carries, minus its type character.
|
||||||
|
const text = info.replace(/^[>;<?]/, "").trim();
|
||||||
|
return text.length ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frame row ──────────────────────────────────────────────────────────────
|
||||||
|
// Shared by the APRS and HF APRS lists, which had a copy each of the same
|
||||||
|
// forty lines of markup and drifted only by one badge.
|
||||||
|
|
||||||
|
export interface AprsRowOptions {
|
||||||
|
/** Marks the newest frame so it can flash on arrival. */
|
||||||
|
fresh?: boolean;
|
||||||
|
/** Leading badge, e.g. the band a copy of this list is dedicated to. */
|
||||||
|
badge?: string;
|
||||||
|
/** Distance from the receiver, already formatted, or "" to leave it out. */
|
||||||
|
distance?: string;
|
||||||
|
/** Opens the map on a frame's position. */
|
||||||
|
onMap?: (lat: number, lon: number) => void;
|
||||||
|
/** Puts a frame's coordinates on the clipboard. */
|
||||||
|
onCopy?: (text: string, button: HTMLElement) => void;
|
||||||
|
/** Opens a log entry for the station that sent the frame. */
|
||||||
|
onLog?: (call: string, gridsquare: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A position as a six-character locator, which is what a log wants. */
|
||||||
|
function maidenheadForLatLon(lat: number, lon: number): string {
|
||||||
|
const adjustedLon = lon + 180;
|
||||||
|
const adjustedLat = lat + 90;
|
||||||
|
const field = String.fromCharCode(65 + Math.floor(adjustedLon / 20))
|
||||||
|
+ String.fromCharCode(65 + Math.floor(adjustedLat / 10));
|
||||||
|
const square = String(Math.floor((adjustedLon % 20) / 2)) + String(Math.floor(adjustedLat % 10));
|
||||||
|
const subLon = ((adjustedLon % 2) * 60) / 5;
|
||||||
|
const subLat = ((adjustedLat % 1) * 60) / 2.5;
|
||||||
|
const sub = String.fromCharCode(97 + Math.floor(subLon)) + String.fromCharCode(97 + Math.floor(subLat));
|
||||||
|
return `${field}${square}${sub}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement {
|
||||||
|
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
|
||||||
|
|| new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const category = aprsPacketCategory(packet);
|
||||||
|
const summary = summarizeAprsPayload(packet);
|
||||||
|
const hasPosition = packet.lat != null && packet.lon != null;
|
||||||
|
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
|
||||||
|
|
||||||
|
row.innerHTML =
|
||||||
|
`<summary class="decode-line">` +
|
||||||
|
`<span class="aprs-time">${escapeAprsHtml(time)}</span>` +
|
||||||
|
(options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") +
|
||||||
|
renderAprsSymbolSlot(packet, escapeAprsHtml) +
|
||||||
|
`<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span>` +
|
||||||
|
`<span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">` +
|
||||||
|
`${escapeAprsHtml(aprsCategoryLabel(category))}</span>` +
|
||||||
|
`<span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` +
|
||||||
|
(packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') +
|
||||||
|
(options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") +
|
||||||
|
`</summary>` +
|
||||||
|
`<div class="decode-expanded">` +
|
||||||
|
`<div class="decode-expanded-meta">` +
|
||||||
|
`<span>>${escapeAprsHtml(packet.destCall || "--")}</span>` +
|
||||||
|
`<span>${escapeAprsHtml(packet.path || "no path")}</span>` +
|
||||||
|
`<span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span>` +
|
||||||
|
`<span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` +
|
||||||
|
(hasPosition
|
||||||
|
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">`
|
||||||
|
+ `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>`
|
||||||
|
: "") +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` +
|
||||||
|
(packet.info_bytes?.length
|
||||||
|
? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>`
|
||||||
|
: "") +
|
||||||
|
`<div class="aprs-row-actions">` +
|
||||||
|
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") +
|
||||||
|
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") +
|
||||||
|
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
||||||
|
(options.onLog && packet.srcCall ? '<button class="aprs-inline-btn" type="button" data-aprs-log="1">Log</button>' : "") +
|
||||||
|
`</div>` +
|
||||||
|
`</div>`;
|
||||||
|
|
||||||
|
row.querySelectorAll<HTMLElement>("[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 as number, lon as number);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const logButton = row.querySelector<HTMLElement>("[data-aprs-log]");
|
||||||
|
if (logButton) {
|
||||||
|
logButton.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const grid = packet.lat != null && packet.lon != null
|
||||||
|
? maidenheadForLatLon(packet.lat, packet.lon)
|
||||||
|
: null;
|
||||||
|
options.onLog?.(packet.srcCall ?? "", grid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const copyButton = row.querySelector<HTMLElement>("[data-aprs-copy]");
|
||||||
|
if (copyButton) {
|
||||||
|
copyButton.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,15 +4,13 @@
|
|||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsHexBytes,
|
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
collapseAprsDuplicates,
|
collapseAprsDuplicates,
|
||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsInfo,
|
renderAprsPacketRow,
|
||||||
renderLocalAprsSymbol,
|
|
||||||
type AprsPacket,
|
type AprsPacket,
|
||||||
type AprsTypeFilter,
|
type AprsTypeFilter,
|
||||||
} from "./aprs-shared";
|
} from "./aprs-shared";
|
||||||
@@ -25,6 +23,7 @@ interface AprsBridge {
|
|||||||
aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
|
aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
|
||||||
getDecodeRigMeta?: () => unknown;
|
getDecodeRigMeta?: () => unknown;
|
||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
|
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
|
||||||
updateAprsBar?: () => void;
|
updateAprsBar?: () => void;
|
||||||
clearAprsBar?: () => void;
|
clearAprsBar?: () => void;
|
||||||
closeAprsBar?: () => void;
|
closeAprsBar?: () => void;
|
||||||
@@ -109,21 +108,29 @@ function aprsFilterMatch(pkt: AprsPacket): boolean {
|
|||||||
return haystack.includes(aprsFilterText);
|
return haystack.includes(aprsFilterText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the selected rig heard. The history holds every rig's traffic, for
|
||||||
|
* the map; the panel is about the rig the operator is working. */
|
||||||
|
function aprsRigPackets(): AprsPacket[] {
|
||||||
|
return forActiveRig(aprsPacketHistory);
|
||||||
|
}
|
||||||
|
|
||||||
function aprsVisiblePackets(): AprsPacket[] {
|
function aprsVisiblePackets(): AprsPacket[] {
|
||||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
const rigPackets = aprsRigPackets();
|
||||||
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
|
||||||
return packets.filter(aprsFilterMatch);
|
return packets.filter(aprsFilterMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAprsSummary() {
|
function updateAprsSummary() {
|
||||||
|
const rigPackets = aprsRigPackets();
|
||||||
const visible = aprsVisiblePackets();
|
const visible = aprsVisiblePackets();
|
||||||
if (aprsTotalCountEl) {
|
if (aprsTotalCountEl) {
|
||||||
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
}
|
}
|
||||||
if (aprsVisibleCountEl) {
|
if (aprsVisibleCountEl) {
|
||||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (aprsLatestSeenEl) {
|
if (aprsLatestSeenEl) {
|
||||||
const latest = aprsPacketHistory[0];
|
const latest = rigPackets[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aprsLatestSeenEl.textContent = "No packets yet";
|
aprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -141,93 +148,27 @@ function updateAprsChipState() {
|
|||||||
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
async function copyAprsCoords(text: string): Promise<void> {
|
||||||
const row = document.createElement("div");
|
try {
|
||||||
row.className = "aprs-packet";
|
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
if (!clipboard) return;
|
||||||
if (isFresh) row.classList.add("aprs-packet-new");
|
await clipboard.writeText(text);
|
||||||
|
showAprsHint("Coordinates copied", 1200);
|
||||||
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
} catch {
|
||||||
const age = aprsAgeText(pkt._tsMs);
|
showAprsHint("Copy failed", 1500);
|
||||||
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">${escapeAprsHtml(pkt.path)}</span>` : "";
|
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
|
|
||||||
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">${escapeAprsHtml(pkt.srcCall ?? "")}</span>` +
|
|
||||||
`<span>>${escapeAprsHtml(pkt.destCall || "")}</span>` +
|
|
||||||
`<span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` +
|
|
||||||
pathBadge +
|
|
||||||
crcBadge +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-meta">` +
|
|
||||||
`<span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` +
|
|
||||||
(distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") +
|
|
||||||
`<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-detail">` +
|
|
||||||
`<span title="${escapeAprsHtml(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">${escapeAprsHtml(pkt.srcCall || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(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">${escapeAprsHtml(pkt.info || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`</details>`;
|
|
||||||
|
|
||||||
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
|
|
||||||
el.addEventListener("click", (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
const raw = el.dataset.aprsMap ?? "";
|
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
|
||||||
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
aprsWindow.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
|
|
||||||
if (copyBtn) {
|
|
||||||
copyBtn.addEventListener("click", () => { void (async () => {
|
|
||||||
const raw = copyBtn.dataset.aprsCopy ?? "";
|
|
||||||
try {
|
|
||||||
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
|
|
||||||
if (clipboard) {
|
|
||||||
await clipboard.writeText(raw);
|
|
||||||
showAprsHint("Coordinates copied", 1200);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
showAprsHint("Copy failed", 1500);
|
|
||||||
}
|
|
||||||
})(); });
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return row;
|
function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
||||||
|
return renderAprsPacketRow(pkt, {
|
||||||
|
fresh: isFresh,
|
||||||
|
distance: aprsDistanceText(pkt),
|
||||||
|
onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); },
|
||||||
|
onCopy: (text) => { void copyAprsCoords(text); },
|
||||||
|
onLog: (call, gridsquare) => {
|
||||||
|
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? undefined, decoder: "aprs" });
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAprsHistory() {
|
function renderAprsHistory() {
|
||||||
@@ -251,7 +192,9 @@ function updateAprsBar() {
|
|||||||
if (!aprsBarOverlay) return;
|
if (!aprsBarOverlay) return;
|
||||||
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
|
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
|
||||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
const okFrames = aprsPacketHistory.filter(
|
||||||
|
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id),
|
||||||
|
);
|
||||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
@@ -301,6 +244,12 @@ function pruneAprsHistoryView(): void {
|
|||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hands a positioned packet to the map, if the map module is loaded yet. */
|
||||||
|
function plotAprsPacket(pkt: AprsPacket): void {
|
||||||
|
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: AprsPacket): void {
|
function addAprsPacket(pkt: AprsPacket): void {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
@@ -309,9 +258,7 @@ function addAprsPacket(pkt: AprsPacket): void {
|
|||||||
aprsPacketHistory.unshift(pkt);
|
aprsPacketHistory.unshift(pkt);
|
||||||
pruneAprsPacketHistory();
|
pruneAprsPacketHistory();
|
||||||
|
|
||||||
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
|
plotAprsPacket(pkt);
|
||||||
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pkt.crcOk) scheduleAprsBarUpdate();
|
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||||
|
|
||||||
@@ -324,17 +271,16 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
|
|||||||
|
|
||||||
function onServerAprsBatch(packets: AprsPacket[]): void {
|
function onServerAprsBatch(packets: AprsPacket[]): void {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
|
||||||
const normalized: AprsPacket[] = [];
|
const normalized: AprsPacket[] = [];
|
||||||
let hasCrcOk = false;
|
let hasCrcOk = false;
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerAprsPacket(pkt);
|
const next = normalizeServerAprsPacket(pkt);
|
||||||
|
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||||
|
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
|
plotAprsPacket(next);
|
||||||
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
|
|
||||||
}
|
|
||||||
if (next.crcOk) hasCrcOk = true;
|
if (next.crcOk) hasCrcOk = true;
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
@@ -394,8 +340,9 @@ if (aprsFilterInput) {
|
|||||||
|
|
||||||
// --- Server-side APRS decode handler ---
|
// --- Server-side APRS decode handler ---
|
||||||
function onServerAprs(pkt: AprsPacket): void {
|
function onServerAprs(pkt: AprsPacket): void {
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
const packet = normalizeServerAprsPacket(pkt);
|
||||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
|
||||||
|
addAprsPacket(packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
@@ -406,4 +353,7 @@ renderAprsHistory();
|
|||||||
restore: onServerAprsBatch,
|
restore: onServerAprsBatch,
|
||||||
reset: resetAprsHistoryView,
|
reset: resetAprsHistoryView,
|
||||||
prune: pruneAprsHistoryView,
|
prune: pruneAprsHistoryView,
|
||||||
|
rerender: () => { updateAprsBar(); renderAprsHistory(); },
|
||||||
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
+149
-64
@@ -66,10 +66,17 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
let bookmarkList: Bookmark[] = [];
|
let bookmarkList: Bookmark[] = [];
|
||||||
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let bgdDirty = false;
|
let bgdDirty = false;
|
||||||
|
/** Last polled state per bookmark, so a row can say what it is doing. */
|
||||||
|
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
|
||||||
|
let lastStatus: BackgroundDecodeStatus | null = null;
|
||||||
|
|
||||||
function initBackgroundDecode(rigId: string | null, role: string | null): void {
|
function initBackgroundDecode(rigId: string | null, role: string | null): void {
|
||||||
backgroundDecodeRole = role;
|
backgroundDecodeRole = role;
|
||||||
currentRigId = rigId || null;
|
// The panel used to take whatever rig it was handed at load and wait to be
|
||||||
|
// told again. Loading before the rig list arrives handed it null, and the
|
||||||
|
// next telling only came when the operator switched rigs, so the panel sat
|
||||||
|
// empty and silent. The host knows the rig; ask it.
|
||||||
|
currentRigId = rigId || hostState.lastActiveRigId || null;
|
||||||
if (currentRigId) loadBackgroundDecode();
|
if (currentRigId) loadBackgroundDecode();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
}
|
}
|
||||||
@@ -171,7 +178,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||||
renderBookmarkChecklist();
|
renderBookmarkChecklist();
|
||||||
|
|
||||||
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
const isControl = isControlRole();
|
||||||
const panel = document.getElementById("background-decode-panel");
|
const panel = document.getElementById("background-decode-panel");
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
|
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
|
||||||
@@ -182,6 +189,11 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||||
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||||
|
syncSaveButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentFilterText(): string {
|
||||||
|
return (document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBookmarkChecklist(filterText = ""): void {
|
function renderBookmarkChecklist(filterText = ""): void {
|
||||||
@@ -203,26 +215,65 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
: all;
|
: all;
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
container.innerHTML = '<div class="bgd-checklist-empty">' +
|
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
|
||||||
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
|
renderSelectionSummary();
|
||||||
'</div>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered.forEach(function (bookmark) {
|
filtered.forEach(function (bookmark) {
|
||||||
const row = document.createElement("label");
|
const row = document.createElement("label");
|
||||||
row.className = "bgd-checklist-row";
|
row.className = "bgd-checklist-row";
|
||||||
|
row.dataset.bmId = bookmark.id;
|
||||||
const decoders = bookmarkDecoderKinds(bookmark);
|
const decoders = bookmarkDecoderKinds(bookmark);
|
||||||
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
const selected = selectedIds.has(bookmark.id);
|
||||||
|
if (selected) row.classList.add("is-selected");
|
||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
'<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
||||||
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
||||||
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</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<HTMLInputElement>("input")?.addEventListener("change", function (e) {
|
row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) {
|
||||||
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
|
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
|
||||||
});
|
});
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
|
renderSelectionSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why the list is empty, in the terms the operator can act on. */
|
||||||
|
function emptyListText(supportedCount: number): string {
|
||||||
|
if (supportedCount > 0) return "No bookmark matches that filter.";
|
||||||
|
if (bookmarkList.length > 0) {
|
||||||
|
return "None of your bookmarks name a decoder that can run in the background."
|
||||||
|
+ " Give one a decoder on the Bookmarks tab to list it here.";
|
||||||
|
}
|
||||||
|
return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live state of a selected bookmark, as the row's own badge. */
|
||||||
|
function stateBadgeHtml(bookmarkId: string, selected: boolean): string {
|
||||||
|
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
|
||||||
|
const entry = statusByBookmark.get(bookmarkId);
|
||||||
|
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
|
||||||
|
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '">'
|
||||||
|
+ '<span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectionSummary(): void {
|
||||||
|
const el = document.getElementById("bgd-selection-summary");
|
||||||
|
if (!el) return;
|
||||||
|
const selected = currentConfig?.bookmark_ids.length ?? 0;
|
||||||
|
if (selected === 0) {
|
||||||
|
el.textContent = "Nothing selected — background decoding is idle.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
|
||||||
|
const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
|
||||||
|
el.textContent = currentConfig?.enabled
|
||||||
|
? `${noun}, ${String(active)} decoding now.`
|
||||||
|
: `${noun}. Switch Enabled on to start decoding them.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onChecklistToggle(bookmarkId: string, checked: boolean): void {
|
function onChecklistToggle(bookmarkId: string, checked: boolean): void {
|
||||||
@@ -260,7 +311,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
showToast(`Save failed: ${errorMessage(err)}`, true);
|
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||||
})
|
})
|
||||||
.finally(function () {
|
.finally(function () {
|
||||||
if (btn) btn.disabled = false;
|
syncSaveButton();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,58 +346,77 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus(status: BackgroundDecodeStatus): void {
|
function renderStatus(status: BackgroundDecodeStatus): void {
|
||||||
const card = document.getElementById("background-decode-status-card");
|
lastStatus = status;
|
||||||
if (!card) return;
|
statusByBookmark = new Map(
|
||||||
const entries = status.entries ?? [];
|
(status.entries ?? [])
|
||||||
if (!entries.length) {
|
.filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0)
|
||||||
card.textContent = "No background decode bookmarks configured.";
|
.map((entry) => [entry.bookmark_id as string, entry]),
|
||||||
return;
|
);
|
||||||
}
|
renderSpanSummary();
|
||||||
const summary = [];
|
// Rows carry the state, so the list repaints — keeping the filter the
|
||||||
if (status.active_rig) {
|
// operator typed and the selection they have not saved yet.
|
||||||
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
renderBookmarkChecklist(currentFilterText());
|
||||||
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
|
||||||
} else {
|
|
||||||
summary.push("This rig is not currently selected for audio.");
|
|
||||||
}
|
|
||||||
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
|
||||||
html += '<div class="bgd-status-list">';
|
|
||||||
entries.forEach(function (entry) {
|
|
||||||
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
|
||||||
const parts = [];
|
|
||||||
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
|
||||||
if (entry.mode) parts.push(entry.mode);
|
|
||||||
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
|
||||||
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
|
||||||
}
|
|
||||||
html +=
|
|
||||||
'<div class="bgd-status-row">' +
|
|
||||||
'<div>' +
|
|
||||||
'<div class="bgd-status-name">' + escHtml(name) + '</div>' +
|
|
||||||
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' +
|
|
||||||
'<svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' +
|
|
||||||
escHtml(prettyState(entry.state)) + '</div>' +
|
|
||||||
'</div>';
|
|
||||||
});
|
|
||||||
html += "</div>";
|
|
||||||
card.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the rig is listening across, which is what decides whether a
|
||||||
|
* selected bookmark can be decoded at all. */
|
||||||
|
function renderSpanSummary(): void {
|
||||||
|
const el = document.getElementById("bgd-span-summary");
|
||||||
|
if (!el) return;
|
||||||
|
const status = lastStatus;
|
||||||
|
if (!status) {
|
||||||
|
el.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one-line reason behind a state, for the row's tooltip. */
|
||||||
|
function stateHelp(state: string | undefined): string {
|
||||||
|
switch (state) {
|
||||||
|
case "active": return "Decoding on a hidden channel.";
|
||||||
|
case "out_of_span": return "Outside the span the rig is tuned across, so it cannot be heard from here.";
|
||||||
|
case "waiting_for_spectrum": return "Waiting for the first spectrum frame from the rig.";
|
||||||
|
case "waiting_for_user": return "Nobody is listening to this rig, so no audio is being pulled.";
|
||||||
|
case "missing_bookmark": return "The bookmark this was selected from is gone.";
|
||||||
|
case "no_supported_decoders": return "No decoder that runs in the background can decode this bookmark.";
|
||||||
|
case "disabled": return "Background decoding is switched off.";
|
||||||
|
case "handled_by_scheduler":
|
||||||
|
case "scheduler_has_control": return "The scheduler is running this bookmark instead.";
|
||||||
|
case "handled_by_virtual_channel": return "A virtual channel is already on this frequency.";
|
||||||
|
case "pending": return "Selected, and not started yet — save to apply.";
|
||||||
|
default: return "Selected, but not decoding.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dot beside the word already says whether this is running, so the
|
||||||
|
// words no longer carry a tick or a triangle of their own.
|
||||||
function prettyState(state: string | undefined): string {
|
function prettyState(state: string | undefined): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "active": return "\u2713 Active";
|
case "active": return "Decoding";
|
||||||
case "out_of_span": return "\u25B3 Out of span";
|
case "out_of_span": return "Out of span";
|
||||||
case "waiting_for_spectrum": return "\u25B3 Waiting";
|
case "waiting_for_spectrum": return "Waiting for spectrum";
|
||||||
case "waiting_for_user": return "\u25B3 No user";
|
case "waiting_for_user": return "Nobody listening";
|
||||||
case "missing_bookmark": return "\u2717 Missing";
|
case "missing_bookmark": return "Bookmark gone";
|
||||||
case "no_supported_decoders": return "\u2717 Unsupported";
|
case "no_supported_decoders": return "No decoder";
|
||||||
case "disabled": return "\u25B3 Disabled";
|
case "disabled": return "Off";
|
||||||
case "handled_by_scheduler": return "\u25B3 Scheduler";
|
case "handled_by_scheduler":
|
||||||
case "scheduler_has_control": return "\u25B3 Scheduler";
|
case "scheduler_has_control": return "Scheduler has it";
|
||||||
case "handled_by_virtual_channel": return "\u25B3 VChan";
|
case "handled_by_virtual_channel": return "On a channel";
|
||||||
default: return "\u25B3 Inactive";
|
case "pending": return "Not saved";
|
||||||
|
default: return "Idle";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,14 +450,25 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
function markBgdDirty() {
|
function markBgdDirty() {
|
||||||
if (bgdDirty) return;
|
if (bgdDirty) return;
|
||||||
bgdDirty = true;
|
bgdDirty = true;
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
syncSaveButton();
|
||||||
if (btn) btn.classList.add("sch-dirty");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearBgdDirty() {
|
function clearBgdDirty() {
|
||||||
bgdDirty = false;
|
bgdDirty = false;
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
syncSaveButton();
|
||||||
if (btn) btn.classList.remove("sch-dirty");
|
}
|
||||||
|
|
||||||
|
/** Save offers itself only when there is something to save. */
|
||||||
|
function syncSaveButton() {
|
||||||
|
const btn = document.getElementById("background-decode-save-btn") as HTMLButtonElement | null;
|
||||||
|
if (!btn) return;
|
||||||
|
btn.classList.toggle("sch-dirty", bgdDirty);
|
||||||
|
btn.disabled = !bgdDirty || !isControlRole();
|
||||||
|
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isControlRole(): boolean {
|
||||||
|
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToast(msg: string, isError: boolean): void {
|
function showToast(msg: string, isError: boolean): void {
|
||||||
@@ -407,7 +488,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
}
|
}
|
||||||
const ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
const ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
||||||
currentConfig.bookmark_ids = ids;
|
currentConfig.bookmark_ids = ids;
|
||||||
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
renderBookmarkChecklist(currentFilterText());
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,7 +497,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
currentConfig.bookmark_ids = [];
|
currentConfig.bookmark_ids = [];
|
||||||
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
renderBookmarkChecklist(currentFilterText());
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +513,11 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
|
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
|
||||||
if (enabledCb && !enabledCb._wired) {
|
if (enabledCb && !enabledCb._wired) {
|
||||||
enabledCb._wired = true;
|
enabledCb._wired = true;
|
||||||
enabledCb.addEventListener("change", function () { markBgdDirty(); });
|
enabledCb.addEventListener("change", function () {
|
||||||
|
if (currentConfig) currentConfig.enabled = enabledCb.checked;
|
||||||
|
markBgdDirty();
|
||||||
|
renderBookmarkChecklist(currentFilterText());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
|
const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
|
||||||
|
|||||||
@@ -456,6 +456,7 @@ function bmApply(bm: Bookmark): void {
|
|||||||
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
|
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
|
||||||
if (modeEl) {
|
if (modeEl) {
|
||||||
modeEl.value = (bm.mode || "").toUpperCase();
|
modeEl.value = (bm.mode || "").toUpperCase();
|
||||||
|
hostCore.syncModePicker();
|
||||||
}
|
}
|
||||||
if (bm.bandwidth_hz) {
|
if (bm.bandwidth_hz) {
|
||||||
hostState.currentBandwidthHz = bm.bandwidth_hz;
|
hostState.currentBandwidthHz = bm.bandwidth_hz;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
@@ -20,8 +21,11 @@ interface CwRenderer {
|
|||||||
drawPoints(points: number[], size: number, color: Rgba): void;
|
drawPoints(points: number[], size: number, color: Rgba): void;
|
||||||
}
|
}
|
||||||
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
|
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
|
||||||
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
|
interface CwEvent { rig_id?: string | null; text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
|
||||||
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number }
|
interface CwLine {
|
||||||
|
rigId: string | null; tsMs: number; ts: string; text: string;
|
||||||
|
wpm: number | null; tone_hz: number | null; lastMs: number;
|
||||||
|
}
|
||||||
interface CwToneRange {
|
interface CwToneRange {
|
||||||
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
|
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
|
||||||
toneSpanHz: number; lowerSideband: boolean; mode: string;
|
toneSpanHz: number; lowerSideband: boolean; mode: string;
|
||||||
@@ -66,7 +70,10 @@ const CW_BAR_LINE_GAP_MS = 5000;
|
|||||||
let cwLastAppendTime = 0;
|
let cwLastAppendTime = 0;
|
||||||
let cwTonePickerRaf: number | null = null;
|
let cwTonePickerRaf: number | null = null;
|
||||||
let cwBarHistory: CwLine[] = [];
|
let cwBarHistory: CwLine[] = [];
|
||||||
let cwBarCurrentLine: CwLine | null = null;
|
// One line in progress per rig, keyed by rig id ("" for a decode that names no
|
||||||
|
// rig). Two rigs copying at the same time each fill their own line instead of
|
||||||
|
// braiding their characters into one.
|
||||||
|
const cwBarCurrentLines = new Map<string, CwLine>();
|
||||||
let cwBarDismissedAtMs = 0;
|
let cwBarDismissedAtMs = 0;
|
||||||
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
||||||
// acknowledged). While set, server-state updates must not override the
|
// acknowledged). While set, server-state updates must not override the
|
||||||
@@ -101,12 +108,13 @@ cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
|
|||||||
applyCwAutoUi(enabled);
|
applyCwAutoUi(enabled);
|
||||||
};
|
};
|
||||||
|
|
||||||
function cwBarFlushCurrentLine(): void {
|
function cwBarFlushCurrentLine(key: string): void {
|
||||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
const line = cwBarCurrentLines.get(key);
|
||||||
cwBarHistory.unshift(cwBarCurrentLine);
|
cwBarCurrentLines.delete(key);
|
||||||
|
if (line?.text.trim()) {
|
||||||
|
cwBarHistory.unshift(line);
|
||||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
}
|
}
|
||||||
cwBarCurrentLine = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCwBar(): void {
|
function updateCwBar(): void {
|
||||||
@@ -114,9 +122,12 @@ function updateCwBar(): void {
|
|||||||
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
||||||
const isCw = mode === "CW" || mode === "CWR";
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
|
||||||
// Prepend the in-progress line so characters appear immediately
|
// Prepend the in-progress lines so characters appear immediately
|
||||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
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);
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
@@ -402,7 +413,7 @@ function resetCwHistoryView(): void {
|
|||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
cwLastAppendTime = 0;
|
cwLastAppendTime = 0;
|
||||||
cwBarHistory = [];
|
cwBarHistory = [];
|
||||||
cwBarCurrentLine = null;
|
cwBarCurrentLines.clear();
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
}
|
}
|
||||||
@@ -421,8 +432,13 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
|||||||
|
|
||||||
// --- Server-side CW decode handler ---
|
// --- Server-side CW decode handler ---
|
||||||
function onServerCw(evt: CwEvent): void {
|
function onServerCw(evt: CwEvent): void {
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
// The panel is a single stream of copied text: characters from a background
|
||||||
if (evt.text && cwOutputEl) {
|
// rig do not belong in it at all, and interleaving them would make both
|
||||||
|
// rigs unreadable. The bar histories below keep every rig, each on its own
|
||||||
|
// line, and pick out the selected rig when they paint.
|
||||||
|
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
|
||||||
|
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
|
||||||
|
if (evt.text && cwOutputEl && forSelectedRig) {
|
||||||
// Append decoded text to output
|
// Append decoded text to output
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
||||||
@@ -445,25 +461,29 @@ function onServerCw(evt: CwEvent): void {
|
|||||||
// Bar history accumulation (regardless of pause state)
|
// Bar history accumulation (regardless of pause state)
|
||||||
if (evt.text) {
|
if (evt.text) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const rigId = evt.rig_id ?? null;
|
||||||
|
const key = rigId ?? "";
|
||||||
if (evt.text === "\n") {
|
if (evt.text === "\n") {
|
||||||
cwBarFlushCurrentLine();
|
cwBarFlushCurrentLine(key);
|
||||||
} else {
|
} else {
|
||||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
let line = cwBarCurrentLines.get(key);
|
||||||
cwBarFlushCurrentLine();
|
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||||
|
cwBarFlushCurrentLine(key);
|
||||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||||
|
cwBarCurrentLines.set(key, line);
|
||||||
}
|
}
|
||||||
cwBarCurrentLine.text += evt.text;
|
line.text += evt.text;
|
||||||
cwBarCurrentLine.lastMs = now;
|
line.lastMs = now;
|
||||||
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
|
||||||
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
|
||||||
}
|
}
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
}
|
}
|
||||||
if (cwSignalIndicator) {
|
if (cwSignalIndicator && forSelectedRig) {
|
||||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||||
}
|
}
|
||||||
if (!cwAutoInput || cwAutoInput.checked) {
|
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
|
||||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||||
}
|
}
|
||||||
@@ -491,6 +511,13 @@ cwWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onMessage: onServerCw,
|
onMessage: onServerCw,
|
||||||
restore: restoreCwHistory,
|
restore: restoreCwHistory,
|
||||||
reset: resetCwHistoryView,
|
reset: resetCwHistoryView,
|
||||||
|
// The copied text of a rig that is no longer on screen cannot be unpicked
|
||||||
|
// from the pane, so the switch starts the new rig's stream from empty.
|
||||||
|
rerender: () => {
|
||||||
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
|
cwLastAppendTime = 0;
|
||||||
|
updateCwBar();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
|
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
|
||||||
@@ -15,6 +16,7 @@ export interface FtxMessage {
|
|||||||
snr_db?: number | undefined;
|
snr_db?: number | undefined;
|
||||||
dt_s?: number | undefined;
|
dt_s?: number | undefined;
|
||||||
freq_hz?: number | undefined;
|
freq_hz?: number | undefined;
|
||||||
|
rig_id?: string | null | undefined;
|
||||||
receiver?: unknown;
|
receiver?: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -47,6 +49,8 @@ interface FtxBridge {
|
|||||||
updateFt8Bar?: () => void;
|
updateFt8Bar?: () => void;
|
||||||
registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
|
registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
|
||||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
|
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
|
||||||
|
/** Opens a log entry from a decode. Pre-fills it; never logs by itself. */
|
||||||
|
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
|
||||||
trxUi: ConfirmApi;
|
trxUi: ConfirmApi;
|
||||||
clearFt8Bar?: () => void;
|
clearFt8Bar?: () => void;
|
||||||
closeFt8Bar?: () => void;
|
closeFt8Bar?: () => void;
|
||||||
@@ -222,14 +226,37 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||||
});
|
});
|
||||||
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
||||||
|
// A decode is not a contact: this opens an entry with what was heard in it
|
||||||
|
// and leaves the logging to the operator.
|
||||||
|
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||||
|
if (station) {
|
||||||
|
const log = document.createElement("button");
|
||||||
|
log.type = "button";
|
||||||
|
log.className = "ft8-log-btn";
|
||||||
|
log.textContent = "Log";
|
||||||
|
log.title = `Start a log entry for ${station}`;
|
||||||
|
log.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const details = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
|
bridge.logContact?.({
|
||||||
|
call: station,
|
||||||
|
gridsquare: details.find((detail) => detail.station === station)?.grid,
|
||||||
|
decoder: id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
row.appendChild(log);
|
||||||
|
}
|
||||||
return row;
|
return row;
|
||||||
};
|
};
|
||||||
const render = (): void => {
|
const render = (): void => {
|
||||||
prune();
|
prune();
|
||||||
if (!messagesElement) return;
|
if (!messagesElement) return;
|
||||||
|
// The history holds every rig's decodes, for the map; the panel is about
|
||||||
|
// the rig the operator is working.
|
||||||
|
const rigMessages = forActiveRig(history);
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const message of history) {
|
for (const message of rigMessages) {
|
||||||
if (count >= 200) break;
|
if (count >= 200) break;
|
||||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||||
fragment.appendChild(renderRow(message));
|
fragment.appendChild(renderRow(message));
|
||||||
@@ -237,20 +264,32 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
}
|
}
|
||||||
messagesElement.replaceChildren(fragment);
|
messagesElement.replaceChildren(fragment);
|
||||||
};
|
};
|
||||||
const normalize = (message: FtxMessage): FtxMessage => {
|
/** Hands a decode's grid squares to the map, if the map module is loaded yet.
|
||||||
|
* Split out of normalize so a decode can be replayed onto a map that
|
||||||
|
* arrived later: the module is lazy, and everything decoded before it
|
||||||
|
* loaded had nowhere to go. */
|
||||||
|
const plotLocator = (message: FtxMessage): void => {
|
||||||
const raw = message.message ?? "";
|
const raw = message.message ?? "";
|
||||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
const grids = locatorDetails.length > 0
|
const grids = locatorDetails.length > 0
|
||||||
? locatorDetails.map(({ grid }) => grid)
|
? locatorDetails.map(({ grid }) => grid)
|
||||||
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
||||||
|
if (grids.length === 0) return;
|
||||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||||
|
// Already an RF frequency on a replay, an audio offset on arrival; the
|
||||||
|
// conversion only fires below 100 kHz, so it is right either way.
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
if (grids.length > 0) {
|
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
|
||||||
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
|
});
|
||||||
});
|
};
|
||||||
}
|
const normalize = (message: FtxMessage): FtxMessage => {
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
plotLocator(message);
|
||||||
return {
|
return {
|
||||||
|
// The rig that heard it, kept so the mini view can tell a decode of the
|
||||||
|
// rig on screen from one a background rig made on another band.
|
||||||
|
rig_id: message.rig_id ?? null,
|
||||||
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||||
ts_ms: message.ts_ms,
|
ts_ms: message.ts_ms,
|
||||||
snr_db: message.snr_db,
|
snr_db: message.snr_db,
|
||||||
@@ -262,7 +301,10 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
};
|
};
|
||||||
const receiveBatch = (messages: FtxMessage[]): void => {
|
const receiveBatch = (messages: FtxMessage[]): void => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
if (status) status.textContent = "Receiving";
|
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||||
|
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
|
||||||
|
status.textContent = "Receiving";
|
||||||
|
}
|
||||||
history = messages.map(normalize).reverse().concat(history);
|
history = messages.map(normalize).reverse().concat(history);
|
||||||
prune();
|
prune();
|
||||||
bridge.setFt8FamilyBarDecoder?.(id);
|
bridge.setFt8FamilyBarDecoder?.(id);
|
||||||
@@ -277,7 +319,8 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
};
|
};
|
||||||
const barFrames = (): BarFrames => {
|
const barFrames = (): BarFrames => {
|
||||||
const recent = history
|
const recent = history
|
||||||
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000)
|
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000
|
||||||
|
&& isActiveRigDecode(message.rig_id))
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
let html = "";
|
let html = "";
|
||||||
for (const message of recent) {
|
for (const message of recent) {
|
||||||
@@ -299,6 +342,9 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
restore: receiveBatch,
|
restore: receiveBatch,
|
||||||
prune: () => { prune(); render(); },
|
prune: () => { prune(); render(); },
|
||||||
reset,
|
reset,
|
||||||
|
rerender: () => { bridge.updateFt8Bar?.(); render(); },
|
||||||
|
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||||
|
syncMap: () => { for (const message of [...history].reverse()) plotLocator(message); },
|
||||||
});
|
});
|
||||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,13 @@
|
|||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsHexBytes,
|
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
collapseAprsDuplicates,
|
collapseAprsDuplicates,
|
||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsInfo,
|
renderAprsPacketRow,
|
||||||
renderLocalAprsSymbol,
|
|
||||||
type AprsPacket,
|
type AprsPacket,
|
||||||
type AprsTypeFilter,
|
type AprsTypeFilter,
|
||||||
} from "./aprs-shared";
|
} from "./aprs-shared";
|
||||||
@@ -22,12 +20,17 @@ interface HfAprsBridge {
|
|||||||
getDecodeHistoryRetentionMs?: () => number;
|
getDecodeHistoryRetentionMs?: () => number;
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
navigateToAprsMap?: (lat: number, lon: number) => void;
|
navigateToAprsMap?: (lat: number, lon: number) => void;
|
||||||
|
aprsMapAddStation?: (
|
||||||
|
call: string, lat: number, lon: number, info: string,
|
||||||
|
symbolTable: string | null | undefined, symbolCode: string | null | undefined,
|
||||||
|
packet: AprsPacket, source: "aprs" | "hf_aprs",
|
||||||
|
) => void;
|
||||||
|
clearMapMarkersByType?: (type: string) => void;
|
||||||
getDecodeRigMeta?: () => unknown;
|
getDecodeRigMeta?: () => unknown;
|
||||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
}
|
}
|
||||||
const hfAprsWindow = window as unknown as HfAprsBridge;
|
const hfAprsWindow = window as unknown as HfAprsBridge;
|
||||||
const escapeHfAprsHtml = (input: string): string => hostCore.escapeMapHtml(input);
|
|
||||||
|
|
||||||
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
|
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
|
||||||
const hfAprsStatus = document.getElementById("hf-aprs-status");
|
const hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||||
@@ -94,23 +97,31 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
|
|||||||
return haystack.includes(hfAprsFilterText);
|
return haystack.includes(hfAprsFilterText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the selected rig heard. The history holds every rig's traffic, for
|
||||||
|
* the map; the panel is about the rig the operator is working. */
|
||||||
|
function hfAprsRigPackets(): AprsPacket[] {
|
||||||
|
return forActiveRig(hfAprsPacketHistory);
|
||||||
|
}
|
||||||
|
|
||||||
function hfAprsVisiblePackets(): AprsPacket[] {
|
function hfAprsVisiblePackets(): AprsPacket[] {
|
||||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
const rigPackets = hfAprsRigPackets();
|
||||||
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
|
||||||
return packets.filter(hfAprsFilterMatch);
|
return packets.filter(hfAprsFilterMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
const collapseHfAprsDuplicates = collapseAprsDuplicates;
|
const collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||||
|
|
||||||
function updateHfAprsSummary() {
|
function updateHfAprsSummary() {
|
||||||
|
const rigPackets = hfAprsRigPackets();
|
||||||
const visible = hfAprsVisiblePackets();
|
const visible = hfAprsVisiblePackets();
|
||||||
if (hfAprsTotalCountEl) {
|
if (hfAprsTotalCountEl) {
|
||||||
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
}
|
}
|
||||||
if (hfAprsVisibleCountEl) {
|
if (hfAprsVisibleCountEl) {
|
||||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (hfAprsLatestSeenEl) {
|
if (hfAprsLatestSeenEl) {
|
||||||
const latest = hfAprsPacketHistory[0];
|
const latest = rigPackets[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -128,95 +139,27 @@ function updateHfAprsChipState() {
|
|||||||
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HF traffic goes in the same row as VHF, marked with the band it came in on.
|
||||||
|
// This was a second copy of the same forty lines of markup.
|
||||||
function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
||||||
const row = document.createElement("div");
|
return renderAprsPacketRow(pkt, {
|
||||||
row.className = "aprs-packet";
|
fresh: isFresh,
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
badge: "HF",
|
||||||
if (isFresh) row.classList.add("aprs-packet-new");
|
distance: hfAprsDistanceText(pkt),
|
||||||
|
onMap: (lat: number, lon: number) => { hfAprsWindow.navigateToAprsMap?.(lat, lon); },
|
||||||
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
onCopy: (text: string) => { void copyHfAprsCoords(text); },
|
||||||
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">${escapeHfAprsHtml(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>';
|
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
|
|
||||||
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">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span>` +
|
|
||||||
`<span>>${escapeHfAprsHtml(pkt.destCall || "")}</span>` +
|
|
||||||
`<span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` +
|
|
||||||
pathBadge +
|
|
||||||
crcBadge +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-meta">` +
|
|
||||||
`<span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` +
|
|
||||||
(distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") +
|
|
||||||
`<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-detail">` +
|
|
||||||
`<span title="${escapeHfAprsHtml(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">${escapeHfAprsHtml(pkt.srcCall || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(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">${escapeHfAprsHtml(pkt.info || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`</details>`;
|
|
||||||
|
|
||||||
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
|
|
||||||
el.addEventListener("click", (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
const raw = el.dataset.aprsMap ?? "";
|
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
|
||||||
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
hfAprsWindow.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
|
async function copyHfAprsCoords(text: string): Promise<void> {
|
||||||
if (copyBtn) {
|
try {
|
||||||
copyBtn.addEventListener("click", () => { void (async () => {
|
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
|
||||||
const raw = copyBtn.dataset.aprsCopy ?? "";
|
if (!clipboard) return;
|
||||||
try {
|
await clipboard.writeText(text);
|
||||||
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
|
hostCore.showHint("Coordinates copied", 1200);
|
||||||
if (clipboard) {
|
} catch {
|
||||||
await clipboard.writeText(raw);
|
hostCore.showHint("Copy failed", 1500);
|
||||||
hostCore.showHint("Coordinates copied", 1200);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
hostCore.showHint("Copy failed", 1500);
|
|
||||||
}
|
|
||||||
})(); });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHfAprsHistory() {
|
function renderHfAprsHistory() {
|
||||||
@@ -240,6 +183,7 @@ function resetHfAprsHistoryView(): void {
|
|||||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
hfAprsPacketHistory = [];
|
hfAprsPacketHistory = [];
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
|
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneHfAprsHistoryView(): void {
|
function pruneHfAprsHistoryView(): void {
|
||||||
@@ -247,6 +191,17 @@ function pruneHfAprsHistoryView(): void {
|
|||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hands a positioned packet to the map, if the map module is loaded yet.
|
||||||
|
* HF traffic goes on as its own source: it is a different band and a
|
||||||
|
* different path, and the map filter offers it separately. */
|
||||||
|
function plotHfAprsPacket(pkt: AprsPacket): void {
|
||||||
|
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
|
||||||
|
hfAprsWindow.aprsMapAddStation(
|
||||||
|
pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "",
|
||||||
|
pkt.symbolTable, pkt.symbolCode, pkt, "hf_aprs",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function addHfAprsPacket(pkt: AprsPacket): void {
|
function addHfAprsPacket(pkt: AprsPacket): void {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
@@ -255,6 +210,8 @@ function addHfAprsPacket(pkt: AprsPacket): void {
|
|||||||
hfAprsPacketHistory.unshift(pkt);
|
hfAprsPacketHistory.unshift(pkt);
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
|
|
||||||
|
plotHfAprsPacket(pkt);
|
||||||
|
|
||||||
scheduleHfAprsHistoryRender();
|
scheduleHfAprsHistoryRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,13 +221,15 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
|
|||||||
|
|
||||||
function onServerHfAprsBatch(packets: AprsPacket[]): void {
|
function onServerHfAprsBatch(packets: AprsPacket[]): void {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
|
||||||
const normalized: AprsPacket[] = [];
|
const normalized: AprsPacket[] = [];
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerHfAprsPacket(pkt);
|
const next = normalizeServerHfAprsPacket(pkt);
|
||||||
|
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||||
|
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
plotHfAprsPacket(next);
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -338,8 +297,9 @@ if (hfAprsFilterInput) {
|
|||||||
|
|
||||||
// --- Server-side HF APRS decode handler ---
|
// --- Server-side HF APRS decode handler ---
|
||||||
function onServerHfAprs(pkt: AprsPacket): void {
|
function onServerHfAprs(pkt: AprsPacket): void {
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
const packet = normalizeServerHfAprsPacket(pkt);
|
||||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||||
|
addHfAprsPacket(packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
@@ -350,4 +310,7 @@ renderHfAprsHistory();
|
|||||||
restore: onServerHfAprsBatch,
|
restore: onServerHfAprsBatch,
|
||||||
reset: resetHfAprsHistoryView,
|
reset: resetHfAprsHistoryView,
|
||||||
prune: pruneHfAprsHistoryView,
|
prune: pruneHfAprsHistoryView,
|
||||||
|
rerender: renderHfAprsHistory,
|
||||||
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => { for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface HostDecoderDescriptor {
|
|||||||
export interface HostState {
|
export interface HostState {
|
||||||
readonly serverLat: number | null;
|
readonly serverLat: number | null;
|
||||||
readonly serverLon: number | null;
|
readonly serverLon: number | null;
|
||||||
|
/** The callsign this station is on the air with, from the client config. */
|
||||||
|
readonly ownerCallsign: string | null;
|
||||||
readonly authEnabled: boolean;
|
readonly authEnabled: boolean;
|
||||||
readonly authRole: string | null;
|
readonly authRole: string | null;
|
||||||
readonly lastActiveRigId: string | null;
|
readonly lastActiveRigId: string | null;
|
||||||
@@ -43,6 +45,7 @@ export interface HostCore {
|
|||||||
armOptimisticFrequency(frequencyHz: number): void;
|
armOptimisticFrequency(frequencyHz: number): void;
|
||||||
escapeMapHtml(value: string): string;
|
escapeMapHtml(value: string): string;
|
||||||
haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
||||||
|
latLonToMaidenhead(lat: number, lon: number): string;
|
||||||
showHint(message: string, durationMs?: number): void;
|
showHint(message: string, durationMs?: number): void;
|
||||||
formatFreqForStep(frequencyHz: number, stepHz: number): string;
|
formatFreqForStep(frequencyHz: number, stepHz: number): string;
|
||||||
refreshFreqDisplay(): void;
|
refreshFreqDisplay(): void;
|
||||||
@@ -59,6 +62,8 @@ export interface HostCore {
|
|||||||
setRigFrequency(frequencyHz: number): void;
|
setRigFrequency(frequencyHz: number): void;
|
||||||
syncBandwidthInput(bandwidthHz: number): void;
|
syncBandwidthInput(bandwidthHz: number): void;
|
||||||
scheduleSpectrumDraw(): void;
|
scheduleSpectrumDraw(): void;
|
||||||
|
/** Repaints the mode buttons from #mode after writing to it. */
|
||||||
|
syncModePicker(): void;
|
||||||
onDecoderRegistryReady(callback: () => void): void;
|
onDecoderRegistryReady(callback: () => void): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// The station log: one panel that opens an entry, keeps the log, and takes
|
||||||
|
// ADIF in and out.
|
||||||
|
//
|
||||||
|
// Times come from the server, never from here. The browser may be a phone in
|
||||||
|
// another timezone with a clock nobody has checked, and a log is only worth
|
||||||
|
// keeping if the times in it are the radio's.
|
||||||
|
|
||||||
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
interface Qso {
|
||||||
|
id: string;
|
||||||
|
started_at: string;
|
||||||
|
call: string;
|
||||||
|
freq_hz: number;
|
||||||
|
band?: string | null;
|
||||||
|
mode: string;
|
||||||
|
submode?: string | null;
|
||||||
|
rst_sent?: string | null;
|
||||||
|
rst_rcvd?: string | null;
|
||||||
|
gridsquare?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
qth?: string | null;
|
||||||
|
comment?: string | null;
|
||||||
|
station_callsign?: string | null;
|
||||||
|
operator?: string | null;
|
||||||
|
my_gridsquare?: string | null;
|
||||||
|
my_rig?: string | null;
|
||||||
|
rig_id?: string | null;
|
||||||
|
contest_id?: string | null;
|
||||||
|
stx?: number | null;
|
||||||
|
srx?: number | null;
|
||||||
|
srx_string?: string | null;
|
||||||
|
qsl_rcvd?: string | null;
|
||||||
|
lotw_qsl_rcvd?: string | null;
|
||||||
|
confirmed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BandStatistics {
|
||||||
|
band: string;
|
||||||
|
contacts: number;
|
||||||
|
stations: number;
|
||||||
|
confirmed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Prefill {
|
||||||
|
started_at: string;
|
||||||
|
epoch_ms: number;
|
||||||
|
freq_hz: number;
|
||||||
|
band: string | null;
|
||||||
|
mode: string;
|
||||||
|
submode: string | null;
|
||||||
|
rig_id: string | null;
|
||||||
|
my_rig: string | null;
|
||||||
|
call: string | null;
|
||||||
|
gridsquare: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogbookBridge {
|
||||||
|
trxUi: {
|
||||||
|
confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean>;
|
||||||
|
notify?(message: string, options?: { kind?: string }): void;
|
||||||
|
};
|
||||||
|
navigateToTab?: (name: string) => void;
|
||||||
|
/** Opens the panel with an entry started from a decode. */
|
||||||
|
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridge = window as unknown as LogbookBridge;
|
||||||
|
|
||||||
|
const el = <T extends HTMLElement>(id: string): T | null => document.getElementById(id) as T | null;
|
||||||
|
|
||||||
|
const form = el<HTMLFormElement>("log-entry-form");
|
||||||
|
const callInput = el<HTMLInputElement>("log-call");
|
||||||
|
const freqInput = el<HTMLInputElement>("log-freq");
|
||||||
|
const modeInput = el<HTMLInputElement>("log-mode");
|
||||||
|
const rstSentInput = el<HTMLInputElement>("log-rst-sent");
|
||||||
|
const rstRcvdInput = el<HTMLInputElement>("log-rst-rcvd");
|
||||||
|
const gridInput = el<HTMLInputElement>("log-grid");
|
||||||
|
const nameInput = el<HTMLInputElement>("log-name");
|
||||||
|
const commentInput = el<HTMLInputElement>("log-comment");
|
||||||
|
const operatorInput = el<HTMLInputElement>("log-operator");
|
||||||
|
const rowsBody = el<HTMLTableSectionElement>("log-rows");
|
||||||
|
const summaryEl = el("log-summary");
|
||||||
|
const workedEl = el("log-worked-before");
|
||||||
|
const clockEl = el("log-clock");
|
||||||
|
const stationCallEl = el("log-station-callsign");
|
||||||
|
const stationRigEl = el("log-station-rig-name");
|
||||||
|
const stationGridEl = el("log-station-grid");
|
||||||
|
const filterCall = el<HTMLInputElement>("log-filter-call");
|
||||||
|
const filterBand = el<HTMLSelectElement>("log-filter-band");
|
||||||
|
const filterMode = el<HTMLSelectElement>("log-filter-mode");
|
||||||
|
const importBtn = el<HTMLButtonElement>("log-import-btn");
|
||||||
|
const importFile = el<HTMLInputElement>("log-import-file");
|
||||||
|
const exportLink = el<HTMLAnchorElement>("log-export-btn");
|
||||||
|
const clearBtn = el<HTMLButtonElement>("log-clear-btn");
|
||||||
|
const saveBtn = el<HTMLButtonElement>("log-save-btn");
|
||||||
|
const contestIdInput = el<HTMLInputElement>("log-contest-id");
|
||||||
|
const stxInput = el<HTMLInputElement>("log-stx");
|
||||||
|
const srxInput = el<HTMLInputElement>("log-srx");
|
||||||
|
const statisticsRows = el<HTMLTableSectionElement>("log-statistics-rows");
|
||||||
|
const cabrilloContest = el<HTMLInputElement>("log-cbr-contest");
|
||||||
|
const cabrilloCallsign = el<HTMLInputElement>("log-cbr-callsign");
|
||||||
|
const cabrilloOperator = el<HTMLSelectElement>("log-cbr-operator");
|
||||||
|
const cabrilloPower = el<HTMLSelectElement>("log-cbr-power");
|
||||||
|
const cabrilloScore = el<HTMLInputElement>("log-cbr-score");
|
||||||
|
const cabrilloExport = el<HTMLAnchorElement>("log-cbr-export");
|
||||||
|
|
||||||
|
/** The time the open entry was started, as the server gave it. */
|
||||||
|
let entryStartedAt: string | null = null;
|
||||||
|
/** The rig the open entry belongs to, likewise. */
|
||||||
|
let entryRigId: string | null = null;
|
||||||
|
let entryRigName: string | null = null;
|
||||||
|
let entryGrid: string | null = null;
|
||||||
|
let qsos: Qso[] = [];
|
||||||
|
let workedRequest = 0;
|
||||||
|
|
||||||
|
function notify(message: string, kind?: string): void {
|
||||||
|
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined);
|
||||||
|
else hostCore.showHint(message, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson<T>(path: string): Promise<T> {
|
||||||
|
const response = await fetch(path);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFreq(hz: number): string {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "";
|
||||||
|
// Through Number, not a trailing-zero trim: trimming "20.000000" as a string
|
||||||
|
// walks back through the point and leaves "2".
|
||||||
|
return String(Number((hz / 1e6).toFixed(6)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the operator typed, in Hz: MHz unless it is plainly already Hz. */
|
||||||
|
function parseFreq(text: string): number | null {
|
||||||
|
const value = Number(text.trim().replace(/\s+/g, "").replace(",", "."));
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return null;
|
||||||
|
return value < 100_000 ? Math.round(value * 1e6) : Math.round(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A serial, or null when the exchange is a word rather than a number. */
|
||||||
|
function numberOrNull(text: string | undefined): number | null {
|
||||||
|
const trimmed = (text ?? "").trim();
|
||||||
|
if (!trimmed || !/^\d+$/.test(trimmed)) return null;
|
||||||
|
const value = Number(trimmed);
|
||||||
|
return Number.isSafeInteger(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcDate(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcTime(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The entry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Open an entry, pre-filled with the six fields the radio and clock answer. */
|
||||||
|
async function openEntry(
|
||||||
|
seed: { call?: string; gridsquare?: string | undefined; decoder?: string } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (hostState.lastActiveRigId) params.set("remote", hostState.lastActiveRigId);
|
||||||
|
if (seed.decoder) params.set("decoder", seed.decoder);
|
||||||
|
if (seed.call) params.set("call", seed.call);
|
||||||
|
if (seed.gridsquare) params.set("gridsquare", seed.gridsquare);
|
||||||
|
try {
|
||||||
|
const prefill = await getJson<Prefill>(`/api/logbook/prefill?${params.toString()}`);
|
||||||
|
applyPrefill(prefill);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("logbook prefill failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPrefill(prefill: Prefill): void {
|
||||||
|
entryStartedAt = prefill.started_at;
|
||||||
|
entryRigId = prefill.rig_id;
|
||||||
|
entryRigName = prefill.my_rig;
|
||||||
|
if (freqInput) freqInput.value = formatFreq(prefill.freq_hz);
|
||||||
|
if (modeInput) modeInput.value = prefill.submode ?? prefill.mode;
|
||||||
|
if (callInput && prefill.call) callInput.value = prefill.call;
|
||||||
|
if (gridInput && prefill.gridsquare) gridInput.value = prefill.gridsquare;
|
||||||
|
if (stationRigEl) stationRigEl.textContent = prefill.my_rig ?? "no rig";
|
||||||
|
showClock(prefill);
|
||||||
|
updateWorkedBefore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The entry's time, and whether this browser agrees with it. */
|
||||||
|
function showClock(prefill: Prefill): void {
|
||||||
|
if (!clockEl) return;
|
||||||
|
const time = utcTime(prefill.started_at);
|
||||||
|
const drift = Math.abs(Date.now() - prefill.epoch_ms);
|
||||||
|
clockEl.textContent = drift > 1000
|
||||||
|
? `${time}Z · your clock is ${(drift / 1000).toFixed(0)}s out`
|
||||||
|
: `${time}Z`;
|
||||||
|
clockEl.classList.toggle("is-adrift", drift > 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the entry and open a fresh one. */
|
||||||
|
function resetEntry(): void {
|
||||||
|
// The contest and the serial sent stay: they belong to the session, not to
|
||||||
|
// the contact just logged.
|
||||||
|
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) {
|
||||||
|
if (input) input.value = "";
|
||||||
|
}
|
||||||
|
if (workedEl) workedEl.textContent = "";
|
||||||
|
void openEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEntry(event: Event): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
const call = callInput?.value.trim() ?? "";
|
||||||
|
if (!call) {
|
||||||
|
notify("A contact needs a callsign", "error");
|
||||||
|
callInput?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const freqHz = parseFreq(freqInput?.value ?? "");
|
||||||
|
if (freqHz == null) {
|
||||||
|
notify("A contact needs a frequency", "error");
|
||||||
|
freqInput?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const typedMode = (modeInput?.value ?? "").trim().toUpperCase();
|
||||||
|
// USB and LSB are how a rig says SSB; the log wants the mode with the
|
||||||
|
// sideband beside it, which is what other loggers read.
|
||||||
|
const isSideband = typedMode === "USB" || typedMode === "LSB";
|
||||||
|
const body = {
|
||||||
|
// The server stamps the time; this is the one it gave when the entry
|
||||||
|
// opened, so a contact logged five minutes later keeps the time it started.
|
||||||
|
started_at: entryStartedAt,
|
||||||
|
call,
|
||||||
|
freq_hz: freqHz,
|
||||||
|
mode: isSideband ? "SSB" : typedMode,
|
||||||
|
submode: isSideband ? typedMode : null,
|
||||||
|
rst_sent: rstSentInput?.value ?? null,
|
||||||
|
rst_rcvd: rstRcvdInput?.value ?? null,
|
||||||
|
gridsquare: gridInput?.value ?? null,
|
||||||
|
name: nameInput?.value ?? null,
|
||||||
|
comment: commentInput?.value ?? null,
|
||||||
|
contest_id: contestIdInput?.value ?? null,
|
||||||
|
// The exchange is a number when it is a serial and a word when it is a
|
||||||
|
// zone or a section; both are kept, and the log writes whichever it has.
|
||||||
|
stx: numberOrNull(stxInput?.value),
|
||||||
|
stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null,
|
||||||
|
srx: numberOrNull(srxInput?.value),
|
||||||
|
srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null,
|
||||||
|
station_callsign: stationCallEl?.textContent?.trim() ?? null,
|
||||||
|
operator: operatorInput?.value ?? null,
|
||||||
|
my_gridsquare: entryGrid,
|
||||||
|
my_rig: entryRigName,
|
||||||
|
rig_id: entryRigId,
|
||||||
|
};
|
||||||
|
if (saveBtn) saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/logbook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.json().catch(() => ({})) as { error?: string };
|
||||||
|
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
|
||||||
|
}
|
||||||
|
notify(`${call} logged`);
|
||||||
|
// A contest runs on serials: the next one is this one plus one, so it is
|
||||||
|
// not retyped forty times an hour.
|
||||||
|
const sent = numberOrNull(stxInput?.value);
|
||||||
|
resetEntry();
|
||||||
|
if (stxInput && sent != null) stxInput.value = String(sent + 1);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
} finally {
|
||||||
|
if (saveBtn) saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this station has been worked, and on what. */
|
||||||
|
function updateWorkedBefore(): void {
|
||||||
|
if (!workedEl) return;
|
||||||
|
const call = callInput?.value.trim() ?? "";
|
||||||
|
if (call.length < 3) {
|
||||||
|
workedEl.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const request = ++workedRequest;
|
||||||
|
void getJson<{ worked: { band: string; mode: string }[] }>(
|
||||||
|
`/api/logbook/worked/${encodeURIComponent(call)}`,
|
||||||
|
).then((answer) => {
|
||||||
|
// A slower answer to an earlier callsign must not overwrite a later one.
|
||||||
|
if (request !== workedRequest || !workedEl) return;
|
||||||
|
if (answer.worked.length === 0) {
|
||||||
|
workedEl.textContent = "Not worked before";
|
||||||
|
workedEl.classList.remove("is-worked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const where = answer.worked.map((entry) => `${entry.band} ${entry.mode}`).join(", ");
|
||||||
|
workedEl.textContent = `Worked before: ${where}`;
|
||||||
|
workedEl.classList.add("is-worked");
|
||||||
|
}).catch(() => { /* the log will still take the contact */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The log ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function currentQuery(): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const call = filterCall?.value.trim();
|
||||||
|
if (call) params.set("call", call);
|
||||||
|
if (filterBand?.value) params.set("band", filterBand.value);
|
||||||
|
if (filterMode?.value) params.set("mode", filterMode.value);
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLog(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const query = currentQuery();
|
||||||
|
const answer = await getJson<{ total: number; qsos: Qso[] }>(
|
||||||
|
`/api/logbook${query ? `?${query}` : ""}`,
|
||||||
|
);
|
||||||
|
qsos = answer.qsos;
|
||||||
|
renderRows();
|
||||||
|
renderFilterOptions();
|
||||||
|
if (summaryEl) {
|
||||||
|
summaryEl.textContent = query
|
||||||
|
? `${String(qsos.length)} of ${String(answer.total)} contacts`
|
||||||
|
: `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
|
||||||
|
await refreshStatistics();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("logbook read failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What has been worked and confirmed, band by band. */
|
||||||
|
async function refreshStatistics(): Promise<void> {
|
||||||
|
if (!statisticsRows) return;
|
||||||
|
try {
|
||||||
|
const answer = await getJson<{ bands: BandStatistics[] }>("/api/logbook/statistics");
|
||||||
|
if (answer.bands.length === 0) {
|
||||||
|
statisticsRows.innerHTML = '<tr><td colspan="4" class="log-empty">Nothing worked yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const band of answer.bands) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
for (const value of [band.band, band.contacts, band.stations, band.confirmed]) {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.textContent = String(value);
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
fragment.appendChild(row);
|
||||||
|
}
|
||||||
|
statisticsRows.replaceChildren(fragment);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("logbook statistics failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Cabrillo link, carrying the header the operator filled in. */
|
||||||
|
function syncCabrilloLink(): void {
|
||||||
|
if (!cabrilloExport) return;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const contest = cabrilloContest?.value.trim();
|
||||||
|
if (contest) {
|
||||||
|
// Only that contest's contacts belong in the entry.
|
||||||
|
params.set("contest", contest);
|
||||||
|
}
|
||||||
|
const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? "");
|
||||||
|
if (callsign) params.set("callsign", callsign);
|
||||||
|
if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value);
|
||||||
|
if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value);
|
||||||
|
const score = numberOrNull(cabrilloScore?.value);
|
||||||
|
if (score != null) params.set("claimed_score", String(score));
|
||||||
|
const operator = operatorInput?.value.trim();
|
||||||
|
if (operator) params.set("operators", operator);
|
||||||
|
cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows(): void {
|
||||||
|
if (!rowsBody) return;
|
||||||
|
if (qsos.length === 0) {
|
||||||
|
rowsBody.innerHTML =
|
||||||
|
'<tr><td colspan="10" class="log-empty">No contacts yet. Work someone and log them here,'
|
||||||
|
+ " or bring a log in with Import ADIF.</td></tr>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const qso of qsos) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
row.dataset.qsoId = qso.id;
|
||||||
|
const cells: (string | null | undefined)[] = [
|
||||||
|
utcDate(qso.started_at),
|
||||||
|
utcTime(qso.started_at),
|
||||||
|
qso.call,
|
||||||
|
qso.band ?? "",
|
||||||
|
qso.submode ?? qso.mode,
|
||||||
|
qso.rst_sent ?? "",
|
||||||
|
qso.rst_rcvd ?? "",
|
||||||
|
qso.gridsquare ?? "",
|
||||||
|
qso.my_rig ?? "",
|
||||||
|
qso.confirmed ? "✓" : "",
|
||||||
|
];
|
||||||
|
for (const [index, value] of cells.entries()) {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.textContent = value ?? "";
|
||||||
|
if (index === 2) cell.className = "log-cell-call";
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
const actions = document.createElement("td");
|
||||||
|
// Confirming is the commonest edit a log gets, so it is a button rather
|
||||||
|
// than a form: a card arrives, and the contact counts towards an award.
|
||||||
|
const confirm = document.createElement("button");
|
||||||
|
confirm.type = "button";
|
||||||
|
confirm.className = "log-row-btn";
|
||||||
|
confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm";
|
||||||
|
confirm.title = qso.confirmed
|
||||||
|
? "Mark this contact as not confirmed"
|
||||||
|
: "Mark this contact confirmed by QSL";
|
||||||
|
confirm.addEventListener("click", () => { void setConfirmed(qso, !qso.confirmed); });
|
||||||
|
actions.appendChild(confirm);
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.type = "button";
|
||||||
|
remove.className = "log-row-btn";
|
||||||
|
remove.textContent = "Delete";
|
||||||
|
remove.setAttribute("aria-label", `Delete the contact with ${qso.call}`);
|
||||||
|
remove.addEventListener("click", () => { void deleteQso(qso); });
|
||||||
|
actions.appendChild(remove);
|
||||||
|
row.appendChild(actions);
|
||||||
|
fragment.appendChild(row);
|
||||||
|
}
|
||||||
|
rowsBody.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bands and modes the log actually holds, so the filters offer only those. */
|
||||||
|
function renderFilterOptions(): void {
|
||||||
|
for (const [select, values] of [
|
||||||
|
[filterBand, [...new Set(qsos.map((q) => q.band).filter((b): b is string => !!b))]],
|
||||||
|
[filterMode, [...new Set(qsos.map((q) => q.submode ?? q.mode).filter(Boolean))]],
|
||||||
|
] as const) {
|
||||||
|
if (!select) continue;
|
||||||
|
const chosen = select.value;
|
||||||
|
const known = new Set([...select.options].map((option) => option.value));
|
||||||
|
for (const value of [...values].sort()) {
|
||||||
|
if (known.has(value)) continue;
|
||||||
|
select.add(new Option(value, value));
|
||||||
|
}
|
||||||
|
select.value = chosen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record, or withdraw, the other station's confirmation. */
|
||||||
|
async function setConfirmed(qso: Qso, confirmed: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...qso,
|
||||||
|
// A card is a card: this is the paper one, and an electronic
|
||||||
|
// confirmation the log already holds is left where it is.
|
||||||
|
qsl_rcvd: confirmed ? "Y" : "N",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteQso(qso: Qso): Promise<void> {
|
||||||
|
const confirmed = await bridge.trxUi.confirm({
|
||||||
|
title: "Delete this contact?",
|
||||||
|
message: `${qso.call} on ${qso.band ?? formatFreq(qso.freq_hz)} will be removed from the log.`,
|
||||||
|
confirmLabel: "Delete",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { method: "DELETE" });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
notify(`Could not delete: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importAdif(file: File): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/logbook/import", { method: "POST", body: await file.arrayBuffer() });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
const outcome = await response.json() as { added: number; duplicate: number; rejected: string[] };
|
||||||
|
const parts = [`${String(outcome.added)} added`];
|
||||||
|
if (outcome.duplicate > 0) parts.push(`${String(outcome.duplicate)} already held`);
|
||||||
|
if (outcome.rejected.length > 0) parts.push(`${String(outcome.rejected.length)} not readable`);
|
||||||
|
notify(parts.join(", "));
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Station line ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderStation(): void {
|
||||||
|
const callsign = hostState.ownerCallsign ?? "";
|
||||||
|
if (stationCallEl) stationCallEl.textContent = callsign || "no callsign set";
|
||||||
|
if (operatorInput && !operatorInput.value) operatorInput.value = callsign;
|
||||||
|
if (stationGridEl) {
|
||||||
|
const grid = hostState.serverLat != null && hostState.serverLon != null
|
||||||
|
? hostCore.latLonToMaidenhead(hostState.serverLat, hostState.serverLon)
|
||||||
|
: "";
|
||||||
|
entryGrid = grid || null;
|
||||||
|
stationGridEl.textContent = grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wiring ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
form?.addEventListener("submit", (event) => { void submitEntry(event); });
|
||||||
|
clearBtn?.addEventListener("click", resetEntry);
|
||||||
|
callInput?.addEventListener("input", updateWorkedBefore);
|
||||||
|
for (const control of [filterCall, filterBand, filterMode]) {
|
||||||
|
control?.addEventListener("input", () => { void refreshLog(); });
|
||||||
|
control?.addEventListener("change", () => { void refreshLog(); });
|
||||||
|
}
|
||||||
|
for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) {
|
||||||
|
control?.addEventListener("input", syncCabrilloLink);
|
||||||
|
control?.addEventListener("change", syncCabrilloLink);
|
||||||
|
}
|
||||||
|
importBtn?.addEventListener("click", () => { importFile?.click(); });
|
||||||
|
importFile?.addEventListener("change", () => {
|
||||||
|
const file = importFile.files?.[0];
|
||||||
|
if (file) void importAdif(file);
|
||||||
|
importFile.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Start an entry from a decode, and show the operator where it went. */
|
||||||
|
bridge.logContact = (seed) => {
|
||||||
|
bridge.navigateToTab?.("logbook");
|
||||||
|
void openEntry(seed).then(() => callInput?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
renderStation();
|
||||||
|
if (cabrilloCallsign && !cabrilloCallsign.value) {
|
||||||
|
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
|
||||||
|
}
|
||||||
|
syncCabrilloLink();
|
||||||
|
void openEntry();
|
||||||
|
void refreshLog();
|
||||||
@@ -9,6 +9,13 @@ export interface DecoderPlugin<TMessage = unknown> {
|
|||||||
restore?(messages: TMessage[]): void;
|
restore?(messages: TMessage[]): void;
|
||||||
reset?(): void;
|
reset?(): void;
|
||||||
prune?(): void;
|
prune?(): void;
|
||||||
|
/** Repaint the plugin's panel from what it already holds. Called when the
|
||||||
|
* selected rig changes: the panels show one rig's traffic, and the decodes
|
||||||
|
* behind them arrived long before the switch. */
|
||||||
|
rerender?(): void;
|
||||||
|
/** Replay everything the plugin is holding onto the map. Called when the map
|
||||||
|
* module attaches, which can happen long after the decodes arrived. */
|
||||||
|
syncMap?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrxPluginRuntime {
|
export interface TrxPluginRuntime {
|
||||||
@@ -19,6 +26,8 @@ export interface TrxPluginRuntime {
|
|||||||
reset(id: string): boolean;
|
reset(id: string): boolean;
|
||||||
resetAll(): void;
|
resetAll(): void;
|
||||||
prune(id: string): boolean;
|
prune(id: string): boolean;
|
||||||
|
rerenderAll(): void;
|
||||||
|
syncMapAll(): void;
|
||||||
clearQueued(): void;
|
clearQueued(): void;
|
||||||
hasDecoder(id: string): boolean;
|
hasDecoder(id: string): boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,12 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
|
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate } from "./satellite-types";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
type SatelliteView = "live" | "history" | "predictions";
|
type SatelliteView = "live" | "history";
|
||||||
interface SatelliteBridge {
|
interface SatelliteBridge {
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
clearSatPredictionDom?: () => void;
|
|
||||||
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
||||||
addSatMapOverlay?: (image: SatelliteImage) => void;
|
addSatMapOverlay?: (image: SatelliteImage) => void;
|
||||||
clearSatMapOverlays?: () => void;
|
clearSatMapOverlays?: () => void;
|
||||||
@@ -25,14 +24,14 @@ const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow;
|
|||||||
// --- SAT Plugin ---
|
// --- SAT Plugin ---
|
||||||
// Live view: decoder state, latest image card
|
// Live view: decoder state, latest image card
|
||||||
// History view: filterable table of all decoded images
|
// History view: filterable table of all decoded images
|
||||||
// Predictions view: next 24 h passes for ham satellites
|
//
|
||||||
|
// Pass predictions live on their own page; see satellite-predictions.ts.
|
||||||
|
|
||||||
// ── DOM references (cached once) ───────────────────────────────────
|
// ── DOM references (cached once) ───────────────────────────────────
|
||||||
const satDom = {
|
const satDom = {
|
||||||
status: document.getElementById("sat-status"),
|
status: document.getElementById("sat-status"),
|
||||||
liveView: document.getElementById("sat-live-view"),
|
liveView: document.getElementById("sat-live-view"),
|
||||||
historyView: document.getElementById("sat-history-view"),
|
historyView: document.getElementById("sat-history-view"),
|
||||||
predictionsView: document.getElementById("sat-predictions-view"),
|
|
||||||
liveLatest: document.getElementById("sat-live-latest"),
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
historyList: document.getElementById("sat-history-list"),
|
historyList: document.getElementById("sat-history-list"),
|
||||||
historyCount: document.getElementById("sat-history-count"),
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
@@ -42,30 +41,13 @@ const satDom = {
|
|||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||||
viewPredBtn: document.getElementById("sat-view-predictions"),
|
|
||||||
predFilter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
|
|
||||||
predMinEl: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
|
|
||||||
predCategory: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
|
|
||||||
predCurrentList: document.getElementById("sat-pred-current-list"),
|
|
||||||
predUpcomingList: document.getElementById("sat-pred-list"),
|
|
||||||
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
|
||||||
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
|
||||||
predStatus: document.getElementById("sat-pred-status"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── State ───────────────────────────────────────────────────────────
|
// ── State ───────────────────────────────────────────────────────────
|
||||||
let satImageHistory: SatelliteImage[] = [];
|
let satImageHistory: SatelliteImage[] = [];
|
||||||
const SAT_MAX_IMAGES = 100;
|
const SAT_MAX_IMAGES = 100;
|
||||||
const SAT_PRED_PAGE_SIZE = 50;
|
|
||||||
let satPredShowAll = false;
|
|
||||||
let satFilterText = "";
|
let satFilterText = "";
|
||||||
let satActiveView: SatelliteView = "live";
|
let satActiveView: SatelliteView = "live";
|
||||||
let satPredData: SatellitePass[] = [];
|
|
||||||
let satPredFilterText = "";
|
|
||||||
let satPredMinEl = 0;
|
|
||||||
let satPredCategory = "all";
|
|
||||||
let satPredSatCount = 0;
|
|
||||||
let satPredCountdownTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
// ── UI scheduler helper ─────────────────────────────────────────────
|
// ── UI scheduler helper ─────────────────────────────────────────────
|
||||||
function scheduleSatUi(key: string, job: () => void): void {
|
function scheduleSatUi(key: string, job: () => void): void {
|
||||||
@@ -78,33 +60,16 @@ function scheduleSatUi(key: string, job: () => void): void {
|
|||||||
|
|
||||||
// ── View switching ──────────────────────────────────────────────────
|
// ── View switching ──────────────────────────────────────────────────
|
||||||
function switchSatView(view: SatelliteView): void {
|
function switchSatView(view: SatelliteView): void {
|
||||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
|
||||||
satActiveView = view;
|
satActiveView = view;
|
||||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
if (view === "history") renderSatHistoryTable();
|
||||||
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
|
||||||
if (leavingPredictions) clearPredictionDom();
|
|
||||||
if (view === "history") {
|
|
||||||
renderSatHistoryTable();
|
|
||||||
} else if (view === "predictions") {
|
|
||||||
satPredShowAll = false;
|
|
||||||
void loadSatPredictions();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPredictionDom() {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
}
|
|
||||||
satWindow.clearSatPredictionDom = clearPredictionDom;
|
|
||||||
|
|
||||||
satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
|
satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
|
||||||
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
|
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
|
||||||
satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); });
|
|
||||||
|
|
||||||
// ── Live view: decoder state ────────────────────────────────────────
|
// ── Live view: decoder state ────────────────────────────────────────
|
||||||
let lastSatLrptOn: boolean | null = null;
|
let lastSatLrptOn: boolean | null = null;
|
||||||
@@ -335,234 +300,6 @@ document
|
|||||||
}
|
}
|
||||||
})(); });
|
})(); });
|
||||||
|
|
||||||
// ── Predictions: helpers ────────────────────────────────────────────
|
|
||||||
function azToCardinal(deg: number): string {
|
|
||||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
|
||||||
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPredTime(ms: number): string {
|
|
||||||
const d = new Date(ms);
|
|
||||||
const now = new Date();
|
|
||||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
||||||
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
|
||||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
|
||||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
|
||||||
return `${day}${hh}:${mm}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPredDuration(s: number): string {
|
|
||||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCountdown(ms: number): string {
|
|
||||||
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
|
||||||
const m = Math.floor(totalSec / 60);
|
|
||||||
const s = totalSec % 60;
|
|
||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function elevationClass(deg: number): string {
|
|
||||||
if (deg >= 45) return "sat-pred-el-high";
|
|
||||||
if (deg >= 10) return "sat-pred-el-mid";
|
|
||||||
return "sat-pred-el-low";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: countdown timer management ─────────────────────────
|
|
||||||
function stopCountdownTimer() {
|
|
||||||
if (satPredCountdownTimer) {
|
|
||||||
clearInterval(satPredCountdownTimer);
|
|
||||||
satPredCountdownTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startCountdownTimer(container: HTMLElement | null): void {
|
|
||||||
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
|
|
||||||
if (countdownEls.length === 0) return;
|
|
||||||
|
|
||||||
satPredCountdownTimer = setInterval(() => {
|
|
||||||
if (satActiveView !== "predictions") {
|
|
||||||
stopCountdownTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const n = Date.now();
|
|
||||||
let anyActive = false;
|
|
||||||
for (const el of countdownEls) {
|
|
||||||
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
|
||||||
const rem = los - n;
|
|
||||||
if (rem > 0) {
|
|
||||||
el.textContent = formatCountdown(rem);
|
|
||||||
anyActive = true;
|
|
||||||
} else {
|
|
||||||
el.textContent = "0:00";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!anyActive) {
|
|
||||||
stopCountdownTimer();
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: row builders ───────────────────────────────────────
|
|
||||||
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row-current";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
const remaining = Math.max(0, pass.los_ms - now);
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
|
||||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: filter state ───────────────────────────────────────
|
|
||||||
function getFilteredPredictions(): SatellitePass[] {
|
|
||||||
let items = satPredData;
|
|
||||||
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
|
||||||
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
|
||||||
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPredFilters() {
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
|
|
||||||
const satPredictionFilter = satDom.predFilter;
|
|
||||||
satPredictionFilter?.addEventListener("input", () => {
|
|
||||||
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
const satPredictionMinElevation = satDom.predMinEl;
|
|
||||||
satPredictionMinElevation?.addEventListener("change", () => {
|
|
||||||
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
const satPredictionCategory = satDom.predCategory;
|
|
||||||
satPredictionCategory?.addEventListener("change", () => {
|
|
||||||
satPredCategory = satPredictionCategory.value;
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Predictions: main render ────────────────────────────────────────
|
|
||||||
function renderSatPredictions(passes: SatellitePass[], error?: string): void {
|
|
||||||
stopCountdownTimer();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(passes) || passes.length === 0) {
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
|
||||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
|
||||||
|
|
||||||
// ── Current passes ──
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
|
||||||
if (satDom.predCurrentList) {
|
|
||||||
if (current.length === 0) {
|
|
||||||
satDom.predCurrentList.innerHTML = "";
|
|
||||||
} else {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
|
||||||
satDom.predCurrentList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Upcoming passes ──
|
|
||||||
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
|
||||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
|
||||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
|
||||||
if (satDom.predUpcomingList) {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
|
||||||
if (hiddenCount > 0) {
|
|
||||||
const moreRow = document.createElement("div");
|
|
||||||
moreRow.className = "sat-pred-row";
|
|
||||||
moreRow.style.cursor = "pointer";
|
|
||||||
moreRow.style.textAlign = "center";
|
|
||||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
|
|
||||||
moreRow.addEventListener("click", () => {
|
|
||||||
satPredShowAll = true;
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
});
|
|
||||||
frag.appendChild(moreRow);
|
|
||||||
}
|
|
||||||
satDom.predUpcomingList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Status ──
|
|
||||||
if (satDom.predStatus) {
|
|
||||||
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
|
|
||||||
if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`;
|
|
||||||
satDom.predStatus.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Countdown timer ──
|
|
||||||
if (current.length > 0 && satActiveView === "predictions") {
|
|
||||||
startCountdownTimer(satDom.predCurrentList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: data loading ───────────────────────────────────────
|
|
||||||
async function loadSatPredictions(): Promise<void> {
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/sat_passes");
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json() as SatellitePassResponse;
|
|
||||||
satPredSatCount = data.satellite_count || 0;
|
|
||||||
if (data.error) {
|
|
||||||
satPredData = [];
|
|
||||||
renderSatPredictions([], data.error);
|
|
||||||
} else {
|
|
||||||
satPredData = data.passes || [];
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Navigate to map centered on satellite image bounds ──────────────
|
// ── Navigate to map centered on satellite image bounds ──────────────
|
||||||
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
|
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
|
||||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||||
|
|||||||
+290
@@ -0,0 +1,290 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import type { SatellitePass, SatellitePassResponse } from "./satellite-types";
|
||||||
|
|
||||||
|
// --- Satellite pass predictions ---
|
||||||
|
//
|
||||||
|
// Its own page rather than a third view inside the weather-satellite decoder:
|
||||||
|
// when a bird comes over is a planning question, and it was buried under
|
||||||
|
// Digital modes beside decoders it has nothing to do with.
|
||||||
|
|
||||||
|
interface PredictionsBridge {
|
||||||
|
clearSatPredictionDom?: () => void;
|
||||||
|
refreshSatPredictions?: () => void;
|
||||||
|
}
|
||||||
|
const predWindow = window as unknown as PredictionsBridge;
|
||||||
|
|
||||||
|
// ── DOM references (cached once) ───────────────────────────────────
|
||||||
|
const dom = {
|
||||||
|
page: document.getElementById("tab-satellites"),
|
||||||
|
filter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
|
||||||
|
minElevation: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
|
||||||
|
category: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
|
||||||
|
currentList: document.getElementById("sat-pred-current-list"),
|
||||||
|
upcomingList: document.getElementById("sat-pred-list"),
|
||||||
|
currentSection: document.getElementById("sat-pred-current-section"),
|
||||||
|
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
|
||||||
|
status: document.getElementById("sat-pred-status"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── State ───────────────────────────────────────────────────────────
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
let predShowAll = false;
|
||||||
|
let predData: SatellitePass[] = [];
|
||||||
|
let predFilterText = "";
|
||||||
|
let predMinEl = 0;
|
||||||
|
let predCategory = "all";
|
||||||
|
let predSatCount = 0;
|
||||||
|
let predCountdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** The countdowns tick once a second, so they stop when nobody is looking. */
|
||||||
|
function isPageVisible(): boolean {
|
||||||
|
return !!dom.page && dom.page.style.display !== "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: helpers ────────────────────────────────────────────
|
||||||
|
function azToCardinal(deg: number): string {
|
||||||
|
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||||
|
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPredTime(ms: number): string {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const now = new Date();
|
||||||
|
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
||||||
|
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||||
|
return `${day}${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPredDuration(s: number): string {
|
||||||
|
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdown(ms: number): string {
|
||||||
|
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elevationClass(deg: number): string {
|
||||||
|
if (deg >= 45) return "sat-pred-el-high";
|
||||||
|
if (deg >= 10) return "sat-pred-el-mid";
|
||||||
|
return "sat-pred-el-low";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: countdown timer management ─────────────────────────
|
||||||
|
function stopCountdownTimer() {
|
||||||
|
if (predCountdownTimer) {
|
||||||
|
clearInterval(predCountdownTimer);
|
||||||
|
predCountdownTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdownTimer(container: HTMLElement | null): void {
|
||||||
|
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
|
||||||
|
if (countdownEls.length === 0) return;
|
||||||
|
|
||||||
|
predCountdownTimer = setInterval(() => {
|
||||||
|
if (!isPageVisible()) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Date.now();
|
||||||
|
let anyActive = false;
|
||||||
|
for (const el of countdownEls) {
|
||||||
|
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
||||||
|
const rem = los - n;
|
||||||
|
if (rem > 0) {
|
||||||
|
el.textContent = formatCountdown(rem);
|
||||||
|
anyActive = true;
|
||||||
|
} else {
|
||||||
|
el.textContent = "0:00";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyActive) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
render(filtered());
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: row builders ───────────────────────────────────────
|
||||||
|
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row-current";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
const remaining = Math.max(0, pass.los_ms - now);
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||||
|
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: filter state ───────────────────────────────────────
|
||||||
|
function filtered(): SatellitePass[] {
|
||||||
|
let items = predData;
|
||||||
|
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
|
||||||
|
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
|
||||||
|
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
render(filtered());
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterInput = dom.filter;
|
||||||
|
filterInput?.addEventListener("input", () => {
|
||||||
|
predFilterText = filterInput.value.trim().toUpperCase();
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
const minElevationSelect = dom.minElevation;
|
||||||
|
minElevationSelect?.addEventListener("change", () => {
|
||||||
|
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
const categorySelect = dom.category;
|
||||||
|
categorySelect?.addEventListener("change", () => {
|
||||||
|
predCategory = categorySelect.value;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Predictions: main render ────────────────────────────────────────
|
||||||
|
function render(passes: SatellitePass[], error?: string): void {
|
||||||
|
stopCountdownTimer();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||||
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||||
|
if (dom.currentSection) dom.currentSection.style.display = "none";
|
||||||
|
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
||||||
|
if (dom.status) dom.status.textContent = error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(passes) || passes.length === 0) {
|
||||||
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||||
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||||
|
if (dom.currentSection) dom.currentSection.style.display = "none";
|
||||||
|
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
||||||
|
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||||
|
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||||
|
|
||||||
|
// ── Current passes ──
|
||||||
|
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
|
||||||
|
if (dom.currentList) {
|
||||||
|
if (current.length === 0) {
|
||||||
|
dom.currentList.innerHTML = "";
|
||||||
|
} else {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||||
|
dom.currentList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upcoming passes ──
|
||||||
|
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
|
||||||
|
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||||
|
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||||
|
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
|
||||||
|
if (dom.upcomingList) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||||
|
if (hiddenCount > 0) {
|
||||||
|
const moreRow = document.createElement("div");
|
||||||
|
moreRow.className = "sat-pred-row";
|
||||||
|
moreRow.style.cursor = "pointer";
|
||||||
|
moreRow.style.textAlign = "center";
|
||||||
|
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
|
||||||
|
moreRow.addEventListener("click", () => {
|
||||||
|
predShowAll = true;
|
||||||
|
render(filtered());
|
||||||
|
});
|
||||||
|
frag.appendChild(moreRow);
|
||||||
|
}
|
||||||
|
dom.upcomingList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status ──
|
||||||
|
if (dom.status) {
|
||||||
|
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
|
||||||
|
if (predSatCount > 0) text += ` \u00B7 ${predSatCount} satellites tracked`;
|
||||||
|
dom.status.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Countdown timer ──
|
||||||
|
if (current.length > 0 && isPageVisible()) {
|
||||||
|
startCountdownTimer(dom.currentList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: data loading ───────────────────────────────────────
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
if (dom.status) dom.status.textContent = "Loading predictions\u2026";
|
||||||
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||||
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/sat_passes");
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const data = await resp.json() as SatellitePassResponse;
|
||||||
|
predSatCount = data.satellite_count || 0;
|
||||||
|
if (data.error) {
|
||||||
|
predData = [];
|
||||||
|
render([], data.error);
|
||||||
|
} else {
|
||||||
|
predData = data.passes || [];
|
||||||
|
render(filtered());
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page lifecycle ──────────────────────────────────────────────────
|
||||||
|
function clearPredictionDom(): void {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||||
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||||
|
}
|
||||||
|
predWindow.clearSatPredictionDom = clearPredictionDom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the router each time the Satellites page is opened, including the
|
||||||
|
* visit that imports this module — so loading is driven from one place only.
|
||||||
|
*/
|
||||||
|
predWindow.refreshSatPredictions = function () {
|
||||||
|
predShowAll = false;
|
||||||
|
void load();
|
||||||
|
};
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// sstv.ts — SSTV decoder panel.
|
||||||
|
//
|
||||||
|
// Live view: decoder state, a canvas the picture fills row by row as it
|
||||||
|
// arrives, and a card for the last one received. History view: a filterable
|
||||||
|
// table of received pictures with thumbnails.
|
||||||
|
//
|
||||||
|
// Watching a picture build up is most of the appeal of the mode, so rows are
|
||||||
|
// painted as they arrive rather than waiting for the frame to finish — a
|
||||||
|
// transmission takes between 36 seconds and two minutes.
|
||||||
|
|
||||||
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
interface SstvImage {
|
||||||
|
rig_id?: string | null;
|
||||||
|
ts_ms?: number;
|
||||||
|
vis?: number;
|
||||||
|
mode?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
lines?: number;
|
||||||
|
complete?: boolean;
|
||||||
|
path?: string;
|
||||||
|
_tsMs?: number;
|
||||||
|
_ts?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SstvProgress {
|
||||||
|
rig_id?: string | null;
|
||||||
|
state?: string;
|
||||||
|
mode?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
line?: number;
|
||||||
|
line_data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SstvBridge {
|
||||||
|
getDecodeHistoryRetentionMs?: () => number;
|
||||||
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
|
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
||||||
|
syncSstvToggle?: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sstvWindow = window as unknown as SstvBridge & PluginRuntimeWindow;
|
||||||
|
|
||||||
|
const 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") as HTMLCanvasElement | null,
|
||||||
|
liveLatest: document.getElementById("sstv-live-latest"),
|
||||||
|
historyList: document.getElementById("sstv-history-list"),
|
||||||
|
historyCount: document.getElementById("sstv-history-count"),
|
||||||
|
filterInput: document.getElementById("sstv-filter") as HTMLInputElement | null,
|
||||||
|
sortSelect: document.getElementById("sstv-sort") as HTMLSelectElement | null,
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const SSTV_MAX_IMAGES = 100;
|
||||||
|
|
||||||
|
let sstvHistory: SstvImage[] = [];
|
||||||
|
let liveCtx: CanvasRenderingContext2D | null = null;
|
||||||
|
let liveMode = "";
|
||||||
|
let liveHeight = 0;
|
||||||
|
let liveRows = 0;
|
||||||
|
let activeView: "live" | "history" = "live";
|
||||||
|
let filterText = "";
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function retentionMs(): number {
|
||||||
|
return sstvWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneHistory() {
|
||||||
|
const cutoff = Date.now() - retentionMs();
|
||||||
|
sstvHistory = sstvHistory.filter((image) => (image._tsMs || 0) > cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: unknown): string {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleUi(key: string, job: () => void): void {
|
||||||
|
if (typeof sstvWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
sstvWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The URL the server serves a saved picture from, given its stored path. */
|
||||||
|
function imageUrl(image: SstvImage): string | null {
|
||||||
|
if (!image.path) return null;
|
||||||
|
const filename = image.path.split(/[\\/]/).pop();
|
||||||
|
return filename ? `/sstv-images/${encodeURIComponent(filename)}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64(data: string): Uint8Array {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View switching ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function switchView(view: "live" | "history"): void {
|
||||||
|
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"); });
|
||||||
|
|
||||||
|
// ── Live canvas ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Start a new picture: size the canvas to the mode and clear it. */
|
||||||
|
function beginPicture(mode: string, width: number, height: number): void {
|
||||||
|
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;
|
||||||
|
// Mid-grey, not black: the rows below the one arriving have not been
|
||||||
|
// received, which is a different thing from having been received as black.
|
||||||
|
liveCtx.fillStyle = "#606060";
|
||||||
|
liveCtx.fillRect(0, 0, width, height);
|
||||||
|
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "";
|
||||||
|
updateLiveInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLiveInfo(): void {
|
||||||
|
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}` : ""}`
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint one row of RGB triples at its own line number. */
|
||||||
|
function paintRow(line: number, rgb: Uint8Array): void {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server messages ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onProgress(msg: SstvProgress): void {
|
||||||
|
// The live canvas draws one picture at a time: a background rig's rows would
|
||||||
|
// paint straight over the one being watched.
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
|
if (msg.state) {
|
||||||
|
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||||
|
// A state update carries the geometry; a row update carries only the row.
|
||||||
|
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: SstvImage): void {
|
||||||
|
const image: SstvImage = { ...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();
|
||||||
|
|
||||||
|
// Kept in the history either way, so switching to that rig finds it there.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The pictures the selected rig decoded, oldest first, as the history is kept. */
|
||||||
|
function sstvRigImages(): SstvImage[] {
|
||||||
|
return forActiveRig(sstvHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLatestCard(): void {
|
||||||
|
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(): SstvImage[] {
|
||||||
|
// Every rig's pictures are kept; the panel shows the selected rig's.
|
||||||
|
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(): void {
|
||||||
|
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(); });
|
||||||
|
|
||||||
|
// ── Decoder history plumbing ────────────────────────────────────────
|
||||||
|
|
||||||
|
function restoreHistory(entries: unknown[]): void {
|
||||||
|
if (!Array.isArray(entries)) return;
|
||||||
|
for (const entry of entries) onImage(entry as SstvImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHistoryView(): void {
|
||||||
|
sstvHistory = [];
|
||||||
|
liveRows = 0;
|
||||||
|
liveMode = "";
|
||||||
|
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "none";
|
||||||
|
if (sstvDom.status) sstvDom.status.textContent = "Idle";
|
||||||
|
renderLatestCard();
|
||||||
|
renderHistoryTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sstvWindow.syncSstvToggle = function syncSstvToggle(enabled: boolean) {
|
||||||
|
const button = sstvDom.toggleBtn as HTMLButtonElement | null;
|
||||||
|
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 as (msg: unknown) => void,
|
||||||
|
restore: restoreHistory,
|
||||||
|
prune: renderHistoryTable,
|
||||||
|
reset: resetHistoryView,
|
||||||
|
rerender: () => { renderLatestCard(); renderHistoryTable(); },
|
||||||
|
});
|
||||||
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "sstv_progress",
|
||||||
|
onMessage: onProgress as (msg: unknown) => void,
|
||||||
|
});
|
||||||
@@ -403,7 +403,10 @@ function vchanSyncModeDisplay() {
|
|||||||
if (!modeEl) return;
|
if (!modeEl) return;
|
||||||
if (vchanIsOnVirtual()) {
|
if (vchanIsOnVirtual()) {
|
||||||
const ch = vchanActiveChannel();
|
const ch = vchanActiveChannel();
|
||||||
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
|
if (ch && ch.mode) {
|
||||||
|
modeEl.value = ch.mode.toUpperCase();
|
||||||
|
hostCore.syncModePicker();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// When on primary channel, app.js rig-state updates handle the picker.
|
// When on primary channel, app.js rig-state updates handle the picker.
|
||||||
const modeUpper = (modeEl.value || "").toUpperCase();
|
const modeUpper = (modeEl.value || "").toUpperCase();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { hostCore } from "./host.js";
|
|||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||||
|
|
||||||
interface VdesMessage {
|
interface VdesMessage {
|
||||||
@@ -116,17 +117,24 @@ function vdesHexPreview(rawBytes: number[] | undefined): string {
|
|||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the selected rig heard. The history holds every rig's traffic, for
|
||||||
|
* the map; the panel is about the rig the operator is working. */
|
||||||
|
function vdesRigMessages(): VdesMessage[] {
|
||||||
|
return forActiveRig(vdesMessageHistory);
|
||||||
|
}
|
||||||
|
|
||||||
function updateVdesSummary() {
|
function updateVdesSummary() {
|
||||||
pruneVdesMessageHistory();
|
pruneVdesMessageHistory();
|
||||||
if (vdesChannelSummaryEl) {
|
if (vdesChannelSummaryEl) {
|
||||||
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||||
}
|
}
|
||||||
|
const rigMessages = vdesRigMessages();
|
||||||
if (vdesFrameCountEl) {
|
if (vdesFrameCountEl) {
|
||||||
const count = vdesMessageHistory.length;
|
const count = rigMessages.length;
|
||||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (vdesLatestSeenEl) {
|
if (vdesLatestSeenEl) {
|
||||||
const latest = vdesMessageHistory[0];
|
const latest = rigMessages[0];
|
||||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,7 +236,9 @@ function updateVdesBar() {
|
|||||||
updateVdesSummary();
|
updateVdesSummary();
|
||||||
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
|
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
|
||||||
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
const messages = vdesMessageHistory
|
||||||
|
.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id))
|
||||||
|
.slice(0, 6);
|
||||||
if (!isVdes || messages.length === 0) {
|
if (!isVdes || messages.length === 0) {
|
||||||
vdesBarOverlay.style.display = "none";
|
vdesBarOverlay.style.display = "none";
|
||||||
vdesBarOverlay.innerHTML = "";
|
vdesBarOverlay.innerHTML = "";
|
||||||
@@ -278,7 +288,7 @@ function renderVdesHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of vdesMessageHistory) {
|
for (const message of vdesRigMessages()) {
|
||||||
fragment.appendChild(renderVdesRow(message));
|
fragment.appendChild(renderVdesRow(message));
|
||||||
}
|
}
|
||||||
vdesMessagesEl.replaceChildren(fragment);
|
vdesMessagesEl.replaceChildren(fragment);
|
||||||
@@ -309,10 +319,11 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
|
|||||||
|
|
||||||
function onServerVdesBatch(messages: VdesMessage[]): void {
|
function onServerVdesBatch(messages: VdesMessage[]): void {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
||||||
const normalized: VdesMessage[] = [];
|
const normalized: VdesMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||||
|
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -320,9 +331,7 @@ function onServerVdesBatch(messages: VdesMessage[]): void {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
plotVdesMessage(next);
|
||||||
vdesWindow.vdesMapAddPoint(next);
|
|
||||||
}
|
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -349,13 +358,17 @@ if (vdesFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hands a positioned message to the map, if the map module is loaded yet. */
|
||||||
|
function plotVdesMessage(msg: VdesMessage): void {
|
||||||
|
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
|
||||||
|
vdesWindow.vdesMapAddPoint(msg);
|
||||||
|
}
|
||||||
|
|
||||||
function onServerVdes(msg: VdesMessage): void {
|
function onServerVdes(msg: VdesMessage): void {
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||||
addVdesMessage(next);
|
addVdesMessage(next);
|
||||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
plotVdesMessage(next);
|
||||||
vdesWindow.vdesMapAddPoint(next);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneVdesHistoryView(): void {
|
function pruneVdesHistoryView(): void {
|
||||||
@@ -372,4 +385,7 @@ updateVdesSummary();
|
|||||||
restore: onServerVdesBatch,
|
restore: onServerVdesBatch,
|
||||||
reset: resetVdesHistoryView,
|
reset: resetVdesHistoryView,
|
||||||
prune: pruneVdesHistoryView,
|
prune: pruneVdesHistoryView,
|
||||||
|
rerender: () => { updateVdesBar(); renderVdesHistory(); },
|
||||||
|
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
interface WefaxImage {
|
interface WefaxImage {
|
||||||
|
rig_id?: string | null;
|
||||||
ts_ms?: number;
|
ts_ms?: number;
|
||||||
ioc?: number;
|
ioc?: number;
|
||||||
lpm?: number;
|
lpm?: number;
|
||||||
@@ -20,6 +22,7 @@ interface WefaxImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface WefaxProgress {
|
interface WefaxProgress {
|
||||||
|
rig_id?: string | null;
|
||||||
state?: string;
|
state?: string;
|
||||||
line_data?: string;
|
line_data?: string;
|
||||||
line_count?: number;
|
line_count?: number;
|
||||||
@@ -156,15 +159,21 @@ function paintLine(lineBytes: Uint8Array): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Live view: latest image card ────────────────────────────────────
|
// ── Live view: latest image card ────────────────────────────────────
|
||||||
|
/** The pictures the selected rig decoded, newest first. */
|
||||||
|
function wefaxRigImages(): WefaxImage[] {
|
||||||
|
return forActiveRig(wefaxImageHistory);
|
||||||
|
}
|
||||||
|
|
||||||
function renderWefaxLatestCard() {
|
function renderWefaxLatestCard() {
|
||||||
if (!wefaxDom.liveLatest) return;
|
if (!wefaxDom.liveLatest) return;
|
||||||
if (wefaxImageHistory.length === 0) {
|
const rigImages = wefaxRigImages();
|
||||||
|
if (rigImages.length === 0) {
|
||||||
wefaxDom.liveLatest.innerHTML =
|
wefaxDom.liveLatest.innerHTML =
|
||||||
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const img = wefaxImageHistory[0];
|
const img = rigImages[0];
|
||||||
if (!img) return;
|
if (!img) return;
|
||||||
const ts = img._ts || '--';
|
const ts = img._ts || '--';
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
||||||
@@ -193,7 +202,8 @@ function renderWefaxLatestCard() {
|
|||||||
|
|
||||||
// ── History view: table ─────────────────────────────────────────────
|
// ── History view: table ─────────────────────────────────────────────
|
||||||
function getWefaxFilteredHistory() {
|
function getWefaxFilteredHistory() {
|
||||||
let items = wefaxImageHistory;
|
// Every rig's pictures are kept; the panel shows the selected rig's.
|
||||||
|
let items = wefaxRigImages();
|
||||||
|
|
||||||
if (wefaxFilterText) {
|
if (wefaxFilterText) {
|
||||||
items = items.filter(function (i) {
|
items = items.filter(function (i) {
|
||||||
@@ -253,7 +263,7 @@ function renderWefaxHistoryTable() {
|
|||||||
wefaxDom.historyList.replaceChildren(fragment);
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
|
|
||||||
if (wefaxDom.historyCount) {
|
if (wefaxDom.historyCount) {
|
||||||
const total = wefaxImageHistory.length;
|
const total = wefaxRigImages().length;
|
||||||
const shown = items.length;
|
const shown = items.length;
|
||||||
wefaxDom.historyCount.textContent =
|
wefaxDom.historyCount.textContent =
|
||||||
total === 0
|
total === 0
|
||||||
@@ -298,6 +308,9 @@ function addWefaxImage(msg: WefaxImage): void {
|
|||||||
|
|
||||||
// ── SSE event handlers (public API) ─────────────────────────────────
|
// ── SSE event handlers (public API) ─────────────────────────────────
|
||||||
function onServerWefaxProgress(msg: WefaxProgress): void {
|
function onServerWefaxProgress(msg: WefaxProgress): void {
|
||||||
|
// The live canvas draws one picture at a time: a background rig's lines
|
||||||
|
// would paint straight over the one being watched.
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
// State-only update (no image data): show decoder state in status.
|
// State-only update (no image data): show decoder state in status.
|
||||||
if (msg.state && !msg.line_data) {
|
if (msg.state && !msg.line_data) {
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
@@ -331,6 +344,8 @@ function onServerWefaxProgress(msg: WefaxProgress): void {
|
|||||||
|
|
||||||
function onServerWefax(msg: WefaxImage): void {
|
function onServerWefax(msg: WefaxImage): void {
|
||||||
addWefaxImage(msg);
|
addWefaxImage(msg);
|
||||||
|
// Kept in the history either way, so switching to that rig finds it there.
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
|
|
||||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
@@ -438,6 +453,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
|
|||||||
restore: restoreWefaxHistory,
|
restore: restoreWefaxHistory,
|
||||||
prune: pruneWefaxHistoryView,
|
prune: pruneWefaxHistoryView,
|
||||||
reset: resetWefaxHistoryView,
|
reset: resetWefaxHistoryView,
|
||||||
|
rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); },
|
||||||
});
|
});
|
||||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "wefax_progress",
|
id: "wefax_progress",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
@@ -15,6 +16,10 @@ interface WsprMessage {
|
|||||||
snr_db?: number | undefined;
|
snr_db?: number | undefined;
|
||||||
dt_s?: number | undefined;
|
dt_s?: number | undefined;
|
||||||
freq_hz?: number | undefined;
|
freq_hz?: number | undefined;
|
||||||
|
rig_id?: string | null | undefined;
|
||||||
|
/** RF frequency the spot was heard on, kept so a map replay does not
|
||||||
|
* recompute it against wherever the dial has moved to since. */
|
||||||
|
_rfHz?: number | null | undefined;
|
||||||
receiver?: unknown;
|
receiver?: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -102,10 +107,12 @@ function renderWsprRow(msg: WsprMessage): HTMLDivElement {
|
|||||||
function renderWsprHistory(): void {
|
function renderWsprHistory(): void {
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
if (!wsprMessagesEl) return;
|
if (!wsprMessagesEl) return;
|
||||||
|
// The history holds every rig's decodes, for the map; the panel is about the
|
||||||
|
// rig the operator is working.
|
||||||
|
const rigMessages = forActiveRig(wsprMessageHistory);
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
for (const message of rigMessages) {
|
||||||
const message = wsprMessageHistory[i];
|
fragment.appendChild(renderWsprRow(message));
|
||||||
if (message) fragment.appendChild(renderWsprRow(message));
|
|
||||||
}
|
}
|
||||||
wsprMessagesEl.replaceChildren(fragment);
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
}
|
}
|
||||||
@@ -130,6 +137,8 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
|
|||||||
station,
|
station,
|
||||||
rfHz,
|
rfHz,
|
||||||
history: {
|
history: {
|
||||||
|
rig_id: msg.rig_id ?? null,
|
||||||
|
_rfHz: rfHz,
|
||||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||||
ts_ms: msg.ts_ms,
|
ts_ms: msg.ts_ms,
|
||||||
snr_db: msg.snr_db,
|
snr_db: msg.snr_db,
|
||||||
@@ -142,16 +151,12 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
|
|||||||
|
|
||||||
function onServerWsprBatch(messages: WsprMessage[]): void {
|
function onServerWsprBatch(messages: WsprMessage[]): void {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
|
||||||
const normalized: WsprMessage[] = [];
|
const normalized: WsprMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||||
...msg,
|
plotWsprLocator(msg);
|
||||||
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
normalized.push(next.history);
|
normalized.push(next.history);
|
||||||
}
|
}
|
||||||
@@ -311,15 +316,24 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
function onServerWspr(msg: WsprMessage): void {
|
/** Hands a spot's grid squares to the map, if the map module is loaded yet.
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
* The module is lazy, so a spot heard before it arrived has to be replayable. */
|
||||||
|
function plotWsprLocator(msg: WsprMessage): void {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
// A replayed spot carries the frequency it was heard on; a fresh one has it
|
||||||
...msg,
|
// worked out from the dial it just arrived against.
|
||||||
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
|
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
|
||||||
});
|
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
}
|
...msg,
|
||||||
|
...(rfHz === null ? {} : { freq_hz: rfHz }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onServerWspr(msg: WsprMessage): void {
|
||||||
|
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||||
|
const next = normalizeServerWsprMessage(msg);
|
||||||
|
plotWsprLocator(msg);
|
||||||
addWsprMessage(next.history);
|
addWsprMessage(next.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,4 +344,7 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
|||||||
restore: onServerWsprBatch,
|
restore: onServerWsprBatch,
|
||||||
prune: pruneWsprHistoryView,
|
prune: pruneWsprHistoryView,
|
||||||
reset: resetWsprHistoryView,
|
reset: resetWsprHistoryView,
|
||||||
|
rerender: renderWsprHistory,
|
||||||
|
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||||
|
syncMap: () => { for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ interface ButtonStateOptions {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
type TabKind = "primary" | "secondary";
|
type TabKind = "primary" | "secondary";
|
||||||
type LayoutCapability = "broadcast" | "digital";
|
type LayoutCapability = "broadcast" | "digital" | "ham";
|
||||||
type LayoutName = "compact" | "broadcast" | "digital" | "full";
|
type LayoutName = "compact" | "broadcast" | "digital" | "full" | "ham";
|
||||||
interface OperatorLayout {
|
interface OperatorLayout {
|
||||||
label: string;
|
label: string;
|
||||||
unavailable?: string;
|
unavailable?: string;
|
||||||
@@ -191,8 +191,11 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
|
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
|
||||||
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
|
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
|
||||||
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
|
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
|
||||||
|
// Working the bands: the log is the task and the radio is the instrument,
|
||||||
|
// so this one opens on the logbook with the controls a keystroke away.
|
||||||
|
ham: { label: "Ham radio", unavailable: "Ham radio needs a rig that can transmit", advanced: true, audio: true, scheduler: false, preferredTab: "logbook", capability: "ham" },
|
||||||
};
|
};
|
||||||
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false };
|
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false, ham: false };
|
||||||
let activeRigId: string | null = null;
|
let activeRigId: string | null = null;
|
||||||
|
|
||||||
// A layout seeds the collapsible sections; it does not hold them there.
|
// A layout seeds the collapsible sections; it does not hold them there.
|
||||||
@@ -317,14 +320,19 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
if (element) body.appendChild(element);
|
if (element) body.appendChild(element);
|
||||||
});
|
});
|
||||||
tray.appendChild(details);
|
// Ahead of the collapsibles the markup ships: the radio's own settings
|
||||||
|
// come before audio and the scheduler, and appending would put the
|
||||||
|
// section built here last whatever the markup says.
|
||||||
|
tray.insertBefore(details, document.getElementById("audio-controls"));
|
||||||
api.applyLayout(savedLayoutName(), { persist: false });
|
api.applyLayout(savedLayoutName(), { persist: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secondary controls, in the order they leave the bar when it gets tight.
|
// Secondary controls, in the order they leave the bar when it gets tight.
|
||||||
// Audio, record and the rig picker are the operating controls and stay.
|
// Audio, record and the rig picker are the operating controls and stay.
|
||||||
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
|
const overflowOrder = [
|
||||||
|
".operator-layout-picker", ".header-style-pick", "#header-share-btn", "#theme-toggle",
|
||||||
|
];
|
||||||
|
|
||||||
// Anchored at paint time in fixed coordinates. An absolutely positioned
|
// Anchored at paint time in fixed coordinates. An absolutely positioned
|
||||||
// dropdown is clipped by any scrolling ancestor and trapped inside the
|
// dropdown is clipped by any scrolling ancestor and trapped inside the
|
||||||
@@ -397,31 +405,40 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
});
|
});
|
||||||
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMenu(); });
|
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMenu(); });
|
||||||
|
|
||||||
// Measured against the bar, not the actions container: the actions are
|
// What "fits" means, measured rather than predicted. Two things have to
|
||||||
// sized by their content, so their own scrollWidth never exceeds their
|
// hold: the controls stay inside the bar, and no tab reaches them. The
|
||||||
// clientWidth. A viewport-width threshold is not enough either — how much
|
// tabs are the test rather than the nav's own box because the nav may
|
||||||
// fits depends on the rig name and the translated labels, so a bar that is
|
// shrink below its content — its box gets smaller while the tabs inside
|
||||||
// wide enough on one rig clips a control on another.
|
// keep their width and slide under the controls, so the container reports
|
||||||
// `bar.scrollWidth > bar.clientWidth` is true even when nothing is clipped,
|
// nothing wrong while destinations become unclickable.
|
||||||
// so it cannot be the test. What actually matters is that the controls stay
|
//
|
||||||
// inside the bar and the page tabs are not squeezed into a scroller: seeing
|
// This was an arithmetic estimate — identity + nav.scrollWidth +
|
||||||
// every tab beats keeping the style picker inline.
|
// actions.scrollWidth + a 48px allowance for the gaps — which under-counts
|
||||||
// Compare natural widths against the space available. Rendered widths
|
// whatever the allowance does not cover. On a platform whose fonts run
|
||||||
// cannot answer this: the nav has min-width 0 and scrolls, so it always
|
// wider than this machine's it declared a fit that overlapped by 9px, and
|
||||||
// shrinks to the leftover space and always reports "scrolling", while the
|
// nothing degraded because nothing thought anything was wrong. Reading
|
||||||
// bar reports overflow even when nothing is clipped. scrollWidth on a
|
// the geometry costs a synchronous layout per step, at most five per pass,
|
||||||
// scroll container is its unconstrained content width, which is what a fit
|
// and cannot disagree with what the operator sees.
|
||||||
// test needs.
|
|
||||||
const barFits = () => {
|
const barFits = () => {
|
||||||
const bar = actions.closest<HTMLElement>(".tab-bar");
|
const bar = actions.closest<HTMLElement>(".tab-bar");
|
||||||
if (!bar) return true;
|
const nav = bar?.querySelector<HTMLElement>(".tab-bar-nav");
|
||||||
const identity = bar.querySelector<HTMLElement>(".header-main");
|
if (!bar || !nav) return true;
|
||||||
const nav = bar.querySelector<HTMLElement>(".tab-bar-nav");
|
const barRect = bar.getBoundingClientRect();
|
||||||
const gutters = 48;
|
const actionsRect = actions.getBoundingClientRect();
|
||||||
const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters;
|
if (actionsRect.right > barRect.right + 1) return false;
|
||||||
return needed <= bar.clientWidth;
|
const tabs = Array.from(nav.querySelectorAll<HTMLElement>(".tab"))
|
||||||
|
.filter((tab) => tab.offsetParent !== null);
|
||||||
|
if (!tabs.length) return true;
|
||||||
|
const tabsRight = Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right));
|
||||||
|
return tabsRight <= actionsRect.left - 1;
|
||||||
};
|
};
|
||||||
const reflowOverflow = () => {
|
const reflowOverflow = () => {
|
||||||
|
const nav = document.querySelector<HTMLElement>(".tab-bar-nav");
|
||||||
|
const bar = actions.closest<HTMLElement>(".tab-bar");
|
||||||
|
// Measure from the roomiest state every time, so the decision is a
|
||||||
|
// function of the current widths alone and cannot ratchet.
|
||||||
|
nav?.classList.remove("nav-icons-only");
|
||||||
|
bar?.classList.remove("bar-tight");
|
||||||
overflowOrder.forEach((selector) => {
|
overflowOrder.forEach((selector) => {
|
||||||
const element = menu.querySelector<HTMLElement>(selector);
|
const element = menu.querySelector<HTMLElement>(selector);
|
||||||
if (element) actions.insertBefore(element, wrap);
|
if (element) actions.insertBefore(element, wrap);
|
||||||
@@ -434,11 +451,32 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
wrap.hidden = false;
|
wrap.hidden = false;
|
||||||
menu.appendChild(element);
|
menu.appendChild(element);
|
||||||
}
|
}
|
||||||
|
// Last resort, once every movable control is already in the menu: drop
|
||||||
|
// the tabs to their icons. Without it the nav — which may shrink below
|
||||||
|
// its content — keeps its tabs at full width and runs them under the
|
||||||
|
// controls, so the destinations nearest the controls become unclickable.
|
||||||
|
// Icon widths are fixed, so this always buys back the labels' width.
|
||||||
|
if (nav && !barFits()) nav.classList.add("nav-icons-only");
|
||||||
|
// Still short with the tabs down to icons: hand the squeeze to the
|
||||||
|
// identity block, which can ellipsise, rather than to the strip, which
|
||||||
|
// can only clip destinations out of reach.
|
||||||
|
if (bar && !barFits()) bar.classList.add("bar-tight");
|
||||||
wrap.hidden = menu.children.length === 0;
|
wrap.hidden = menu.children.length === 0;
|
||||||
if (wrap.hidden) closeMenu();
|
if (wrap.hidden) closeMenu();
|
||||||
};
|
};
|
||||||
reflowOverflow();
|
reflowOverflow();
|
||||||
window.addEventListener("resize", reflowOverflow);
|
window.addEventListener("resize", reflowOverflow);
|
||||||
|
// A resize is not the only thing that changes what fits: the rig name
|
||||||
|
// arrives from the server, the style picker fills in, a web font swaps in
|
||||||
|
// wider metrics. Each changes the bar's content without touching the
|
||||||
|
// window, and the strip stayed as it was through all of them.
|
||||||
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
|
const bar = actions.closest<HTMLElement>(".tab-bar");
|
||||||
|
const observer = new ResizeObserver(() => { reflowOverflow(); });
|
||||||
|
if (bar) observer.observe(bar);
|
||||||
|
observer.observe(actions);
|
||||||
|
}
|
||||||
|
document.fonts?.ready.then(() => { reflowOverflow(); }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function installMobileMore() {
|
function installMobileMore() {
|
||||||
@@ -464,7 +502,7 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
if (restoreFocus) more.focus();
|
if (restoreFocus) more.focus();
|
||||||
};
|
};
|
||||||
api.closeMobileOverlays = closeMore;
|
api.closeMobileOverlays = closeMore;
|
||||||
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
const item = document.createElement("button");
|
const item = document.createElement("button");
|
||||||
@@ -506,7 +544,7 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
select.setAttribute("aria-label", "Decoder view");
|
select.setAttribute("aria-label", "Decoder view");
|
||||||
const groups: Array<[string, string[]]> = [
|
const groups: Array<[string, string[]]> = [
|
||||||
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
||||||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
|
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax", "sstv"]],
|
||||||
];
|
];
|
||||||
groups.forEach(([label, ids]) => {
|
groups.forEach(([label, ids]) => {
|
||||||
const group = document.createElement("optgroup");
|
const group = document.createElement("optgroup");
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// The radio page and the digital modes page both describe one rig: the one the
|
||||||
|
// operator selected. Every rig a client is connected to feeds the same decode
|
||||||
|
// stream, so both the mini views over the waterfall and the panels behind them
|
||||||
|
// filter on the rig that heard each decode. The map is the exception and keeps
|
||||||
|
// all of them — it has its own rig filter — which is why the histories these
|
||||||
|
// views read from are never pruned by rig.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
import { createHost } from "./host-fixture.mjs";
|
||||||
|
|
||||||
|
class ElementFixture {
|
||||||
|
constructor(value = "") {
|
||||||
|
this.children = [];
|
||||||
|
this.innerHTML = "";
|
||||||
|
this.textContent = "";
|
||||||
|
this.value = value;
|
||||||
|
this.style = {};
|
||||||
|
this.dataset = {};
|
||||||
|
this.classList = { add() {}, remove() {}, toggle() {} };
|
||||||
|
}
|
||||||
|
appendChild(child) { this.children.push(child); return child; }
|
||||||
|
removeChild(child) { this.children.splice(this.children.indexOf(child), 1); }
|
||||||
|
replaceChildren(...nodes) { this.children = nodes.flatMap((node) => node.children ?? [node]); }
|
||||||
|
addEventListener() {}
|
||||||
|
setAttribute() {}
|
||||||
|
querySelector() { return null; }
|
||||||
|
querySelectorAll() { return []; }
|
||||||
|
get firstChild() { return this.children[0] ?? null; }
|
||||||
|
get lastElementChild() { return this.children.at(-1) ?? null; }
|
||||||
|
get scrollHeight() { return this.children.length; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A document whose named elements exist and whose unknown ones do not. */
|
||||||
|
function createDocument(elements) {
|
||||||
|
return {
|
||||||
|
documentElement: {},
|
||||||
|
getElementById: (id) => elements.get(id) ?? null,
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
createElement: () => new ElementFixture(),
|
||||||
|
createDocumentFragment: () => new ElementFixture(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPlugin(entry, { window, document: doc, extras = {} }) {
|
||||||
|
const context = vm.createContext({
|
||||||
|
window,
|
||||||
|
document: doc,
|
||||||
|
navigator: {},
|
||||||
|
requestAnimationFrame(callback) { callback(); return 1; },
|
||||||
|
getComputedStyle: () => ({ getPropertyValue: () => "" }),
|
||||||
|
Date, Number, String, Math, Array, Map, Set, Reflect, console,
|
||||||
|
...extras,
|
||||||
|
});
|
||||||
|
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
|
||||||
|
const source = await bundleEntry(new URL(`../src/plugins/${entry}.ts`, import.meta.url));
|
||||||
|
new vm.Script(runtime).runInContext(context);
|
||||||
|
new vm.Script(source).runInContext(context);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the APRS mini view and panel both keep to the active rig", async () => {
|
||||||
|
const overlay = new ElementFixture();
|
||||||
|
const packets = new ElementFixture();
|
||||||
|
const totals = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["aprs-bar-overlay", overlay],
|
||||||
|
["aprs-packets", packets],
|
||||||
|
["aprs-total-count", totals],
|
||||||
|
["mode", new ElementFixture("PKT")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
aprsMapAddStation: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("aprs", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
|
||||||
|
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.match(overlay.innerHTML, /SP1AAA/, "the active rig's frame is missing from the mini view");
|
||||||
|
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's frame reached the mini view");
|
||||||
|
assert.equal(packets.children.length, 1, "the APRS panel listed a background rig's frame");
|
||||||
|
assert.equal(totals.textContent, "1 total", `the APRS panel counted "${totals.textContent}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the APRS mini view shows every frame until a rig is known", async () => {
|
||||||
|
const overlay = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["aprs-bar-overlay", overlay],
|
||||||
|
["mode", new ElementFixture("PKT")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost(),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
aprsMapAddStation: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("aprs", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
|
||||||
|
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB" });
|
||||||
|
|
||||||
|
assert.match(overlay.innerHTML, /SP1AAA/);
|
||||||
|
assert.match(overlay.innerHTML, /SP2BBB/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the AIS mini view keeps to the active rig", async () => {
|
||||||
|
const overlay = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["ais-bar-overlay", overlay],
|
||||||
|
["mode", new ElementFixture("AIS")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
aisMapAddVessel: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("ais", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.match(overlay.innerHTML, /NEARBY/);
|
||||||
|
assert.doesNotMatch(overlay.innerHTML, /ELSEWHERE/, "a background rig's vessel reached the mini view");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the VDES mini view keeps to the active rig", async () => {
|
||||||
|
const overlay = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["vdes-bar-overlay", overlay],
|
||||||
|
["mode", new ElementFixture("VDES")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
vdesMapAddPoint: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("vdes", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.match(overlay.innerHTML, /SP1AAA/);
|
||||||
|
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's burst reached the mini view");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the CW mini view keeps to the active rig and does not braid two rigs into a line", async () => {
|
||||||
|
const overlay = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["cw-bar-overlay", overlay],
|
||||||
|
["cw-output", new ElementFixture()],
|
||||||
|
["mode", new ElementFixture("CW")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
addEventListener() {},
|
||||||
|
};
|
||||||
|
await runPlugin("cw", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("cw", { text: "DX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
|
||||||
|
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
||||||
|
|
||||||
|
assert.match(overlay.innerHTML, /CQ SP1AAA/, "the active rig's line was broken up or lost");
|
||||||
|
assert.doesNotMatch(overlay.innerHTML, /DX/, "a background rig's characters reached the mini view");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the FT8 mini view keeps to the active rig", async () => {
|
||||||
|
const overlay = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["ft8-bar-overlay", overlay],
|
||||||
|
["mode", new ElementFixture("DIG")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
ft8BaseHz: 7_074_000,
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
mapAddLocator: () => {},
|
||||||
|
setInterval() { return 1; },
|
||||||
|
};
|
||||||
|
await runPlugin("ft8", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.match(overlay.innerHTML, /SP1AAA/);
|
||||||
|
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── The digital modes panels ───────────────────────────────────────────────
|
||||||
|
// The same rule one page over: a panel lists the selected rig's decodes only.
|
||||||
|
|
||||||
|
test("the AIS panel lists the active rig's vessels only", async () => {
|
||||||
|
const messages = new ElementFixture();
|
||||||
|
const count = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["ais-messages", messages],
|
||||||
|
["ais-vessel-count", count],
|
||||||
|
["mode", new ElementFixture("AIS")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
aisMapAddVessel: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("ais", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.equal(messages.children.length, 1, "the AIS panel listed a background rig's vessel");
|
||||||
|
assert.equal(count.textContent, "1 vessel", `the panel counted "${count.textContent}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the VDES panel lists the active rig's bursts only", async () => {
|
||||||
|
const messages = new ElementFixture();
|
||||||
|
const count = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["vdes-messages", messages],
|
||||||
|
["vdes-frame-count", count],
|
||||||
|
["mode", new ElementFixture("VDES")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
vdesMapAddPoint: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("vdes", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.equal(messages.children.length, 1, "the VDES panel listed a background rig's burst");
|
||||||
|
assert.equal(count.textContent, "1 burst", `the panel counted "${count.textContent}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the FT8 panel lists the active rig's decodes only", async () => {
|
||||||
|
const messages = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["ft8-messages", messages],
|
||||||
|
["mode", new ElementFixture("DIG")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
ft8BaseHz: 7_074_000,
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
mapAddLocator: () => {},
|
||||||
|
setInterval() { return 1; },
|
||||||
|
};
|
||||||
|
await runPlugin("ft8", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.equal(messages.children.length, 1, "the FT8 panel listed a background rig's decode");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the WSPR panel lists the active rig's spots only", async () => {
|
||||||
|
const messages = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["wspr-messages", messages],
|
||||||
|
["mode", new ElementFixture("DIG")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
ft8BaseHz: 14_095_600,
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
mapAddLocator: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("wspr", {
|
||||||
|
window,
|
||||||
|
document: createDocument(elements),
|
||||||
|
extras: { setInterval: () => 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("wspr", { message: "SP1AAA JO91 30", freq_hz: 1_500, ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("wspr", { message: "SP2BBB JO94 27", freq_hz: 1_520, ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.equal(messages.children.length, 1, "the WSPR panel listed a background rig's spot");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the CW pane copies the active rig only", async () => {
|
||||||
|
const output = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["cw-output", output],
|
||||||
|
["cw-bar-overlay", new ElementFixture()],
|
||||||
|
["mode", new ElementFixture("CW")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
addEventListener() {},
|
||||||
|
};
|
||||||
|
await runPlugin("cw", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("cw", { text: "XX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
|
||||||
|
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
||||||
|
|
||||||
|
const copied = output.children.map((line) => line.textContent).join("");
|
||||||
|
assert.equal(copied, "CQ SP1AAA", `the pane copied "${copied}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching rigs repaints the panels through the runtime", async () => {
|
||||||
|
const packets = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["aprs-packets", packets],
|
||||||
|
["aprs-bar-overlay", new ElementFixture()],
|
||||||
|
["mode", new ElementFixture("PKT")],
|
||||||
|
]);
|
||||||
|
// A mutable host: the rig picker writes lastActiveRigId, and the panels read
|
||||||
|
// it as they paint.
|
||||||
|
const host = createHost({ state: { lastActiveRigId: "rig-a" } });
|
||||||
|
let activeRigId = "rig-a";
|
||||||
|
Object.defineProperty(host.trx.state, "lastActiveRigId", { get: () => activeRigId });
|
||||||
|
const window = { ...host, trxUi: { confirm: async () => true }, aprsMapAddStation: () => {} };
|
||||||
|
await runPlugin("aprs", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
|
||||||
|
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
|
||||||
|
assert.equal(packets.children.length, 1);
|
||||||
|
|
||||||
|
activeRigId = "rig-b";
|
||||||
|
window.trxPluginRuntime.rerenderAll();
|
||||||
|
assert.equal(packets.children.length, 1, "the panel did not follow the switch");
|
||||||
|
const shown = packets.children.map((row) => row.innerHTML).join("");
|
||||||
|
assert.match(shown, /SP2BBB/, "the panel still shows the rig that was switched away from");
|
||||||
|
assert.doesNotMatch(shown, /SP1AAA/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import vm from "node:vm";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
|
const sharedUrl = new URL("../src/plugins/aprs-shared.ts", import.meta.url);
|
||||||
|
const stylePath = new URL("../../assets/web/style.css", import.meta.url);
|
||||||
|
|
||||||
|
async function loadShared() {
|
||||||
|
const source = await bundleEntry(sharedUrl, "trxAprsShared");
|
||||||
|
const context = vm.createContext({ Math, String, Number, Array, Date, Set, console });
|
||||||
|
new vm.Script(source).runInContext(context);
|
||||||
|
return context.trxAprsShared;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sheets are 16x6 grids of 24px cells covering 0x21..0x7E, so cell index
|
||||||
|
// is `code - 0x21`. Getting this wrong shifts every station to a neighbouring
|
||||||
|
// icon, which is invisible in a screenshot but wrong on every packet.
|
||||||
|
test("sprite cells are indexed from the first printable symbol code", async () => {
|
||||||
|
const { aprsSymbolSprite } = await loadShared();
|
||||||
|
|
||||||
|
const first = aprsSymbolSprite("/", "!");
|
||||||
|
assert.equal(first.className, "aprs-symbol-primary");
|
||||||
|
assert.equal(first.backgroundPosition, "0px 0px");
|
||||||
|
assert.equal(first.label, "Primary APRS symbol /!");
|
||||||
|
// '>' is 0x3E -> index 29 -> column 13, row 1.
|
||||||
|
assert.equal(aprsSymbolSprite("/", ">").backgroundPosition, "-312px -24px");
|
||||||
|
// '~' is 0x7E -> index 93 -> the last cell of the last row.
|
||||||
|
assert.equal(aprsSymbolSprite("/", "~").backgroundPosition, "-312px -120px");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the table identifier selects the primary, alternate, or overlay sheet", async () => {
|
||||||
|
const { aprsSymbolSprite } = await loadShared();
|
||||||
|
|
||||||
|
assert.equal(aprsSymbolSprite("/", "_").className, "aprs-symbol-primary");
|
||||||
|
assert.equal(aprsSymbolSprite("\\", "_").className, "aprs-symbol-alternate");
|
||||||
|
|
||||||
|
// An alphanumeric table identifier is an overlay character drawn on top of
|
||||||
|
// the alternate symbol: overlay cell first, then the symbol cell.
|
||||||
|
const overlaid = aprsSymbolSprite("S", ">");
|
||||||
|
assert.equal(overlaid.className, "aprs-symbol-overlaid");
|
||||||
|
assert.equal(overlaid.backgroundPosition, "-48px -72px, -312px -24px");
|
||||||
|
assert.equal(overlaid.label, "Alternate APRS symbol \\> with overlay S");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("codes outside the sprite sheets fall back to the raw character", async () => {
|
||||||
|
const { aprsSymbolSprite, renderLocalAprsSymbol } = await loadShared();
|
||||||
|
const escape = (value) => value;
|
||||||
|
|
||||||
|
assert.equal(aprsSymbolSprite("/", " "), null);
|
||||||
|
assert.equal(aprsSymbolSprite("/", ""), null);
|
||||||
|
assert.equal(aprsSymbolSprite("/", "ab"), null);
|
||||||
|
assert.equal(aprsSymbolSprite(null, ">"), null);
|
||||||
|
|
||||||
|
assert.equal(renderLocalAprsSymbol({ symbolTable: "/", symbolCode: " " }, escape),
|
||||||
|
'<span class="aprs-symbol aprs-symbol-local" title="Primary APRS symbol "> </span>');
|
||||||
|
assert.equal(renderLocalAprsSymbol({}, escape), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rendered symbols carry a sprite class and an inline cell offset", async () => {
|
||||||
|
const { renderLocalAprsSymbol } = await loadShared();
|
||||||
|
const html = renderLocalAprsSymbol({ symbolTable: "/", symbolCode: ">" }, (value) => value);
|
||||||
|
|
||||||
|
assert.match(html, /class="aprs-symbol aprs-symbol-primary"/);
|
||||||
|
assert.match(html, /style="background-position:-312px -24px"/);
|
||||||
|
assert.match(html, /aria-label="Primary APRS symbol \/>"/);
|
||||||
|
assert.equal(html.includes("http"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The cell offsets are computed in the bundles, so the sheet URLs and the grid
|
||||||
|
// geometry have to stay in lockstep with them here.
|
||||||
|
test("the stylesheet serves every sheet locally at the sprite geometry", async () => {
|
||||||
|
const css = await readFile(stylePath, "utf8");
|
||||||
|
|
||||||
|
assert.match(css, /\.aprs-symbol\s*\{[^}]*background-size:\s*384px 144px/);
|
||||||
|
for (const sheet of ["24-0", "24-1", "24-2", "24-0-2x", "24-1-2x", "24-2-2x"]) {
|
||||||
|
assert.ok(css.includes(`url('/vendor/aprs-symbols-${sheet}.png')`), `missing sheet ${sheet}`);
|
||||||
|
}
|
||||||
|
assert.match(css, /\.aprs-symbol-overlaid\s*\{[^}]*aprs-symbols-24-2\.png'\), url\('\/vendor\/aprs-symbols-24-1\.png'\)/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// The background decode panel: one list that both picks the bookmarks and says
|
||||||
|
// what each one is doing. It used to be two lists of the same bookmarks — one
|
||||||
|
// to choose from, one to read state off — and it filled neither until the
|
||||||
|
// operator switched rigs, because it took whatever rig it was handed at load
|
||||||
|
// and that was nothing yet.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
|
/* global document */
|
||||||
|
|
||||||
|
const BOOKMARKS = [
|
||||||
|
{ id: "bm-ft8-20", name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoders: ["ft8"] },
|
||||||
|
{ id: "bm-ft8-40", name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoders: ["ft8"] },
|
||||||
|
{ id: "bm-ft4-20", name: "FT4 20 m", freq_hz: 14_080_000, mode: "USB", decoders: ["ft4"] },
|
||||||
|
{ id: "bm-aprs", name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoders: ["aprs"] },
|
||||||
|
// Nothing that runs in the background can decode CW, so it must not be offered.
|
||||||
|
{ id: "bm-cw", name: "CW practice", freq_hz: 7_030_000, mode: "CW", decoders: ["cw"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const backgroundDecode = {
|
||||||
|
config: { remote: "rig-a", enabled: true, bookmark_ids: ["bm-ft8-20", "bm-ft8-40", "bm-aprs"] },
|
||||||
|
status: {
|
||||||
|
active_rig: true,
|
||||||
|
center_hz: 14_100_000,
|
||||||
|
sample_rate: 2_400_000,
|
||||||
|
entries: [
|
||||||
|
{ bookmark_id: "bm-ft8-20", bookmark_name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "active" },
|
||||||
|
{ bookmark_id: "bm-ft8-40", bookmark_name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "out_of_span" },
|
||||||
|
{ bookmark_id: "bm-aprs", bookmark_name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoder_kinds: ["aprs"], state: "handled_by_scheduler" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const readRows = () => page.evaluate(() => {
|
||||||
|
const rows = [...document.querySelectorAll("#bgd-bookmark-checklist .bgd-checklist-row")];
|
||||||
|
return rows.map((row) => ({
|
||||||
|
name: row.querySelector(".bgd-checklist-name")?.textContent ?? "",
|
||||||
|
checked: row.querySelector("input[type=checkbox]")?.checked ?? false,
|
||||||
|
state: row.querySelector(".bgd-state")?.dataset.state ?? null,
|
||||||
|
stateText: row.querySelector(".bgd-state")?.textContent?.trim() ?? "",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const openPanel = async () => {
|
||||||
|
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#tab-settings").waitFor({ state: "visible" });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixture = await startWebFixture({ spectrum: true, bookmarks: BOOKMARKS, backgroundDecode });
|
||||||
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 950 });
|
||||||
|
await openPanel();
|
||||||
|
|
||||||
|
// Nothing was switched, clicked or reloaded: the panel asked the host which
|
||||||
|
// rig it is on and filled itself.
|
||||||
|
const rows = await readRows();
|
||||||
|
assert.deepEqual(rows.map((row) => row.name), ["FT8 20 m", "FT8 40 m", "FT4 20 m", "APRS 2 m"],
|
||||||
|
"the list does not hold the bookmarks a background channel can decode");
|
||||||
|
assert.deepEqual(rows.map((row) => row.checked), [true, true, false, true],
|
||||||
|
"the saved selection is not reflected in the list");
|
||||||
|
|
||||||
|
// Each row carries its own state, so there is no second list to consult.
|
||||||
|
assert.deepEqual(rows.map((row) => row.state),
|
||||||
|
["active", "out_of_span", "unselected", "handled_by_scheduler"],
|
||||||
|
"the rows do not carry the state of the bookmark they name");
|
||||||
|
assert.match(rows[0].stateText, /Decoding/);
|
||||||
|
assert.match(rows[1].stateText, /Out of span/);
|
||||||
|
|
||||||
|
// What the rig can hear, and what the selection adds up to.
|
||||||
|
const context = await page.evaluate(() => ({
|
||||||
|
span: document.getElementById("bgd-span-summary")?.textContent?.trim(),
|
||||||
|
summary: document.getElementById("bgd-selection-summary")?.textContent?.trim(),
|
||||||
|
}));
|
||||||
|
assert.match(context.span, /14\.1 MHz/, `the span reads "${context.span}"`);
|
||||||
|
assert.match(context.summary, /3 bookmarks selected, 1 decoding now\./, `the summary reads "${context.summary}"`);
|
||||||
|
|
||||||
|
// Save has nothing to do until something changes, and says so again once it
|
||||||
|
// has been done.
|
||||||
|
const saveState = () => page.evaluate(() => {
|
||||||
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
|
return { disabled: btn.disabled, dirty: btn.classList.contains("sch-dirty") };
|
||||||
|
});
|
||||||
|
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save offers itself with nothing to save");
|
||||||
|
|
||||||
|
await page.locator('#bgd-bookmark-checklist .bgd-checklist-row:nth-child(3) input').click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
assert.deepEqual(await saveState(), { disabled: false, dirty: true }, "Save stayed inert after a change");
|
||||||
|
|
||||||
|
await page.locator("#background-decode-save-btn").click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save stayed live after saving");
|
||||||
|
|
||||||
|
// The selection reached the server and comes back on the next load.
|
||||||
|
await openPanel();
|
||||||
|
const saved = await readRows();
|
||||||
|
assert.deepEqual(saved.map((row) => row.checked), [true, true, true, true],
|
||||||
|
"the newly selected bookmark did not survive a reload");
|
||||||
|
|
||||||
|
assert.deepEqual(runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no bookmarks at all the panel says what to do about it rather than
|
||||||
|
// showing an empty box.
|
||||||
|
const emptyFixture = await startWebFixture({ spectrum: true, bookmarks: [] });
|
||||||
|
const empty = await startBrowser(chromium);
|
||||||
|
try {
|
||||||
|
await empty.page.setViewportSize({ width: 1400, height: 950 });
|
||||||
|
await empty.page.goto(`${emptyFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
||||||
|
await empty.page.locator("#tab-settings").waitFor({ state: "visible" });
|
||||||
|
await empty.page.waitForTimeout(2000);
|
||||||
|
await empty.page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
|
||||||
|
await empty.page.waitForTimeout(1000);
|
||||||
|
const text = await empty.page.evaluate(() =>
|
||||||
|
document.getElementById("bgd-bookmark-checklist")?.textContent?.trim() ?? "");
|
||||||
|
assert.match(text, /No bookmarks yet/, `the empty list reads "${text}"`);
|
||||||
|
const summary = await empty.page.evaluate(() =>
|
||||||
|
document.getElementById("bgd-selection-summary")?.textContent?.trim() ?? "");
|
||||||
|
assert.match(summary, /Nothing selected/, `the summary reads "${summary}"`);
|
||||||
|
} finally {
|
||||||
|
await empty.browser.close();
|
||||||
|
await emptyFixture.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("background decode panel tests passed");
|
||||||
@@ -29,7 +29,7 @@ class ElementFixture {
|
|||||||
// Mirrors the `window.trx` host contract published by app.ts. The plugin is a
|
// Mirrors the `window.trx` host contract published by app.ts. The plugin is a
|
||||||
// separate bundle, so every application service it uses arrives this way.
|
// separate bundle, so every application service it uses arrives this way.
|
||||||
function hostFixture(overrides = {}) {
|
function hostFixture(overrides = {}) {
|
||||||
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0 };
|
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
|
||||||
const state = {
|
const state = {
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
authRole: "control",
|
authRole: "control",
|
||||||
@@ -50,6 +50,7 @@ function hostFixture(overrides = {}) {
|
|||||||
armOptimisticFrequency: (hz) => { calls.armOptimisticFrequency.push(hz); },
|
armOptimisticFrequency: (hz) => { calls.armOptimisticFrequency.push(hz); },
|
||||||
syncBandwidthInput: (hz) => { calls.syncBandwidthInput.push(hz); },
|
syncBandwidthInput: (hz) => { calls.syncBandwidthInput.push(hz); },
|
||||||
scheduleSpectrumDraw: () => { calls.scheduleSpectrumDraw += 1; },
|
scheduleSpectrumDraw: () => { calls.scheduleSpectrumDraw += 1; },
|
||||||
|
syncModePicker: () => { calls.syncModePicker += 1; },
|
||||||
onDecoderRegistryReady: () => {},
|
onDecoderRegistryReady: () => {},
|
||||||
};
|
};
|
||||||
return { window: { trx: { state, core, modules: {} }, trxUi: { confirm: async () => true } }, calls };
|
return { window: { trx: { state, core, modules: {} }, trxUi: { confirm: async () => true } }, calls };
|
||||||
|
|||||||
@@ -3,167 +3,18 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import http from "node:http";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { chromium } from "playwright-core";
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
// page.evaluate callbacks run in the browser, not in this Node process.
|
||||||
const webDir = path.resolve(frontendDir, "../assets/web");
|
/* global document, getComputedStyle, window, location */
|
||||||
const generatedDir = path.join(webDir, "generated");
|
|
||||||
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
|
||||||
remote,
|
|
||||||
display_name: remote === "rig-a" ? "Primary fixture" : "Secondary fixture",
|
|
||||||
manufacturer: "Smoke",
|
|
||||||
model: "Fixture",
|
|
||||||
supported_modes: ["FM"],
|
|
||||||
tx: false,
|
|
||||||
filter_controls: false,
|
|
||||||
initialized: true,
|
|
||||||
latitude: null,
|
|
||||||
longitude: null,
|
|
||||||
}));
|
|
||||||
const rigsResponse = { rigs: rigItems, active_remote: "rig-a" };
|
|
||||||
const selectedRigs = [];
|
|
||||||
|
|
||||||
const jsonRoutes = new Map([
|
const fixture = await startWebFixture();
|
||||||
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
|
const { selectedRigs } = fixture;
|
||||||
["/decoders", []],
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
["/rigs", rigsResponse],
|
|
||||||
["/status", {
|
|
||||||
info: {
|
|
||||||
manufacturer: "Smoke",
|
|
||||||
model: "Fixture",
|
|
||||||
revision: "1",
|
|
||||||
access: { Tcp: { addr: "127.0.0.1:0" } },
|
|
||||||
capabilities: {
|
|
||||||
min_freq_step_hz: 1,
|
|
||||||
supported_bands: [],
|
|
||||||
supported_modes: ["FM"],
|
|
||||||
num_vfos: 1,
|
|
||||||
lock: false,
|
|
||||||
lockable: false,
|
|
||||||
attenuator: false,
|
|
||||||
preamp: false,
|
|
||||||
rit: false,
|
|
||||||
rpt: false,
|
|
||||||
split: false,
|
|
||||||
tx: false,
|
|
||||||
tx_limit: false,
|
|
||||||
vfo_switch: false,
|
|
||||||
filter_controls: false,
|
|
||||||
signal_meter: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: null, lock: null },
|
|
||||||
band: null,
|
|
||||||
enabled: true,
|
|
||||||
initialized: true,
|
|
||||||
cw_auto: false,
|
|
||||||
cw_wpm: 20,
|
|
||||||
cw_tone_hz: 700,
|
|
||||||
aprs_decode_enabled: false,
|
|
||||||
hf_aprs_decode_enabled: false,
|
|
||||||
cw_decode_enabled: false,
|
|
||||||
ft8_decode_enabled: false,
|
|
||||||
ft4_decode_enabled: false,
|
|
||||||
ft2_decode_enabled: false,
|
|
||||||
wspr_decode_enabled: false,
|
|
||||||
lrpt_decode_enabled: false,
|
|
||||||
wefax_decode_enabled: false,
|
|
||||||
recorder_enabled: false,
|
|
||||||
clients: 1,
|
|
||||||
rigctl_clients: 0,
|
|
||||||
audio_clients: 0,
|
|
||||||
active_remote: "rig-a",
|
|
||||||
remotes: ["rig-a", "rig-b"],
|
|
||||||
show_sdr_gain_control: false,
|
|
||||||
initial_map_zoom: 10,
|
|
||||||
spectrum_coverage_margin_hz: 50_000,
|
|
||||||
spectrum_usable_span_ratio: 0.92,
|
|
||||||
bandplan_enabled: false,
|
|
||||||
bandplan_region: "iaru1",
|
|
||||||
decode_history_retention_min: 1440,
|
|
||||||
server_connected: true,
|
|
||||||
}],
|
|
||||||
["/bandplan.json", {}],
|
|
||||||
["/api/recorder/status", []],
|
|
||||||
["/api/recorder/files", []],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const contentTypes = new Map([
|
|
||||||
[".css", "text/css; charset=utf-8"],
|
|
||||||
[".html", "text/html; charset=utf-8"],
|
|
||||||
[".js", "application/javascript; charset=utf-8"],
|
|
||||||
[".json", "application/json; charset=utf-8"],
|
|
||||||
[".png", "image/png"],
|
|
||||||
[".woff2", "font/woff2"],
|
|
||||||
]);
|
|
||||||
|
|
||||||
function assetPath(urlPath) {
|
|
||||||
if (urlPath === "/") return path.join(webDir, "index.html");
|
|
||||||
if (urlPath.startsWith("/vendor/")) return path.join(webDir, urlPath);
|
|
||||||
const generated = path.join(generatedDir, path.basename(urlPath));
|
|
||||||
if (urlPath.endsWith(".js")) return generated;
|
|
||||||
return path.join(webDir, urlPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = http.createServer(async (request, response) => {
|
|
||||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
||||||
if (url.pathname === "/select_rig" && request.method === "POST") {
|
|
||||||
const remote = url.searchParams.get("remote");
|
|
||||||
if (remote) {
|
|
||||||
rigsResponse.active_remote = remote;
|
|
||||||
jsonRoutes.get("/status").active_remote = remote;
|
|
||||||
selectedRigs.push(remote);
|
|
||||||
}
|
|
||||||
response.writeHead(200).end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (jsonRoutes.has(url.pathname)) {
|
|
||||||
response.writeHead(200, { "content-type": "application/json" });
|
|
||||||
response.end(JSON.stringify(jsonRoutes.get(url.pathname)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.pathname === "/audio") {
|
|
||||||
response.writeHead(404).end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
|
|
||||||
response.writeHead(200, {
|
|
||||||
"cache-control": "no-cache",
|
|
||||||
connection: "keep-alive",
|
|
||||||
"content-type": "text/event-stream",
|
|
||||||
});
|
|
||||||
response.write(": browser smoke stream\n\n");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const file = assetPath(url.pathname);
|
|
||||||
const bytes = await readFile(file);
|
|
||||||
response.writeHead(200, {
|
|
||||||
"content-type": contentTypes.get(path.extname(file)) ?? "application/octet-stream",
|
|
||||||
});
|
|
||||||
response.end(bytes);
|
|
||||||
} catch {
|
|
||||||
response.writeHead(404).end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
||||||
const address = server.address();
|
|
||||||
assert(address && typeof address === "object");
|
|
||||||
|
|
||||||
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
|
||||||
?? "/usr/bin/chromium";
|
|
||||||
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
|
|
||||||
const page = await browser.newPage();
|
|
||||||
const runtimeErrors = [];
|
|
||||||
page.on("pageerror", (error) => runtimeErrors.push(error.stack ?? error.message));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "domcontentloaded" });
|
await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
assert.deepEqual(runtimeErrors, []);
|
assert.deepEqual(runtimeErrors, []);
|
||||||
await page.locator("#content").waitFor({ state: "visible" });
|
await page.locator("#content").waitFor({ state: "visible" });
|
||||||
@@ -172,6 +23,167 @@ try {
|
|||||||
await page.locator("summary", { hasText: "Audio controls" }).click();
|
await page.locator("summary", { hasText: "Audio controls" }).click();
|
||||||
assert.equal(await page.locator("#rx-audio-btn").count(), 1);
|
assert.equal(await page.locator("#rx-audio-btn").count(), 1);
|
||||||
|
|
||||||
|
// Digital modes: the decoders are a list down the side, and the panel for the
|
||||||
|
// selected one sits beside it. A horizontal strip put thirteen decoders in a
|
||||||
|
// scroller and marked the open one with a single underline among them.
|
||||||
|
await page.locator('.tab[data-tab="digital-modes"]').click();
|
||||||
|
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
|
||||||
|
await page.locator('.sub-tab[data-subtab="ft8"]').click();
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
const digital = await page.evaluate(() => {
|
||||||
|
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar").getBoundingClientRect();
|
||||||
|
const panel = document.getElementById("subtab-ft8").getBoundingClientRect();
|
||||||
|
const shown = [...document.querySelectorAll("#tab-digital-modes > .sub-tab-panel")]
|
||||||
|
.filter((element) => getComputedStyle(element).display !== "none")
|
||||||
|
.map((element) => element.id);
|
||||||
|
return {
|
||||||
|
sidebarIsColumn: bar.height > bar.width,
|
||||||
|
panelBesideSidebar: Math.round(panel.left) >= Math.round(bar.right),
|
||||||
|
panelsShown: shown,
|
||||||
|
decoders: document.querySelectorAll("#tab-digital-modes > .sub-tab-bar .sub-tab").length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.ok(digital.sidebarIsColumn, "the decoder list is not a sidebar");
|
||||||
|
assert.ok(digital.panelBesideSidebar, "the decoder panel does not sit beside the sidebar");
|
||||||
|
assert.deepEqual(digital.panelsShown, ["subtab-ft8"], `panels shown: ${JSON.stringify(digital.panelsShown)}`);
|
||||||
|
assert.ok(digital.decoders >= 10, `only ${digital.decoders} decoders in the sidebar`);
|
||||||
|
|
||||||
|
// Each decoder's list fills its panel. FT8, FT4, FT2 and WSPR size against
|
||||||
|
// the panel with flex, so a panel sized to its own content collapsed them to
|
||||||
|
// their 120px minimum with the rest of the page left empty; the marine lists
|
||||||
|
// were sized by a viewport formula that stopped matching when the panel
|
||||||
|
// changed shape.
|
||||||
|
for (const [subtab, list] of [["ft8", "ft8-messages"], ["wspr", "wspr-messages"],
|
||||||
|
["ais", "ais-messages"], ["aprs", "aprs-packets"], ["hf-aprs", "hf-aprs-packets"]]) {
|
||||||
|
await page.locator(`.sub-tab[data-subtab="${subtab}"]`).click();
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
const filled = await page.evaluate((id) => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
const panel = element.closest(".sub-tab-panel");
|
||||||
|
return {
|
||||||
|
list: Math.round(element.getBoundingClientRect().height),
|
||||||
|
panel: Math.round(panel.getBoundingClientRect().height),
|
||||||
|
scrolls: getComputedStyle(element).overflowY,
|
||||||
|
};
|
||||||
|
}, list);
|
||||||
|
assert.ok(filled.list > filled.panel * 0.6,
|
||||||
|
`${subtab}: the list is ${filled.list}px in a ${filled.panel}px panel`);
|
||||||
|
assert.equal(filled.scrolls, "auto", `${subtab}: the list does not scroll on its own`);
|
||||||
|
}
|
||||||
|
await page.locator('.tab[data-tab="main"]').click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Map links from decode rows, before anything has opened the Map tab. The
|
||||||
|
// lazy map module used to install these globals itself, so an AIS pin threw
|
||||||
|
// "not a function" and an APRS link silently did nothing until the tab had
|
||||||
|
// been visited once.
|
||||||
|
const mapLinkReady = await page.evaluate(() => ({
|
||||||
|
position: typeof window.navigateToAprsMap,
|
||||||
|
locator: typeof window.navigateToMapLocator,
|
||||||
|
mapModuleLoaded: !!window.trx.modules.map,
|
||||||
|
}));
|
||||||
|
assert.equal(mapLinkReady.position, "function", "navigateToAprsMap is missing before the map loads");
|
||||||
|
assert.equal(mapLinkReady.locator, "function", "navigateToMapLocator is missing before the map loads");
|
||||||
|
assert.equal(mapLinkReady.mapModuleLoaded, false, "the map module was already loaded, so this proves nothing");
|
||||||
|
|
||||||
|
await page.evaluate(() => { window.navigateToAprsMap(52.2, 21.0); });
|
||||||
|
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const followed = await page.evaluate(() => ({
|
||||||
|
path: location.pathname,
|
||||||
|
active: [...document.querySelectorAll(".tab-bar .tab.active")].map((tab) => tab.dataset.tab || tab.id),
|
||||||
|
mapHeight: Math.round(document.getElementById("aprs-map").getBoundingClientRect().height),
|
||||||
|
}));
|
||||||
|
assert.equal(followed.path, "/map", `the map link left the page on ${followed.path}`);
|
||||||
|
assert.ok(followed.active.includes("map"), `the strip marks ${JSON.stringify(followed.active)}`);
|
||||||
|
assert.ok(followed.mapHeight > 100, `the map came up ${followed.mapHeight}px tall`);
|
||||||
|
await page.locator('.tab[data-tab="main"]').click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Section order in the tray. "Advanced radio controls" is built at runtime,
|
||||||
|
// so it lands wherever ui-core puts it rather than where the markup says —
|
||||||
|
// appending, as it once did, always left it last.
|
||||||
|
const sections = await page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll(".controls-tray > details")].map((section) =>
|
||||||
|
section.querySelector("summary").textContent.trim()));
|
||||||
|
assert.deepEqual(sections, ["Advanced radio controls", "Audio controls", "Scheduler controls"],
|
||||||
|
`tray sections are ${JSON.stringify(sections)}`);
|
||||||
|
|
||||||
|
// Mode is a button group over a hidden <select>, which stays the value a
|
||||||
|
// dozen call sites and several plugins read. The click has to reach it, and
|
||||||
|
// the select must not take part in layout while it does.
|
||||||
|
const modeBefore = await page.evaluate(() => ({
|
||||||
|
buttons: document.querySelectorAll("#mode-picker button").length,
|
||||||
|
value: document.getElementById("mode").value,
|
||||||
|
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
|
||||||
|
}));
|
||||||
|
assert.ok(modeBefore.buttons > 1, `mode picker rendered ${modeBefore.buttons} buttons`);
|
||||||
|
assert.equal(modeBefore.active, modeBefore.value, "mode picker disagrees with the select");
|
||||||
|
const target = await page.evaluate(() => {
|
||||||
|
const other = [...document.querySelectorAll("#mode-picker button")]
|
||||||
|
.find((btn) => btn.dataset.mode !== document.getElementById("mode").value);
|
||||||
|
return other?.dataset.mode;
|
||||||
|
});
|
||||||
|
await page.locator(`#mode-picker button[data-mode="${target}"]`).click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const modeAfter = await page.evaluate(() => ({
|
||||||
|
value: document.getElementById("mode").value,
|
||||||
|
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
|
||||||
|
selectWidth: Math.round(document.getElementById("mode").getBoundingClientRect().width),
|
||||||
|
}));
|
||||||
|
assert.equal(modeAfter.value, target, `clicking ${target} left the select at ${modeAfter.value}`);
|
||||||
|
assert.equal(modeAfter.active, target, "the clicked mode is not the marked one");
|
||||||
|
assert.ok(modeAfter.selectWidth <= 2, `the hidden select still occupies ${modeAfter.selectWidth}px`);
|
||||||
|
|
||||||
|
// Mode-specific controls live on their own row, which has to leave with them:
|
||||||
|
// an empty one would still take a track and a gap in the tray and draw its
|
||||||
|
// divider under the controls every rig has.
|
||||||
|
const modeRowState = async () => page.evaluate(() => {
|
||||||
|
const row = document.getElementById("mode-controls-row");
|
||||||
|
return { display: getComputedStyle(row).display, height: Math.round(row.getBoundingClientRect().height) };
|
||||||
|
});
|
||||||
|
await page.locator('#mode-picker button[data-mode="WFM"]').click();
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
const withWfm = await modeRowState();
|
||||||
|
assert.notEqual(withWfm.display, "none", "WFM controls did not bring their row up");
|
||||||
|
assert.ok(withWfm.height > 0, `WFM row has no height (${withWfm.height}px)`);
|
||||||
|
await page.locator('#mode-picker button[data-mode="FM"]').click();
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
const withoutWfm = await modeRowState();
|
||||||
|
assert.equal(withoutWfm.display, "none", "the mode row stayed behind with nothing in it");
|
||||||
|
|
||||||
|
// Scheduler controls read left to right: step, hand back, then the entry on
|
||||||
|
// air. The separator is drawn by the current-entry block, so it can only sit
|
||||||
|
// in the right place if that block is last.
|
||||||
|
const schedulerRow = await page.evaluate(() => [...document.querySelectorAll(".scheduler-action-row > *")]
|
||||||
|
.map((el) => el.id || [...el.children].map((c) => c.id).join("+")));
|
||||||
|
assert.deepEqual(schedulerRow,
|
||||||
|
["scheduler-prev-btn+scheduler-next-btn", "scheduler-release-btn", "scheduler-cycle-status"],
|
||||||
|
`scheduler control order is ${JSON.stringify(schedulerRow)}`);
|
||||||
|
|
||||||
|
// The footer status pill colours its dot from data-state, so a hint written
|
||||||
|
// straight to textContent would leave the dot stuck on the previous state.
|
||||||
|
const hint = await page.evaluate(() => {
|
||||||
|
const element = document.getElementById("power-hint");
|
||||||
|
return { state: element.dataset.state, text: element.textContent.trim() };
|
||||||
|
});
|
||||||
|
assert.ok(["ok", "busy", "error"].includes(hint.state), `status pill state is ${hint.state}`);
|
||||||
|
assert.equal(hint.state, "ok", `fixture reports "${hint.text}" but the pill is ${hint.state}`);
|
||||||
|
|
||||||
|
// Rig names, and they have to survive the state stream. The updates carry
|
||||||
|
// only rig ids — /rigs is what knows the names — and applying one used to
|
||||||
|
// clear the names, so the picker and the header fell back to the lowercase
|
||||||
|
// ids a second after load and stayed there.
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const rigLabels = await page.evaluate(() => ({
|
||||||
|
options: [...document.getElementById("header-rig-switch-select").options].map((o) => o.textContent),
|
||||||
|
subtitle: document.getElementById("rig-subtitle").textContent,
|
||||||
|
}));
|
||||||
|
assert.deepEqual(rigLabels.options, ["Primary fixture", "Secondary fixture"],
|
||||||
|
`the picker reads ${JSON.stringify(rigLabels.options)}`);
|
||||||
|
assert.equal(rigLabels.subtitle, "Rig: Primary fixture",
|
||||||
|
`the header reads "${rigLabels.subtitle}"`);
|
||||||
|
|
||||||
const rigPicker = page.locator("#header-rig-switch-select");
|
const rigPicker = page.locator("#header-rig-switch-select");
|
||||||
await rigPicker.locator("option").nth(1).waitFor({ state: "attached" });
|
await rigPicker.locator("option").nth(1).waitFor({ state: "attached" });
|
||||||
await rigPicker.selectOption("rig-b");
|
await rigPicker.selectOption("rig-b");
|
||||||
@@ -181,6 +193,44 @@ try {
|
|||||||
await page.locator('.tab[data-tab="map"]').click();
|
await page.locator('.tab[data-tab="map"]').click();
|
||||||
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
||||||
assert.equal(new URL(page.url()).pathname, "/map");
|
assert.equal(new URL(page.url()).pathname, "/map");
|
||||||
|
|
||||||
|
// The selected destination is marked by a box on all four sides, so a rule
|
||||||
|
// that drops one edge (the mobile nav used to lose its bottom border) is a
|
||||||
|
// regression even though the tab still reads as "active".
|
||||||
|
const activeTab = await page.evaluate(() => {
|
||||||
|
const style = getComputedStyle(document.querySelector(".tab-bar-nav .tab.active"));
|
||||||
|
return ["Top", "Right", "Bottom", "Left"].map((side) => ({
|
||||||
|
width: style.getPropertyValue(`border-${side.toLowerCase()}-width`),
|
||||||
|
color: style.getPropertyValue(`border-${side.toLowerCase()}-color`),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
for (const edge of activeTab) {
|
||||||
|
assert.notEqual(edge.width, "0px", `active tab border: ${JSON.stringify(activeTab)}`);
|
||||||
|
assert.ok(!/rgba\(0, 0, 0, 0\)|transparent/.test(edge.color), `active tab border: ${JSON.stringify(activeTab)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The map is full-bleed: it breaks out of the centred .card column and
|
||||||
|
// reaches both viewport edges, without pushing the page sideways.
|
||||||
|
const stage = await page.evaluate(() => {
|
||||||
|
const rect = document.getElementById("map-stage").getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
left: Math.round(rect.left),
|
||||||
|
right: Math.round(rect.right),
|
||||||
|
viewport: document.documentElement.clientWidth,
|
||||||
|
sideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||||
|
gapToFooter: Math.round(document.querySelector(".footer").getBoundingClientRect().top - rect.bottom),
|
||||||
|
pageScrolls: document.documentElement.scrollHeight > document.documentElement.clientHeight + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(stage.left, 0, `map stage starts at ${stage.left}px, not the viewport edge`);
|
||||||
|
assert.equal(stage.right, stage.viewport, `map stage ends at ${stage.right}px, not ${stage.viewport}px`);
|
||||||
|
assert.equal(stage.sideways, false, "full-bleed map makes the page scroll sideways");
|
||||||
|
// ...and fills the column down to the footer. Capping the height at a
|
||||||
|
// fraction of the viewport left a dead band that grew with the window.
|
||||||
|
assert.ok(stage.gapToFooter <= 16,
|
||||||
|
`${stage.gapToFooter}px of dead space between the map and the footer`);
|
||||||
|
assert.equal(stage.pageScrolls, false, "the map grew past the viewport");
|
||||||
|
|
||||||
await page.locator('.tab[data-tab="main"]').click();
|
await page.locator('.tab[data-tab="main"]').click();
|
||||||
assert.equal(new URL(page.url()).pathname, "/");
|
assert.equal(new URL(page.url()).pathname, "/");
|
||||||
|
|
||||||
@@ -195,7 +245,150 @@ try {
|
|||||||
await page.locator("#tab-main").waitFor({ state: "visible" });
|
await page.locator("#tab-main").waitFor({ state: "visible" });
|
||||||
assert.equal(new URL(page.url()).pathname, "/");
|
assert.equal(new URL(page.url()).pathname, "/");
|
||||||
assert.deepEqual(runtimeErrors, []);
|
assert.deepEqual(runtimeErrors, []);
|
||||||
|
|
||||||
|
// Refreshing or deep-linking must mark the destination, not Tools. The first
|
||||||
|
// route navigation runs while the card is still behind the loading state, so
|
||||||
|
// a test that asked whether the tab was displayed saw "none" for every tab
|
||||||
|
// and lit Tools up on every refresh of every page.
|
||||||
|
for (const [route, tab, toolsLit] of [["/map", "map", false], ["/about", "about", true]]) {
|
||||||
|
await page.goto(`${fixture.origin}${route}`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator(`#tab-${tab}`).waitFor({ state: "visible" });
|
||||||
|
const marked = await page.evaluate(() => ({
|
||||||
|
actives: [...document.querySelectorAll(".tab-bar .tab.active")].map((t) => t.dataset.tab || t.id),
|
||||||
|
tools: document.getElementById("mobile-more-btn").classList.contains("active"),
|
||||||
|
}));
|
||||||
|
assert.ok(marked.actives.includes(tab), `${route} marks ${JSON.stringify(marked.actives)}`);
|
||||||
|
assert.equal(marked.tools, toolsLit, `${route}: Tools active is ${marked.tools}`);
|
||||||
|
}
|
||||||
|
await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#tab-main").waitFor({ state: "visible" });
|
||||||
|
assert.deepEqual(runtimeErrors, []);
|
||||||
|
|
||||||
|
// --- Layout regressions -------------------------------------------------
|
||||||
|
// Every fault below shipped at some point while the rest of this file
|
||||||
|
// passed, because nothing here looked at geometry: a header whose height
|
||||||
|
// tracked the viewport, controls that stretched, a tab strip that ran under
|
||||||
|
// the controls, and a dropdown that opened underneath the spectrum.
|
||||||
|
for (const width of [1440, 1280, 1100, 900]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
const header = await page.evaluate(() => {
|
||||||
|
const bar = document.querySelector(".tab-bar");
|
||||||
|
const nav = document.querySelector(".tab-bar-nav");
|
||||||
|
const actions = document.querySelector(".top-bar-actions");
|
||||||
|
const controls = [...actions.children]
|
||||||
|
.filter((el) => !el.hidden && el.getBoundingClientRect().height > 0)
|
||||||
|
.map((el) => Math.round(el.getBoundingClientRect().height));
|
||||||
|
return {
|
||||||
|
barHeight: Math.round(bar.getBoundingClientRect().height),
|
||||||
|
// The tabs, not the strip: with the strip allowed to overflow its box
|
||||||
|
// shrinks while its content paints across the controls, so the
|
||||||
|
// container's own rect never registers the collision.
|
||||||
|
overlap: Math.round(Math.max(...[...nav.querySelectorAll(".tab")]
|
||||||
|
.filter((tab) => tab.offsetParent !== null)
|
||||||
|
.map((tab) => tab.getBoundingClientRect().right))
|
||||||
|
- actions.getBoundingClientRect().left),
|
||||||
|
heights: [...new Set(controls)],
|
||||||
|
pageScrollsSideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.ok(header.barHeight <= 96, `header is ${header.barHeight}px at ${width}px; it should stay one row`);
|
||||||
|
assert.ok(header.overlap <= 0, `tab strip overlaps the controls by ${header.overlap}px at ${width}px`);
|
||||||
|
assert.ok(header.heights.length <= 2, `controls have heights ${header.heights.join(", ")} at ${width}px`);
|
||||||
|
assert.equal(header.pageScrollsSideways, false, `page scrolls sideways at ${width}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dropdown must paint over the page, not inside the header: fixed
|
||||||
|
// positioning escapes clipping but not the header's stacking context.
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
const menu = await page.evaluate(() => {
|
||||||
|
document.getElementById("mobile-more-btn").click();
|
||||||
|
const element = document.getElementById("mobile-more-menu");
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||||
|
return { width: Math.round(rect.width), height: Math.round(rect.height), onTop: element.contains(hit) };
|
||||||
|
});
|
||||||
|
assert.ok(menu.height > 40 && menu.width > 80, `menu rendered ${menu.width}x${menu.height}`);
|
||||||
|
assert.ok(menu.onTop, "menu is painted underneath the page");
|
||||||
|
|
||||||
|
// Wider text than this machine renders. This suite passed on macOS twice
|
||||||
|
// while CI, whose system font is wider, put the tab strip into the controls —
|
||||||
|
// the second time by 9px at 1440px with no scaling at all, because the fit
|
||||||
|
// test was an arithmetic estimate whose fixed allowance for the gaps did not
|
||||||
|
// cover them at those metrics. A single scale factor cannot stand in for
|
||||||
|
// another platform's fonts, so sweep: somewhere in this range is whatever CI
|
||||||
|
// renders, and the strip has to hold at every step of it.
|
||||||
|
const applyTextScale = (scale) => page.addStyleTag({ content: `
|
||||||
|
.tab-bar .title { font-size: ${1.05 * scale}rem !important; }
|
||||||
|
.tab-bar .subtitle { font-size: ${0.78 * scale}rem !important; }
|
||||||
|
.tab-bar .tab { font-size: ${0.95 * scale}rem !important; }
|
||||||
|
.tab-bar select, .tab-bar button { font-size: ${0.95 * scale}rem !important; }
|
||||||
|
` });
|
||||||
|
const measureBar = () => page.evaluate(() => {
|
||||||
|
const nav = document.querySelector(".tab-bar-nav");
|
||||||
|
const actions = document.querySelector(".top-bar-actions");
|
||||||
|
const tabs = [...nav.querySelectorAll(".tab")].filter((tab) => tab.offsetParent !== null);
|
||||||
|
return {
|
||||||
|
overlap: Math.round(Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right))
|
||||||
|
- actions.getBoundingClientRect().left),
|
||||||
|
iconsOnly: nav.classList.contains("nav-icons-only"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const scale of [1.0, 1.15, 1.3, 1.5, 1.75, 2.0]) {
|
||||||
|
await applyTextScale(scale);
|
||||||
|
// 1100px is the narrowest bar in the app: the bookmark gutters take 9.5rem
|
||||||
|
// a side above that width, leaving less room than 900px has.
|
||||||
|
for (const width of [1440, 1280, 1100, 900]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.waitForTimeout(120);
|
||||||
|
const crowded = await measureBar();
|
||||||
|
assert.ok(crowded.overlap <= 0,
|
||||||
|
`at ${scale}x text the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A station name long enough that the bar cannot hold it, which is the rung
|
||||||
|
// below icons: the identity has to give, not the strip.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.getElementById("rig-subtitle").textContent =
|
||||||
|
"Rig: Shack SDR — RTL-SDR v4 on the attic dipole, north-west";
|
||||||
|
});
|
||||||
|
await applyTextScale(1.6);
|
||||||
|
for (const width of [1440, 1100]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const crowded = await measureBar();
|
||||||
|
assert.ok(crowded.overlap <= 0,
|
||||||
|
`with a long station name the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...and when the text changes under a bar that is not resized. The rig name
|
||||||
|
// arrives from the server, a web font swaps in: neither is a window resize,
|
||||||
|
// and the strip used to sit there as it was.
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await applyTextScale(2.4);
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const unresized = await measureBar();
|
||||||
|
assert.ok(unresized.overlap <= 0,
|
||||||
|
`text grew without a resize and the strip overlaps by ${unresized.overlap}px`);
|
||||||
|
assert.equal(unresized.iconsOnly, true, "the strip kept its labels with no room for them");
|
||||||
|
|
||||||
|
// The frequency readouts are typed into, so the browser remembers what went
|
||||||
|
// in and offers it back in a dropdown over the reading — Edge does this by
|
||||||
|
// default. Nothing here wants to be autofilled from what was tuned last week.
|
||||||
|
const autofill = await page.evaluate(() => ["freq", "center-freq"].map((id) => {
|
||||||
|
const input = document.getElementById(id);
|
||||||
|
return { id, autocomplete: input?.getAttribute("autocomplete") ?? null };
|
||||||
|
}));
|
||||||
|
for (const field of autofill) {
|
||||||
|
assert.equal(field.autocomplete, "off",
|
||||||
|
`#${field.id} offers autofill (autocomplete=${field.autocomplete})`);
|
||||||
|
}
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
await fixture.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { build } from "esbuild";
|
import { build } from "esbuild";
|
||||||
|
|
||||||
export async function bundleEntry(entryUrl) {
|
export async function bundleEntry(entryUrl, globalName) {
|
||||||
const result = await build({
|
const result = await build({
|
||||||
entryPoints: [entryUrl.pathname],
|
entryPoints: [entryUrl.pathname],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
@@ -12,6 +12,7 @@ export async function bundleEntry(entryUrl) {
|
|||||||
platform: "browser",
|
platform: "browser",
|
||||||
target: "es2022",
|
target: "es2022",
|
||||||
write: false,
|
write: false,
|
||||||
|
...(globalName ? { globalName } : {}),
|
||||||
});
|
});
|
||||||
const output = result.outputFiles[0];
|
const output = result.outputFiles[0];
|
||||||
if (!output) throw new Error(`No bundle output for ${entryUrl.pathname}`);
|
if (!output) throw new Error(`No bundle output for ${entryUrl.pathname}`);
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// What happens to a decode after it arrives: the panel on its tab, the mini
|
||||||
|
// view over the waterfall, the marker on the map, and the link between them.
|
||||||
|
// Nothing exercised this before — the fixture served an empty decode stream —
|
||||||
|
// which is how the map links came to be broken for every decoder at once.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
|
/* global document, getComputedStyle, window, location, requestAnimationFrame, MutationObserver */
|
||||||
|
|
||||||
|
const VESSEL = {
|
||||||
|
type: "ais", mmsi: 244660000, lat: 52.37, lon: 4.89, vessel_name: "NEDERLAND",
|
||||||
|
callsign: "PBTX", sog_knots: 8.2, cog_deg: 91, channel: "A", message_type: 1, rig_id: "rig-a",
|
||||||
|
};
|
||||||
|
const BEACON = {
|
||||||
|
type: "aprs", src_call: "SP2SJG-9", dest_call: "APRS", path: "WIDE1-1", info: "Test beacon",
|
||||||
|
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
|
||||||
|
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
||||||
|
};
|
||||||
|
// A second rig listening in the background, on its own band. Both pages
|
||||||
|
// describe the rig on screen, so its traffic belongs in neither the panel nor
|
||||||
|
// the mini view — only on the map, which shows the whole station.
|
||||||
|
// HF APRS travels a different band and a different path from the VHF list, so
|
||||||
|
// it goes on the map under a source of its own that the filter can hide.
|
||||||
|
const HF_BEACON = {
|
||||||
|
type: "hf_aprs", src_call: "SP5HF-7", dest_call: "APRS", path: "WIDE2-2", info: "HF beacon",
|
||||||
|
packet_type: "position", crc_ok: true, lat: 50.06, lon: 19.94,
|
||||||
|
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
||||||
|
};
|
||||||
|
const OTHER_RIG_VESSEL = {
|
||||||
|
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
|
||||||
|
lat: 51.92, lon: 4.48, rig_id: "rig-b",
|
||||||
|
};
|
||||||
|
|
||||||
|
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
|
||||||
|
const fixture = await startWebFixture({
|
||||||
|
spectrum: true, decodes: [VESSEL, BEACON, HF_BEACON, OTHER_RIG_VESSEL], mode: "AIS",
|
||||||
|
});
|
||||||
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1500, height: 950 });
|
||||||
|
await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// The decoders with panels on this tab have to load with it. They used to
|
||||||
|
// come only with the map group, so these panels stayed empty — decodes
|
||||||
|
// queued in the plugin runtime — until something opened the Map tab.
|
||||||
|
const panels = await page.evaluate(() => ({
|
||||||
|
ais: document.getElementById("ais-messages")?.children.length ?? 0,
|
||||||
|
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
|
||||||
|
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
|
||||||
|
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
|
||||||
|
otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false,
|
||||||
|
aisRows: document.getElementById("ais-messages")?.children.length ?? 0,
|
||||||
|
mapLoaded: !!window.trx.modules.map,
|
||||||
|
}));
|
||||||
|
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
|
||||||
|
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
|
||||||
|
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
|
||||||
|
assert.equal(panels.otherRig, false, "the AIS panel listed a background rig's vessel");
|
||||||
|
|
||||||
|
// The mini view rides over the waterfall on the radio page.
|
||||||
|
await page.locator('.tab[data-tab="main"]').click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const miniView = await page.evaluate(() => {
|
||||||
|
const bar = document.getElementById("ais-bar-overlay");
|
||||||
|
return {
|
||||||
|
shown: getComputedStyle(bar).display !== "none",
|
||||||
|
pins: bar.querySelectorAll(".aprs-bar-pin").length,
|
||||||
|
names: bar.textContent.includes("NEDERLAND"),
|
||||||
|
otherRig: bar.textContent.includes("ELDERBERRY"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
|
||||||
|
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
|
||||||
|
assert.equal(miniView.names, true, "the mini view does not name the vessel");
|
||||||
|
assert.equal(miniView.otherRig, false, "the mini view shows a background rig's vessel");
|
||||||
|
|
||||||
|
// Following the pin: the map opens, on the vessel. This is the path that was
|
||||||
|
// broken for every decoder — the module that owned the navigation had not
|
||||||
|
// been loaded, so the pin did nothing at all.
|
||||||
|
await page.locator("#ais-bar-overlay .aprs-bar-pin").first().click();
|
||||||
|
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const followed = await page.evaluate(() => {
|
||||||
|
const centre = window.trx.modules.map?.aprsMap?.getCenter?.();
|
||||||
|
return {
|
||||||
|
path: location.pathname,
|
||||||
|
lat: centre ? Number(centre.lat.toFixed(2)) : null,
|
||||||
|
lon: centre ? Number(centre.lng.toFixed(2)) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(followed.path, "/map", `the pin left the page on ${followed.path}`);
|
||||||
|
assert.equal(followed.lat, VESSEL.lat, `the map centred on ${followed.lat}, not the vessel`);
|
||||||
|
assert.equal(followed.lon, VESSEL.lon, `the map centred on ${followed.lon}, not the vessel`);
|
||||||
|
|
||||||
|
// Both decoders put their own marker on it.
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
const markers = await page.evaluate(() => {
|
||||||
|
const map = window.trx.modules.map;
|
||||||
|
const size = (collection) => (collection instanceof Map
|
||||||
|
? collection.size
|
||||||
|
: Object.keys(collection ?? {}).length);
|
||||||
|
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
|
||||||
|
});
|
||||||
|
assert.ok(markers.stations > 0, "the APRS station never reached the map");
|
||||||
|
// Both APRS stations are on it, each under its own source: the HF one used
|
||||||
|
// to be dropped entirely, since the HF list never plotted anything.
|
||||||
|
const aprsSources = await page.evaluate(() => {
|
||||||
|
const entries = [...(window.trx.modules.map?.stationMarkers ?? new Map()).entries()];
|
||||||
|
return entries.map(([key, entry]) => `${entry?.type ?? "?"}:${entry?.call ?? key}`).sort();
|
||||||
|
});
|
||||||
|
assert.deepEqual(aprsSources, ["aprs:SP2SJG-9", "hf_aprs:SP5HF-7"],
|
||||||
|
`the map holds ${JSON.stringify(aprsSources)}`);
|
||||||
|
// And the filter offers HF APRS on a chip of its own, next to the VHF one.
|
||||||
|
await page.locator('#map-locator-phase .map-locator-phase-btn[data-phase="type"]').click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const chips = await page.evaluate(() => [...document.querySelectorAll("#map-locator-choice-filter .map-locator-chip")]
|
||||||
|
.map((chip) => chip.dataset.filterKey));
|
||||||
|
assert.ok(chips.includes("aprs") && chips.includes("hf_aprs"),
|
||||||
|
`the Show chips are ${JSON.stringify(chips)}`);
|
||||||
|
|
||||||
|
// Turning that chip off takes the HF station off the map and leaves the VHF
|
||||||
|
// one on: the two are filtered apart, which is the whole point of the split.
|
||||||
|
await page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="hf_aprs"]').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const shownAfterFilter = await page.evaluate(() => {
|
||||||
|
const map = window.trx.modules.map;
|
||||||
|
const onMap = [];
|
||||||
|
(map?.stationMarkers ?? new Map()).forEach((entry, key) => {
|
||||||
|
if (entry?.marker && map.aprsMap?.hasLayer(entry.marker)) onMap.push(entry?.call ?? key);
|
||||||
|
});
|
||||||
|
return onMap.sort();
|
||||||
|
});
|
||||||
|
assert.deepEqual(shownAfterFilter, ["SP2SJG-9"],
|
||||||
|
`hiding HF APRS left ${JSON.stringify(shownAfterFilter)} on the map`);
|
||||||
|
// The map is the whole station's view — the one place a background rig's
|
||||||
|
// traffic belongs — so both vessels are on it even though the panel and the
|
||||||
|
// mini view show only the selected rig's.
|
||||||
|
assert.equal(markers.ais, 2, `${markers.ais} of 2 vessels reached the map`);
|
||||||
|
|
||||||
|
assert.deepEqual(runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stored history, which is what is on screen a second after a page load. The
|
||||||
|
// endpoint answers in CBOR, so the fixture speaks CBOR: serving anything else
|
||||||
|
// left the client on its retry path and the history path untested.
|
||||||
|
const HISTORY_AIS = 900;
|
||||||
|
const HISTORY_APRS = 300;
|
||||||
|
const HISTORY_FT8 = 12;
|
||||||
|
const HISTORY_WSPR = 8;
|
||||||
|
const historyFixture = await startWebFixture({
|
||||||
|
spectrum: true,
|
||||||
|
mode: "AIS",
|
||||||
|
history: {
|
||||||
|
ais: Array.from({ length: HISTORY_AIS }, (_, index) => ({
|
||||||
|
mmsi: 244660000 + index, lat: 52.3 + index * 0.001, lon: 4.8,
|
||||||
|
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
|
||||||
|
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||||
|
})),
|
||||||
|
ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({
|
||||||
|
message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2,
|
||||||
|
freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||||
|
})),
|
||||||
|
wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({
|
||||||
|
message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5,
|
||||||
|
freq_hz: 1500 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||||
|
})),
|
||||||
|
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
|
||||||
|
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
|
||||||
|
info: `history ${index}`, packet_type: "position", crc_ok: true,
|
||||||
|
lat: 54.3 + (index % 15) * 0.01, lon: 18.6, rig_id: "rig-a",
|
||||||
|
ts_ms: Date.now() - (index + 1) * 1000,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const replay = await startBrowser(chromium);
|
||||||
|
|
||||||
|
// Installed before the page's own scripts, so nothing can be missed: every
|
||||||
|
// time the progress element becomes visible, its geometry is recorded.
|
||||||
|
await replay.page.addInitScript(() => {
|
||||||
|
window.__historyProgressSamples = [];
|
||||||
|
const watch = () => {
|
||||||
|
const element = document.getElementById("decode-history-overlay");
|
||||||
|
if (!element) { requestAnimationFrame(watch); return; }
|
||||||
|
const sample = () => {
|
||||||
|
if (element.classList.contains("is-hidden")) return;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
window.__historyProgressSamples.push({
|
||||||
|
width: Math.round(rect.width),
|
||||||
|
coversCentre: document.elementFromPoint(700, 450)?.id === "decode-history-overlay",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
new MutationObserver(sample).observe(element, { attributes: true, attributeFilter: ["class"] });
|
||||||
|
sample();
|
||||||
|
};
|
||||||
|
watch();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await replay.page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await replay.page.goto(`${historyFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
|
||||||
|
await replay.page.locator("#tab-digital-modes").waitFor({ state: "visible" });
|
||||||
|
|
||||||
|
// While it loads, the operator can still see the radio. This used to be a
|
||||||
|
// full-screen scrim over everything for as long as the replay ran.
|
||||||
|
//
|
||||||
|
// Watched from inside the page rather than polled from here: a fast replay
|
||||||
|
// can start and finish between two polls, and then the test reports that no
|
||||||
|
// progress was ever shown when what happened is that it blinked.
|
||||||
|
const shown = await replay.page.evaluate(() => window.__historyProgressSamples ?? []);
|
||||||
|
assert.ok(shown.length > 0, "no progress was shown while the history loaded");
|
||||||
|
for (const sample of shown) {
|
||||||
|
assert.ok(sample.width < 700, `the progress covers ${sample.width}px of a 1400px page`);
|
||||||
|
assert.equal(sample.coversCentre, false, "the progress sits over the page");
|
||||||
|
}
|
||||||
|
|
||||||
|
// And all of it arrives, on the first load.
|
||||||
|
await replay.page.waitForTimeout(2000);
|
||||||
|
const restored = await replay.page.evaluate(() => ({
|
||||||
|
ais: document.getElementById("ais-messages")?.children.length ?? 0,
|
||||||
|
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
|
||||||
|
progressHidden: document.getElementById("decode-history-overlay").classList.contains("is-hidden"),
|
||||||
|
}));
|
||||||
|
assert.equal(restored.ais, HISTORY_AIS, `restored ${restored.ais} of ${HISTORY_AIS} AIS records`);
|
||||||
|
assert.equal(restored.aprs, HISTORY_APRS, `restored ${restored.aprs} of ${HISTORY_APRS} APRS records`);
|
||||||
|
assert.equal(restored.progressHidden, true, "the progress stayed up after the replay finished");
|
||||||
|
|
||||||
|
// Opening the map for the first time has to show the stored history too.
|
||||||
|
// The map module is lazy, so at the moment the history was restored its
|
||||||
|
// aprsMapAddStation/aisMapAddVessel hooks did not exist yet and every
|
||||||
|
// position was dropped. Nothing replayed them when the module finally
|
||||||
|
// arrived, so the map came up empty and only filled in from decodes heard
|
||||||
|
// afterwards -- a station heard once was never plotted at all, and it took a
|
||||||
|
// second reload (module cached, so loaded early enough to beat the history
|
||||||
|
// fetch) before the map showed anything.
|
||||||
|
//
|
||||||
|
// This fixture serves no live decode stream on purpose: with one, fresh
|
||||||
|
// frames arriving after the module loads would paper over the whole thing.
|
||||||
|
const mapLoadedDuringReplay = await replay.page.evaluate(() => !!window.trx.modules.map);
|
||||||
|
assert.equal(mapLoadedDuringReplay, false, "the map was already loaded, so this proves nothing");
|
||||||
|
|
||||||
|
await replay.page.locator('.tab[data-tab="map"]').click();
|
||||||
|
await replay.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
||||||
|
await replay.page.waitForTimeout(1500);
|
||||||
|
const plotted = await replay.page.evaluate(() => {
|
||||||
|
const map = window.trx.modules.map;
|
||||||
|
const size = (collection) => (collection instanceof Map
|
||||||
|
? collection.size
|
||||||
|
: Object.keys(collection ?? {}).length);
|
||||||
|
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
|
||||||
|
});
|
||||||
|
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
|
||||||
|
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
|
||||||
|
|
||||||
|
// The Statistics page counts the same history. Its decode log lives in the
|
||||||
|
// map module, which is lazy, so every decode that arrived before the module
|
||||||
|
// did used to be recorded into nothing at all — the page opened empty and
|
||||||
|
// only filled in from decodes heard afterwards, and it took a reload landing
|
||||||
|
// on the tab (module loaded at startup, before the history) to show the lot.
|
||||||
|
await replay.page.evaluate(() => window.navigateToTab("statistics"));
|
||||||
|
await replay.page.waitForTimeout(1500);
|
||||||
|
// The counters are written with toLocaleString(), so a four-figure count
|
||||||
|
// arrives as "1,220" — whichever separator the runner's locale picks. Read
|
||||||
|
// the digits rather than the formatting.
|
||||||
|
const counted = await replay.page.evaluate(() => {
|
||||||
|
const count = (id) => Number((document.getElementById(id)?.textContent ?? "").replace(/\D/g, ""));
|
||||||
|
return { decodes: count("stats-total-decodes"), grids: count("stats-unique-grids") };
|
||||||
|
});
|
||||||
|
assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR,
|
||||||
|
`the statistics counted ${counted.decodes} decodes`);
|
||||||
|
// Grid squares come from the FT8 and WSPR spots, which had no map replay of
|
||||||
|
// their own: the locators of everything heard before the map loaded were lost.
|
||||||
|
assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR,
|
||||||
|
`the statistics counted ${counted.grids} grid squares`);
|
||||||
|
|
||||||
|
assert.deepEqual(replay.runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await replay.browser.close();
|
||||||
|
await historyFixture.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The APRS list: one line per frame, with the information field read for the
|
||||||
|
// operator rather than shown as it arrives on the air.
|
||||||
|
const APRS_FRAMES = [
|
||||||
|
{ packet_type: "weather", info: "_10090556c220s004g005t077r000p000P000h50b09900" },
|
||||||
|
{ packet_type: "message", info: ":SP2SJG-9 :Hello from the field{01" },
|
||||||
|
{ packet_type: "telemetry", info: "T#005,199,000,255,073,123,01101001" },
|
||||||
|
{ packet_type: "position", info: "!5421.30N/01839.20E>Test beacon 73", lat: 54.35, lon: 18.65 },
|
||||||
|
].map((frame, index) => ({
|
||||||
|
src_call: `SP2SJG-${index}`, dest_call: "APRS", path: "WIDE1-1", crc_ok: true,
|
||||||
|
// Only position reports carry a symbol here, which is the case the columns
|
||||||
|
// have to survive: a frame without one used to close the gap and shift
|
||||||
|
// everything after it left.
|
||||||
|
...(frame.packet_type === "position" ? { symbol_table: "/", symbol_code: ">" } : {}),
|
||||||
|
rig_id: "rig-a", ts_ms: Date.now() - index * 1000,
|
||||||
|
...frame,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The AIS list is the same shape: a line per message, saying where the vessel
|
||||||
|
// is and what it is doing, with the identifiers behind it.
|
||||||
|
const AIS_MESSAGES = [
|
||||||
|
{ message_type: 1, mmsi: 244660001, vessel_name: "NEDERLAND", channel: "A",
|
||||||
|
lat: 54.35, lon: 18.65, sog_knots: 8.2, cog_deg: 91.4 },
|
||||||
|
{ message_type: 5, mmsi: 244660002, vessel_name: "STENA SPIRIT", channel: "B",
|
||||||
|
callsign: "PBTX", destination: "GDANSK" },
|
||||||
|
].map((message, index) => ({ rig_id: "rig-a", ts_ms: Date.now() - index * 1000, ...message }));
|
||||||
|
|
||||||
|
const aprsFixture = await startWebFixture({
|
||||||
|
spectrum: true,
|
||||||
|
mode: "AIS",
|
||||||
|
history: { aprs: APRS_FRAMES, ais: AIS_MESSAGES },
|
||||||
|
});
|
||||||
|
const aprs = await startBrowser(chromium);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await aprs.page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await aprs.page.goto(`${aprsFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
|
||||||
|
await aprs.page.locator("#tab-digital-modes").waitFor({ state: "visible" });
|
||||||
|
await aprs.page.waitForTimeout(2000);
|
||||||
|
await aprs.page.locator('.sub-tab[data-subtab="aprs"]').click();
|
||||||
|
await aprs.page.waitForTimeout(400);
|
||||||
|
|
||||||
|
const rows = await aprs.page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({
|
||||||
|
tag: row.tagName,
|
||||||
|
height: Math.round(row.getBoundingClientRect().height),
|
||||||
|
type: row.querySelector(".aprs-badge-type")?.textContent?.trim() ?? "",
|
||||||
|
summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "",
|
||||||
|
})));
|
||||||
|
// Newest first, so find each by the type it carries rather than by position.
|
||||||
|
const summaryOf = (type) => rows.find((row) => row.type === type)?.summary ?? "";
|
||||||
|
assert.equal(rows.length, APRS_FRAMES.length, `rendered ${rows.length} frames`);
|
||||||
|
for (const row of rows) {
|
||||||
|
assert.equal(row.tag, "DETAILS", "a frame is not expandable in place");
|
||||||
|
assert.ok(row.height < 44, `a frame is ${row.height}px tall; it used to be a card of about 140`);
|
||||||
|
}
|
||||||
|
// Each payload read rather than echoed: 25 °C from t077, the addressee from
|
||||||
|
// a message, the sequence from telemetry, the fix from a position report.
|
||||||
|
assert.match(summaryOf("Weather"), /25 °C/, `weather summary: ${summaryOf("Weather")}`);
|
||||||
|
assert.match(summaryOf("Weather"), /990\.0 hPa/, `weather summary: ${summaryOf("Weather")}`);
|
||||||
|
assert.match(summaryOf("Message"), /→ SP2SJG-9: Hello from the field/, `message summary: ${summaryOf("Message")}`);
|
||||||
|
assert.match(summaryOf("Telemetry"), /^#005/, `telemetry summary: ${summaryOf("Telemetry")}`);
|
||||||
|
assert.match(summaryOf("Position"), /54\.3500, 18\.6500/, `position summary: ${summaryOf("Position")}`);
|
||||||
|
|
||||||
|
// Frames with and without a symbol line up: the slot is held open either way.
|
||||||
|
const columns = await aprs.page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({
|
||||||
|
call: Math.round(row.querySelector(".aprs-call").getBoundingClientRect().x),
|
||||||
|
summary: Math.round(row.querySelector(".decode-line-summary").getBoundingClientRect().x),
|
||||||
|
symbol: !!row.querySelector(".aprs-symbol:not(.aprs-symbol-empty)"),
|
||||||
|
})));
|
||||||
|
assert.ok(columns.some((column) => column.symbol) && columns.some((column) => !column.symbol),
|
||||||
|
"the sample has to mix frames with and without a symbol to test this");
|
||||||
|
assert.equal(new Set(columns.map((column) => column.call)).size, 1,
|
||||||
|
`callsigns start at ${JSON.stringify(columns.map((column) => column.call))}`);
|
||||||
|
assert.equal(new Set(columns.map((column) => column.summary)).size, 1,
|
||||||
|
`summaries start at ${JSON.stringify(columns.map((column) => column.summary))}`);
|
||||||
|
|
||||||
|
// The frame as it arrived is still there, one click away.
|
||||||
|
await aprs.page.locator("#aprs-packets .aprs-packet", { hasText: "25 °C" })
|
||||||
|
.locator(".decode-line").first().click();
|
||||||
|
await aprs.page.waitForTimeout(200);
|
||||||
|
const expanded = await aprs.page.evaluate(() => {
|
||||||
|
const row = document.querySelector("#aprs-packets .aprs-packet[open]");
|
||||||
|
return {
|
||||||
|
open: row.hasAttribute("open"),
|
||||||
|
raw: row.querySelector(".decode-expanded-raw")?.textContent?.trim() ?? "",
|
||||||
|
meta: row.querySelector(".decode-expanded-meta")?.textContent ?? "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(expanded.open, true, "the frame did not open");
|
||||||
|
assert.match(expanded.raw, /^_10090556c220s004g005t077/, `raw frame: ${expanded.raw}`);
|
||||||
|
assert.match(expanded.meta, /WIDE1-1/, `expanded meta: ${expanded.meta}`);
|
||||||
|
|
||||||
|
// AIS, on the same row.
|
||||||
|
await aprs.page.locator('.sub-tab[data-subtab="ais"]').click();
|
||||||
|
await aprs.page.waitForTimeout(300);
|
||||||
|
const aisRows = await aprs.page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll("#ais-messages .ais-message")].map((row) => ({
|
||||||
|
tag: row.tagName,
|
||||||
|
height: Math.round(row.getBoundingClientRect().height),
|
||||||
|
name: row.querySelector(".ais-call")?.textContent?.trim() ?? "",
|
||||||
|
summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "",
|
||||||
|
})));
|
||||||
|
assert.equal(aisRows.length, AIS_MESSAGES.length, `rendered ${aisRows.length} messages`);
|
||||||
|
for (const row of aisRows) {
|
||||||
|
assert.equal(row.tag, "DETAILS", "an AIS message is not expandable in place");
|
||||||
|
assert.ok(row.height < 44, `an AIS message is ${row.height}px tall`);
|
||||||
|
}
|
||||||
|
const positionRow = aisRows.find((row) => row.name === "NEDERLAND");
|
||||||
|
const staticRow = aisRows.find((row) => row.name === "STENA SPIRIT");
|
||||||
|
assert.match(positionRow?.summary ?? "", /54\.3500, 18\.6500/, `position: ${positionRow?.summary}`);
|
||||||
|
assert.match(positionRow?.summary ?? "", /8\.2 kn/, `position: ${positionRow?.summary}`);
|
||||||
|
// A static report carries no fix, so it says where the vessel is going.
|
||||||
|
assert.match(staticRow?.summary ?? "", /PBTX -> GDANSK/, `static: ${staticRow?.summary}`);
|
||||||
|
|
||||||
|
await aprs.page.locator("#ais-messages .ais-message .decode-line").first().click();
|
||||||
|
await aprs.page.waitForTimeout(200);
|
||||||
|
const aisExpanded = await aprs.page.evaluate(() =>
|
||||||
|
document.querySelector("#ais-messages .ais-message[open] .decode-expanded-meta")?.textContent ?? "");
|
||||||
|
assert.match(aisExpanded, /MMSI 2446600/, `expanded AIS: ${aisExpanded}`);
|
||||||
|
assert.match(aisExpanded, /MHz/, `expanded AIS: ${aisExpanded}`);
|
||||||
|
|
||||||
|
assert.deepEqual(aprs.runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await aprs.browser.close();
|
||||||
|
await aprsFixture.close();
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
|
|||||||
setRigFrequency: record("setRigFrequency"),
|
setRigFrequency: record("setRigFrequency"),
|
||||||
syncBandwidthInput: record("syncBandwidthInput"),
|
syncBandwidthInput: record("syncBandwidthInput"),
|
||||||
scheduleSpectrumDraw: record("scheduleSpectrumDraw"),
|
scheduleSpectrumDraw: record("scheduleSpectrumDraw"),
|
||||||
|
syncModePicker: record("syncModePicker"),
|
||||||
onDecoderRegistryReady: record("onDecoderRegistryReady"),
|
onDecoderRegistryReady: record("onDecoderRegistryReady"),
|
||||||
...core,
|
...core,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// The station log: the panel opens an entry pre-filled from the radio and the
|
||||||
|
// server's clock, writes a contact, keeps it, and says whether the station has
|
||||||
|
// been worked before. And the ham layout, which is offered only where a rig can
|
||||||
|
// transmit -- a log is of contacts made.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
|
/* global document, window, location */
|
||||||
|
|
||||||
|
const HELD = [
|
||||||
|
{
|
||||||
|
id: "q1", started_at: "2026-08-06T14:22:00Z", call: "DL3ABC", freq_hz: 14_074_000,
|
||||||
|
band: "20m", mode: "FT8", rst_sent: "-07", rst_rcvd: "-12", gridsquare: "JO31",
|
||||||
|
my_rig: "Primary fixture",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q2", started_at: "2026-08-06T13:05:00Z", call: "SP9XYZ", freq_hz: 7_120_000,
|
||||||
|
band: "40m", mode: "SSB", submode: "LSB", rst_sent: "59", rst_rcvd: "57",
|
||||||
|
gridsquare: "JO90", my_rig: "Primary fixture",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fixture = await startWebFixture({ spectrum: true, tx: true, logbook: [...HELD] });
|
||||||
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
|
const readPanel = () => page.evaluate(() => ({
|
||||||
|
rows: [...(document.getElementById("log-rows")?.children ?? [])].map((row) => ({
|
||||||
|
call: row.querySelector(".log-cell-call")?.textContent ?? "",
|
||||||
|
cells: [...row.querySelectorAll("td")].map((cell) => cell.textContent),
|
||||||
|
})),
|
||||||
|
summary: document.getElementById("log-summary")?.textContent ?? "",
|
||||||
|
freq: document.getElementById("log-freq")?.value ?? "",
|
||||||
|
mode: document.getElementById("log-mode")?.value ?? "",
|
||||||
|
call: document.getElementById("log-call")?.value ?? "",
|
||||||
|
grid: document.getElementById("log-grid")?.value ?? "",
|
||||||
|
rig: document.getElementById("log-station-rig-name")?.textContent ?? "",
|
||||||
|
clock: document.getElementById("log-clock")?.textContent ?? "",
|
||||||
|
worked: document.getElementById("log-worked-before")?.textContent ?? "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 950 });
|
||||||
|
await page.goto(`${fixture.origin}/logbook`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#tab-logbook").waitFor({ state: "visible" });
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
|
||||||
|
// The log is there, newest first, with the band and mode of each contact.
|
||||||
|
const opened = await readPanel();
|
||||||
|
assert.deepEqual(opened.rows.map((row) => row.call), ["DL3ABC", "SP9XYZ"]);
|
||||||
|
assert.equal(opened.summary, "2 contacts");
|
||||||
|
// A phone contact shows the sideband it was worked on, not just "SSB".
|
||||||
|
assert.ok(opened.rows[1].cells.includes("LSB"), `the row reads ${JSON.stringify(opened.rows[1].cells)}`);
|
||||||
|
|
||||||
|
// The entry opened pre-filled: frequency and mode from the rig, the rig's
|
||||||
|
// name, and the time from the server.
|
||||||
|
assert.equal(opened.freq, "100", `the frequency pre-filled as "${opened.freq}"`);
|
||||||
|
assert.equal(opened.mode, "USB", `the mode pre-filled as "${opened.mode}"`);
|
||||||
|
assert.equal(opened.rig, "Primary fixture");
|
||||||
|
assert.match(opened.clock, /^\d{2}:\d{2}:\d{2}Z$/, `the clock reads "${opened.clock}"`);
|
||||||
|
// ...and nothing else was: a report is the operator's to give.
|
||||||
|
assert.equal(opened.call, "");
|
||||||
|
assert.equal(await page.evaluate(() => document.getElementById("log-rst-sent")?.value), "");
|
||||||
|
|
||||||
|
// Worked-before answers as the callsign is typed.
|
||||||
|
await page.locator("#log-call").fill("DL3ABC");
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
assert.match((await readPanel()).worked, /Worked before: 20m FT8/);
|
||||||
|
await page.locator("#log-call").fill("OZ1NEW");
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
assert.match((await readPanel()).worked, /Not worked before/);
|
||||||
|
|
||||||
|
// Logging writes the contact and clears the entry for the next one.
|
||||||
|
await page.locator("#log-rst-sent").fill("59");
|
||||||
|
await page.locator("#log-rst-rcvd").fill("57");
|
||||||
|
await page.locator("#log-save-btn").click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const afterLogging = await readPanel();
|
||||||
|
assert.deepEqual(afterLogging.rows.map((row) => row.call), ["OZ1NEW", "DL3ABC", "SP9XYZ"]);
|
||||||
|
assert.equal(afterLogging.summary, "3 contacts");
|
||||||
|
assert.equal(afterLogging.call, "", "the entry kept the callsign after logging it");
|
||||||
|
assert.equal(afterLogging.freq, "100", "the entry did not re-open pre-filled");
|
||||||
|
|
||||||
|
// A decode row starts an entry rather than logging one: what was heard goes
|
||||||
|
// into the form, and the operator finishes it.
|
||||||
|
await page.evaluate(() => { window.navigateToTab("digital-modes"); });
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.locator('#tab-digital-modes .sub-tab[data-subtab="ft8"]').click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.trxPluginRuntime.dispatch("ft8", {
|
||||||
|
message: "CQ SP2SJG JO94", snr_db: -7, dt_s: 0.2, freq_hz: 1200,
|
||||||
|
ts_ms: Date.now(), rig_id: "rig-a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.locator("#ft8-messages .ft8-log-btn").first().click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const fromDecode = await page.evaluate(() => ({
|
||||||
|
path: location.pathname,
|
||||||
|
call: document.getElementById("log-call")?.value ?? "",
|
||||||
|
grid: document.getElementById("log-grid")?.value ?? "",
|
||||||
|
mode: document.getElementById("log-mode")?.value ?? "",
|
||||||
|
rows: document.getElementById("log-rows")?.children.length ?? 0,
|
||||||
|
}));
|
||||||
|
assert.equal(fromDecode.path, "/logbook", `the Log button landed on ${fromDecode.path}`);
|
||||||
|
assert.equal(fromDecode.call, "SP2SJG", "the heard callsign did not reach the entry");
|
||||||
|
assert.equal(fromDecode.grid, "JO94", "the heard locator did not reach the entry");
|
||||||
|
// The decoder answers for the mode, because a rig in DIG cannot.
|
||||||
|
assert.equal(fromDecode.mode, "FT8", `the mode came through as "${fromDecode.mode}"`);
|
||||||
|
assert.equal(fromDecode.rows, 3, "a decode logged itself instead of opening an entry");
|
||||||
|
|
||||||
|
// The ham layout is on offer, because this rig can transmit.
|
||||||
|
const layouts = await page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
|
||||||
|
assert.ok(layouts.includes("ham"), `the layouts are ${JSON.stringify(layouts)}`);
|
||||||
|
// Choosing it the way the picker does. The <select> itself measures zero
|
||||||
|
// wide in a headless window, so Playwright will not click it; the change
|
||||||
|
// event is what the picker is wired to either way.
|
||||||
|
await page.evaluate(() => { window.navigateToTab("main"); });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const select = document.getElementById("operator-layout-select");
|
||||||
|
select.value = "ham";
|
||||||
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const inHamLayout = await page.evaluate(() => ({
|
||||||
|
layout: document.body.dataset.operatorLayout,
|
||||||
|
path: location.pathname,
|
||||||
|
}));
|
||||||
|
assert.equal(inHamLayout.layout, "ham");
|
||||||
|
assert.equal(inHamLayout.path, "/logbook", "the ham layout opened somewhere else");
|
||||||
|
|
||||||
|
// ── Contest working ─────────────────────────────────────────────────────
|
||||||
|
// The exchange belongs to the session: the contest and the serial sent stay
|
||||||
|
// between contacts, and the serial counts on by itself.
|
||||||
|
await page.evaluate(() => { window.navigateToTab("logbook"); });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.locator("#log-contest-block > summary").click();
|
||||||
|
await page.locator("#log-contest-id").fill("CQ-WW-SSB");
|
||||||
|
await page.locator("#log-stx").fill("1");
|
||||||
|
await page.locator("#log-call").fill("DL9CON");
|
||||||
|
await page.locator("#log-srx").fill("014");
|
||||||
|
await page.locator("#log-rst-sent").fill("59");
|
||||||
|
await page.locator("#log-rst-rcvd").fill("59");
|
||||||
|
await page.locator("#log-save-btn").click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const afterContest = await page.evaluate(() => ({
|
||||||
|
contest: document.getElementById("log-contest-id")?.value ?? "",
|
||||||
|
stx: document.getElementById("log-stx")?.value ?? "",
|
||||||
|
srx: document.getElementById("log-srx")?.value ?? "",
|
||||||
|
call: document.getElementById("log-call")?.value ?? "",
|
||||||
|
}));
|
||||||
|
assert.equal(afterContest.contest, "CQ-WW-SSB", "the contest was cleared with the contact");
|
||||||
|
assert.equal(afterContest.stx, "2", `the serial sent went to "${afterContest.stx}"`);
|
||||||
|
assert.equal(afterContest.srx, "", "the received exchange was kept for the next station");
|
||||||
|
assert.equal(afterContest.call, "");
|
||||||
|
|
||||||
|
// The Cabrillo link carries the header, and names the contest so that only
|
||||||
|
// its contacts are entered.
|
||||||
|
await page.locator("#log-contest-export-block > summary").click();
|
||||||
|
await page.locator("#log-cbr-contest").fill("CQ-WW-SSB");
|
||||||
|
await page.locator("#log-cbr-score").fill("4242");
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const cabrillo = await page.evaluate(() =>
|
||||||
|
document.getElementById("log-cbr-export")?.getAttribute("href") ?? "");
|
||||||
|
assert.match(cabrillo, /contest=CQ-WW-SSB/, `the Cabrillo link reads "${cabrillo}"`);
|
||||||
|
assert.match(cabrillo, /claimed_score=4242/, `the Cabrillo link reads "${cabrillo}"`);
|
||||||
|
const entry = await page.evaluate(async (href) => {
|
||||||
|
const response = await fetch(href);
|
||||||
|
return await response.text();
|
||||||
|
}, cabrillo);
|
||||||
|
assert.match(entry, /^START-OF-LOG: 3\.0/, entry);
|
||||||
|
assert.match(entry, /DL9CON/, "the contest contact is not in the entry");
|
||||||
|
assert.ok(!entry.includes("OZ1NEW"), "a contact outside the contest was entered");
|
||||||
|
|
||||||
|
// ── Confirmations ───────────────────────────────────────────────────────
|
||||||
|
// A card arrives: the contact is marked confirmed, and the per-band report
|
||||||
|
// counts it.
|
||||||
|
const confirmRow = page.locator("#log-rows tr", { hasText: "DL9CON" }).first();
|
||||||
|
await confirmRow.getByRole("button", { name: "Confirm" }).click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.locator("#log-statistics-block > summary").click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const confirmed = await page.evaluate(() => ({
|
||||||
|
marks: [...document.querySelectorAll("#log-rows tr")]
|
||||||
|
.map((row) => [...row.querySelectorAll("td")].at(-2)?.textContent ?? ""),
|
||||||
|
bands: [...document.querySelectorAll("#log-statistics-rows tr")]
|
||||||
|
.map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent)),
|
||||||
|
}));
|
||||||
|
assert.equal(confirmed.marks.filter((mark) => mark === "✓").length, 1,
|
||||||
|
`the QSL column reads ${JSON.stringify(confirmed.marks)}`);
|
||||||
|
const twenty = confirmed.bands.find((band) => band[0] === "20m");
|
||||||
|
assert.ok(twenty, `the bands are ${JSON.stringify(confirmed.bands)}`);
|
||||||
|
assert.equal(twenty[3], "1", `20m shows ${twenty[3]} confirmed`);
|
||||||
|
|
||||||
|
assert.deepEqual(runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a transmitter there is nothing to log, so the layout is not offered.
|
||||||
|
const listenerFixture = await startWebFixture({ spectrum: true, tx: false });
|
||||||
|
const listener = await startBrowser(chromium);
|
||||||
|
try {
|
||||||
|
await listener.page.setViewportSize({ width: 1400, height: 950 });
|
||||||
|
await listener.page.goto(`${listenerFixture.origin}/`, { waitUntil: "domcontentloaded" });
|
||||||
|
await listener.page.locator("#content").waitFor({ state: "visible" });
|
||||||
|
await listener.page.waitForTimeout(2000);
|
||||||
|
const layouts = await listener.page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
|
||||||
|
assert.ok(!layouts.includes("ham"), `a receiver was offered the ham layout: ${JSON.stringify(layouts)}`);
|
||||||
|
} finally {
|
||||||
|
await listener.browser.close();
|
||||||
|
await listenerFixture.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("logbook tests passed");
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user