Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b78c4a4dd4 | ||
|
|
f396e8f235 | ||
|
|
46c9827e8a | ||
|
|
0fc977f19f | ||
|
|
084f629b5b | ||
|
|
bc63ded583 | ||
|
|
cfaeb6ee15 | ||
|
|
76bcce8c54 | ||
|
|
88ed3da6cc | ||
|
|
7c69e0de08 | ||
|
|
fbc4f6e398 | ||
|
|
d42ca4f030 | ||
|
|
335922fecc | ||
|
|
bede2e34fe | ||
|
|
da58a004fe | ||
|
|
77b283cb78 | ||
|
|
6284747339 | ||
|
|
14ad6e241b | ||
|
|
06971ff65c | ||
|
|
18107ce07e | ||
|
|
a0b0c0ed81 | ||
|
|
a2c630a92b | ||
|
|
09634eb851 | ||
|
|
90ab7781ad | ||
|
|
88d04253ca | ||
|
|
08005c5c07 | ||
|
|
026f816ddb | ||
|
|
d31b6f545f | ||
|
|
39f551c914 | ||
|
|
6d25ecdc11 | ||
|
|
37987b2779 | ||
|
|
67a7bace4e | ||
|
|
b48cc23d6e | ||
|
|
c1899229a0 | ||
|
|
84a99a3636 | ||
|
|
84bdf2593c | ||
|
|
c527f20a88 | ||
|
|
9f495021f0 | ||
|
|
33aa7b807c | ||
|
|
2c56d82a81 | ||
|
|
0fc2115973 | ||
|
|
2c1df75d19 | ||
|
|
aefd36c4b1 | ||
|
|
25b9c31c9b | ||
|
|
d68a84f7f9 | ||
|
|
4fa191e9b6 | ||
|
|
c073d03ffb | ||
|
|
730f129404 | ||
|
|
f95f3d0104 | ||
|
|
e70e82c8c0 | ||
|
|
23cc0db1fa | ||
|
|
657f952a2c | ||
|
|
1f256cbb68 | ||
|
|
92fbdb692c | ||
|
|
b2fbcb318d | ||
|
|
44721b579c | ||
|
|
889d00007d | ||
|
|
6f53592041 | ||
|
|
709939f60b | ||
|
|
5b7dd493d4 | ||
|
|
02fe492dbf | ||
|
|
1bc2c88e2f | ||
|
|
863a6d8fd4 | ||
|
|
b252717f6b | ||
|
|
92697b11c5 | ||
|
|
b409c57296 | ||
|
|
00191c8d7a | ||
|
|
56c363a517 | ||
|
|
5ad91b4ab6 | ||
|
|
1843522b45 | ||
|
|
41ddecf5a7 | ||
|
|
b06c37affa | ||
|
|
b4912f5879 | ||
|
|
27f2558193 | ||
|
|
3a9bf1b7ce | ||
|
|
dd5760c436 | ||
|
|
eee3630f04 | ||
|
|
b31790ff48 | ||
|
|
2f4973ed70 | ||
|
|
26b00608b2 | ||
|
|
c2455bb08c | ||
|
|
22ff1349f3 | ||
|
|
8b72573521 | ||
|
|
f64032dcbe | ||
|
|
8fd1761688 | ||
|
|
7022f20b76 | ||
|
|
0d4c657b97 | ||
|
|
23dbcac5b6 |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "trx-rs SDK",
|
||||
"image": "git.haxx.space/sjg/trx-rs/sdk:latest",
|
||||
"workspaceFolder": "/work",
|
||||
"workspaceMount": "source=${localWorkspaceFolder},target=/work,type=bind",
|
||||
"mounts": [
|
||||
"source=trx-rs-sccache,target=/sccache,type=volume"
|
||||
],
|
||||
"containerEnv": {
|
||||
"RUSTC_WRAPPER": "sccache",
|
||||
"CARGO_INCREMENTAL": "0",
|
||||
"SCCACHE_DIR": "/sccache"
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"rust-lang.rust-analyzer",
|
||||
"tamasfe.even-better-toml"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-14
@@ -2,10 +2,11 @@
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# CI for the self-hosted, host-executor Podman runners (see container/).
|
||||
# The runner image bakes in the Rust toolchain and all build dependencies,
|
||||
# so jobs go straight to cargo — no apt/rustup setup steps (which also
|
||||
# collided on the dpkg lock when jobs ran concurrently in the same runner).
|
||||
# CI for the Docker-executor runner (VM). The lint, test and frontend jobs run
|
||||
# inside the shared trx-rs SDK image (container/Containerfile), which bakes in
|
||||
# the pinned Rust toolchain, Node.js, Chromium and all build dependencies. The
|
||||
# reuse job uses the upstream Docker action, which the Docker executor launches
|
||||
# as a sibling container.
|
||||
|
||||
name: CI
|
||||
|
||||
@@ -16,28 +17,44 @@ on:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# sccache: shared compilation cache persisted on the runner host (see the
|
||||
# -v mount in runner-config.example.yaml). CARGO_INCREMENTAL=0 because
|
||||
# sccache cannot cache incremental artifacts.
|
||||
RUSTC_WRAPPER: sccache
|
||||
CARGO_INCREMENTAL: "0"
|
||||
SCCACHE_DIR: /sccache
|
||||
SCCACHE_CACHE_SIZE: "20G"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: rustfmt
|
||||
run: cargo fmt --all -- --check
|
||||
- name: clippy
|
||||
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
- name: sccache stats
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: cargo build --workspace --all-targets --locked
|
||||
- name: Test
|
||||
run: cargo test --workspace --locked
|
||||
- name: sccache stats
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/trx-client/trx-frontend/trx-frontend-http/frontend
|
||||
@@ -58,22 +75,15 @@ jobs:
|
||||
run: npm run lint
|
||||
- name: Test
|
||||
run: npm test
|
||||
- name: Install browser smoke dependency
|
||||
run: command -v chromium >/dev/null || (sudo apt-get update && sudo apt-get install -y --no-install-recommends chromium)
|
||||
# Chromium comes from the SDK image at the path the smoke test defaults
|
||||
# to, so there is nothing to install here.
|
||||
- name: Browser smoke test
|
||||
run: npm run test:browser
|
||||
- name: Verify generated assets
|
||||
run: npm run verify-generated
|
||||
- name: Verify generated-file licensing
|
||||
working-directory: .
|
||||
run: reuse lint
|
||||
|
||||
reuse:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: REUSE compliance
|
||||
# `reuse` CLI instead of fsfe/reuse-action: the latter is a Docker
|
||||
# action, which the host-executor runners cannot run. `reuse` is baked
|
||||
# into the runner image (see container/Containerfile).
|
||||
run: reuse lint
|
||||
- uses: fsfe/reuse-action@v5
|
||||
|
||||
@@ -24,6 +24,12 @@ cargo test -p trx-core
|
||||
./target/release/trx-server --print-config > trx-server.toml
|
||||
./target/release/trx-client --print-config > trx-client.toml
|
||||
|
||||
# Validate a config without starting anything (reports every problem)
|
||||
./target/release/trx-server --check-config --config trx-rs.toml
|
||||
|
||||
# Regenerate trx-rs.toml.example after changing a config struct
|
||||
cargo run -p trx-config --example generate_example
|
||||
|
||||
# Run server
|
||||
./target/release/trx-server --config trx-server.toml
|
||||
# or via CLI args:
|
||||
@@ -41,7 +47,8 @@ This is a Cargo workspace. All crates live under `src/`:
|
||||
src/
|
||||
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
|
||||
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
|
||||
trx-app/ # Shared application helpers (config paths, logging init)
|
||||
trx-app/ # Shared application helpers (logging init, name normalization)
|
||||
trx-config/ # Client + server config structs, loader, validators (~2,500 LOC)
|
||||
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
|
||||
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
|
||||
trx-backend/ # Backend abstraction trait + factory + dummy
|
||||
|
||||
Generated
+43
-4
@@ -2412,6 +2412,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_ignored"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
@@ -3031,10 +3041,6 @@ dependencies = [
|
||||
name = "trx-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@@ -3115,6 +3121,7 @@ dependencies = [
|
||||
"toml",
|
||||
"tracing",
|
||||
"trx-app",
|
||||
"trx-config",
|
||||
"trx-core",
|
||||
"trx-frontend",
|
||||
"trx-frontend-http",
|
||||
@@ -3124,6 +3131,23 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-config"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"toml_edit 0.22.27",
|
||||
"tracing",
|
||||
"trx-core",
|
||||
"trx-decode-log",
|
||||
"trx-reporting",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-configurator"
|
||||
version = "0.1.0"
|
||||
@@ -3132,13 +3156,16 @@ dependencies = [
|
||||
"dialoguer",
|
||||
"tempfile",
|
||||
"tokio-serial",
|
||||
"toml",
|
||||
"toml_edit 0.22.27",
|
||||
"trx-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -3294,12 +3321,14 @@ dependencies = [
|
||||
"trx-app",
|
||||
"trx-aprs",
|
||||
"trx-backend",
|
||||
"trx-config",
|
||||
"trx-core",
|
||||
"trx-cw",
|
||||
"trx-decode-log",
|
||||
"trx-ftx",
|
||||
"trx-protocol",
|
||||
"trx-reporting",
|
||||
"trx-sstv",
|
||||
"trx-vdes",
|
||||
"trx-wefax",
|
||||
"trx-wspr",
|
||||
@@ -3307,6 +3336,16 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-sstv"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"png",
|
||||
"tracing",
|
||||
"trx-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-vdes"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -12,11 +12,13 @@ members = [
|
||||
"src/decoders/trx-ftx",
|
||||
"src/decoders/trx-rds",
|
||||
"src/decoders/trx-vdes",
|
||||
"src/decoders/trx-sstv",
|
||||
"src/decoders/trx-wefax",
|
||||
"src/decoders/trx-wspr",
|
||||
"src/trx-core",
|
||||
"src/trx-protocol",
|
||||
"src/trx-app",
|
||||
"src/trx-config",
|
||||
"src/trx-reporting",
|
||||
"src/trx-server",
|
||||
"src/trx-server/trx-backend",
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
APRS symbol set (https://github.com/hessu/aprs-symbols)
|
||||
=========================================================
|
||||
|
||||
Verbatim copy of the upstream COPYRIGHT.md, retrieved 2026-08-03 from
|
||||
https://github.com/hessu/aprs-symbols. The set has no single SPDX license:
|
||||
individual symbols carry different terms, summarized below. Attribution
|
||||
requirement from the upstream README: "If you use this symbol set, please
|
||||
provide a pointer to the source (http://github.com/hessu/aprs-symbols/)."
|
||||
|
||||
---
|
||||
|
||||
|
||||
Copyright and licensing information
|
||||
======================================
|
||||
|
||||
This is a collection of vectorized symbols for use on the APRS system.
|
||||
|
||||
The copyright status of this collection is a bit complicated, since the
|
||||
symbols come from various sources, each having different copyright owners.
|
||||
|
||||
Most of the vectorized symbols are loosely based on the low-resolution
|
||||
"standard" bitmap symbol set as distributed by Stephen Smith, WA8LMF. That
|
||||
set is used by most APRS software around the world. The low resolution of
|
||||
those symbols does not allow direct vector conversion, so I've drawn new
|
||||
symbols in a similar layout. The vector versions try to mimic the original
|
||||
appearance and colours, with the intention of keeping the set recognizable
|
||||
and familiar to existing users. In some cases the vector versions are
|
||||
probably similar enough to the originals, so that they cannot be considered
|
||||
"original work" by myself. In some of these cases, the originals are
|
||||
probably also mimicking someone else's design.
|
||||
|
||||
The original symbols do not come with any information on their licensing.
|
||||
They've been distributed with a lot of APRS software over time, but I don't
|
||||
know who designed which symbol originally. Most likely all of them are
|
||||
drawn by one of:
|
||||
|
||||
* Roger Barker, G4IDE, "original set provided with UI-View" (SK)
|
||||
* Steve Dimse, KH4G, "U.S. customary set"
|
||||
* Stephen Smith, WA8LMF
|
||||
|
||||
The Adobe Illustrator (.ai) file contains a copy of the original bitmaps
|
||||
as a hidden layer, just for reference.
|
||||
|
||||
Some symbols I obtained from other sources, such as Wikipedia. In those
|
||||
cases I picked SVG versions which allow commercial reuse (source known, and
|
||||
the work is placed on public domain, or with a CC license which allows
|
||||
adaptation and commercial reuse).
|
||||
|
||||
Some symbols are vectorized versions of product or brand logos. The
|
||||
copyright of those is owned by the respective companies (Apple, Microsoft,
|
||||
Kenwood), and each of those may have some opinions on how the logos are
|
||||
used. Please check for yourself if you can use them or not.
|
||||
|
||||
In the list below I try to summarize the licensing status for each symbol.
|
||||
|
||||
|
||||
Shorthand notation for common licensing status
|
||||
-------------------------------------------------
|
||||
|
||||
* *VEC-OH7LZB* - Vectorized by OH7LZB, based on original APRS symbol set
|
||||
* Source of original bitmap: http://wa8lmf.net/aprs/APRS_symbols.htm
|
||||
* Original designer of individual symbol unknown at this time, but one of:
|
||||
* Roger Barker, G4IDE
|
||||
* Steve Dimse, KH4G
|
||||
* Stephen Smith, WA8LMF
|
||||
* Vectorized versions are designed to look similar
|
||||
* Licensing: Unknown
|
||||
* *OH7LZB* - Original vector design by Heikki Hannikainen, OH7LZB
|
||||
* Different enough (by author's opinion) to make it a new original work,
|
||||
instead of a copy of the old symbol
|
||||
* License: CC BY-SA 2.0
|
||||
* https://creativecommons.org/licenses/by-sa/2.0/
|
||||
|
||||
|
||||
Primary table
|
||||
----------------
|
||||
|
||||
* /! - Police station
|
||||
* VEC-OH7LZB
|
||||
* /# - Digipeater / Green star with D in middle
|
||||
* VEC-OH7LZB
|
||||
* /$ - Telephone
|
||||
* VEC-OH7LZB
|
||||
* /% - DX cluster
|
||||
* VEC-OH7LZB
|
||||
* /& - HF gateway
|
||||
* VEC-OH7LZB
|
||||
* /' - Small aircraft
|
||||
* https://openclipart.org/detail/27182/topdown-airplane-view
|
||||
* Author: Wirelizard (Brian Burger)
|
||||
* With color and some other small tuning added by OH7LZB
|
||||
* PD: https://openclipart.org/share
|
||||
* /( - Mobile satellite station
|
||||
* OH7LZB
|
||||
* /) - Wheelchair, handicapped
|
||||
* PD wheelchair symbol
|
||||
* Vectorized from bitmap by OH7LZB
|
||||
* /* - Snowmobile
|
||||
* https://openclipart.org/detail/15849/snowmobile
|
||||
* Author: Mystica (https://openclipart.org/user-detail/mystica)
|
||||
* PD: https://openclipart.org/share
|
||||
* /+ - Red Cross
|
||||
* VEC-OH7LZB
|
||||
* /, - Boy Scouts
|
||||
* VEC-OH7LZB
|
||||
* /- - House
|
||||
* VEC-OH7LZB
|
||||
* /. - Red X
|
||||
* VEC-OH7LZB
|
||||
* // - Red dot
|
||||
* VEC-OH7LZB
|
||||
* /0 to /9 - Numbered circles
|
||||
* VEC-OH7LZB
|
||||
* Fire
|
||||
* http://commons.wikimedia.org/wiki/File:FireIcon.svg
|
||||
* Author: Piotr Jaworski
|
||||
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
|
||||
* Tent
|
||||
* https://openclipart.org/detail/174933/green-tent-by-stamps-174933
|
||||
* Author: stamps
|
||||
* PD: https://openclipart.org/share
|
||||
* Motorcycle
|
||||
* http://commons.wikimedia.org/wiki/File:MUTCD_W8-15P.svg
|
||||
* This file is in the public domain because it comes from the Manual on
|
||||
Uniform Traffic Control Devices, sign number W8-15P, which states
|
||||
specifically on page I-1 that: Any traffic control device design or
|
||||
application provision contained in this Manual shall be considered to
|
||||
be in the public domain. Traffic control devices contained in this
|
||||
Manual shall not be protected by a patent, trademark, or copyright,
|
||||
except for the Interstate Shield and any other items owned by FHWA.
|
||||
* Colour version by OH7LZB
|
||||
* /= - Railroad engine
|
||||
* http://commons.wikimedia.org/wiki/File:Icon_train.svg
|
||||
* Author: http://en.wikipedia.org/wiki/User:Richtom80
|
||||
* CC-BY-SA-2.5,2.0,1.0
|
||||
* /> - Car
|
||||
* OH7LZB
|
||||
* /? - File server
|
||||
* https://openclipart.org/detail/163717/file-server-by-lyte
|
||||
* Author: lyte
|
||||
* PD: https://openclipart.org/share
|
||||
* /@ - Hurricane predicted path
|
||||
* VEC-OH7LZB
|
||||
* /A - Aid station
|
||||
* VEC-OH7LZB
|
||||
* Mail (BBS)
|
||||
* https://openclipart.org/detail/29268/yellow-mail-by-rg1024-29268
|
||||
* Author: rg1024
|
||||
* PD: https://openclipart.org/share
|
||||
* /C - Canoe
|
||||
* https://openclipart.org/detail/179047/red-canoe-by-rambo-tribble-179047
|
||||
* https://openclipart.org/detail/179041/canoe-paddle-by-rambo-tribble-179041
|
||||
* Author: Rambo Tribble
|
||||
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
|
||||
* /E - Eyeball
|
||||
* http://commons.wikimedia.org/wiki/File:Blue_eye.svg
|
||||
* PD: "This file is from the Open Clip Art Library, which released it explicitly into the public domain"
|
||||
* PD: https://openclipart.org/share
|
||||
* /F - Tractor
|
||||
* https://openclipart.org/detail/191654/farm-tractor-by-tmjbeary-191654
|
||||
* Author: tmjbeary
|
||||
* PD: https://openclipart.org/share
|
||||
* /G - Grid square, 3 by 3
|
||||
* VEC-OH7LZB
|
||||
* /H - Hotel
|
||||
* VEC-OH7LZB
|
||||
* /I - TCP/IP
|
||||
* VEC-OH7LZB
|
||||
* /K - School
|
||||
* OH7LZB
|
||||
* /L - PC user
|
||||
* OH7LZB
|
||||
* /M - Mac apple
|
||||
* Apple
|
||||
* /N - NTS
|
||||
* VEC-OH7LZB
|
||||
* /O - Hot air balloon
|
||||
* OH7LZB
|
||||
* /P - Police
|
||||
* OH7LZB
|
||||
* /R - RV
|
||||
* OH7LZB
|
||||
* /S - Space Shuttle
|
||||
* https://openclipart.org/detail/814/space-shuttle-by-johnny_automatic
|
||||
* PD: Published by the NASA, in "The Brain in Space"
|
||||
* /T - SSTV
|
||||
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
|
||||
* Author: rg1024
|
||||
* Adjusted by OH7LZB
|
||||
* PD: https://openclipart.org/share
|
||||
* /U - Bus
|
||||
* OH7LZB
|
||||
* /V - ATV, amateur television
|
||||
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
|
||||
* Author: rg1024
|
||||
* Adjusted by OH7LZB
|
||||
* PD: https://openclipart.org/share
|
||||
* /W - Wx, Weather service site
|
||||
* VEC-OH7LZB
|
||||
* /X - Helicopter
|
||||
* OH7LZB
|
||||
* /Y - Sailboat
|
||||
* OH7LZB
|
||||
* /Z - Windows flag
|
||||
* Microsoft
|
||||
* /[ - Human
|
||||
* VEC-OH7LZB
|
||||
* /\ - DF triangle
|
||||
* VEC-OH7LZB
|
||||
* /] - Mailbox, post office, letter
|
||||
* /^ - Large aircraft
|
||||
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
|
||||
* Author: SketchArtist
|
||||
* PD: https://openclipart.org/share
|
||||
* /_ - Weather station
|
||||
* VEC-OH7LZB
|
||||
* /` - Satellite dish
|
||||
* OH7LZB
|
||||
* /a - Ambulance
|
||||
* OH7LZB
|
||||
* /b - Bicycle
|
||||
* http://commons.wikimedia.org/wiki/File:Bicycle_evolution-numbers.svg
|
||||
* Author: Wikipedia user: Al2
|
||||
* CC BY 3.0
|
||||
* /c - Incident command post
|
||||
* VEC-OH7LZB
|
||||
* /d - Fire station
|
||||
* VEC-OH7LZB
|
||||
* /e - Horse, equestrian
|
||||
* https://openclipart.org/detail/142627/horse-riding-lesson-by-olku
|
||||
* Author: OlKu
|
||||
* PD: https://openclipart.org/share
|
||||
* /f - Fire truck
|
||||
* OH7LZB
|
||||
* /g - Hang glider
|
||||
* OH7LZB
|
||||
* /h - Hospital
|
||||
* VEC-OH7LZB
|
||||
* /i - IOTA, islands on the air
|
||||
* http://commons.wikimedia.org/wiki/File:Palm_Island_R.svg
|
||||
* PI
|
||||
* /j - Jeep
|
||||
* OH7LZB
|
||||
* /k - Truck
|
||||
* OH7LZB
|
||||
* /l - Laptop
|
||||
* OH7LZB
|
||||
* /m - Mic-E repeater
|
||||
* VEC-OH7LZB
|
||||
* /n - Node, black bulls-eye
|
||||
* VEC-OH7LZB
|
||||
* /o - Emergency operations center
|
||||
* VEC-OH7LZB
|
||||
* /p - Dog(e)
|
||||
* OH7LZB
|
||||
* /q - Grid square, 2 by 2
|
||||
* VEC-OH7LZB
|
||||
* /r - Repeater tower
|
||||
* OH7LZB
|
||||
* /s - Ship, power boat
|
||||
* OH7LZB
|
||||
* /t - Truck stop
|
||||
* VEC-OH7LZB
|
||||
* /u - Semi-trailer truck, 18-wheeler
|
||||
* OH7LZB
|
||||
* /v - Van
|
||||
* OH7LZB
|
||||
* /w - Water station
|
||||
* VEC-OH7LZB
|
||||
* /x - X / Unix
|
||||
* https://commons.wikimedia.org/wiki/File:X11.svg
|
||||
* PD
|
||||
* /y - House, yagi antenna
|
||||
* VEC-OH7LZB
|
||||
* /z - Shelter
|
||||
* VEC-OH7LZB
|
||||
|
||||
|
||||
Secondary table
|
||||
------------------
|
||||
|
||||
* Emergency
|
||||
* VEC-OH7LZB
|
||||
* Numbered digipeater / Green star
|
||||
* VEC-OH7LZB
|
||||
* Bank
|
||||
* VEC-OH7LZB
|
||||
* Numbered gateway / Black diamond
|
||||
* VEC-OH7LZB
|
||||
* Crash site
|
||||
* OH7LZB
|
||||
* Cloudy
|
||||
* OH7LZB
|
||||
* MEO
|
||||
* VEC-OH7LZB
|
||||
* Snowflake
|
||||
* http://commons.wikimedia.org/wiki/File:Snowflake_01.svg
|
||||
* Author: Wikipedia user: Amada44
|
||||
* Public Domain
|
||||
* Church
|
||||
* VEC-OH7LZB
|
||||
* Girl Scout
|
||||
* VEC-OH7LZB
|
||||
* Looks slightly like the common USA girl scouts logos. Should be different
|
||||
enough to not infringe on "Girl Scouts of the USA" copyrights.
|
||||
* Home (HF antenna)
|
||||
* VEC-OH7LZB
|
||||
* Unknown position
|
||||
* VEC-OH7LZB
|
||||
* Destination
|
||||
* VEC-OH7LZB
|
||||
* Numbered circle
|
||||
* VEC-OH7LZB
|
||||
|
||||
* Petrol Station
|
||||
* OH7LZB
|
||||
* Hail
|
||||
* VEC-OH7LZB
|
||||
* Park
|
||||
* VEC-OH7LZB
|
||||
* Gale Flag
|
||||
* VEC-OH7LZB
|
||||
* Red car from above
|
||||
* OH7LZB
|
||||
* Info Kiosk
|
||||
* VEC-OH7LZB
|
||||
* Hurricane
|
||||
* OH7LZB
|
||||
|
||||
* Numbered white box
|
||||
* VEC-OH7LZB
|
||||
* Snow blowing
|
||||
* VEC-OH7LZB
|
||||
* Coast Guard
|
||||
* VEC-OH7LZB
|
||||
* Drizzle
|
||||
* VEC-OH7LZB
|
||||
* Smoke / Chimney
|
||||
* VEC-OH7LZB
|
||||
* Freezing rain
|
||||
* VEC-OH7LZB
|
||||
* Snow Shwr
|
||||
* VEC-OH7LZB
|
||||
* Haze
|
||||
* VEC-OH7LZB
|
||||
* Rain Shower
|
||||
* VEC-OH7LZB
|
||||
* Lightning
|
||||
* OH7LZB
|
||||
* "Kenwood radio"
|
||||
* Kenwood logo, vectorized
|
||||
* "Lighthouse"
|
||||
* CC BY-SA 2.0
|
||||
* http://wiki.openstreetmap.org/wiki/File:Lighthouse.svg
|
||||
* Nav Buoy
|
||||
* OH7LZB
|
||||
* Rocket
|
||||
* http://www.clker.com/clipart-gglkuglug.html
|
||||
* PD according to clker.com license
|
||||
* Parking
|
||||
* VEC-OH7LZB
|
||||
|
||||
* Earthquake, Restaurant
|
||||
* VEC-OH7LZB
|
||||
* Satellite
|
||||
* OH7LZB
|
||||
* Thunderstorm
|
||||
* OH7LZB
|
||||
* Sunny
|
||||
* OH7LZB
|
||||
* VORTAC, Numbered WXS
|
||||
* VEC-OH7LZB
|
||||
* Pharmacy Rx
|
||||
* OH7LZB
|
||||
* Wall Cloud
|
||||
* OH7LZB
|
||||
* Numbered plane
|
||||
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
|
||||
* Author: SketchArtist
|
||||
* PD: https://openclipart.org/share
|
||||
* Numbered WX Station
|
||||
* VEC-OH7LZB
|
||||
* Rain
|
||||
* Source: http://commons.wikimedia.org/wiki/File:Heavy-rain-shower-transparent.svg
|
||||
* Author: Wikipedia user: Peepo
|
||||
* Public Domain
|
||||
* With modifications by OH7LZB
|
||||
|
||||
* Numbered diamond
|
||||
* VEC-OH7LZB
|
||||
* Dust blowing
|
||||
* NA
|
||||
* Numbered civil defence
|
||||
* VEC-OH7LZB
|
||||
* DX spot
|
||||
* VEC-OH7LZB
|
||||
* Sleet
|
||||
* NA
|
||||
* Funnel Cloud
|
||||
* NA
|
||||
* Gale
|
||||
* VEC-OH7LZB
|
||||
* Store
|
||||
* https://openclipart.org/detail/89299/cart-medium-by-martins.bruvelis
|
||||
* Author: martins.bruvelis
|
||||
* Public Domain
|
||||
* Adjustments by OH7LZB
|
||||
* Numbered black box
|
||||
* VEC-OH7LZB
|
||||
* Work zone / Excavator
|
||||
* Based on http://www.clker.com/clipart-292480.html PNG version
|
||||
* Vectorized and colors adjusted by OH7LZB
|
||||
* PD according to clker.com documentation, uploader KURSVEIAL
|
||||
* SUV
|
||||
* OH7LZB
|
||||
* Milepost, Numbered triangle, Circle sm
|
||||
* VEC-OH7LZB
|
||||
* Partly cloudy
|
||||
* OH7LZB
|
||||
|
||||
* Restrooms, Numbered boat
|
||||
* VEC-OH7LZB
|
||||
* Tornado (also used in Funnel cloud, Skywarn)
|
||||
* https://openclipart.org/detail/104887/tornado-by-laabadon
|
||||
* Author: Laabadon
|
||||
* Public Domain
|
||||
* Numbered truck
|
||||
* OH7LZB
|
||||
* Numbered van
|
||||
* OH7LZB
|
||||
* Flooding
|
||||
* NA
|
||||
* Sky warn, Numbered shelter, fog
|
||||
* VEC-OH7LZB
|
||||
@@ -93,13 +93,17 @@ The wizard walks you through rig selection, serial port detection, audio
|
||||
settings, and frontend options, then writes `trx-server.toml` and
|
||||
`trx-client.toml`.
|
||||
|
||||
Alternatively, generate example configs and edit them by hand:
|
||||
Alternatively, copy `trx-rs.toml.example` — a commented example covering every
|
||||
setting — and edit it by hand:
|
||||
|
||||
```bash
|
||||
./target/release/trx-server --print-config > trx-server.toml
|
||||
./target/release/trx-client --print-config > trx-client.toml
|
||||
cp trx-rs.toml.example trx-rs.toml
|
||||
./target/release/trx-server --check-config --config trx-rs.toml
|
||||
```
|
||||
|
||||
`--check-config` reports everything wrong with a config without starting
|
||||
anything. `--print-config` prints the same settings without comments.
|
||||
|
||||
### 4. Run
|
||||
|
||||
```bash
|
||||
@@ -107,6 +111,9 @@ Alternatively, generate example configs and edit them by hand:
|
||||
./target/release/trx-client --config trx-client.toml
|
||||
```
|
||||
|
||||
A single `trx-rs.toml` can configure both: the server reads its `[trx-server]`
|
||||
section and the client reads `[trx-client]`.
|
||||
|
||||
Open the configured HTTP frontend address in a browser (default `http://localhost:8080`).
|
||||
|
||||
## How It Works
|
||||
@@ -144,4 +151,7 @@ a unified set of frontends.
|
||||
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
|
||||
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).
|
||||
|
||||
+18
@@ -12,6 +12,8 @@ path = [
|
||||
"trx-rs.toml.example",
|
||||
"docs/**",
|
||||
"aidocs/**",
|
||||
"container/**",
|
||||
".devcontainer/**",
|
||||
"src/decoders/trx-ftx/README.md",
|
||||
"src/decoders/trx-wxsat/README.md",
|
||||
"assets/trx-logo.png",
|
||||
@@ -54,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"]
|
||||
SPDX-FileCopyrightText = "2021-2025 Ethan Halsall"
|
||||
SPDX-License-Identifier = "MIT"
|
||||
|
||||
# Vendored APRS symbol sprites (https://github.com/hessu/aprs-symbols), rev H.
|
||||
# The set has no single upstream license -- individual symbols carry different
|
||||
# terms, catalogued in LICENSES/LicenseRef-APRS-Symbols.txt. Upstream asks that
|
||||
# users point back to https://github.com/hessu/aprs-symbols/.
|
||||
[[annotations]]
|
||||
path = [
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-0.png",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-1.png",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-2.png",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-0-2x.png",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-1-2x.png",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-2-2x.png",
|
||||
]
|
||||
SPDX-FileCopyrightText = "Heikki Hannikainen OH7LZB and the APRS symbol set authors (https://github.com/hessu/aprs-symbols)"
|
||||
SPDX-License-Identifier = "LicenseRef-APRS-Symbols"
|
||||
|
||||
+45
-38
@@ -2,60 +2,67 @@
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Gitea Actions runner image for trx-rs CI (host-executor / "Pattern B").
|
||||
# trx-rs SDK / build image.
|
||||
#
|
||||
# All build dependencies, the Rust toolchain, Node.js (for JS actions such as
|
||||
# actions/checkout and actions/cache) and the `reuse` tool are baked in, so CI
|
||||
# runs skip the per-run apt/rustup install cost. `sudo` is present so the
|
||||
# existing workflow's `sudo apt-get ...` / rustup steps remain valid — they
|
||||
# just become fast no-ops because everything is already installed.
|
||||
# Single source of truth for the build environment. Used two ways:
|
||||
# * CI — as the job container for the lint/test jobs (Docker executor).
|
||||
# * Dev — run locally or via .devcontainer for a reproducible toolchain.
|
||||
#
|
||||
# Pinning the Rust version here (and in rust-toolchain.toml) means CI and every
|
||||
# developer share the exact same rustc/clippy, so "works locally, fails in CI"
|
||||
# cannot happen.
|
||||
FROM docker.io/library/debian:bookworm-slim
|
||||
|
||||
ARG ACT_RUNNER_VERSION=0.2.11
|
||||
# Keep in sync with rust-toolchain.toml.
|
||||
ARG RUST_VERSION=1.97.1
|
||||
ARG NODE_MAJOR=20
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
RUSTUP_HOME=/opt/rustup \
|
||||
CARGO_HOME=/opt/cargo \
|
||||
PATH=/opt/cargo/bin:/usr/local/bin:/usr/bin:/bin
|
||||
RUSTUP_HOME=/usr/local/rustup \
|
||||
CARGO_HOME=/usr/local/cargo \
|
||||
PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
# Base tooling + trx-rs build dependencies (mirrors .gitea/workflows/ci.yml).
|
||||
# Build dependencies (mirror README's manual instructions).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl xz-utils git sudo pipx \
|
||||
ca-certificates curl git \
|
||||
build-essential pkg-config cmake clang libclang-dev \
|
||||
libopus-dev libasound2-dev libsoapysdr-dev chromium \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js (JS-based actions need node in PATH under the host executor).
|
||||
# Node.js — JS-based actions (actions/checkout, actions/cache) run *inside*
|
||||
# the job container under the Docker executor, so node must be present.
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# REUSE >= 3 (Debian's packaged reuse is too old for REUSE.toml).
|
||||
# The [charset-normalizer] extra provides an encoding-detection backend;
|
||||
# without it (and without libmagic) reuse fails to import at runtime.
|
||||
RUN PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install 'reuse[charset-normalizer]'
|
||||
|
||||
# Rust stable with rustfmt + clippy, installed system-wide.
|
||||
# Pinned Rust toolchain, installed world-readable so any UID the runner or a
|
||||
# devcontainer uses can invoke cargo.
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal \
|
||||
--component rustfmt --component clippy \
|
||||
&& chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME"
|
||||
| sh -s -- -y --no-modify-path \
|
||||
--default-toolchain "${RUST_VERSION}" --profile minimal \
|
||||
--component rustfmt --component clippy \
|
||||
&& chmod -R a+rwX "$RUSTUP_HOME" "$CARGO_HOME"
|
||||
|
||||
# act_runner binary.
|
||||
RUN arch="$(dpkg --print-architecture)"; \
|
||||
case "$arch" in amd64) rarch=amd64;; arm64) rarch=arm64;; *) echo "unsupported arch $arch" >&2; exit 1;; esac; \
|
||||
curl -fsSL -o /usr/local/bin/act_runner \
|
||||
"https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-${rarch}" \
|
||||
&& chmod +x /usr/local/bin/act_runner
|
||||
# sccache — shared compilation cache. Enabled at build time via
|
||||
# RUSTC_WRAPPER (see the CI workflow and .devcontainer), not repo-wide, so
|
||||
# non-SDK builds are unaffected. musl build is static and runs anywhere.
|
||||
#
|
||||
# The release asset is per-architecture, so resolve it from `uname -m` rather
|
||||
# than hardcoding one triple: everything else in this image is arch-agnostic,
|
||||
# and a pinned x86_64 URL is what forces an amd64 build (and Rosetta or qemu)
|
||||
# on an arm64 host. `uname -m` reflects the build platform under plain
|
||||
# docker/podman build as well as buildx, unlike the BuildKit-only TARGETARCH.
|
||||
ARG SCCACHE_VERSION=0.8.2
|
||||
RUN set -eux; \
|
||||
case "$(uname -m)" in \
|
||||
x86_64) sccache_arch=x86_64 ;; \
|
||||
aarch64|arm64) sccache_arch=aarch64 ;; \
|
||||
*) echo "unsupported architecture for sccache: $(uname -m)" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
sccache_dist="sccache-v${SCCACHE_VERSION}-${sccache_arch}-unknown-linux-musl"; \
|
||||
curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/${sccache_dist}.tar.gz" \
|
||||
| tar -xz -C /tmp; \
|
||||
install -m755 "/tmp/${sccache_dist}/sccache" /usr/local/bin/sccache; \
|
||||
rm -rf /tmp/sccache-*
|
||||
|
||||
# Default config template (seeded into the /data volume on first boot).
|
||||
COPY config.yaml /etc/act_runner/config.yaml
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# /data holds the .runner registration, cache and workflow workspaces.
|
||||
VOLUME /data
|
||||
WORKDIR /data
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
WORKDIR /work
|
||||
|
||||
+156
-81
@@ -3,116 +3,191 @@ SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Podman-based Gitea Actions runners
|
||||
# trx-rs SDK image
|
||||
|
||||
Run two independent Gitea Actions runners on one host as rootless Podman
|
||||
containers managed by systemd (Quadlet) — one per project — instead of two
|
||||
VMs. Uses the **host executor**: workflow steps run directly inside a
|
||||
purpose-built runner image that already has the Rust toolchain and all build
|
||||
dependencies baked in, so CI runs skip the per-run install cost and no
|
||||
Docker/Podman socket is needed.
|
||||
|
||||
## Files
|
||||
A single container image that is the canonical build environment for trx-rs,
|
||||
used **both** by CI and by developers. It bakes in the pinned Rust toolchain
|
||||
(matching `rust-toolchain.toml`) and every build dependency, so the compiler
|
||||
and `clippy` are identical everywhere — no "works on my machine".
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Containerfile` | Runner image: Debian + build deps + clang + Rust + Node + `reuse` + `act_runner`. |
|
||||
| `entrypoint.sh` | Registers on first boot (if needed), then runs the daemon. |
|
||||
| `config.yaml` | act_runner config template (seeded into each runner's volume). |
|
||||
| `trx-rs-runner.container` | Quadlet unit for the trx-rs runner. |
|
||||
| `project2-runner.container` | Quadlet unit for the second project's runner. |
|
||||
| `Containerfile` | The SDK image (Debian + build deps + pinned Rust + Node + git). |
|
||||
| `runner-config.example.yaml` | Example act_runner config for the CI VM (Docker executor). |
|
||||
|
||||
## Prerequisites (once per host)
|
||||
## Build and publish
|
||||
|
||||
Rootless Podman with cgroups v2 (default on modern distros). As the unprivileged
|
||||
user that will own the runners:
|
||||
Nothing in the image is architecture-specific: the base image, the Debian build
|
||||
dependencies, Node.js, `rustup` and the `sccache` release all resolve per
|
||||
architecture, so the same `Containerfile` builds natively on x86_64 and arm64.
|
||||
|
||||
Single architecture — the tag then only works on the architecture you built it
|
||||
on:
|
||||
|
||||
```bash
|
||||
# Survive logout / start on boot without an interactive session.
|
||||
loginctl enable-linger "$USER"
|
||||
# from the repo root
|
||||
podman build -t git.haxx.space/sjg/trx-rs/sdk:latest container
|
||||
podman login git.haxx.space
|
||||
podman push git.haxx.space/sjg/trx-rs/sdk:latest
|
||||
```
|
||||
|
||||
No `podman.socket` is required for the host executor.
|
||||
|
||||
## 1. Build the image
|
||||
**Both architectures without emulation.** The CI runner is x86_64 and Apple
|
||||
Silicon developer machines are arm64, so `:latest` has to be a manifest list —
|
||||
a single-architecture tag makes the other side fall back to Rosetta or qemu.
|
||||
Build each half natively on a host of that architecture, then join them:
|
||||
|
||||
```bash
|
||||
cd container
|
||||
podman build -t trx-rs-ci:latest .
|
||||
# on an x86_64 host
|
||||
podman build --platform linux/amd64 -t git.haxx.space/sjg/trx-rs/sdk:latest-amd64 container
|
||||
podman push git.haxx.space/sjg/trx-rs/sdk:latest-amd64
|
||||
|
||||
# on an arm64 host
|
||||
podman build --platform linux/arm64 -t git.haxx.space/sjg/trx-rs/sdk:latest-arm64 container
|
||||
podman push git.haxx.space/sjg/trx-rs/sdk:latest-arm64
|
||||
|
||||
# from either, once both are pushed
|
||||
podman manifest create git.haxx.space/sjg/trx-rs/sdk:latest \
|
||||
git.haxx.space/sjg/trx-rs/sdk:latest-amd64 \
|
||||
git.haxx.space/sjg/trx-rs/sdk:latest-arm64
|
||||
podman manifest push --all git.haxx.space/sjg/trx-rs/sdk:latest
|
||||
```
|
||||
|
||||
## 2. Get a registration token
|
||||
Building both from one machine is a single command
|
||||
(`podman build --platform linux/amd64,linux/arm64 --manifest ...`), but the
|
||||
foreign half runs under emulation and is slow — the two-host flow above is
|
||||
what keeps every build native.
|
||||
|
||||
For **each** repo: *Settings → Actions → Runners → Create new Runner* and copy
|
||||
the token. (Org- or instance-level tokens work too if you prefer wider scope.)
|
||||
Tag with the Rust version too (e.g. `:1.97.1`) if you want reproducible pins.
|
||||
Make the package **public** (Gitea → Packages → the image → Settings) so the CI
|
||||
runner and developers can pull it without credentials. If you keep it private,
|
||||
add `credentials:` under the workflow's `container:` and log the runner into the
|
||||
registry.
|
||||
|
||||
## 3. Install and start the runners
|
||||
Pushing a rebuilt image is not enough on its own: `:latest` is a moving tag, and
|
||||
act_runner reuses whatever it cached the first time unless `force_pull: true` is
|
||||
set (see `runner-config.example.yaml`). Without it the job log says
|
||||
`Image exists? true` and the run behaves as though the image were never
|
||||
rebuilt — a tool added to the `Containerfile` reads as missing from the image.
|
||||
Either set `force_pull`, or refresh the VM's copy by hand:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/containers/systemd
|
||||
cp trx-rs-runner.container project2-runner.container ~/.config/containers/systemd/
|
||||
|
||||
# Paste each repo's token for the FIRST boot only:
|
||||
# Environment=GITEA_RUNNER_REGISTRATION_TOKEN=xxxx…
|
||||
$EDITOR ~/.config/containers/systemd/trx-rs-runner.container
|
||||
$EDITOR ~/.config/containers/systemd/project2-runner.container
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user start trx-rs-runner
|
||||
systemctl --user start project2-runner
|
||||
|
||||
systemctl --user status trx-rs-runner
|
||||
podman logs -f gitea-runner-trx-rs
|
||||
docker pull git.haxx.space/sjg/trx-rs/sdk:latest
|
||||
docker run --rm git.haxx.space/sjg/trx-rs/sdk:latest sccache --version
|
||||
```
|
||||
|
||||
Once each runner shows **online** in the repo's runner list, blank out the
|
||||
`GITEA_RUNNER_REGISTRATION_TOKEN` line again (the registration is persisted in
|
||||
the `…-data` volume) and `systemctl --user daemon-reload`.
|
||||
### macOS note
|
||||
|
||||
## Required workflow change: the `reuse` job
|
||||
Apple's `container` CLI builds through a BuildKit helper VM that is configured
|
||||
with Rosetta whether or not the target is x86_64, so `container build` fails
|
||||
with *"Rosetta is not installed"* on a clean machine. That is a property of the
|
||||
builder, not of this image — `container run` works natively without it. Either
|
||||
install Rosetta once (`softwareupdate --install-rosetta`, after which an arm64
|
||||
build still produces a native arm64 image), or build with Podman, whose arm64
|
||||
BuildKit needs no emulation.
|
||||
|
||||
The host executor runs steps directly in the container and therefore **cannot
|
||||
run Docker-based actions**. The current `reuse` job uses `fsfe/reuse-action@v5`,
|
||||
which is a Docker action. `reuse` is baked into the image, so replace that job
|
||||
with a plain command:
|
||||
## Developer use
|
||||
|
||||
```yaml
|
||||
reuse:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: REUSE compliance
|
||||
run: reuse lint
|
||||
Reproducible one-off build, no local toolchain needed:
|
||||
|
||||
```bash
|
||||
podman run --rm -it -v "$PWD":/work -w /work \
|
||||
git.haxx.space/sjg/trx-rs/sdk:latest \
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The `lint` and `test` jobs need no changes: their `sudo apt-get …` and rustup
|
||||
steps still run, but become fast no-ops because the image already has those
|
||||
packages and the toolchain. (`sudo` is included in the image for exactly this
|
||||
reason.)
|
||||
Or open the repo in the image via VS Code / JetBrains "Reopen in Container"
|
||||
(`.devcontainer/devcontainer.json` points at the same image).
|
||||
|
||||
> If you would rather keep Docker-based actions and per-run images, use the
|
||||
> **Docker executor** instead: drop the `:host` suffix from the label in
|
||||
> `config.yaml`, enable `systemctl --user --now enable podman.socket`, mount it
|
||||
> into the container, and set `container.docker_host` to the socket path. That
|
||||
> trades the baked-in speed for stronger per-job isolation.
|
||||
Building outside the container? `rust-toolchain.toml` pins the same rustc, so
|
||||
`rustup` installs the matching toolchain automatically.
|
||||
|
||||
## Tuning
|
||||
## CI use
|
||||
|
||||
- **`capacity`** (in `config.yaml`) — concurrent jobs per runner. Rust builds
|
||||
are heavy; 1–2 is sensible when two runners share a host.
|
||||
- **`PodmanArgs=--cpus/--memory`** (in each `.container`) — hard resource caps
|
||||
so one project cannot starve the other.
|
||||
- **SELinux** — the `:Z` volume flag is already set; keep it if SELinux is
|
||||
enforcing.
|
||||
`.gitea/workflows/ci.yml` runs the `lint`, `test` and `frontend` jobs *inside*
|
||||
this image via the `container:` key, so they skip all setup and go straight to
|
||||
`cargo` and `npm`. The frontend job needs three things from the image beyond
|
||||
Rust: Node.js for the toolchain, Chromium at `/usr/bin/chromium` for the
|
||||
browser smoke test, and `cargo` — `npm run verify-generated` regenerates the
|
||||
Rust wire contracts before checking for drift.
|
||||
|
||||
## Committing these files
|
||||
The `reuse` job stays on the upstream `fsfe/reuse-action` (a Docker action the
|
||||
Docker executor launches as a sibling container) — nothing REUSE-related is
|
||||
baked into the SDK, and it lints the whole repository, so no job runs its own
|
||||
licence check.
|
||||
|
||||
If you add this directory to a REUSE-checked repo, register the markdown in
|
||||
`REUSE.toml` (the other files carry inline SPDX headers):
|
||||
## Compilation cache (sccache)
|
||||
|
||||
```toml
|
||||
[[annotations]]
|
||||
path = ["container/**"]
|
||||
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
|
||||
SPDX-License-Identifier = "GPL-2.0-or-later"
|
||||
The SDK image ships [`sccache`](https://github.com/mozilla/sccache). It is
|
||||
enabled via `RUSTC_WRAPPER=sccache` in CI and the devcontainer (not repo-wide,
|
||||
so plain `cargo` builds outside the SDK are unaffected).
|
||||
|
||||
- **CI** persists the cache on the runner host — create the dir once:
|
||||
`mkdir -p /var/cache/sccache`. It is bind-mounted into each job container at
|
||||
`/sccache` (see `runner-config.example.yaml`), so cache survives across runs
|
||||
and is shared between the lint/test jobs and both projects.
|
||||
- **Devcontainer** uses a named volume (`trx-rs-sccache`).
|
||||
- Check effectiveness with `sccache --show-stats` (the CI jobs print it).
|
||||
|
||||
`CARGO_INCREMENTAL=0` is set wherever sccache is on, since sccache cannot cache
|
||||
incremental artifacts.
|
||||
|
||||
## CI runner (Alpine / OpenRC)
|
||||
|
||||
The runner uses the **Docker executor** (not the host executor): per-job
|
||||
container isolation and standard `ubuntu-latest` semantics. `act_runner` runs
|
||||
as an OpenRC service. Files provided:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `act_runner.openrc` | OpenRC init script (`supervise-daemon`, depends on docker). |
|
||||
| `act_runner.confd.example` | Per-instance `conf.d` settings for multi-runner hosts. |
|
||||
|
||||
**Cap the thread budget.** In a VM, pin its vCPUs to specific host threads
|
||||
(libvirt/KVM):
|
||||
|
||||
```xml
|
||||
<vcpu placement='static'>2</vcpu>
|
||||
<cputune>
|
||||
<vcpupin vcpu='0' cpuset='4'/>
|
||||
<vcpupin vcpu='1' cpuset='5'/>
|
||||
</cputune>
|
||||
```
|
||||
|
||||
On bare metal, the `container.options: "--cpus=2"` and `capacity: 1` in
|
||||
`runner-config.example.yaml` already bound each runner.
|
||||
|
||||
**Set it up:**
|
||||
|
||||
```bash
|
||||
# 1. Docker + a dedicated user with socket access
|
||||
apk add docker docker-cli
|
||||
rc-update add docker default && rc-service docker start
|
||||
adduser -S -D -H -h /var/lib/act_runner act
|
||||
addgroup act docker
|
||||
|
||||
# 2. act_runner binary (static Go build, works on musl)
|
||||
# Upstream publishes per-architecture builds; pick the host's.
|
||||
case "$(uname -m)" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac
|
||||
curl -fsSL -o /usr/local/bin/act_runner \
|
||||
"https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-${arch}"
|
||||
chmod +x /usr/local/bin/act_runner
|
||||
|
||||
# 3. Config + register one runner per project (scope keeps their jobs apart)
|
||||
install -Dm644 container/runner-config.example.yaml /etc/act_runner/trx-rs.yaml
|
||||
install -d -o act /var/lib/act_runner/trx-rs
|
||||
su act -s /bin/sh -c 'cd /var/lib/act_runner/trx-rs && \
|
||||
act_runner register --no-interactive \
|
||||
--instance https://git.haxx.space --token <TOKEN> \
|
||||
--name trx-rs-ci \
|
||||
--labels "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"'
|
||||
|
||||
# 4. OpenRC service (repeat the symlink+conf.d for the second project)
|
||||
install -m755 container/act_runner.openrc /etc/init.d/act_runner
|
||||
ln -s act_runner /etc/init.d/act_runner.trx-rs
|
||||
install -m644 container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
|
||||
rc-update add act_runner.trx-rs default
|
||||
rc-service act_runner.trx-rs start
|
||||
```
|
||||
|
||||
Check it with `rc-service act_runner.trx-rs status` and
|
||||
`tail -f /var/log/act_runner.trx-rs.log`.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Per-instance settings for an act_runner OpenRC service.
|
||||
# Copy to /etc/conf.d/<service-name>, e.g. /etc/conf.d/act_runner.trx-rs
|
||||
# (the name must match the /etc/init.d/ symlink).
|
||||
|
||||
# User that runs the daemon. Must be a member of the `docker` group.
|
||||
runner_user="act"
|
||||
|
||||
# Per-instance state dir (holds the .runner registration) and config file,
|
||||
# so two runners on one host stay independent.
|
||||
runner_dir="/var/lib/act_runner/trx-rs"
|
||||
runner_config="/etc/act_runner/trx-rs.yaml"
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/sbin/openrc-run
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# OpenRC service for a Gitea act_runner (Docker executor) on Alpine.
|
||||
#
|
||||
# Install as /etc/init.d/act_runner (chmod +x). Single instance uses
|
||||
# /etc/act_runner/config.yaml. For one runner per project, symlink this script
|
||||
# and add a matching conf.d file:
|
||||
#
|
||||
# ln -s act_runner /etc/init.d/act_runner.trx-rs
|
||||
# cp container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
|
||||
# $EDITOR /etc/conf.d/act_runner.trx-rs # set runner_dir / runner_config
|
||||
# rc-update add act_runner.trx-rs default
|
||||
# rc-service act_runner.trx-rs start
|
||||
|
||||
description="Gitea Actions runner"
|
||||
|
||||
: "${runner_user:=act}"
|
||||
: "${runner_dir:=/var/lib/act_runner}"
|
||||
: "${runner_config:=/etc/act_runner/config.yaml}"
|
||||
|
||||
command="/usr/local/bin/act_runner"
|
||||
command_args="daemon --config ${runner_config}"
|
||||
# No group given, so supplementary groups (incl. docker) are initialised.
|
||||
command_user="${runner_user}"
|
||||
directory="${runner_dir}"
|
||||
|
||||
supervisor="supervise-daemon"
|
||||
respawn_delay=5
|
||||
respawn_max=0
|
||||
pidfile="/run/${RC_SVCNAME}.pid"
|
||||
output_log="/var/log/${RC_SVCNAME}.log"
|
||||
error_log="/var/log/${RC_SVCNAME}.log"
|
||||
|
||||
depend() {
|
||||
need docker
|
||||
use net dns
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
checkpath -d -m 0750 -o "${runner_user}" "${runner_dir}"
|
||||
checkpath -f -m 0640 -o "${runner_user}" "${output_log}"
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# act_runner configuration template. Seeded into /data/config.yaml on first
|
||||
# boot; edit the copy inside the volume to change settings per runner.
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
# Registration state. Relative to the daemon's working directory (/data).
|
||||
file: .runner
|
||||
# Concurrent jobs this runner will pick up. Rust builds are heavy — keep this
|
||||
# modest, especially if two runners share one host. The trx-rs workflow has
|
||||
# three parallel jobs (lint, test, reuse); capacity 2 lets two overlap.
|
||||
capacity: 2
|
||||
timeout: 3h
|
||||
# Map the workflow's `runs-on: ubuntu-latest` to the HOST executor, i.e. run
|
||||
# steps directly inside THIS container (which already has all the toolchain).
|
||||
# No Docker/Podman socket is required in this mode.
|
||||
labels:
|
||||
- "ubuntu-latest:host"
|
||||
|
||||
cache:
|
||||
# Built-in actions cache server (used by actions/cache). Stored in the volume.
|
||||
enabled: true
|
||||
dir: "/data/cache"
|
||||
|
||||
host:
|
||||
# Where per-job workspaces are created.
|
||||
workdir_parent: /data/workflows
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Registers the runner on first boot (if no .runner state exists in /data),
|
||||
# then runs the act_runner daemon. Idempotent: on subsequent boots it reuses
|
||||
# the stored registration and ignores the token.
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG_FILE="${CONFIG_FILE:-/data/config.yaml}"
|
||||
|
||||
cd /data
|
||||
|
||||
# Seed the config from the image's template on first boot so it lives in the
|
||||
# persistent volume and can be edited there.
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
cp /etc/act_runner/config.yaml "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# runner.file in config.yaml is ".runner" (relative to this CWD => /data/.runner).
|
||||
if [ ! -f /data/.runner ]; then
|
||||
if [ -z "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" ]; then
|
||||
echo "ERROR: no /data/.runner registration and GITEA_RUNNER_REGISTRATION_TOKEN is empty." >&2
|
||||
echo " Grab a token from the repo's Settings -> Actions -> Runners and set it" >&2
|
||||
echo " in the Quadlet unit for the first boot only." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Registering runner '${GITEA_RUNNER_NAME:-podman}' with ${GITEA_INSTANCE_URL} ..."
|
||||
act_runner register --no-interactive \
|
||||
--config "$CONFIG_FILE" \
|
||||
--instance "${GITEA_INSTANCE_URL:?set GITEA_INSTANCE_URL}" \
|
||||
--token "$GITEA_RUNNER_REGISTRATION_TOKEN" \
|
||||
--name "${GITEA_RUNNER_NAME:-podman}" \
|
||||
--labels "${GITEA_RUNNER_LABELS:-ubuntu-latest:host}"
|
||||
fi
|
||||
|
||||
exec act_runner daemon --config "$CONFIG_FILE"
|
||||
@@ -1,40 +0,0 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Rootless Podman Quadlet for the SECOND project's Gitea Actions runner,
|
||||
# co-located on the same host as the trx-rs runner.
|
||||
#
|
||||
# It has its own name, its own data volume and its own registration token, so
|
||||
# the two runners are fully independent. They share the `ubuntu-latest` label,
|
||||
# but registration SCOPE (which repo each token came from) keeps their jobs
|
||||
# separate — neither will pick up the other's work.
|
||||
#
|
||||
# If project 2 needs different build dependencies, build it its own image from
|
||||
# an adjusted Containerfile and point Image= at that instead of reusing the
|
||||
# trx-rs image below.
|
||||
|
||||
[Unit]
|
||||
Description=Gitea Actions runner — project 2
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=localhost/gitea-act-runner:latest
|
||||
ContainerName=gitea-runner-project2
|
||||
Volume=gitea-runner-project2-data:/data:Z
|
||||
|
||||
Environment=CONFIG_FILE=/data/config.yaml
|
||||
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
|
||||
Environment=GITEA_RUNNER_NAME=project2-podman
|
||||
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
|
||||
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
|
||||
|
||||
PodmanArgs=--cpus=4.0 --memory=6g
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
TimeoutStartSec=0
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,47 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Example act_runner config for the Docker-executor runner that lives in the
|
||||
# CI VM. This is NOT the SDK image — it configures the runner that launches
|
||||
# per-job containers (including the trx-rs SDK image referenced by the
|
||||
# workflow's `container:` key). Copy to the VM and pass with
|
||||
# `act_runner daemon --config`.
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
file: .runner
|
||||
# One concurrent job. With one runner per project on a 2-vCPU VM this keeps
|
||||
# total CI usage at ~2 threads.
|
||||
capacity: 1
|
||||
timeout: 3h
|
||||
# Docker executor: no ":host" suffix. Maps runs-on labels to base images
|
||||
# (the workflow overrides these per job via `container:`).
|
||||
labels:
|
||||
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
|
||||
|
||||
cache:
|
||||
enabled: true
|
||||
|
||||
container:
|
||||
# Cap every job container's CPU so CI stays within the 2-thread budget even
|
||||
# if capacity is raised later. The -v mount persists the sccache cache on the
|
||||
# host (create it first: `mkdir -p /var/cache/sccache`), matching SCCACHE_DIR
|
||||
# in the workflow.
|
||||
options: "--cpus=2 -v /var/cache/sccache:/sccache"
|
||||
# act_runner rejects every bind mount unless it is listed here — the default
|
||||
# is an empty allowlist, so the -v above is dropped with only a
|
||||
# "[...] is not a valid volume, will be ignored" line in the job log, and
|
||||
# SCCACHE_DIR then points at a directory that does not outlive the job.
|
||||
valid_volumes:
|
||||
- /var/cache/sccache
|
||||
# Reuse the host VM's Docker network for the built-in cache/artifact server.
|
||||
network: "host"
|
||||
# The workflow pulls the SDK image by the moving `:latest` tag. Without this
|
||||
# the runner logs "Image exists? true" and reuses whatever it cached the
|
||||
# first time, so pushing a rebuilt image has no effect until someone pulls
|
||||
# on the VM by hand — which looks like the image is missing a tool it in
|
||||
# fact has. The extra registry round-trip per job is nothing next to a build.
|
||||
force_pull: true
|
||||
@@ -1,41 +0,0 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Rootless Podman Quadlet for the trx-rs Gitea Actions runner.
|
||||
# Install to ~/.config/containers/systemd/trx-rs-runner.container then:
|
||||
# systemctl --user daemon-reload
|
||||
# systemctl --user start trx-rs-runner
|
||||
#
|
||||
# First boot only: paste a registration token (repo Settings -> Actions ->
|
||||
# Runners) into GITEA_RUNNER_REGISTRATION_TOKEN. After the runner appears
|
||||
# online you can blank it again — the registration is persisted in the volume.
|
||||
|
||||
[Unit]
|
||||
Description=Gitea Actions runner — trx-rs
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=localhost/trx-rs-ci:latest
|
||||
ContainerName=gitea-runner-trx-rs
|
||||
# Persistent state: .runner registration, cache, workspaces.
|
||||
Volume=gitea-runner-trx-rs-data:/data:Z
|
||||
|
||||
Environment=CONFIG_FILE=/data/config.yaml
|
||||
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
|
||||
Environment=GITEA_RUNNER_NAME=trx-rs-podman
|
||||
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
|
||||
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
|
||||
|
||||
# Resource caps so a heavy Rust build here cannot starve the other project's
|
||||
# runner on the same host. Tune to your box.
|
||||
PodmanArgs=--cpus=4.0 --memory=6g
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
# A cold Rust build can be slow; don't let systemd consider startup failed.
|
||||
TimeoutStartSec=0
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -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.
|
||||
+209
-22
@@ -17,30 +17,61 @@ frontends.
|
||||
|
||||
## Configuration
|
||||
|
||||
Both `trx-server` and `trx-client` use TOML configuration files. Use
|
||||
`--print-config` to generate a fully commented example.
|
||||
Both `trx-server` and `trx-client` read TOML. The server takes its settings
|
||||
from the `[trx-server]` section and the client from `[trx-client]`, so one
|
||||
`trx-rs.toml` can configure both — or each may live in its own file with the
|
||||
section header left off.
|
||||
|
||||
`trx-rs.toml.example` in the repository root is a complete, commented example
|
||||
generated from the config definitions themselves. `--print-config` prints the
|
||||
same settings without the comments.
|
||||
|
||||
### File Locations
|
||||
|
||||
**trx-server** lookup order:
|
||||
1. `--config <FILE>`
|
||||
2. `./trx-server.toml`
|
||||
3. `~/.trx-server.toml`
|
||||
4. `~/.config/trx-rs/server.toml`
|
||||
5. `/etc/trx-rs/server.toml`
|
||||
Both binaries use the same lookup order:
|
||||
|
||||
**trx-client** lookup order:
|
||||
1. `--config <FILE>`
|
||||
2. `./trx-client.toml`
|
||||
3. `~/.config/trx-rs/client.toml`
|
||||
4. `/etc/trx-rs/client.toml`
|
||||
2. `./trx-rs.toml`
|
||||
3. `~/.config/trx-rs/trx-rs.toml`
|
||||
4. `/etc/trx-rs/trx-rs.toml`
|
||||
|
||||
CLI arguments override config file values.
|
||||
|
||||
### Environment Variables
|
||||
### Checking a Config
|
||||
|
||||
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by
|
||||
both server and client.
|
||||
`--check-config` loads the file, reports every problem it finds — unknown keys,
|
||||
invalid values, listeners fighting over a port — and exits without starting
|
||||
anything:
|
||||
|
||||
```bash
|
||||
trx-server --check-config --config trx-rs.toml
|
||||
trx-client --check-config --config trx-rs.toml
|
||||
```
|
||||
|
||||
Unknown keys are warnings by default, so a config written for a newer version
|
||||
still runs on an older binary. `--strict-config` makes them fatal.
|
||||
|
||||
`trx-configurator --check <FILE>` runs the same checks.
|
||||
|
||||
### Environment Variables and Secrets
|
||||
|
||||
Any string in the config may reference an environment variable as `${VAR}`;
|
||||
an unset variable is an error rather than an empty value.
|
||||
|
||||
Credentials can be kept out of the config entirely by pointing at a file
|
||||
instead. Every secret has a `*_file` sibling — set one or the other, never
|
||||
both:
|
||||
|
||||
| Inline key | File key | Contents |
|
||||
|------------|----------|----------|
|
||||
| `[listen.auth].tokens` | `tokens_file` | one token per line |
|
||||
| `[[remotes]].auth.token` | `token_file` | the token |
|
||||
| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase |
|
||||
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
|
||||
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
|
||||
|
||||
Blank lines and `#` comments are ignored in the list files. A config that holds
|
||||
credentials inline and is readable by group or others is flagged at startup.
|
||||
|
||||
### Server Options
|
||||
|
||||
@@ -96,6 +127,7 @@ CLI arguments override config file values.
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
|
||||
| `tokens_file` | string | — | Read tokens from this file, one per line |
|
||||
|
||||
#### `[audio]`
|
||||
|
||||
@@ -121,6 +153,13 @@ When audio is enabled, at least one of `rx_enabled` or `tx_enabled` must be true
|
||||
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
|
||||
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
|
||||
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
|
||||
| `spectrum_fft_size` | usize | `1024` | Spectrum FFT bins; power of two, 128–8192 |
|
||||
| `spectrum_interval_ms` | u64 | `50` | How often a spectrum frame is pushed to subscribed clients |
|
||||
|
||||
Spectrum is the largest thing on the client connection. On a slow or
|
||||
high-latency link, halving `spectrum_fft_size` halves the bytes per frame (at
|
||||
half the frequency resolution) and raising `spectrum_interval_ms` sends fewer of
|
||||
them; see [Spectrum over a slow link](#spectrum-over-a-slow-link).
|
||||
|
||||
#### `[sdr.gain]`
|
||||
|
||||
@@ -197,6 +236,29 @@ Notes:
|
||||
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
|
||||
`%MM%`, `%DD%` (UTC).
|
||||
|
||||
#### `[decoders]`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | string[] | all decoders | Decoders to run for this rig |
|
||||
| `output_dir` | string | `"$XDG_CACHE_HOME/trx-rs"` | Base directory for decoders that write images |
|
||||
|
||||
Valid decoder names: `aprs`, `aprs_hf`, `ais`, `cw`, `ft2`, `ft4`, `ft8`,
|
||||
`lrpt`, `sstv`, `vdes`, `wefax`, `wspr` — the same names `[[sdr.channels]]`
|
||||
uses. An unrecognised name is a config error.
|
||||
|
||||
Every decoder runs by default, which costs real CPU on a small machine. On a
|
||||
station that only works digital modes, listing just what you use is worth it:
|
||||
|
||||
```toml
|
||||
[decoders]
|
||||
enabled = ["ft8", "ft4", "wspr"]
|
||||
```
|
||||
|
||||
`sstv`, `wefax` and `lrpt` write images into a subdirectory of `output_dir`
|
||||
named after the decoder. `ais` and `vdes` additionally require an SDR channel
|
||||
configured to feed them.
|
||||
|
||||
#### Multi-Rig Configuration
|
||||
|
||||
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
|
||||
@@ -240,12 +302,32 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
|-------|------|---------|-------------|
|
||||
| `url` | string | — | Server address (e.g. `localhost:4530`) |
|
||||
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
||||
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
|
||||
|
||||
#### `[remote.auth]`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `token` | string | — | Auth token (must not be empty if set) |
|
||||
| `token_file` | string | — | Read the token from this file instead |
|
||||
|
||||
#### `[[remotes]]`
|
||||
|
||||
Preferred over the single `[remote]` section: one entry per rig, each mapping a
|
||||
short name to a server and an optional server-side rig id.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `name` | string | — | Short name used everywhere in the client |
|
||||
| `url` | string | — | Server address (`host:port`) |
|
||||
| `rig_id` | string | — | Rig id on a multi-rig server |
|
||||
| `auth.token` | string | — | Auth token |
|
||||
| `auth.token_file` | string | — | Read the token from this file instead |
|
||||
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
||||
|
||||
The `name` is the key used by `default_rig_name`, `rigctl.rig_ports`,
|
||||
`audio.rig_urls`, `audio.rig_ports` and `decode_history_retention_min_by_rig`.
|
||||
A name in any of those maps that no remote answers to is a config error.
|
||||
|
||||
#### `[frontends.http]`
|
||||
|
||||
@@ -254,6 +336,31 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
| `enabled` | bool | `true` | Enable web UI |
|
||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||
| `port` | u16 | `8080` | Bind port |
|
||||
| `default_rig_name` | string | — | Remote selected on startup |
|
||||
| `initial_map_zoom` | u8 | `10` | Starting zoom for the APRS map |
|
||||
| `show_sdr_gain_control` | bool | `true` | Expose the RF gain control |
|
||||
| `bandplan_enabled` | bool | `true` | Show the bandplan strip |
|
||||
| `bandplan_region` | string | `"iaru_r1"` | `iaru_r1`, `iaru_r2`, or `iaru_r3` |
|
||||
| `decode_history_retention_min` | u64 | `1440` | Decode history retention |
|
||||
| `decode_history_retention_min_by_rig` | table | `{}` | Per-remote retention override |
|
||||
| `spectrum_coverage_margin_hz` | u32 | `50000` | Centre-retune guard margin |
|
||||
| `spectrum_usable_span_ratio` | f32 | `0.92` | Usable fraction of the sampled span |
|
||||
|
||||
#### `[frontends.http.auth]`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Require a passphrase |
|
||||
| `rx_passphrase` | string | — | Passphrase granting receive-only access |
|
||||
| `rx_passphrase_file` | string | — | Read it from this file instead |
|
||||
| `control_passphrase` | string | — | Passphrase granting full control |
|
||||
| `control_passphrase_file` | string | — | Read it from this file instead |
|
||||
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users |
|
||||
| `session_ttl_min` | u64 | `480` | Session lifetime |
|
||||
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
|
||||
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
|
||||
|
||||
With `enabled = true`, at least one passphrase must be set.
|
||||
|
||||
#### `[frontends.rigctl]`
|
||||
|
||||
@@ -261,7 +368,11 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Enable Hamlib rigctl |
|
||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||
| `port` | u16 | `4532` | Bind port |
|
||||
| `rig_ports` | table | `{}` | Remote name → local port; one listener each |
|
||||
|
||||
One listener is started per `rig_ports` entry, each routing to its rig, so
|
||||
`rig_ports` must name at least one remote when the frontend is enabled. The
|
||||
older single `port` key and `--rigctl-port` are ignored.
|
||||
|
||||
#### `[frontends.http_json]`
|
||||
|
||||
@@ -271,13 +382,17 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
|
||||
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
|
||||
| `auth.tokens_file` | string | — | Read tokens from this file, one per line |
|
||||
|
||||
#### `[frontends.audio]`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `true` | Enable audio client |
|
||||
| `server_port` | u16 | `4531` | Server audio port |
|
||||
| `server_url` | string | — | Audio endpoint for every remote |
|
||||
| `rig_urls` | table | `{}` | Remote name → audio URL (wins over `server_url`) |
|
||||
| `server_port` | u16 | `4531` | Fallback port when no URL is configured |
|
||||
| `rig_ports` | table | `{}` | Remote name → port; superseded by `rig_urls` |
|
||||
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
|
||||
| `bridge.rx_output_device` | string | — | Local playback device |
|
||||
| `bridge.tx_input_device` | string | — | Local capture device |
|
||||
@@ -287,16 +402,88 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
|
||||
loopback on Linux, BlackHole on macOS).
|
||||
|
||||
### Spectrum over a slow link
|
||||
|
||||
Spectrum dominates the server↔client connection: everything else is a few
|
||||
hundred bytes, a frame is a few kilobytes. Three things govern what it costs.
|
||||
|
||||
**Frames are pushed, not polled.** The client subscribes and the server sends
|
||||
frames at `[sdr].spectrum_interval_ms`. Polling cost a round trip per frame, so
|
||||
the rate was capped at 1/RTT — on a 200 ms link you could not exceed 5 frames a
|
||||
second however often the client asked. Clients fall back to polling
|
||||
automatically against a server too old to stream.
|
||||
|
||||
**Bins travel as whole dBFS.** They are base64-encoded `i8` on the wire, about
|
||||
an eighth of the JSON array of floats they used to be, at the resolution the
|
||||
display draws anyway.
|
||||
|
||||
**Both ends have a rate, and the slower one wins.** The server pushes no faster
|
||||
than `[sdr].spectrum_interval_ms`; the client asks for no more than
|
||||
`[[remotes]].spectrum_interval_ms`.
|
||||
|
||||
For a link that struggles, start here:
|
||||
|
||||
```toml
|
||||
[trx-server.sdr]
|
||||
spectrum_fft_size = 512 # half the bins, half the bytes
|
||||
spectrum_interval_ms = 200 # 5 frames/s instead of 20
|
||||
|
||||
[[trx-client.remotes]]
|
||||
name = "remote-site"
|
||||
url = "radio.example.com:4530"
|
||||
spectrum_interval_ms = 200
|
||||
```
|
||||
|
||||
That is roughly 0.7 KB per frame at 5 frames/s — about 3.5 KB/s, against
|
||||
roughly 200 KB/s for 1024 float bins at 20 frames/s.
|
||||
|
||||
### CLI Override Summary
|
||||
|
||||
**trx-server:**
|
||||
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
|
||||
`--port`. SDR options are file-only.
|
||||
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`,
|
||||
`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only.
|
||||
|
||||
**trx-client:**
|
||||
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
|
||||
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
|
||||
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
|
||||
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`,
|
||||
`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`,
|
||||
`--http-port`, `--rigctl-listen`, `--http-json-listen`, `--http-json-port`,
|
||||
`--callsign`.
|
||||
|
||||
`--listen` on the server overrides the bind address of both the control
|
||||
listener and every rig's audio listener.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -39,6 +39,15 @@ entry, so they use three intentional host namespaces:
|
||||
| `window.trxPluginRuntime` | Typed decoder registration and message dispatch | Runtime object is installed once; plugins register lifecycle handlers through its API. |
|
||||
| `window.trxUi` | Notifications, confirmations, tab accessibility, and control presentation | Installed once by `ui-core.ts`; consumers call methods but do not replace them. |
|
||||
|
||||
`frontend/src/plugins/host.ts` declares the typed view of `window.trx.state`
|
||||
and `window.trx.core` that feature bundles consume. Feature entries import it
|
||||
instead of re-deriving the contract, so a service that moves out of the
|
||||
application entry is added in one place. Application state that a feature needs
|
||||
belongs in `trx.state`, and shared behavior belongs in `trx.core`; a feature
|
||||
that has to intercept application behavior registers a `modules.*` method the
|
||||
application calls, as the virtual-channel entry does for tuning, mode,
|
||||
bandwidth, and frequency display.
|
||||
|
||||
The WebGL adapter exposes `createTrxWebGlRenderer`, `trxParseCssColor`,
|
||||
`trxHslToRgba`, and `trxClearCssColorCache` for the application bundle. Leaflet
|
||||
adds `L.TrxAisTrackSymbol` and `L.trxAisTrackSymbol` to the vendored Leaflet
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Pins the Rust toolchain for reproducible builds. Keep in sync with the
|
||||
# SDK image (container/Containerfile, ARG RUST_VERSION). rustup honours this
|
||||
# automatically for local builds outside the SDK container.
|
||||
|
||||
[toolchain]
|
||||
channel = "1.97.1"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -0,0 +1,14 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-sstv"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../trx-core" }
|
||||
base64 = "0.22"
|
||||
png = "0.17"
|
||||
tracing = "0.1"
|
||||
@@ -0,0 +1,91 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Encode a test card, decode it back, write both as PNGs to a directory.
|
||||
//!
|
||||
//! `cargo run -p trx-sstv --example round_trip_png -- /tmp/out`
|
||||
|
||||
use trx_sstv::encode::{encode, Frame};
|
||||
use trx_sstv::mode::mode_for_vis;
|
||||
use trx_sstv::{ImageCanvas, SstvConfig, SstvDecoder, SstvEvent};
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).unwrap_or_else(|| ".".into());
|
||||
for vis in [44u8, 60, 12, 8, 95] {
|
||||
let mode = mode_for_vis(vis).expect("mode in table");
|
||||
let (w, h) = (usize::from(mode.width), usize::from(mode.height));
|
||||
let mut rgb = vec![0u8; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let at = (y * w + x) * 3;
|
||||
let (r, g, b) = if y < h / 3 {
|
||||
[
|
||||
(255u8, 255u8, 255u8),
|
||||
(255, 255, 0),
|
||||
(0, 255, 255),
|
||||
(0, 255, 0),
|
||||
(255, 0, 255),
|
||||
(255, 0, 0),
|
||||
(0, 0, 255),
|
||||
(0, 0, 0),
|
||||
][x * 8 / w]
|
||||
} else if y < 2 * h / 3 {
|
||||
let t = (x * 255 / w) as u8;
|
||||
(t, 255 - t, ((y * 255) / h) as u8)
|
||||
} else {
|
||||
// Diagonal stripes: a line-timing error shows up as a kink.
|
||||
if ((x + y) / 16) % 2 == 0 {
|
||||
(240, 240, 40)
|
||||
} else {
|
||||
(20, 20, 90)
|
||||
}
|
||||
};
|
||||
rgb[at] = r;
|
||||
rgb[at + 1] = g;
|
||||
rgb[at + 2] = b;
|
||||
}
|
||||
}
|
||||
let name = mode.name.replace(' ', "-");
|
||||
let mut sent = ImageCanvas::new(w, h);
|
||||
for y in 0..h {
|
||||
sent.put_row(y, &rgb[y * w * 3..(y + 1) * w * 3]);
|
||||
}
|
||||
std::fs::write(
|
||||
format!("{dir}/{name}-sent.png"),
|
||||
sent.to_png().expect("png"),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let frame = Frame {
|
||||
width: w,
|
||||
height: h,
|
||||
rgb: &rgb,
|
||||
};
|
||||
let mut audio = encode(mode, &frame, 48_000);
|
||||
audio.extend(std::iter::repeat_n(0.0, 4800));
|
||||
let mut decoder = SstvDecoder::new(48_000, SstvConfig::default());
|
||||
let mut got = None;
|
||||
for block in audio.chunks(1024) {
|
||||
for event in decoder.process_samples(block) {
|
||||
if let SstvEvent::Complete(image) = event {
|
||||
got = Some(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
let image = got.expect("no image decoded");
|
||||
let mut canvas = ImageCanvas::new(w, h);
|
||||
for y in 0..h {
|
||||
canvas.put_row(y, &image.rgb[y * w * 3..(y + 1) * w * 3]);
|
||||
}
|
||||
std::fs::write(
|
||||
format!("{dir}/{name}-decoded.png"),
|
||||
canvas.to_png().expect("png"),
|
||||
)
|
||||
.expect("write");
|
||||
println!(
|
||||
"{}: {} lines, complete={}",
|
||||
mode.name, image.lines, image.complete
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! SSTV decoder configuration.
|
||||
|
||||
/// Settings for [`crate::decoder::SstvDecoder`].
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SstvConfig {
|
||||
/// VIS code of a mode to assume when no header is heard, so tuning in
|
||||
/// part-way through a transmission still produces a picture. `None` means
|
||||
/// wait for a header, which is the safe default: guessing wrong yields a
|
||||
/// convincing image of nothing.
|
||||
pub force_mode: Option<u8>,
|
||||
/// Directory for saved PNGs. `None` keeps images in memory only.
|
||||
pub output_dir: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! The decoder: audio in, pictures out.
|
||||
//!
|
||||
//! Reception is a small state machine. It listens for a VIS header, and once
|
||||
//! one names a mode it walks the transmission a line at a time, sampling each
|
||||
//! scan at the offsets the mode's segment list gives. Every line is looked for
|
||||
//! at the time the mode says it should arrive, then nudged into place by the
|
||||
//! sync pulse actually found near it — a transmitter's clock and a receiver's
|
||||
//! sound card never agree exactly, and over two minutes of Martin M1 an
|
||||
//! uncorrected error of a few parts per million visibly shears the picture.
|
||||
//!
|
||||
//! Rows are emitted as they are decoded so a picture can be watched arriving,
|
||||
//! which is most of the appeal of the mode.
|
||||
|
||||
use crate::config::SstvConfig;
|
||||
use crate::demod::FreqDemod;
|
||||
use crate::image::ImageCanvas;
|
||||
use crate::mode::{level_from_hz, mode_for_vis, Channel, ColorModel, SstvMode};
|
||||
use crate::vis::find_vis;
|
||||
|
||||
/// Anything below this is the sync pulse rather than picture: black is 1500 Hz
|
||||
/// and sync is 1200 Hz, so the line sits between them.
|
||||
const SYNC_THRESHOLD_HZ: f32 = 1350.0;
|
||||
|
||||
/// How much of the signal to keep while hunting for a header. A header is 940
|
||||
/// ms; two seconds leaves room for one to straddle several blocks of audio.
|
||||
const SEARCH_HISTORY_MS: f64 = 2000.0;
|
||||
|
||||
/// From the start bit to the end of the stop bit: ten 30 ms cells. A header
|
||||
/// search must never retire a stretch shorter than this, or a start bit split
|
||||
/// across two blocks of audio is dismissed on half a view of it.
|
||||
const HEADER_SPAN_MS: f64 = 330.0;
|
||||
|
||||
/// How far from its predicted position a line's sync pulse is looked for.
|
||||
const SYNC_SEARCH_MS: f64 = 12.0;
|
||||
|
||||
/// Fraction of the observed timing error applied to the next line. Damped,
|
||||
/// because a sync pulse found in noise is worth less than the prediction.
|
||||
const SYNC_CORRECTION: f64 = 0.45;
|
||||
|
||||
/// Consecutive lines with no sync pulse anywhere near the prediction before
|
||||
/// the transmission is taken to have ended.
|
||||
const MISSING_SYNC_LIMIT: u32 = 8;
|
||||
|
||||
/// What the decoder has to say.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SstvEvent {
|
||||
/// A header was decoded and reception has begun.
|
||||
Started {
|
||||
vis: u8,
|
||||
mode: &'static str,
|
||||
width: u16,
|
||||
height: u16,
|
||||
},
|
||||
/// One image row is ready, as RGB triples.
|
||||
Row { line: u16, rgb: Vec<u8> },
|
||||
/// Reception finished — at the bottom of the frame, or because the signal
|
||||
/// went away. Carries the picture either way.
|
||||
Complete(SstvImage),
|
||||
}
|
||||
|
||||
/// A received picture.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SstvImage {
|
||||
pub vis: u8,
|
||||
pub mode: &'static str,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
/// Rows actually received, which is the height only if it ran to the end.
|
||||
pub lines: u16,
|
||||
/// Whether the whole frame arrived.
|
||||
pub complete: bool,
|
||||
/// RGB triples, `width * height * 3` bytes. Rows never received are grey.
|
||||
pub rgb: Vec<u8>,
|
||||
/// When reception started, in milliseconds since the epoch.
|
||||
pub started_ms: i64,
|
||||
}
|
||||
|
||||
impl SstvImage {
|
||||
/// The picture as a canvas, for saving or encoding.
|
||||
pub fn canvas(&self) -> ImageCanvas {
|
||||
let width = usize::from(self.width);
|
||||
let mut canvas = ImageCanvas::new(width, usize::from(self.height));
|
||||
for (y, row) in self.rgb.chunks(width * 3).enumerate() {
|
||||
canvas.put_row(y, row);
|
||||
}
|
||||
canvas
|
||||
}
|
||||
|
||||
/// The picture as PNG bytes.
|
||||
pub fn to_png(&self) -> Result<Vec<u8>, String> {
|
||||
self.canvas().to_png()
|
||||
}
|
||||
|
||||
/// The picture as a base64 PNG, for the journey to a client.
|
||||
pub fn to_png_base64(&self) -> Result<String, String> {
|
||||
self.canvas().to_png_base64()
|
||||
}
|
||||
|
||||
/// Write the picture into `dir`, named for when and where it arrived.
|
||||
pub fn save_png(
|
||||
&self,
|
||||
dir: &std::path::Path,
|
||||
freq_hz: u64,
|
||||
) -> Result<std::path::PathBuf, String> {
|
||||
self.canvas()
|
||||
.save_png(dir, freq_hz, self.mode, &stamp(self.started_ms))
|
||||
}
|
||||
}
|
||||
|
||||
/// `YYYYMMDDTHHMMSSZ` for a millisecond timestamp, for file names that sort.
|
||||
fn stamp(ms: i64) -> String {
|
||||
let secs = ms.div_euclid(1000);
|
||||
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
let (hour, minute, second) = (rest / 3600, (rest % 3600) / 60, rest % 60);
|
||||
format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}Z")
|
||||
}
|
||||
|
||||
/// Days since the Unix epoch to a calendar date (Howard Hinnant's algorithm).
|
||||
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
enum State {
|
||||
/// Listening for a header.
|
||||
Searching,
|
||||
Receiving(Box<Reception>),
|
||||
}
|
||||
|
||||
struct Reception {
|
||||
mode: &'static SstvMode,
|
||||
canvas: ImageCanvas,
|
||||
/// Absolute sample index at which the next transmitted line begins.
|
||||
next_line: f64,
|
||||
/// Next image row to write.
|
||||
row: u16,
|
||||
started_ms: i64,
|
||||
missing_syncs: u32,
|
||||
/// Set until the first line has been decoded. A VIS header ends exactly
|
||||
/// where the picture begins, so the first line is already aligned — and a
|
||||
/// search would find the header's own 30 ms stop bit, which is at the sync
|
||||
/// frequency and sits immediately before the picture.
|
||||
first_line: bool,
|
||||
/// Robot 36 sends one chroma channel per line and expects the decoder to
|
||||
/// carry the other over from the line before.
|
||||
last_chroma_r: Option<Vec<u8>>,
|
||||
last_chroma_b: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub struct SstvDecoder {
|
||||
sample_rate: u32,
|
||||
config: SstvConfig,
|
||||
demod: FreqDemod,
|
||||
/// Instantaneous frequency, one entry per audio sample. Delayed by the
|
||||
/// demodulator's group delay, which is constant and so shifts the whole
|
||||
/// stream — header and lines alike — without disturbing their spacing.
|
||||
freqs: Vec<f32>,
|
||||
/// Absolute index of `freqs[0]`, so positions survive the buffer being
|
||||
/// trimmed.
|
||||
base: u64,
|
||||
/// Absolute index the header search has already covered.
|
||||
searched_to: u64,
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl SstvDecoder {
|
||||
pub fn new(sample_rate: u32, config: SstvConfig) -> Self {
|
||||
Self {
|
||||
sample_rate,
|
||||
config,
|
||||
demod: FreqDemod::new(sample_rate),
|
||||
freqs: Vec::new(),
|
||||
base: 0,
|
||||
searched_to: 0,
|
||||
state: State::Searching,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a picture is currently arriving.
|
||||
pub fn is_receiving(&self) -> bool {
|
||||
matches!(self.state, State::Receiving(_))
|
||||
}
|
||||
|
||||
/// Feed a block of mono audio. Returns whatever it produced.
|
||||
pub fn process_samples(&mut self, samples: &[f32]) -> Vec<SstvEvent> {
|
||||
self.demod.process_into(samples, &mut self.freqs);
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
let progressed = match self.state {
|
||||
State::Searching => self.try_start(&mut events),
|
||||
State::Receiving(_) => self.try_line(&mut events),
|
||||
};
|
||||
if !progressed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.trim();
|
||||
events
|
||||
}
|
||||
|
||||
/// Abandon a reception in progress, returning the picture so far.
|
||||
pub fn reset(&mut self) -> Vec<SstvEvent> {
|
||||
let mut events = Vec::new();
|
||||
if let State::Receiving(reception) = std::mem::replace(&mut self.state, State::Searching) {
|
||||
events.push(SstvEvent::Complete(finish(&reception)));
|
||||
}
|
||||
self.demod.reset();
|
||||
self.freqs.clear();
|
||||
self.base = 0;
|
||||
self.searched_to = 0;
|
||||
events
|
||||
}
|
||||
|
||||
/// Look for a header, or for a bare sync pulse when the mode is forced.
|
||||
fn try_start(&mut self, events: &mut Vec<SstvEvent>) -> bool {
|
||||
let from = self.searched_to.saturating_sub(self.base) as usize;
|
||||
if from >= self.freqs.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(hit) = find_vis(&self.freqs, self.sample_rate, from) {
|
||||
if let Some(mode) = mode_for_vis(hit.code) {
|
||||
self.begin(mode, self.base + hit.image_start as u64, events);
|
||||
return true;
|
||||
}
|
||||
// A header that parses but names a mode this decoder does not know
|
||||
// is still a header: skip past it rather than finding it again.
|
||||
tracing::debug!(vis = hit.code, "SSTV: unsupported mode");
|
||||
self.searched_to = self.base + hit.image_start as u64;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tuning in mid-transmission means no header to find. With a mode
|
||||
// named in the configuration, the first sync pulse is enough to start.
|
||||
if let Some(mode) = self.config.force_mode.and_then(mode_for_vis) {
|
||||
if let Some(sync) = self.find_sync(from, self.freqs.len(), mode) {
|
||||
let line_start = (self.base + sync as u64) as f64
|
||||
- ms_to_samples(mode.sync_offset_ms, self.sample_rate);
|
||||
self.begin_at(mode, line_start.max(0.0), events);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing yet. Rewind the cursor by a header's worth of samples before
|
||||
// marking the buffer searched: audio arrives in blocks of a few
|
||||
// milliseconds, so the search regularly runs over a start bit that is
|
||||
// only half here. Advancing past it would retire the header for good
|
||||
// on the strength of a partial view of it.
|
||||
let unsearchable = ms_to_samples(HEADER_SPAN_MS, self.sample_rate) as u64;
|
||||
let end = self.base + self.freqs.len() as u64;
|
||||
self.searched_to = self.searched_to.max(end.saturating_sub(unsearchable));
|
||||
false
|
||||
}
|
||||
|
||||
fn begin(&mut self, mode: &'static SstvMode, image_start: u64, events: &mut Vec<SstvEvent>) {
|
||||
self.begin_at(mode, image_start as f64, events);
|
||||
}
|
||||
|
||||
fn begin_at(&mut self, mode: &'static SstvMode, line_start: f64, events: &mut Vec<SstvEvent>) {
|
||||
events.push(SstvEvent::Started {
|
||||
vis: mode.vis,
|
||||
mode: mode.name,
|
||||
width: mode.width,
|
||||
height: mode.height,
|
||||
});
|
||||
self.state = State::Receiving(Box::new(Reception {
|
||||
mode,
|
||||
canvas: ImageCanvas::new(usize::from(mode.width), usize::from(mode.height)),
|
||||
next_line: line_start,
|
||||
row: 0,
|
||||
started_ms: now_ms(),
|
||||
missing_syncs: 0,
|
||||
first_line: true,
|
||||
last_chroma_r: None,
|
||||
last_chroma_b: None,
|
||||
}));
|
||||
}
|
||||
|
||||
/// Decode one transmitted line, if all of it has arrived.
|
||||
fn try_line(&mut self, events: &mut Vec<SstvEvent>) -> bool {
|
||||
let State::Receiving(reception) = &mut self.state else {
|
||||
return false;
|
||||
};
|
||||
let mode = reception.mode;
|
||||
let line_samples = ms_to_samples(mode.line_ms, self.sample_rate);
|
||||
let margin = ms_to_samples(SYNC_SEARCH_MS, self.sample_rate);
|
||||
|
||||
let start = reception.next_line;
|
||||
// Enough for the line's own scans, and for the sync search to reach as
|
||||
// far ahead as it looks — no further. Demanding the search margin past
|
||||
// the end of every line would cost the last line of every picture,
|
||||
// which is exactly where the transmission stops.
|
||||
let sync_reach = start + ms_to_samples(mode.sync_offset_ms, self.sample_rate) + margin;
|
||||
let end = (start + line_samples).max(sync_reach);
|
||||
let available = (self.base + self.freqs.len() as u64) as f64;
|
||||
if end > available {
|
||||
return false;
|
||||
}
|
||||
// The line may begin before what is still buffered if the caller fed a
|
||||
// huge block; nothing can be done about that but skip forward.
|
||||
if start < self.base as f64 {
|
||||
reception.next_line = self.base as f64;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Line up on the sync pulse near where this line is predicted to be.
|
||||
let expected_sync = start + ms_to_samples(mode.sync_offset_ms, self.sample_rate);
|
||||
let from = (expected_sync - margin).max(self.base as f64) as u64;
|
||||
let to = (expected_sync + margin) as u64;
|
||||
let searching = !matches!(&self.state, State::Receiving(r) if r.first_line);
|
||||
let found = if searching {
|
||||
self.find_sync_between(from, to, mode)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let State::Receiving(reception) = &mut self.state else {
|
||||
return false;
|
||||
};
|
||||
let start = match found {
|
||||
Some(sync_at) => {
|
||||
reception.missing_syncs = 0;
|
||||
let error = sync_at as f64 - expected_sync;
|
||||
reception.next_line += error * SYNC_CORRECTION;
|
||||
reception.next_line
|
||||
}
|
||||
None if reception.first_line => start,
|
||||
None => {
|
||||
reception.missing_syncs += 1;
|
||||
start
|
||||
}
|
||||
};
|
||||
|
||||
if reception.missing_syncs >= MISSING_SYNC_LIMIT {
|
||||
let image = finish(reception);
|
||||
self.state = State::Searching;
|
||||
self.searched_to = self.base + self.freqs.len() as u64;
|
||||
events.push(SstvEvent::Complete(image));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sample every scan of the line, then colour the rows.
|
||||
let mut scans: Vec<(Channel, Vec<u8>)> = Vec::new();
|
||||
for (channel, offset_ms, ms) in mode.scans() {
|
||||
let pixels = mode.scan_pixels(channel);
|
||||
let at = start + ms_to_samples(offset_ms, self.sample_rate);
|
||||
let values = sample_scan(&self.freqs, self.base, at, ms, pixels, self.sample_rate);
|
||||
scans.push((channel, values));
|
||||
}
|
||||
|
||||
let State::Receiving(reception) = &mut self.state else {
|
||||
return false;
|
||||
};
|
||||
let rows = compose_rows(reception, &scans);
|
||||
for (offset, row) in rows.into_iter().enumerate() {
|
||||
let line = reception.row + offset as u16;
|
||||
reception.canvas.put_row(usize::from(line), &row);
|
||||
events.push(SstvEvent::Row { line, rgb: row });
|
||||
}
|
||||
reception.first_line = false;
|
||||
reception.row += mode.lines_per_transmission;
|
||||
reception.next_line += line_samples;
|
||||
|
||||
if reception.row >= mode.height {
|
||||
let image = finish(reception);
|
||||
self.state = State::Searching;
|
||||
self.searched_to = self.base + self.freqs.len() as u64;
|
||||
events.push(SstvEvent::Complete(image));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// First sync pulse of about the right length in `freqs[from..to]`,
|
||||
/// as an index of its leading edge.
|
||||
///
|
||||
/// Works on a smoothed copy of the window: a sync pulse is 1200 Hz, where
|
||||
/// the raw per-sample estimate swings by ±95 Hz, so single samples cross
|
||||
/// and re-cross the threshold throughout a pulse and no run is ever long
|
||||
/// enough. Pixels are sampled from the raw signal, where averaging over
|
||||
/// the pixel does the same job without blurring across its edges.
|
||||
fn find_sync(&self, from: usize, to: usize, mode: &SstvMode) -> Option<usize> {
|
||||
let want = sync_ms(mode);
|
||||
let min_run = (ms_to_samples(want, self.sample_rate) * 0.6) as usize;
|
||||
// A sync pulse ends. Silence and a dead carrier demodulate to near
|
||||
// zero, which is below the threshold too, and without an upper bound a
|
||||
// decoder left running on an empty channel finds sync everywhere and
|
||||
// fills the picture with noise it invented.
|
||||
let max_run = (ms_to_samples(want, self.sample_rate) * 3.0) as usize;
|
||||
let to = to.min(self.freqs.len());
|
||||
if from >= to {
|
||||
return None;
|
||||
}
|
||||
// Reach past the end of the search window by a whole pulse: a sync
|
||||
// starting at the last moment the window allows still has to be
|
||||
// measurable to its full length, or it is rejected for being short and
|
||||
// the line it belongs to goes unaligned.
|
||||
let pad = ms_to_samples(want + 2.0, self.sample_rate) as usize;
|
||||
let window_from = from.saturating_sub(pad);
|
||||
let window_to = (to + pad).min(self.freqs.len());
|
||||
let smoothed = crate::demod::smooth(
|
||||
&self.freqs[window_from..window_to],
|
||||
ms_to_samples(1.0, self.sample_rate) as usize,
|
||||
);
|
||||
|
||||
let mut i = from - window_from;
|
||||
let scan_to = to - window_from;
|
||||
while i < scan_to {
|
||||
if smoothed[i] >= SYNC_THRESHOLD_HZ {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut run = 0;
|
||||
while i + run < smoothed.len() && smoothed[i + run] < SYNC_THRESHOLD_HZ {
|
||||
run += 1;
|
||||
}
|
||||
if run >= min_run && run <= max_run {
|
||||
return Some(window_from + i);
|
||||
}
|
||||
i += run.max(1);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// As [`Self::find_sync`], over an absolute index range.
|
||||
fn find_sync_between(&self, from: u64, to: u64, mode: &SstvMode) -> Option<u64> {
|
||||
let from = from.saturating_sub(self.base) as usize;
|
||||
let to = to.saturating_sub(self.base) as usize;
|
||||
if from >= self.freqs.len() {
|
||||
return None;
|
||||
}
|
||||
self.find_sync(from, to, mode)
|
||||
.map(|at| self.base + at as u64)
|
||||
}
|
||||
|
||||
/// Drop what is behind the decoder, so a long reception does not grow the
|
||||
/// buffer without bound.
|
||||
fn trim(&mut self) {
|
||||
let keep_from = match &self.state {
|
||||
State::Searching => {
|
||||
let history = ms_to_samples(SEARCH_HISTORY_MS, self.sample_rate) as u64;
|
||||
(self.base + self.freqs.len() as u64).saturating_sub(history)
|
||||
}
|
||||
State::Receiving(reception) => {
|
||||
let margin = ms_to_samples(SYNC_SEARCH_MS * 2.0, self.sample_rate) as u64;
|
||||
(reception.next_line as u64).saturating_sub(margin)
|
||||
}
|
||||
};
|
||||
if keep_from <= self.base {
|
||||
return;
|
||||
}
|
||||
let drop = (keep_from - self.base) as usize;
|
||||
if drop >= self.freqs.len() {
|
||||
self.freqs.clear();
|
||||
} else {
|
||||
self.freqs.drain(..drop);
|
||||
}
|
||||
self.base = keep_from;
|
||||
self.searched_to = self.searched_to.max(self.base);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sync pulse length of a mode, read out of its own segment list.
|
||||
fn sync_ms(mode: &SstvMode) -> f64 {
|
||||
mode.segments
|
||||
.iter()
|
||||
.find_map(|segment| match segment {
|
||||
crate::mode::Segment::Sync(ms) => Some(*ms),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(9.0)
|
||||
}
|
||||
|
||||
/// Milliseconds since the epoch, for stamping a picture with when it arrived.
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
fn ms_to_samples(ms: f64, sample_rate: u32) -> f64 {
|
||||
ms / 1000.0 * f64::from(sample_rate)
|
||||
}
|
||||
|
||||
/// Average the frequency across each pixel's window and turn it into a level.
|
||||
///
|
||||
/// The middle 60% of the window is used: a pixel's edges carry the
|
||||
/// demodulator's transition from the pixel before, and including them smears
|
||||
/// every edge in the picture.
|
||||
fn sample_scan(
|
||||
freqs: &[f32],
|
||||
base: u64,
|
||||
start: f64,
|
||||
ms: f64,
|
||||
pixels: usize,
|
||||
sample_rate: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(pixels);
|
||||
let width = ms_to_samples(ms, sample_rate) / pixels as f64;
|
||||
for x in 0..pixels {
|
||||
let pixel_start = start + width * x as f64;
|
||||
let from = (pixel_start + width * 0.2 - base as f64).max(0.0) as usize;
|
||||
let to = ((pixel_start + width * 0.8 - base as f64).max(0.0) as usize).min(freqs.len());
|
||||
// A pixel narrower than a sample still has to produce one.
|
||||
let (from, to) = if to > from {
|
||||
(from, to)
|
||||
} else {
|
||||
let at = (pixel_start - base as f64).max(0.0) as usize;
|
||||
(
|
||||
at.min(freqs.len().saturating_sub(1)),
|
||||
(at + 1).min(freqs.len()),
|
||||
)
|
||||
};
|
||||
if to <= from {
|
||||
out.push(0);
|
||||
continue;
|
||||
}
|
||||
let window = &freqs[from..to];
|
||||
let mean = window.iter().sum::<f32>() / window.len() as f32;
|
||||
out.push(level_from_hz(mean));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Turn one line's scans into image rows.
|
||||
fn compose_rows(reception: &mut Reception, scans: &[(Channel, Vec<u8>)]) -> Vec<Vec<u8>> {
|
||||
let mode = reception.mode;
|
||||
let width = usize::from(mode.width);
|
||||
let find = |channel: Channel| scans.iter().find(|(c, _)| *c == channel).map(|(_, v)| v);
|
||||
|
||||
match mode.color {
|
||||
ColorModel::Rgb => {
|
||||
let red = find(Channel::Red);
|
||||
let green = find(Channel::Green);
|
||||
let blue = find(Channel::Blue);
|
||||
let mut row = vec![0u8; width * 3];
|
||||
for x in 0..width {
|
||||
row[x * 3] = red.and_then(|c| c.get(x).copied()).unwrap_or(0);
|
||||
row[x * 3 + 1] = green.and_then(|c| c.get(x).copied()).unwrap_or(0);
|
||||
row[x * 3 + 2] = blue.and_then(|c| c.get(x).copied()).unwrap_or(0);
|
||||
}
|
||||
vec![row]
|
||||
}
|
||||
ColorModel::YCrCb => {
|
||||
let luma = find(Channel::LumaOdd).cloned().unwrap_or_default();
|
||||
let cr = find(Channel::ChromaR).cloned().unwrap_or_default();
|
||||
let cb = find(Channel::ChromaB).cloned().unwrap_or_default();
|
||||
vec![ycrcb_row(&luma, &cr, &cb, width)]
|
||||
}
|
||||
ColorModel::YCrCbAlternating => {
|
||||
// This line carries one chroma channel; the other is the one from
|
||||
// the line before, which is what the mode expects a decoder to do.
|
||||
let luma = find(Channel::LumaOdd).cloned().unwrap_or_default();
|
||||
let chroma = find(Channel::ChromaAlternating)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if reception.row.is_multiple_of(2) {
|
||||
reception.last_chroma_r = Some(chroma);
|
||||
} else {
|
||||
reception.last_chroma_b = Some(chroma);
|
||||
}
|
||||
let neutral = vec![128u8; luma.len().max(1) / 2];
|
||||
let cr = reception
|
||||
.last_chroma_r
|
||||
.clone()
|
||||
.unwrap_or_else(|| neutral.clone());
|
||||
let cb = reception.last_chroma_b.clone().unwrap_or(neutral);
|
||||
vec![ycrcb_row(&luma, &cr, &cb, width)]
|
||||
}
|
||||
ColorModel::YCrCbPaired => {
|
||||
let odd = find(Channel::LumaOdd).cloned().unwrap_or_default();
|
||||
let even = find(Channel::LumaEven).cloned().unwrap_or_default();
|
||||
let cr = find(Channel::ChromaR).cloned().unwrap_or_default();
|
||||
let cb = find(Channel::ChromaB).cloned().unwrap_or_default();
|
||||
vec![
|
||||
ycrcb_row(&odd, &cr, &cb, width),
|
||||
ycrcb_row(&even, &cr, &cb, width),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One RGB row from luminance and chrominance, stretching the chroma scans
|
||||
/// across the width when they are narrower than it.
|
||||
fn ycrcb_row(luma: &[u8], cr: &[u8], cb: &[u8], width: usize) -> Vec<u8> {
|
||||
let mut row = vec![0u8; width * 3];
|
||||
let pick = |channel: &[u8], x: usize| -> f32 {
|
||||
if channel.is_empty() {
|
||||
return 128.0;
|
||||
}
|
||||
let at = x * channel.len() / width.max(1);
|
||||
f32::from(channel[at.min(channel.len() - 1)])
|
||||
};
|
||||
for x in 0..width {
|
||||
let y = if luma.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
let at = x * luma.len() / width.max(1);
|
||||
f32::from(luma[at.min(luma.len() - 1)])
|
||||
};
|
||||
let (r, g, b) = ycrcb_to_rgb(y, pick(cr, x), pick(cb, x));
|
||||
row[x * 3] = r;
|
||||
row[x * 3 + 1] = g;
|
||||
row[x * 3 + 2] = b;
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
/// The inverse of the studio-swing conversion SSTV encoders use.
|
||||
fn ycrcb_to_rgb(y: f32, cr: f32, cb: f32) -> (u8, u8, u8) {
|
||||
let r = 298.082 * y / 256.0 + 408.583 * cr / 256.0 - 222.921;
|
||||
let g = 298.082 * y / 256.0 - 100.291 * cb / 256.0 - 208.120 * cr / 256.0 + 135.576;
|
||||
let b = 298.082 * y / 256.0 + 516.412 * cb / 256.0 - 276.836;
|
||||
(
|
||||
r.clamp(0.0, 255.0) as u8,
|
||||
g.clamp(0.0, 255.0) as u8,
|
||||
b.clamp(0.0, 255.0) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
fn finish(reception: &Reception) -> SstvImage {
|
||||
SstvImage {
|
||||
vis: reception.mode.vis,
|
||||
mode: reception.mode.name,
|
||||
width: reception.mode.width,
|
||||
height: reception.mode.height,
|
||||
lines: reception.canvas.filled_rows() as u16,
|
||||
complete: reception.row >= reception.mode.height,
|
||||
rgb: reception.canvas.rgb().to_vec(),
|
||||
started_ms: reception.started_ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_stamps_are_utc_and_sort_in_time_order() {
|
||||
// Known instants, checked against `date -u -r <secs>`.
|
||||
assert_eq!(stamp(0), "19700101T000000Z");
|
||||
assert_eq!(stamp(1_000_000_000_000), "20010909T014640Z");
|
||||
assert_eq!(stamp(1_770_000_000_000), "20260202T024000Z");
|
||||
// Sorting the names sorts the pictures.
|
||||
assert!(stamp(1_770_000_000_000) < stamp(1_770_000_001_000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Instantaneous frequency estimation.
|
||||
//!
|
||||
//! SSTV carries every pixel as a frequency between 1500 and 2300 Hz, and every
|
||||
//! line boundary as a 1200 Hz pulse, so one measurement serves the whole
|
||||
//! decoder: the frequency of the signal at each sample. A Hilbert transform
|
||||
//! FIR forms the analytic signal and the phase difference between consecutive
|
||||
//! samples gives the frequency.
|
||||
//!
|
||||
//! The same approach drives the WEFAX decoder, which maps the result straight
|
||||
//! to luminance. Here the frequency itself is the output, because the VIS
|
||||
//! header and the sync detector read tones far outside the pixel band.
|
||||
//!
|
||||
//! Block-based linear processing, per `docs/Optimization-Guidelines.md`: the
|
||||
//! FIR runs over a contiguous `[tail | samples]` buffer so the inner loop is
|
||||
//! straight indexing the compiler can vectorise.
|
||||
|
||||
use std::f32::consts::PI;
|
||||
|
||||
/// Taps for the Hilbert transform FIR. Odd, so the delay is a whole sample.
|
||||
const HILBERT_TAPS: usize = 65;
|
||||
|
||||
/// Group delay of the FIR, in samples.
|
||||
const HILBERT_DELAY: usize = HILBERT_TAPS / 2;
|
||||
|
||||
/// Taps for the input band-pass. Long enough to be worth having, short enough
|
||||
/// that its delay is a couple of milliseconds.
|
||||
const BANDPASS_TAPS: usize = 127;
|
||||
|
||||
/// The band SSTV lives in: sync at 1200 Hz, black at 1500, white at 2300.
|
||||
const BAND_LOW_HZ: f32 = 900.0;
|
||||
const BAND_HIGH_HZ: f32 = 2700.0;
|
||||
|
||||
/// Produces instantaneous frequency in Hz from real audio samples.
|
||||
pub struct FreqDemod {
|
||||
/// Band-pass, applied first. A phase-difference frequency detector answers
|
||||
/// whatever is loudest, so hiss outside the SSTV band steers the estimate
|
||||
/// even when the signal is much stronger — the picture tears rather than
|
||||
/// grows grainy. Every real decoder filters to the band first.
|
||||
bandpass: Vec<f32>,
|
||||
bandpass_tail: Vec<f32>,
|
||||
coeffs: [f32; HILBERT_TAPS],
|
||||
/// The last `HILBERT_TAPS - 1` input samples, priming the next block.
|
||||
tail: Vec<f32>,
|
||||
prev_i: f32,
|
||||
prev_q: f32,
|
||||
/// `sample_rate / 2π`, the constant turning phase step into Hz.
|
||||
hz_per_radian: f32,
|
||||
}
|
||||
|
||||
impl FreqDemod {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
Self {
|
||||
bandpass: design_bandpass_fir(sample_rate),
|
||||
bandpass_tail: vec![0.0; BANDPASS_TAPS - 1],
|
||||
coeffs: design_hilbert_fir(),
|
||||
tail: vec![0.0; HILBERT_TAPS - 1],
|
||||
prev_i: 0.0,
|
||||
prev_q: 0.0,
|
||||
hz_per_radian: sample_rate as f32 / (2.0 * PI),
|
||||
}
|
||||
}
|
||||
|
||||
/// Band-pass a block, carrying the filter's state across the seam.
|
||||
fn filter(&mut self, samples: &[f32]) -> Vec<f32> {
|
||||
let taps = BANDPASS_TAPS;
|
||||
let tail_len = taps - 1;
|
||||
let mut work = Vec::with_capacity(tail_len + samples.len());
|
||||
work.extend_from_slice(&self.bandpass_tail);
|
||||
work.extend_from_slice(samples);
|
||||
|
||||
let mut out = Vec::with_capacity(samples.len());
|
||||
for i in 0..samples.len() {
|
||||
let window = &work[i..i + taps];
|
||||
let mut acc = 0.0f32;
|
||||
for k in 0..taps {
|
||||
acc += self.bandpass[k] * window[taps - 1 - k];
|
||||
}
|
||||
out.push(acc);
|
||||
}
|
||||
let work_len = work.len();
|
||||
self.bandpass_tail
|
||||
.copy_from_slice(&work[work_len - tail_len..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Frequency in Hz for each input sample, appended to `out`.
|
||||
///
|
||||
/// Output is delayed by the FIR's group delay, which is constant and so
|
||||
/// affects only the absolute timing of the whole stream, not the spacing
|
||||
/// between the events in it.
|
||||
pub fn process_into(&mut self, samples: &[f32], out: &mut Vec<f32>) {
|
||||
if samples.is_empty() {
|
||||
return;
|
||||
}
|
||||
let samples = self.filter(samples);
|
||||
let samples = samples.as_slice();
|
||||
let taps = HILBERT_TAPS;
|
||||
let tail_len = taps - 1;
|
||||
|
||||
let mut work = Vec::with_capacity(tail_len + samples.len());
|
||||
work.extend_from_slice(&self.tail);
|
||||
work.extend_from_slice(samples);
|
||||
|
||||
out.reserve(samples.len());
|
||||
for i in 0..samples.len() {
|
||||
let window = &work[i..i + taps];
|
||||
let mut q = 0.0f32;
|
||||
for k in 0..taps {
|
||||
q += self.coeffs[k] * window[taps - 1 - k];
|
||||
}
|
||||
// In phase with the quadrature output: the input, delayed by the
|
||||
// FIR's own group delay.
|
||||
let i_val = window[HILBERT_DELAY];
|
||||
|
||||
// f = |arg(z[n] · conj(z[n-1]))| · fs / 2π
|
||||
let di = i_val * self.prev_i + q * self.prev_q;
|
||||
let dq = q * self.prev_i - i_val * self.prev_q;
|
||||
out.push(dq.atan2(di).abs() * self.hz_per_radian);
|
||||
|
||||
self.prev_i = i_val;
|
||||
self.prev_q = q;
|
||||
}
|
||||
|
||||
let work_len = work.len();
|
||||
self.tail.copy_from_slice(&work[work_len - tail_len..]);
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.bandpass_tail.fill(0.0);
|
||||
self.tail.fill(0.0);
|
||||
self.prev_i = 0.0;
|
||||
self.prev_q = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Boxcar mean over `window` samples, centred, returning one value per input.
|
||||
///
|
||||
/// The per-sample estimate ripples — badly at the low end of the band, where
|
||||
/// the Hilbert approximation is weakest: a clean 1200 Hz tone reads anywhere
|
||||
/// between 1110 and 1300 Hz sample to sample, though its mean is exact. Pixels
|
||||
/// are averaged over their own window and so come out right regardless, but
|
||||
/// anything that classifies a single sample by frequency — the VIS bits, the
|
||||
/// sync pulses — has to look at a mean or it is reading the ripple.
|
||||
pub fn smooth(freqs: &[f32], window: usize) -> Vec<f32> {
|
||||
let window = window.max(1);
|
||||
if freqs.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Prefix sums in f64: a minute of audio is three million samples, and a
|
||||
// running f32 total drifts long before that.
|
||||
let mut prefix = Vec::with_capacity(freqs.len() + 1);
|
||||
prefix.push(0.0f64);
|
||||
for &freq in freqs {
|
||||
prefix.push(prefix[prefix.len() - 1] + f64::from(freq));
|
||||
}
|
||||
let half = window / 2;
|
||||
(0..freqs.len())
|
||||
.map(|i| {
|
||||
// Shrinks at the ends rather than reaching past them.
|
||||
let from = i.saturating_sub(half);
|
||||
let to = (i + window - half).min(freqs.len());
|
||||
((prefix[to] - prefix[from]) / (to - from) as f64) as f32
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Windowed-sinc band-pass over the SSTV band, Hamming-windowed and
|
||||
/// linear-phase, so every frequency in the band is delayed alike.
|
||||
fn design_bandpass_fir(sample_rate: u32) -> Vec<f32> {
|
||||
let sr = sample_rate as f64;
|
||||
let low = f64::from(BAND_LOW_HZ) / sr;
|
||||
let high = (f64::from(BAND_HIGH_HZ) / sr).min(0.499);
|
||||
let m = (BANDPASS_TAPS - 1) as f64;
|
||||
let mid = m / 2.0;
|
||||
let sinc = |x: f64| {
|
||||
if x.abs() < 1e-9 {
|
||||
1.0
|
||||
} else {
|
||||
(std::f64::consts::PI * x).sin() / (std::f64::consts::PI * x)
|
||||
}
|
||||
};
|
||||
let mut coeffs = Vec::with_capacity(BANDPASS_TAPS);
|
||||
for i in 0..BANDPASS_TAPS {
|
||||
let n = i as f64 - mid;
|
||||
// Difference of two low-passes is a band-pass.
|
||||
let ideal = 2.0 * high * sinc(2.0 * high * n) - 2.0 * low * sinc(2.0 * low * n);
|
||||
let window = 0.54 - 0.46 * (2.0 * std::f64::consts::PI * i as f64 / m).cos();
|
||||
coeffs.push((ideal * window) as f32);
|
||||
}
|
||||
coeffs
|
||||
}
|
||||
|
||||
/// Type III FIR approximating a 90° phase shift: h[n] = 2/(πn) for odd n,
|
||||
/// Blackman-windowed. Independent of sample rate, so the decoder can run at
|
||||
/// whatever rate the audio arrives in.
|
||||
fn design_hilbert_fir() -> [f32; HILBERT_TAPS] {
|
||||
let mut coeffs = [0.0f32; HILBERT_TAPS];
|
||||
let m = (HILBERT_TAPS - 1) as f64;
|
||||
let mid = m / 2.0;
|
||||
for (i, coeff) in coeffs.iter_mut().enumerate() {
|
||||
let n = i as f64 - mid;
|
||||
let ni = n.round() as i64;
|
||||
if ni != 0 && ni % 2 != 0 {
|
||||
let h = 2.0 / (std::f64::consts::PI * n);
|
||||
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / m).cos()
|
||||
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / m).cos();
|
||||
*coeff = (h * w) as f32;
|
||||
}
|
||||
}
|
||||
coeffs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tone(freq: f32, sample_rate: u32, samples: usize) -> Vec<f32> {
|
||||
(0..samples)
|
||||
.map(|n| (2.0 * PI * freq * n as f32 / sample_rate as f32).sin())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Measured on the settled part of the output: the first `HILBERT_TAPS`
|
||||
/// samples are the filter filling up.
|
||||
fn measure(freq: f32, sample_rate: u32) -> f32 {
|
||||
let mut demod = FreqDemod::new(sample_rate);
|
||||
let mut out = Vec::new();
|
||||
demod.process_into(
|
||||
&tone(freq, sample_rate, sample_rate as usize / 10),
|
||||
&mut out,
|
||||
);
|
||||
let settled = &out[HILBERT_TAPS * 2..];
|
||||
settled.iter().sum::<f32>() / settled.len() as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_tones_sstv_is_made_of() {
|
||||
for rate in [8000u32, 11025, 44100, 48000] {
|
||||
for freq in [1200.0f32, 1500.0, 1900.0, 2300.0] {
|
||||
let measured = measure(freq, rate);
|
||||
assert!(
|
||||
(measured - freq).abs() < 5.0,
|
||||
"{rate} Hz: {freq} Hz tone measured as {measured:.1} Hz",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks are whatever size the audio pipeline hands over, and a tone that
|
||||
/// straddles two of them must not produce a discontinuity at the seam.
|
||||
#[test]
|
||||
fn block_boundaries_do_not_disturb_the_estimate() {
|
||||
let rate = 48_000;
|
||||
let samples = tone(1900.0, rate, 9600);
|
||||
let mut whole = FreqDemod::new(rate);
|
||||
let mut expected = Vec::new();
|
||||
whole.process_into(&samples, &mut expected);
|
||||
|
||||
let mut split = FreqDemod::new(rate);
|
||||
let mut actual = Vec::new();
|
||||
for chunk in samples.chunks(137) {
|
||||
split.process_into(chunk, &mut actual);
|
||||
}
|
||||
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
for (i, (a, b)) in actual.iter().zip(&expected).enumerate() {
|
||||
assert!((a - b).abs() < 0.01, "sample {i}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follows_a_step_between_tones_within_a_pixel() {
|
||||
let rate = 48_000;
|
||||
let mut samples = tone(1500.0, rate, 4800);
|
||||
samples.extend(tone(2300.0, rate, 4800));
|
||||
let mut demod = FreqDemod::new(rate);
|
||||
let mut out = Vec::new();
|
||||
demod.process_into(&samples, &mut out);
|
||||
|
||||
// Well before the step it reads black; well after it, white. The step
|
||||
// itself takes the filter's length to pass through.
|
||||
let before = out[4800 - 200..4800 - 100].iter().sum::<f32>() / 100.0;
|
||||
let after = out[4800 + 200..4800 + 300].iter().sum::<f32>() / 100.0;
|
||||
assert!(
|
||||
(before - 1500.0).abs() < 10.0,
|
||||
"before the step: {before:.1} Hz"
|
||||
);
|
||||
assert!(
|
||||
(after - 2300.0).abs() < 10.0,
|
||||
"after the step: {after:.1} Hz"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Turning an image into an SSTV signal.
|
||||
//!
|
||||
//! This exists so the decoder can be held to a picture rather than to a
|
||||
//! description of one: the tests encode a known image, decode the audio back
|
||||
//! and compare. Nothing in the receive path uses it.
|
||||
//!
|
||||
//! It is written to the same mode table the decoder reads, which makes a
|
||||
//! round-trip a test of the decoder and not of the timings — the timings are
|
||||
//! checked separately, against the published line durations, in [`crate::mode`].
|
||||
|
||||
use crate::mode::{Channel, Segment, SstvMode, BLACK_HZ, SYNC_HZ, WHITE_HZ};
|
||||
|
||||
/// A stretch of constant tone.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Tone {
|
||||
pub hz: f32,
|
||||
pub ms: f64,
|
||||
}
|
||||
|
||||
/// Frequency for an 8-bit level: the inverse of [`crate::mode::level_from_hz`].
|
||||
pub fn hz_from_level(level: u8) -> f32 {
|
||||
BLACK_HZ + (WHITE_HZ - BLACK_HZ) * f32::from(level) / 255.0
|
||||
}
|
||||
|
||||
/// The tones of a VIS header announcing `vis`.
|
||||
pub fn vis_tones(vis: u8) -> Vec<Tone> {
|
||||
let mut tones = vec![
|
||||
Tone {
|
||||
hz: 1900.0,
|
||||
ms: 300.0,
|
||||
},
|
||||
Tone {
|
||||
hz: 1200.0,
|
||||
ms: 10.0,
|
||||
},
|
||||
Tone {
|
||||
hz: 1900.0,
|
||||
ms: 300.0,
|
||||
},
|
||||
Tone {
|
||||
hz: 1200.0,
|
||||
ms: 30.0,
|
||||
}, // start bit
|
||||
];
|
||||
let mut ones = 0;
|
||||
for bit in 0..7 {
|
||||
let set = vis & (1 << bit) != 0;
|
||||
if set {
|
||||
ones += 1;
|
||||
}
|
||||
tones.push(Tone {
|
||||
hz: if set { 1100.0 } else { 1300.0 },
|
||||
ms: 30.0,
|
||||
});
|
||||
}
|
||||
// Even parity over the seven data bits.
|
||||
tones.push(Tone {
|
||||
hz: if ones % 2 == 1 { 1100.0 } else { 1300.0 },
|
||||
ms: 30.0,
|
||||
});
|
||||
tones.push(Tone {
|
||||
hz: 1200.0,
|
||||
ms: 30.0,
|
||||
}); // stop bit
|
||||
tones
|
||||
}
|
||||
|
||||
/// RGB source for the encoder: `width * height * 3` bytes.
|
||||
pub struct Frame<'a> {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub rgb: &'a [u8],
|
||||
}
|
||||
|
||||
impl Frame<'_> {
|
||||
fn pixel(&self, x: usize, y: usize) -> (u8, u8, u8) {
|
||||
let x = x.min(self.width.saturating_sub(1));
|
||||
let y = y.min(self.height.saturating_sub(1));
|
||||
let at = (y * self.width + x) * 3;
|
||||
(self.rgb[at], self.rgb[at + 1], self.rgb[at + 2])
|
||||
}
|
||||
|
||||
/// The colour components SSTV actually sends, for a pixel.
|
||||
fn ycrcb(&self, x: usize, y: usize) -> (u8, u8, u8) {
|
||||
let (r, g, b) = self.pixel(x, y);
|
||||
let (r, g, b) = (f32::from(r), f32::from(g), f32::from(b));
|
||||
let y_val = 16.0 + (0.003_906 * ((65.738 * r) + (129.057 * g) + (25.064 * b)));
|
||||
let cr = 128.0 + (0.003_906 * ((112.439 * r) + (-94.154 * g) + (-18.285 * b)));
|
||||
let cb = 128.0 + (0.003_906 * ((-37.945 * r) + (-74.494 * g) + (112.439 * b)));
|
||||
(
|
||||
y_val.clamp(0.0, 255.0) as u8,
|
||||
cr.clamp(0.0, 255.0) as u8,
|
||||
cb.clamp(0.0, 255.0) as u8,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode `frame` in `mode`, returning the tones of the whole transmission.
|
||||
pub fn encode_tones(mode: &SstvMode, frame: &Frame<'_>) -> Vec<Tone> {
|
||||
let mut tones = vis_tones(mode.vis);
|
||||
let per_line = usize::from(mode.lines_per_transmission);
|
||||
let transmissions = usize::from(mode.height) / per_line;
|
||||
|
||||
for transmission in 0..transmissions {
|
||||
let top = transmission * per_line;
|
||||
for segment in mode.segments {
|
||||
match *segment {
|
||||
Segment::Sync(ms) => tones.push(Tone { hz: SYNC_HZ, ms }),
|
||||
Segment::Gap(ms) => tones.push(Tone { hz: BLACK_HZ, ms }),
|
||||
Segment::Scan { channel, ms } => {
|
||||
// A chroma scan carries fewer pixels than the image is
|
||||
// wide, and takes proportionally less time per pixel.
|
||||
let pixels = mode.scan_pixels(channel);
|
||||
let pixel_ms = ms / pixels as f64;
|
||||
for x in 0..pixels {
|
||||
let level = channel_level(mode, frame, channel, x, top, transmission);
|
||||
tones.push(Tone {
|
||||
hz: hz_from_level(level),
|
||||
ms: pixel_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tones
|
||||
}
|
||||
|
||||
fn channel_level(
|
||||
mode: &SstvMode,
|
||||
frame: &Frame<'_>,
|
||||
channel: Channel,
|
||||
x: usize,
|
||||
top: usize,
|
||||
transmission: usize,
|
||||
) -> u8 {
|
||||
match channel {
|
||||
Channel::Red => frame.pixel(x, top).0,
|
||||
Channel::Green => frame.pixel(x, top).1,
|
||||
Channel::Blue => frame.pixel(x, top).2,
|
||||
Channel::LumaOdd => frame.ycrcb(x, top).0,
|
||||
Channel::LumaEven => frame.ycrcb(x, top + 1).0,
|
||||
Channel::ChromaR => chroma(mode, frame, x, top, true),
|
||||
Channel::ChromaB => chroma(mode, frame, x, top, false),
|
||||
// Robot 36 sends R-Y on odd transmitted lines and B-Y on even ones.
|
||||
Channel::ChromaAlternating => chroma(mode, frame, x, top, transmission.is_multiple_of(2)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Chroma scans are half the width of the image in the Robot modes, so each
|
||||
/// value covers two pixels; PD averages the two image lines of the pair too.
|
||||
fn chroma(mode: &SstvMode, frame: &Frame<'_>, x: usize, top: usize, want_cr: bool) -> u8 {
|
||||
let scale = usize::from(mode.width) / mode.scan_pixels(Channel::ChromaR).max(1);
|
||||
let x0 = x * scale;
|
||||
let mut total = 0u32;
|
||||
let mut count = 0u32;
|
||||
let rows = usize::from(mode.lines_per_transmission);
|
||||
for row in 0..rows {
|
||||
for dx in 0..scale {
|
||||
let (_, cr, cb) = frame.ycrcb(x0 + dx, top + row);
|
||||
total += u32::from(if want_cr { cr } else { cb });
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
(total / count.max(1)) as u8
|
||||
}
|
||||
|
||||
/// Render tones to audio at `sample_rate`, with continuous phase so the
|
||||
/// demodulator sees no step at a tone boundary that isn't in the signal.
|
||||
pub fn render(tones: &[Tone], sample_rate: u32) -> Vec<f32> {
|
||||
let sr = f64::from(sample_rate);
|
||||
let mut out =
|
||||
Vec::with_capacity((tones.iter().map(|t| t.ms).sum::<f64>() / 1000.0 * sr) as usize);
|
||||
let mut phase = 0.0f64;
|
||||
// Each tone's *end* is rounded to a sample, rather than its length: a
|
||||
// pixel of 25.5 samples rounded up on its own puts a whole line 600
|
||||
// samples late by the end of it, which is a timing error no receiver
|
||||
// should have to chase and no transmitter would produce.
|
||||
let mut elapsed_ms = 0.0f64;
|
||||
let mut emitted = 0usize;
|
||||
for tone in tones {
|
||||
elapsed_ms += tone.ms;
|
||||
let end = (elapsed_ms / 1000.0 * sr).round() as usize;
|
||||
let samples = end.saturating_sub(emitted);
|
||||
emitted = end;
|
||||
let step = 2.0 * std::f64::consts::PI * f64::from(tone.hz) / sr;
|
||||
for _ in 0..samples {
|
||||
out.push(phase.sin() as f32);
|
||||
phase += step;
|
||||
if phase > std::f64::consts::TAU {
|
||||
phase -= std::f64::consts::TAU;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Encode a frame straight to audio.
|
||||
pub fn encode(mode: &SstvMode, frame: &Frame<'_>, sample_rate: u32) -> Vec<f32> {
|
||||
render(&encode_tones(mode, frame), sample_rate)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mode::{level_from_hz, mode_for_vis};
|
||||
|
||||
#[test]
|
||||
fn levels_survive_the_trip_through_frequency() {
|
||||
for level in [0u8, 1, 64, 128, 200, 255] {
|
||||
assert_eq!(level_from_hz(hz_from_level(level)), level);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transmission_lasts_as_long_as_the_mode_says() {
|
||||
let mode = mode_for_vis(44).expect("Martin M1");
|
||||
let rgb = vec![128u8; 320 * 256 * 3];
|
||||
let frame = Frame {
|
||||
width: 320,
|
||||
height: 256,
|
||||
rgb: &rgb,
|
||||
};
|
||||
let audio = encode(mode, &frame, 48_000);
|
||||
// Header plus 256 lines, within a line of the published duration.
|
||||
let header_s = 0.94;
|
||||
let expected = header_s + mode.frame_secs();
|
||||
let actual = audio.len() as f64 / 48_000.0;
|
||||
assert!(
|
||||
(actual - expected).abs() < mode.line_ms / 1000.0,
|
||||
"encoded {actual:.2} s, expected {expected:.2} s",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Assembling decoded lines into an image, and getting it out of the process.
|
||||
//!
|
||||
//! Rows arrive one at a time and the picture is worth looking at before it is
|
||||
//! finished, so the assembler holds a full-size RGB canvas from the start and
|
||||
//! fills it in. An unfinished frame is grey below the last decoded row rather
|
||||
//! than black, which reads as "not here yet" instead of "received as black".
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::Engine;
|
||||
|
||||
/// Value the canvas starts at: mid-grey, for rows not yet received.
|
||||
const UNWRITTEN: u8 = 96;
|
||||
|
||||
pub struct ImageCanvas {
|
||||
width: usize,
|
||||
height: usize,
|
||||
rgb: Vec<u8>,
|
||||
/// Highest row index written, plus one.
|
||||
filled_rows: usize,
|
||||
}
|
||||
|
||||
impl ImageCanvas {
|
||||
pub fn new(width: usize, height: usize) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
rgb: vec![UNWRITTEN; width * height * 3],
|
||||
filled_rows: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.height
|
||||
}
|
||||
|
||||
/// Rows written so far.
|
||||
pub fn filled_rows(&self) -> usize {
|
||||
self.filled_rows
|
||||
}
|
||||
|
||||
/// Write one row of RGB triples. Rows past the bottom of the image are
|
||||
/// dropped: a transmission that runs long is not a reason to grow.
|
||||
pub fn put_row(&mut self, y: usize, row: &[u8]) {
|
||||
if y >= self.height {
|
||||
return;
|
||||
}
|
||||
let at = y * self.width * 3;
|
||||
let take = row.len().min(self.width * 3);
|
||||
self.rgb[at..at + take].copy_from_slice(&row[..take]);
|
||||
self.filled_rows = self.filled_rows.max(y + 1);
|
||||
}
|
||||
|
||||
pub fn row(&self, y: usize) -> Option<&[u8]> {
|
||||
if y >= self.height {
|
||||
return None;
|
||||
}
|
||||
let at = y * self.width * 3;
|
||||
Some(&self.rgb[at..at + self.width * 3])
|
||||
}
|
||||
|
||||
pub fn rgb(&self) -> &[u8] {
|
||||
&self.rgb
|
||||
}
|
||||
|
||||
/// Encode the canvas as a PNG.
|
||||
pub fn to_png(&self) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut out, self.width as u32, self.height as u32);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder
|
||||
.write_header()
|
||||
.map_err(|e| format!("PNG header: {e}"))?;
|
||||
writer
|
||||
.write_image_data(&self.rgb)
|
||||
.map_err(|e| format!("PNG data: {e}"))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The PNG, base64-encoded for the journey to a browser.
|
||||
pub fn to_png_base64(&self) -> Result<String, String> {
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(self.to_png()?))
|
||||
}
|
||||
|
||||
/// Write the PNG into `dir`, named for when and where it was received.
|
||||
pub fn save_png(
|
||||
&self,
|
||||
dir: &Path,
|
||||
freq_hz: u64,
|
||||
mode_name: &str,
|
||||
stamp: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
|
||||
let slug: String = mode_name
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let path = dir.join(format!("SSTV_{stamp}_{freq_hz}_{slug}.png"));
|
||||
std::fs::write(&path, self.to_png()?)
|
||||
.map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rows_land_where_they_are_put_and_the_rest_stays_unwritten() {
|
||||
let mut canvas = ImageCanvas::new(4, 3);
|
||||
canvas.put_row(1, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
assert_eq!(canvas.row(1).unwrap()[0..3], [1, 2, 3]);
|
||||
assert_eq!(canvas.row(0).unwrap()[0], UNWRITTEN);
|
||||
assert_eq!(canvas.filled_rows(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_past_the_bottom_is_dropped_rather_than_growing_the_image() {
|
||||
let mut canvas = ImageCanvas::new(2, 2);
|
||||
canvas.put_row(9, &[1, 2, 3, 4, 5, 6]);
|
||||
assert_eq!(canvas.filled_rows(), 0);
|
||||
assert_eq!(canvas.rgb().len(), 2 * 2 * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_a_png_a_decoder_can_read_back() {
|
||||
let mut canvas = ImageCanvas::new(2, 2);
|
||||
canvas.put_row(0, &[255, 0, 0, 0, 255, 0]);
|
||||
let png_bytes = canvas.to_png().expect("png");
|
||||
let decoder = png::Decoder::new(png_bytes.as_slice());
|
||||
let mut reader = decoder.read_info().expect("png info");
|
||||
let mut buf = vec![0; reader.output_buffer_size()];
|
||||
let info = reader.next_frame(&mut buf).expect("png frame");
|
||||
assert_eq!((info.width, info.height), (2, 2));
|
||||
assert_eq!(&buf[0..6], &[255, 0, 0, 0, 255, 0]);
|
||||
assert!(!canvas.to_png_base64().expect("base64").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! SSTV (Slow-Scan Television) decoder.
|
||||
//!
|
||||
//! Pure Rust, covering Martin, Scottie, Robot, PD and Wraase SC2-180, with the
|
||||
//! mode taken from the VIS header that precedes every transmission. Rows are
|
||||
//! emitted as they arrive so a picture can be watched building up.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent};
|
||||
//!
|
||||
//! let mut decoder = SstvDecoder::new(48_000, SstvConfig::default());
|
||||
//! # let audio: Vec<f32> = Vec::new();
|
||||
//! for event in decoder.process_samples(&audio) {
|
||||
//! match event {
|
||||
//! SstvEvent::Started { mode, .. } => println!("receiving {mode}"),
|
||||
//! SstvEvent::Row { line, .. } => println!("line {line}"),
|
||||
//! SstvEvent::Complete(image) => println!("{} lines", image.lines),
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod config;
|
||||
pub mod decoder;
|
||||
pub mod demod;
|
||||
pub mod encode;
|
||||
pub mod image;
|
||||
pub mod mode;
|
||||
pub mod vis;
|
||||
|
||||
pub use config::SstvConfig;
|
||||
pub use decoder::{SstvDecoder, SstvEvent, SstvImage};
|
||||
pub use image::ImageCanvas;
|
||||
pub use mode::{mode_for_vis, SstvMode, MODES};
|
||||
@@ -0,0 +1,615 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! SSTV mode table: what a VIS code means in timings, geometry and colour.
|
||||
//!
|
||||
//! Every mode transmits a line as a sequence of *segments* — a sync pulse, a
|
||||
//! porch or separator at a fixed tone, and one scan per colour channel. The
|
||||
//! decoder needs only the segment layout and the offset of each scan within
|
||||
//! the line, so that is what a [`SstvMode`] is: a list of segments plus the
|
||||
//! rules for turning the scans back into pixels.
|
||||
//!
|
||||
//! Timings follow the published mode specifications (JL Barber, N7CXI,
|
||||
//! "Proposal for SSTV Mode Specifications", 2000), which is the same table
|
||||
//! MMSSTV, QSSTV and slowrx work from.
|
||||
|
||||
/// Tone that marks a line boundary, in Hz.
|
||||
pub const SYNC_HZ: f32 = 1200.0;
|
||||
/// Tone for black, in Hz.
|
||||
pub const BLACK_HZ: f32 = 1500.0;
|
||||
/// Tone for white, in Hz.
|
||||
pub const WHITE_HZ: f32 = 2300.0;
|
||||
|
||||
/// What a scan segment carries.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Channel {
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
/// Luminance for the odd (first) image line of the pair.
|
||||
LumaOdd,
|
||||
/// Luminance for the even (second) image line of a PD pair.
|
||||
LumaEven,
|
||||
/// R-Y chrominance.
|
||||
ChromaR,
|
||||
/// B-Y chrominance.
|
||||
ChromaB,
|
||||
/// Robot 36 alternates R-Y and B-Y between transmitted lines: odd lines
|
||||
/// carry R-Y, even lines B-Y, and each is held over both.
|
||||
ChromaAlternating,
|
||||
}
|
||||
|
||||
/// One piece of a transmitted line.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Segment {
|
||||
/// Sync pulse at [`SYNC_HZ`].
|
||||
Sync(f64),
|
||||
/// Porch, separator or gap at a fixed tone; the tone itself is not decoded.
|
||||
Gap(f64),
|
||||
/// A scan carrying pixels for one channel.
|
||||
Scan { channel: Channel, ms: f64 },
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
pub fn duration_ms(&self) -> f64 {
|
||||
match *self {
|
||||
Segment::Sync(ms) | Segment::Gap(ms) => ms,
|
||||
Segment::Scan { ms, .. } => ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the scans of a line become pixels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ColorModel {
|
||||
/// Scans are red, green and blue directly (Martin, Scottie, Wraase).
|
||||
Rgb,
|
||||
/// Y plus one alternating chroma channel per line (Robot 36).
|
||||
YCrCbAlternating,
|
||||
/// Y, R-Y and B-Y in every line (Robot 72).
|
||||
YCrCb,
|
||||
/// Two image lines per transmitted line: Y odd, R-Y, B-Y, Y even (PD).
|
||||
YCrCbPaired,
|
||||
}
|
||||
|
||||
/// A decodable SSTV mode.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SstvMode {
|
||||
/// VIS code as sent in the header.
|
||||
pub vis: u8,
|
||||
/// Human-readable name, e.g. "Martin M1".
|
||||
pub name: &'static str,
|
||||
/// Pixels across.
|
||||
pub width: u16,
|
||||
/// Image lines in a full frame.
|
||||
pub height: u16,
|
||||
/// Transmitted line duration in milliseconds.
|
||||
pub line_ms: f64,
|
||||
/// Segments in transmission order.
|
||||
pub segments: &'static [Segment],
|
||||
pub color: ColorModel,
|
||||
/// Image lines produced by one transmitted line (2 for PD, otherwise 1).
|
||||
pub lines_per_transmission: u16,
|
||||
/// Offset from the start of a line to the leading edge of its sync pulse.
|
||||
/// Zero for most modes; Scottie sends the sync in the middle of the line,
|
||||
/// so a line detected at its sync starts before it.
|
||||
pub sync_offset_ms: f64,
|
||||
}
|
||||
|
||||
impl SstvMode {
|
||||
/// Total of every segment, which must equal [`SstvMode::line_ms`].
|
||||
pub fn segments_ms(&self) -> f64 {
|
||||
self.segments.iter().map(Segment::duration_ms).sum()
|
||||
}
|
||||
|
||||
/// Start offset, in milliseconds from the line start, of each scan.
|
||||
pub fn scans(&self) -> Vec<(Channel, f64, f64)> {
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0.0;
|
||||
for segment in self.segments {
|
||||
if let Segment::Scan { channel, ms } = *segment {
|
||||
out.push((channel, at, ms));
|
||||
}
|
||||
at += segment.duration_ms();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pixels carried by a scan of this channel.
|
||||
///
|
||||
/// Chroma is sent at half the width in the Robot modes — the eye takes
|
||||
/// colour more coarsely than brightness, and the saving is what makes 36
|
||||
/// seconds possible. PD sends chroma at full width and saves its time by
|
||||
/// sharing one pair of chroma scans between two image lines instead.
|
||||
pub fn scan_pixels(&self, channel: Channel) -> usize {
|
||||
let width = usize::from(self.width);
|
||||
match channel {
|
||||
Channel::ChromaR | Channel::ChromaB | Channel::ChromaAlternating
|
||||
if self.color != ColorModel::YCrCbPaired =>
|
||||
{
|
||||
width / 2
|
||||
}
|
||||
_ => width,
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds a full frame takes to send.
|
||||
pub fn frame_secs(&self) -> f64 {
|
||||
self.line_ms * f64::from(self.height) / f64::from(self.lines_per_transmission) / 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Martin — sync, porch, then green, blue, red, each followed by a separator.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MARTIN_M1: &[Segment] = &[
|
||||
Segment::Sync(4.862),
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 146.432,
|
||||
},
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 146.432,
|
||||
},
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 146.432,
|
||||
},
|
||||
Segment::Gap(0.572),
|
||||
];
|
||||
|
||||
const MARTIN_M2: &[Segment] = &[
|
||||
Segment::Sync(4.862),
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 73.216,
|
||||
},
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 73.216,
|
||||
},
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 73.216,
|
||||
},
|
||||
Segment::Gap(0.572),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scottie — the sync pulse sits between the blue and red scans, so a line
|
||||
// starts one separator before the green scan and the sync of the *previous*
|
||||
// line is what marks it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SCOTTIE_S1: &[Segment] = &[
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 138.240,
|
||||
},
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 138.240,
|
||||
},
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 138.240,
|
||||
},
|
||||
];
|
||||
|
||||
const SCOTTIE_S2: &[Segment] = &[
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 88.064,
|
||||
},
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 88.064,
|
||||
},
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 88.064,
|
||||
},
|
||||
];
|
||||
|
||||
const SCOTTIE_DX: &[Segment] = &[
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 345.6,
|
||||
},
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 345.6,
|
||||
},
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 345.6,
|
||||
},
|
||||
];
|
||||
|
||||
/// Scottie's sync arrives after the green and blue scans: 1.5 + 138.24 + 1.5 +
|
||||
/// 138.24 for S1, and the equivalent for the others.
|
||||
const fn scottie_sync_offset(scan_ms: f64) -> f64 {
|
||||
1.5 + scan_ms + 1.5 + scan_ms
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Robot — luminance plus chrominance, the chroma scans at half the width.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROBOT_36: &[Segment] = &[
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(3.0),
|
||||
Segment::Scan {
|
||||
channel: Channel::LumaOdd,
|
||||
ms: 88.0,
|
||||
},
|
||||
Segment::Gap(4.5),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::ChromaAlternating,
|
||||
ms: 44.0,
|
||||
},
|
||||
];
|
||||
|
||||
const ROBOT_72: &[Segment] = &[
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(3.0),
|
||||
Segment::Scan {
|
||||
channel: Channel::LumaOdd,
|
||||
ms: 138.0,
|
||||
},
|
||||
Segment::Gap(4.5),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::ChromaR,
|
||||
ms: 69.0,
|
||||
},
|
||||
Segment::Gap(4.5),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::ChromaB,
|
||||
ms: 69.0,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PD — one transmitted line carries two image lines: the luminance of both,
|
||||
// with a single pair of chroma scans shared between them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
macro_rules! pd_segments {
|
||||
($name:ident, $scan:expr) => {
|
||||
const $name: &[Segment] = &[
|
||||
Segment::Sync(20.0),
|
||||
Segment::Gap(2.08),
|
||||
Segment::Scan {
|
||||
channel: Channel::LumaOdd,
|
||||
ms: $scan,
|
||||
},
|
||||
Segment::Scan {
|
||||
channel: Channel::ChromaR,
|
||||
ms: $scan,
|
||||
},
|
||||
Segment::Scan {
|
||||
channel: Channel::ChromaB,
|
||||
ms: $scan,
|
||||
},
|
||||
Segment::Scan {
|
||||
channel: Channel::LumaEven,
|
||||
ms: $scan,
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
pd_segments!(PD_50, 91.520);
|
||||
pd_segments!(PD_90, 170.240);
|
||||
pd_segments!(PD_120, 121.600);
|
||||
pd_segments!(PD_160, 195.584);
|
||||
pd_segments!(PD_180, 183.040);
|
||||
pd_segments!(PD_240, 244.672);
|
||||
pd_segments!(PD_290, 228.800);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wraase SC2-180 — red, green, blue in that order after one porch.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WRAASE_SC2_180: &[Segment] = &[
|
||||
Segment::Sync(5.5225),
|
||||
Segment::Gap(0.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 235.0,
|
||||
},
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 235.0,
|
||||
},
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 235.0,
|
||||
},
|
||||
];
|
||||
|
||||
/// Every mode this decoder knows, in VIS order.
|
||||
pub static MODES: &[SstvMode] = &[
|
||||
SstvMode {
|
||||
vis: 8,
|
||||
name: "Robot 36",
|
||||
width: 320,
|
||||
height: 240,
|
||||
line_ms: 150.0,
|
||||
segments: ROBOT_36,
|
||||
color: ColorModel::YCrCbAlternating,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 12,
|
||||
name: "Robot 72",
|
||||
width: 320,
|
||||
height: 240,
|
||||
line_ms: 300.0,
|
||||
segments: ROBOT_72,
|
||||
color: ColorModel::YCrCb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 40,
|
||||
name: "Martin M2",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 226.798,
|
||||
segments: MARTIN_M2,
|
||||
color: ColorModel::Rgb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 44,
|
||||
name: "Martin M1",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 446.446,
|
||||
segments: MARTIN_M1,
|
||||
color: ColorModel::Rgb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 55,
|
||||
name: "Wraase SC2-180",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 711.0225,
|
||||
segments: WRAASE_SC2_180,
|
||||
color: ColorModel::Rgb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 56,
|
||||
name: "Scottie S2",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 277.692,
|
||||
segments: SCOTTIE_S2,
|
||||
color: ColorModel::Rgb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: scottie_sync_offset(88.064),
|
||||
},
|
||||
SstvMode {
|
||||
vis: 60,
|
||||
name: "Scottie S1",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 428.22,
|
||||
segments: SCOTTIE_S1,
|
||||
color: ColorModel::Rgb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: scottie_sync_offset(138.240),
|
||||
},
|
||||
SstvMode {
|
||||
vis: 76,
|
||||
name: "Scottie DX",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 1050.3,
|
||||
segments: SCOTTIE_DX,
|
||||
color: ColorModel::Rgb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: scottie_sync_offset(345.6),
|
||||
},
|
||||
SstvMode {
|
||||
vis: 93,
|
||||
name: "PD50",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 388.16,
|
||||
segments: PD_50,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 94,
|
||||
name: "PD290",
|
||||
width: 800,
|
||||
height: 616,
|
||||
line_ms: 937.28,
|
||||
segments: PD_290,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 95,
|
||||
name: "PD120",
|
||||
width: 640,
|
||||
height: 496,
|
||||
line_ms: 508.48,
|
||||
segments: PD_120,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 96,
|
||||
name: "PD180",
|
||||
width: 640,
|
||||
height: 496,
|
||||
line_ms: 754.24,
|
||||
segments: PD_180,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 97,
|
||||
name: "PD240",
|
||||
width: 640,
|
||||
height: 496,
|
||||
line_ms: 1000.768,
|
||||
segments: PD_240,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 98,
|
||||
name: "PD160",
|
||||
width: 512,
|
||||
height: 400,
|
||||
line_ms: 804.416,
|
||||
segments: PD_160,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 99,
|
||||
name: "PD90",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 703.04,
|
||||
segments: PD_90,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
];
|
||||
|
||||
/// Look a mode up by the VIS code that announced it.
|
||||
pub fn mode_for_vis(vis: u8) -> Option<&'static SstvMode> {
|
||||
MODES.iter().find(|mode| mode.vis == vis)
|
||||
}
|
||||
|
||||
/// Map an instantaneous frequency to an 8-bit level: 1500 Hz is black, 2300 Hz
|
||||
/// white. Frequencies outside the band clamp rather than wrap, so a sync pulse
|
||||
/// that lands inside a scan reads as black instead of as bright noise.
|
||||
pub fn level_from_hz(hz: f32) -> u8 {
|
||||
let level = (hz - BLACK_HZ) * (255.0 / (WHITE_HZ - BLACK_HZ));
|
||||
level.clamp(0.0, 255.0).round() as u8
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The segment list and the published line time are two statements of the
|
||||
// same fact, entered by hand from the specification. If a digit is wrong in
|
||||
// one it is unlikely to be wrong identically in the other.
|
||||
#[test]
|
||||
fn segments_add_up_to_the_published_line_time() {
|
||||
for mode in MODES {
|
||||
let sum = mode.segments_ms();
|
||||
assert!(
|
||||
(sum - mode.line_ms).abs() < 0.001,
|
||||
"{}: segments total {:.4} ms, line time says {:.4} ms",
|
||||
mode.name,
|
||||
sum,
|
||||
mode.line_ms,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vis_codes_are_unique_and_resolvable() {
|
||||
for mode in MODES {
|
||||
assert_eq!(mode_for_vis(mode.vis).map(|m| m.name), Some(mode.name));
|
||||
}
|
||||
let mut codes: Vec<u8> = MODES.iter().map(|m| m.vis).collect();
|
||||
codes.sort_unstable();
|
||||
let count = codes.len();
|
||||
codes.dedup();
|
||||
assert_eq!(codes.len(), count, "two modes claim the same VIS code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mode_scans_enough_channels_for_its_colour_model() {
|
||||
for mode in MODES {
|
||||
let scans = mode.scans();
|
||||
let expected = match mode.color {
|
||||
ColorModel::Rgb => 3,
|
||||
ColorModel::YCrCbAlternating => 2,
|
||||
ColorModel::YCrCb => 3,
|
||||
ColorModel::YCrCbPaired => 4,
|
||||
};
|
||||
assert_eq!(
|
||||
scans.len(),
|
||||
expected,
|
||||
"{} has {} scans",
|
||||
mode.name,
|
||||
scans.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Frame durations are what operators know these modes by — the number in
|
||||
// the name is the number of seconds.
|
||||
#[test]
|
||||
fn frame_durations_match_the_names() {
|
||||
for (vis, secs) in [(8u8, 36.0), (12, 72.0), (93, 50.0), (99, 90.0), (95, 126.0)] {
|
||||
let mode = mode_for_vis(vis).expect("mode in table");
|
||||
let actual = mode.frame_secs();
|
||||
assert!(
|
||||
(actual - secs).abs() < 1.5,
|
||||
"{} takes {:.1} s, expected about {:.0} s",
|
||||
mode.name,
|
||||
actual,
|
||||
secs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_span_black_to_white_and_clamp_outside() {
|
||||
assert_eq!(level_from_hz(BLACK_HZ), 0);
|
||||
assert_eq!(level_from_hz(WHITE_HZ), 255);
|
||||
assert_eq!(level_from_hz(1900.0), 128);
|
||||
assert_eq!(level_from_hz(SYNC_HZ), 0, "a sync pulse must read as black");
|
||||
assert_eq!(level_from_hz(3000.0), 255);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! VIS header detection.
|
||||
//!
|
||||
//! Every transmission announces its mode in a fixed preamble:
|
||||
//!
|
||||
//! | Part | Tone | Duration |
|
||||
//! |------|------|----------|
|
||||
//! | Leader | 1900 Hz | 300 ms |
|
||||
//! | Break | 1200 Hz | 10 ms |
|
||||
//! | Leader | 1900 Hz | 300 ms |
|
||||
//! | Start bit | 1200 Hz | 30 ms |
|
||||
//! | 7 data bits, LSB first | 1100 Hz = 1, 1300 Hz = 0 | 30 ms each |
|
||||
//! | Even parity | as above | 30 ms |
|
||||
//! | Stop bit | 1200 Hz | 30 ms |
|
||||
//!
|
||||
//! The detector looks for the start bit standing behind a leader, reads the
|
||||
//! eight bits that follow, and checks the parity. Parity is the only integrity
|
||||
//! check the header has, so a code that fails it is discarded rather than
|
||||
//! guessed at — decoding 114 seconds of Martin M1 as Scottie DX produces a
|
||||
//! convincing-looking image of nothing.
|
||||
|
||||
/// Tone durations, in milliseconds.
|
||||
const BIT_MS: f64 = 30.0;
|
||||
const LEADER_MS: f64 = 300.0;
|
||||
|
||||
/// How far a tone may sit from its nominal frequency and still be recognised.
|
||||
/// Wide enough for a rig tuned a little off, narrow enough that 1100, 1200,
|
||||
/// 1300 and 1900 Hz stay distinct.
|
||||
const TONE_TOLERANCE_HZ: f32 = 60.0;
|
||||
|
||||
const LEADER_HZ: f32 = 1900.0;
|
||||
const START_HZ: f32 = 1200.0;
|
||||
const ONE_HZ: f32 = 1100.0;
|
||||
const ZERO_HZ: f32 = 1300.0;
|
||||
|
||||
/// A VIS header found in the stream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct VisHit {
|
||||
/// The code, which names the mode.
|
||||
pub code: u8,
|
||||
/// Index just past the stop bit: where the image itself begins.
|
||||
pub image_start: usize,
|
||||
}
|
||||
|
||||
fn near(freq: f32, target: f32) -> bool {
|
||||
(freq - target).abs() <= TONE_TOLERANCE_HZ
|
||||
}
|
||||
|
||||
/// Mean frequency over the middle 60% of a bit cell, which keeps the filter's
|
||||
/// transitions at either edge out of the measurement.
|
||||
fn bit_frequency(freqs: &[f32], start: f64, samples_per_bit: f64) -> Option<f32> {
|
||||
let from = (start + samples_per_bit * 0.2).round() as usize;
|
||||
let to = (start + samples_per_bit * 0.8).round() as usize;
|
||||
if to <= from || to > freqs.len() {
|
||||
return None;
|
||||
}
|
||||
let window = &freqs[from..to];
|
||||
Some(window.iter().sum::<f32>() / window.len() as f32)
|
||||
}
|
||||
|
||||
/// Search `freqs` for a VIS header, starting at `from`.
|
||||
///
|
||||
/// Returns the first header whose parity checks out. `freqs` is instantaneous
|
||||
/// frequency in Hz, one entry per audio sample.
|
||||
pub fn find_vis(freqs: &[f32], sample_rate: u32, from: usize) -> Option<VisHit> {
|
||||
let sr = f64::from(sample_rate);
|
||||
// Header tones are 10 ms at the shortest, so a millisecond of averaging
|
||||
// costs nothing and takes the demodulator's ripple — ±95 Hz at 1200 Hz —
|
||||
// out of tones that are 100 Hz apart.
|
||||
let smoothed = crate::demod::smooth(freqs, (sr / 1000.0).round() as usize);
|
||||
let freqs = smoothed.as_slice();
|
||||
let samples_per_bit = BIT_MS / 1000.0 * sr;
|
||||
let leader_samples = (LEADER_MS / 1000.0 * sr) as usize;
|
||||
// The start bit must be at least this long to be one.
|
||||
let min_start_run = (samples_per_bit * 0.7) as usize;
|
||||
|
||||
// A leader has to precede the start bit. Half of one is enough evidence,
|
||||
// and asking for less than the full 300 ms means the search still works on
|
||||
// a buffer that begins part-way through the header.
|
||||
let leader_needed = leader_samples / 2;
|
||||
|
||||
// The bits themselves may run to the very end of what has arrived so far;
|
||||
// reading them is what decides whether there is enough, not this bound.
|
||||
let mut i = from.max(leader_needed);
|
||||
while i < freqs.len() {
|
||||
if !near(freqs[i], START_HZ) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Measure the run of start tone.
|
||||
let mut run = 0usize;
|
||||
while i + run < freqs.len() && near(freqs[i + run], START_HZ) {
|
||||
run += 1;
|
||||
}
|
||||
if run < min_start_run {
|
||||
i += run.max(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// What came before it: the leader. Sampled rather than scanned in
|
||||
// full, since only its identity matters, not its exact length.
|
||||
let leader_from = i - leader_needed;
|
||||
let leader_hits = freqs[leader_from..i]
|
||||
.iter()
|
||||
.step_by(16)
|
||||
.filter(|&&f| near(f, LEADER_HZ))
|
||||
.count();
|
||||
let leader_total = freqs[leader_from..i].iter().step_by(16).count();
|
||||
if leader_total == 0 || (leader_hits as f64) < 0.7 * leader_total as f64 {
|
||||
i += run;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bits follow the start bit, which the run just measured. Use the run's
|
||||
// own end rather than a nominal offset, so a start bit stretched or
|
||||
// clipped by the filter does not shift every bit after it.
|
||||
let bits_start = (i + run) as f64;
|
||||
let mut bits = [false; 8];
|
||||
let mut readable = true;
|
||||
for (index, bit) in bits.iter_mut().enumerate() {
|
||||
let at = bits_start + samples_per_bit * index as f64;
|
||||
match bit_frequency(freqs, at, samples_per_bit) {
|
||||
Some(freq) if near(freq, ONE_HZ) => *bit = true,
|
||||
Some(freq) if near(freq, ZERO_HZ) => *bit = false,
|
||||
_ => {
|
||||
readable = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !readable {
|
||||
i += run;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Seven data bits, LSB first, then even parity over them.
|
||||
let code = bits[..7]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(0u8, |acc, (index, &set)| acc | (u8::from(set) << index));
|
||||
let ones = bits[..7].iter().filter(|&&b| b).count() + usize::from(bits[7]);
|
||||
if ones % 2 != 0 {
|
||||
i += run;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Past the stop bit is the image.
|
||||
let image_start = (bits_start + samples_per_bit * 9.0).round() as usize;
|
||||
return Some(VisHit { code, image_start });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::encode::{vis_tones, Tone};
|
||||
|
||||
fn freqs_from_tones(tones: &[Tone], sample_rate: u32) -> Vec<f32> {
|
||||
let mut out = Vec::new();
|
||||
for tone in tones {
|
||||
let samples = (tone.ms / 1000.0 * f64::from(sample_rate)).round() as usize;
|
||||
out.extend(std::iter::repeat_n(tone.hz, samples));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_every_code_the_mode_table_knows() {
|
||||
for mode in crate::mode::MODES {
|
||||
let freqs = freqs_from_tones(&vis_tones(mode.vis), 48_000);
|
||||
let hit = find_vis(&freqs, 48_000, 0)
|
||||
.unwrap_or_else(|| panic!("{} header not found", mode.name));
|
||||
assert_eq!(
|
||||
hit.code, mode.vis,
|
||||
"{} decoded as VIS {}",
|
||||
mode.name, hit.code
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_image_starts_after_the_stop_bit() {
|
||||
let sample_rate = 48_000;
|
||||
let freqs = freqs_from_tones(&vis_tones(44), sample_rate);
|
||||
let hit = find_vis(&freqs, sample_rate, 0).expect("header");
|
||||
// Header is 300 + 10 + 300 ms of leader and break, then ten 30 ms bits.
|
||||
let expected = ((300.0 + 10.0 + 300.0 + 300.0) / 1000.0 * f64::from(sample_rate)) as usize;
|
||||
let slack = sample_rate as usize / 100; // 10 ms
|
||||
assert!(
|
||||
hit.image_start.abs_diff(expected) < slack,
|
||||
"image starts at {}, expected about {expected}",
|
||||
hit.image_start,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_with_broken_parity_is_not_a_header() {
|
||||
let sample_rate = 48_000;
|
||||
let mut tones = vis_tones(44);
|
||||
// Flip the parity bit alone: seven data bits still say Martin M1, but
|
||||
// nothing now vouches for them.
|
||||
let parity = tones.len() - 2;
|
||||
tones[parity].hz = if tones[parity].hz == ONE_HZ {
|
||||
ZERO_HZ
|
||||
} else {
|
||||
ONE_HZ
|
||||
};
|
||||
let freqs = freqs_from_tones(&tones, sample_rate);
|
||||
assert_eq!(find_vis(&freqs, sample_rate, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tones_without_a_leader_are_not_a_header() {
|
||||
let sample_rate = 48_000;
|
||||
let mut tones = vis_tones(44);
|
||||
// Same bits, but the leader before them is a pixel-band tone — which is
|
||||
// what a passing image looks like.
|
||||
tones[0].hz = 2000.0;
|
||||
tones[2].hz = 2000.0;
|
||||
let freqs = freqs_from_tones(&tones, sample_rate);
|
||||
assert_eq!(find_vis(&freqs, sample_rate, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_part_way_into_the_buffer_is_still_found() {
|
||||
let sample_rate = 48_000;
|
||||
let mut freqs = vec![1750.0f32; sample_rate as usize]; // a second of picture
|
||||
freqs.extend(freqs_from_tones(&vis_tones(60), sample_rate));
|
||||
let hit = find_vis(&freqs, sample_rate, 0).expect("header");
|
||||
assert_eq!(hit.code, 60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Decode what was encoded, and compare the pictures.
|
||||
//!
|
||||
//! A decoder for a picture format can only really be tested against a picture.
|
||||
//! These tests build a test card, transmit it in each mode through the
|
||||
//! encoder, and hold the decoder to what comes back — pixel by pixel, with a
|
||||
//! tolerance that accounts for the round trip through frequency and, for the
|
||||
//! colour modes, through a chroma channel at half the width.
|
||||
//!
|
||||
//! What this does not test is the timing table itself: the encoder reads the
|
||||
//! same numbers as the decoder, so a wrong line duration would cancel out.
|
||||
//! That is checked in `mode.rs` against the published line times instead.
|
||||
|
||||
use trx_sstv::encode::{encode, Frame};
|
||||
use trx_sstv::mode::mode_for_vis;
|
||||
use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent, SstvImage};
|
||||
|
||||
const SAMPLE_RATE: u32 = 48_000;
|
||||
|
||||
/// A test card with something for every part of the decoder to get wrong:
|
||||
/// vertical colour bars catch channels swapped or shifted, the horizontal
|
||||
/// gradient catches a line-timing drift, and the corner blocks catch a picture
|
||||
/// that arrives upside down or mirrored.
|
||||
fn test_card(width: usize, height: usize) -> Vec<u8> {
|
||||
let mut rgb = vec![0u8; width * height * 3];
|
||||
let bars: [(u8, u8, u8); 8] = [
|
||||
(255, 255, 255),
|
||||
(255, 255, 0),
|
||||
(0, 255, 255),
|
||||
(0, 255, 0),
|
||||
(255, 0, 255),
|
||||
(255, 0, 0),
|
||||
(0, 0, 255),
|
||||
(0, 0, 0),
|
||||
];
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let at = (y * width + x) * 3;
|
||||
let (r, g, b) = if y < height / 2 {
|
||||
bars[x * bars.len() / width]
|
||||
} else {
|
||||
let ramp = (x * 255 / width.max(1)) as u8;
|
||||
let down = (y * 255 / height.max(1)) as u8;
|
||||
(ramp, down, 255 - ramp)
|
||||
};
|
||||
rgb[at] = r;
|
||||
rgb[at + 1] = g;
|
||||
rgb[at + 2] = b;
|
||||
}
|
||||
}
|
||||
// Corner marks: red top-left, blue bottom-right.
|
||||
for y in 0..height.min(8) {
|
||||
for x in 0..width.min(8) {
|
||||
let at = (y * width + x) * 3;
|
||||
rgb[at] = 255;
|
||||
rgb[at + 1] = 0;
|
||||
rgb[at + 2] = 0;
|
||||
}
|
||||
}
|
||||
for y in height.saturating_sub(8)..height {
|
||||
for x in width.saturating_sub(8)..width {
|
||||
let at = (y * width + x) * 3;
|
||||
rgb[at] = 0;
|
||||
rgb[at + 1] = 0;
|
||||
rgb[at + 2] = 255;
|
||||
}
|
||||
}
|
||||
rgb
|
||||
}
|
||||
|
||||
/// Run audio through the decoder in blocks the size a sound card delivers.
|
||||
///
|
||||
/// A little silence is fed after the signal, as a receiver that keeps
|
||||
/// listening supplies: the demodulator is a filter, so the last millisecond of
|
||||
/// any transmission needs the samples after it before it can be read.
|
||||
fn decode(audio: &[f32]) -> (Vec<SstvImage>, usize) {
|
||||
let mut tail = audio.to_vec();
|
||||
tail.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize / 20));
|
||||
let audio = tail.as_slice();
|
||||
|
||||
let mut decoder = SstvDecoder::new(SAMPLE_RATE, SstvConfig::default());
|
||||
let mut images = Vec::new();
|
||||
let mut rows = 0;
|
||||
for block in audio.chunks(1024) {
|
||||
for event in decoder.process_samples(block) {
|
||||
match event {
|
||||
SstvEvent::Row { .. } => rows += 1,
|
||||
SstvEvent::Complete(image) => images.push(image),
|
||||
SstvEvent::Started { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
(images, rows)
|
||||
}
|
||||
|
||||
/// Mean absolute error per colour channel between two same-sized images.
|
||||
fn mean_error(a: &[u8], b: &[u8]) -> f64 {
|
||||
assert_eq!(a.len(), b.len());
|
||||
let total: u64 = a
|
||||
.iter()
|
||||
.zip(b)
|
||||
.map(|(x, y)| u64::from(x.abs_diff(*y)))
|
||||
.sum();
|
||||
total as f64 / a.len() as f64
|
||||
}
|
||||
|
||||
/// Error over the part of the picture away from channel edges, where a decoder
|
||||
/// that is a pixel out on a hard colour boundary would otherwise dominate.
|
||||
fn interior_error(mode_width: usize, height: usize, sent: &[u8], got: &[u8]) -> f64 {
|
||||
let mut total = 0u64;
|
||||
let mut count = 0u64;
|
||||
for y in 2..height.saturating_sub(2) {
|
||||
for x in 4..mode_width.saturating_sub(4) {
|
||||
// Skip the columns where the bars change, which is where a
|
||||
// half-pixel timing difference shows up as a whole-colour error.
|
||||
if x % (mode_width / 8) < 3 {
|
||||
continue;
|
||||
}
|
||||
let at = (y * mode_width + x) * 3;
|
||||
for channel in 0..3 {
|
||||
total += u64::from(sent[at + channel].abs_diff(got[at + channel]));
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
total as f64 / count.max(1) as f64
|
||||
}
|
||||
|
||||
fn round_trip(vis: u8, tolerance: f64) {
|
||||
let mode = mode_for_vis(vis).expect("mode in table");
|
||||
let width = usize::from(mode.width);
|
||||
let height = usize::from(mode.height);
|
||||
let sent = test_card(width, height);
|
||||
let frame = Frame {
|
||||
width,
|
||||
height,
|
||||
rgb: &sent,
|
||||
};
|
||||
let audio = encode(mode, &frame, SAMPLE_RATE);
|
||||
|
||||
let (images, rows) = decode(&audio);
|
||||
assert_eq!(
|
||||
images.len(),
|
||||
1,
|
||||
"{}: expected one picture, got {}",
|
||||
mode.name,
|
||||
images.len()
|
||||
);
|
||||
let image = &images[0];
|
||||
assert!(
|
||||
image.complete,
|
||||
"{}: reception did not reach the bottom",
|
||||
mode.name
|
||||
);
|
||||
assert_eq!(image.mode, mode.name);
|
||||
assert_eq!(
|
||||
image.lines, mode.height,
|
||||
"{}: {} of {} lines",
|
||||
mode.name, image.lines, mode.height
|
||||
);
|
||||
assert_eq!(
|
||||
rows, height,
|
||||
"{}: emitted {rows} rows for {height} lines",
|
||||
mode.name
|
||||
);
|
||||
|
||||
let error = interior_error(width, height, &sent, &image.rgb);
|
||||
assert!(
|
||||
error < tolerance,
|
||||
"{}: mean error {error:.1} levels, tolerance {tolerance:.1}",
|
||||
mode.name,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn martin_m1_round_trips() {
|
||||
round_trip(44, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn martin_m2_round_trips() {
|
||||
round_trip(40, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scottie_s1_round_trips() {
|
||||
round_trip(60, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scottie_s2_round_trips() {
|
||||
round_trip(56, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraase_sc2_180_round_trips() {
|
||||
round_trip(55, 6.0);
|
||||
}
|
||||
|
||||
// The colour-difference modes lose chroma resolution by design, so the bars
|
||||
// bleed into one another at their edges; the tolerance is on the interior.
|
||||
#[test]
|
||||
fn robot_72_round_trips() {
|
||||
round_trip(12, 14.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn robot_36_round_trips() {
|
||||
// One chroma channel per line, the other carried over from the line
|
||||
// before, so alternate lines are a line stale in one channel.
|
||||
round_trip(8, 26.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pd90_round_trips() {
|
||||
round_trip(99, 14.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pd120_round_trips() {
|
||||
round_trip(95, 14.0);
|
||||
}
|
||||
|
||||
/// Silence before and after is the normal case — a receiver is not started at
|
||||
/// the instant the transmission does.
|
||||
#[test]
|
||||
fn survives_silence_around_the_transmission() {
|
||||
let mode = mode_for_vis(44).expect("Martin M1");
|
||||
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||
let sent = test_card(width, height);
|
||||
let frame = Frame {
|
||||
width,
|
||||
height,
|
||||
rgb: &sent,
|
||||
};
|
||||
let mut audio = vec![0.0f32; SAMPLE_RATE as usize * 2];
|
||||
audio.extend(encode(mode, &frame, SAMPLE_RATE));
|
||||
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize));
|
||||
|
||||
let (images, _) = decode(&audio);
|
||||
assert_eq!(
|
||||
images.len(),
|
||||
1,
|
||||
"expected one picture from a transmission in silence"
|
||||
);
|
||||
assert!(images[0].complete);
|
||||
}
|
||||
|
||||
/// A transmission cut off part-way is what a fade or a shut-down transmitter
|
||||
/// produces. The lines that did arrive are worth keeping.
|
||||
#[test]
|
||||
fn a_truncated_transmission_still_yields_its_lines() {
|
||||
let mode = mode_for_vis(44).expect("Martin M1");
|
||||
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||
let sent = test_card(width, height);
|
||||
let frame = Frame {
|
||||
width,
|
||||
height,
|
||||
rgb: &sent,
|
||||
};
|
||||
let full = encode(mode, &frame, SAMPLE_RATE);
|
||||
// Two thirds of the picture, then silence for long enough that the decoder
|
||||
// stops waiting for the rest.
|
||||
let mut audio = full[..full.len() * 2 / 3].to_vec();
|
||||
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize * 5));
|
||||
|
||||
let (images, _) = decode(&audio);
|
||||
assert_eq!(
|
||||
images.len(),
|
||||
1,
|
||||
"a cut-off transmission produced no picture"
|
||||
);
|
||||
let image = &images[0];
|
||||
assert!(
|
||||
!image.complete,
|
||||
"a two-thirds transmission reported as complete"
|
||||
);
|
||||
assert!(
|
||||
image.lines > mode.height / 2 && image.lines < mode.height,
|
||||
"{} lines of {} arrived",
|
||||
image.lines,
|
||||
mode.height,
|
||||
);
|
||||
// What did arrive is the top of the picture, and it is right.
|
||||
let rows = usize::from(image.lines).saturating_sub(4);
|
||||
let error = mean_error(&sent[..width * rows * 3], &image.rgb[..width * rows * 3]);
|
||||
assert!(
|
||||
error < 12.0,
|
||||
"the lines that arrived are wrong: mean error {error:.1}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two pictures back to back: the decoder has to finish the first and pick up
|
||||
/// the header of the second.
|
||||
#[test]
|
||||
fn decodes_a_second_transmission_after_the_first() {
|
||||
let mode = mode_for_vis(40).expect("Martin M2");
|
||||
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||
let sent = test_card(width, height);
|
||||
let frame = Frame {
|
||||
width,
|
||||
height,
|
||||
rgb: &sent,
|
||||
};
|
||||
let one = encode(mode, &frame, SAMPLE_RATE);
|
||||
|
||||
let mut audio = one.clone();
|
||||
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize / 2));
|
||||
audio.extend(one);
|
||||
|
||||
let (images, _) = decode(&audio);
|
||||
assert_eq!(
|
||||
images.len(),
|
||||
2,
|
||||
"expected two pictures, got {}",
|
||||
images.len()
|
||||
);
|
||||
assert!(
|
||||
images.iter().all(|image| image.complete),
|
||||
"a picture did not finish"
|
||||
);
|
||||
}
|
||||
|
||||
/// Noise on the signal is the normal condition on HF. The picture should
|
||||
/// degrade, not fall apart.
|
||||
#[test]
|
||||
fn decodes_through_noise() {
|
||||
let mode = mode_for_vis(44).expect("Martin M1");
|
||||
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||
let sent = test_card(width, height);
|
||||
let frame = Frame {
|
||||
width,
|
||||
height,
|
||||
rgb: &sent,
|
||||
};
|
||||
let clean = encode(mode, &frame, SAMPLE_RATE);
|
||||
|
||||
// Deterministic pseudo-noise at about 20 dB below the signal.
|
||||
let mut seed = 0x5eed_1234u32;
|
||||
let noisy: Vec<f32> = clean
|
||||
.iter()
|
||||
.map(|sample| {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
let noise = (seed >> 8) as f32 / f32::from(u16::MAX) / 256.0 - 0.5;
|
||||
sample + noise * 0.2
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (images, _) = decode(&noisy);
|
||||
assert_eq!(images.len(), 1, "noise cost the whole picture");
|
||||
let image = &images[0];
|
||||
assert!(image.complete, "noise cost the bottom of the picture");
|
||||
let error = interior_error(width, height, &sent, &image.rgb);
|
||||
assert!(error < 20.0, "mean error through noise {error:.1} levels");
|
||||
}
|
||||
|
||||
/// The sound card that plays the signal and the one that records it never
|
||||
/// agree exactly. A part-per-thousand error is far worse than reality and the
|
||||
/// picture should still stand up.
|
||||
#[test]
|
||||
fn tolerates_a_transmitter_clock_that_runs_fast() {
|
||||
let mode = mode_for_vis(44).expect("Martin M1");
|
||||
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||
let sent = test_card(width, height);
|
||||
let frame = Frame {
|
||||
width,
|
||||
height,
|
||||
rgb: &sent,
|
||||
};
|
||||
// Encoding at a slightly different rate and decoding at 48 kHz is exactly
|
||||
// a clock error: every duration is stretched by the same factor.
|
||||
let audio = encode(mode, &frame, 48_048);
|
||||
|
||||
let (images, _) = decode(&audio);
|
||||
assert_eq!(images.len(), 1, "a 0.1% clock error cost the picture");
|
||||
let error = interior_error(width, height, &sent, &images[0].rgb);
|
||||
assert!(
|
||||
error < 12.0,
|
||||
"mean error with a fast clock {error:.1} levels"
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,5 @@ edition = "2021"
|
||||
license = "GPL-2.0-or-later"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dirs = "6"
|
||||
thiserror = "2"
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("Failed to read config file {0}: {1}")]
|
||||
ReadError(PathBuf, String),
|
||||
|
||||
#[error("Failed to parse config file {0}: {1}")]
|
||||
ParseError(PathBuf, String),
|
||||
}
|
||||
|
||||
/// Returns the default search paths for `trx-rs.toml`
|
||||
/// (current directory → XDG config → /etc).
|
||||
fn config_search_paths() -> Vec<PathBuf> {
|
||||
let mut paths = vec![PathBuf::from("trx-rs.toml")];
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
paths.push(config_dir.join("trx-rs").join("trx-rs.toml"));
|
||||
}
|
||||
paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml"));
|
||||
paths
|
||||
}
|
||||
|
||||
/// Extract and deserialize a named section from a TOML file.
|
||||
///
|
||||
/// Returns `Ok(Some(cfg))` when the section is present and parses cleanly,
|
||||
/// `Ok(None)` when the section is absent, or `Err` on I/O / parse failure.
|
||||
fn load_section_from_file<T: DeserializeOwned>(
|
||||
path: &Path,
|
||||
key: &str,
|
||||
) -> Result<Option<T>, ConfigError> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
|
||||
|
||||
let table: toml::Table = toml::from_str(&content)
|
||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
|
||||
|
||||
let Some(section) = table.get(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Re-serialize the section then parse as T so all serde defaults apply.
|
||||
let section_toml = toml::to_string(section)
|
||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
|
||||
let cfg = toml::from_str::<T>(§ion_toml)
|
||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
|
||||
Ok(Some(cfg))
|
||||
}
|
||||
|
||||
/// Trait for loading configuration from a `trx-rs.toml` section.
|
||||
pub trait ConfigFile: Sized + Default + DeserializeOwned {
|
||||
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
|
||||
fn section_key() -> &'static str;
|
||||
|
||||
/// Load the section from a specific file path.
|
||||
///
|
||||
/// Returns an error if the file cannot be read, is not valid TOML, or
|
||||
/// does not contain the expected `[<section_key>]` header.
|
||||
fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
|
||||
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
|
||||
ConfigError::ParseError(
|
||||
path.to_path_buf(),
|
||||
format!("missing [{}] section", Self::section_key()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
|
||||
/// the first file that contains the expected section.
|
||||
///
|
||||
/// Returns `(config, path_where_found)` or `(Default::default(), None)`
|
||||
/// when no config file is found.
|
||||
fn load_from_default_paths() -> Result<(Self, Option<PathBuf>), ConfigError> {
|
||||
for path in config_search_paths() {
|
||||
if path.exists() {
|
||||
if let Some(cfg) = load_section_from_file::<Self>(&path, Self::section_key())? {
|
||||
return Ok((cfg, Some(path)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((Self::default(), None))
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
pub mod config;
|
||||
//! Shared application helpers.
|
||||
//!
|
||||
//! Configuration types and their loader live in the `trx-config` crate.
|
||||
|
||||
pub mod logging;
|
||||
pub mod shared_config;
|
||||
pub mod util;
|
||||
|
||||
pub use config::{ConfigError, ConfigFile};
|
||||
pub use logging::init_logging;
|
||||
pub use shared_config::{validate_log_level, validate_tokens};
|
||||
pub use util::normalize_name;
|
||||
|
||||
@@ -21,6 +21,7 @@ uuid = { workspace = true }
|
||||
cpal = "0.15"
|
||||
opus = "0.3"
|
||||
trx-app = { path = "../trx-app" }
|
||||
trx-config = { path = "../trx-config" }
|
||||
trx-core = { path = "../trx-core" }
|
||||
trx-protocol = { path = "../trx-protocol" }
|
||||
trx-frontend = { path = "trx-frontend" }
|
||||
|
||||
@@ -27,11 +27,11 @@ use trx_core::audio::{
|
||||
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
||||
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
||||
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO,
|
||||
AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED,
|
||||
AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB,
|
||||
AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS,
|
||||
AUDIO_MSG_WSPR_DECODE,
|
||||
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_SSTV_DECODE,
|
||||
AUDIO_MSG_SSTV_PROGRESS, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
|
||||
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
|
||||
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
|
||||
AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
||||
};
|
||||
use trx_core::decode::DecodedMessage;
|
||||
use trx_frontend::VChanAudioCmd;
|
||||
@@ -533,7 +533,9 @@ async fn handle_single_rig_connection(
|
||||
| AUDIO_MSG_LRPT_IMAGE
|
||||
| AUDIO_MSG_LRPT_PROGRESS
|
||||
| AUDIO_MSG_WEFAX_DECODE
|
||||
| AUDIO_MSG_WEFAX_PROGRESS,
|
||||
| AUDIO_MSG_WEFAX_PROGRESS
|
||||
| AUDIO_MSG_SSTV_DECODE
|
||||
| AUDIO_MSG_SSTV_PROGRESS,
|
||||
payload,
|
||||
)) => {
|
||||
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
||||
|
||||
+5
-1214
File diff suppressed because it is too large
Load Diff
+138
-8
@@ -20,6 +20,7 @@ use tokio::task::JoinHandle;
|
||||
use tracing::{error, info};
|
||||
|
||||
use trx_app::{init_logging, normalize_name};
|
||||
use trx_config::ConfigFile;
|
||||
use trx_core::audio::AudioStreamInfo;
|
||||
|
||||
use trx_core::decode::DecodedMessage;
|
||||
@@ -50,6 +51,12 @@ struct Cli {
|
||||
/// Print example configuration and exit
|
||||
#[arg(long = "print-config")]
|
||||
print_config: bool,
|
||||
/// Treat unknown configuration keys as a fatal error
|
||||
#[arg(long = "strict-config")]
|
||||
strict_config: bool,
|
||||
/// Validate the configuration and exit without starting anything
|
||||
#[arg(long = "check-config")]
|
||||
check_config: bool,
|
||||
/// Remote server URL (host:port)
|
||||
#[arg(short = 'u', long = "url")]
|
||||
url: Option<String>,
|
||||
@@ -74,7 +81,7 @@ struct Cli {
|
||||
/// rigctl frontend listen address
|
||||
#[arg(long = "rigctl-listen")]
|
||||
rigctl_listen: Option<IpAddr>,
|
||||
/// rigctl frontend listen port
|
||||
/// Deprecated: ignored, use [frontends.rigctl].rig_ports
|
||||
#[arg(long = "rigctl-port")]
|
||||
rigctl_port: Option<u16>,
|
||||
/// JSON TCP frontend listen address
|
||||
@@ -109,6 +116,65 @@ async fn main() -> DynResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--check-config`: report everything wrong with the configuration and exit.
|
||||
///
|
||||
/// Unlike startup, this reports every problem it finds rather than stopping at
|
||||
/// the first, so a config can be fixed in one pass. The file is checked as
|
||||
/// written, without CLI overrides.
|
||||
fn check_config(loaded: &trx_config::ConfigLoad<ClientConfig>) -> DynResult<()> {
|
||||
match &loaded.path {
|
||||
Some(path) => println!("{}", path.display()),
|
||||
None => println!("(no config file found; checking built-in defaults)"),
|
||||
}
|
||||
|
||||
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
|
||||
warnings.extend(ClientConfig::deprecations(&loaded.present_keys));
|
||||
|
||||
let mut cfg = loaded.config.clone();
|
||||
let mut errors = Vec::new();
|
||||
if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) {
|
||||
errors.push(e);
|
||||
}
|
||||
let cfg = &cfg;
|
||||
let remotes = cfg.resolved_remotes();
|
||||
if remotes.is_empty() {
|
||||
warnings.push(
|
||||
"no remotes configured; --url will be required at startup (add [[remotes]] entries)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
errors.extend(cfg.validate_all());
|
||||
if !remotes.is_empty() {
|
||||
errors.extend(cfg.validate_resolved_all(&remotes));
|
||||
}
|
||||
|
||||
for w in &warnings {
|
||||
println!(" warning: {}", w);
|
||||
}
|
||||
for e in &errors {
|
||||
println!(" error: {}", e);
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
println!(
|
||||
" OK: {} remote(s) configured: {}",
|
||||
remotes.len(),
|
||||
remotes
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
if !warnings.is_empty() {
|
||||
println!(" {} warning(s)", warnings.len());
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the state needed after async initialization completes.
|
||||
struct AppState {
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
@@ -135,20 +201,44 @@ async fn async_init() -> DynResult<AppState> {
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
let (cfg, config_path) = if let Some(ref path) = cli.config {
|
||||
let cfg = ClientConfig::load_from_file(path)?;
|
||||
(cfg, Some(path.clone()))
|
||||
let loaded = if let Some(ref path) = cli.config {
|
||||
ClientConfig::load_from_file(path)?
|
||||
} else {
|
||||
ClientConfig::load_from_default_paths()?
|
||||
};
|
||||
cfg.validate()
|
||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||
let config_path = loaded.path.clone();
|
||||
|
||||
init_logging(cfg.general.log_level.as_deref());
|
||||
if cli.check_config {
|
||||
match check_config(&loaded) {
|
||||
Ok(()) => std::process::exit(0),
|
||||
Err(e) => {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logging comes up before any config complaint so the warnings are visible.
|
||||
init_logging(loaded.config.general.log_level.as_deref());
|
||||
|
||||
if let Some(ref path) = config_path {
|
||||
info!("Loaded configuration from {}", path.display());
|
||||
}
|
||||
loaded.report_unknown_keys(cli.strict_config)?;
|
||||
loaded.report_deprecations();
|
||||
if cli.rigctl_port.is_some() {
|
||||
tracing::warn!(
|
||||
"--rigctl-port is ignored; give each rig its own listener via \
|
||||
[frontends.rigctl].rig_ports"
|
||||
);
|
||||
}
|
||||
|
||||
let mut cfg = loaded.config;
|
||||
// Secrets configured as *_file are read before validation, so everything
|
||||
// downstream sees resolved values.
|
||||
cfg.resolve_secrets(config_path.as_deref())?;
|
||||
cfg.validate()
|
||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||
|
||||
frontend_runtime.http_auth.tokens = cfg
|
||||
.frontends
|
||||
@@ -198,10 +288,14 @@ async fn async_init() -> DynResult<AppState> {
|
||||
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
|
||||
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
|
||||
vec![RemoteEntry {
|
||||
spectrum_interval_ms: cfg.remote.spectrum_interval_ms,
|
||||
name,
|
||||
url: url.clone(),
|
||||
rig_id,
|
||||
auth: config::RemoteAuthConfig { token },
|
||||
auth: config::RemoteAuthConfig {
|
||||
token,
|
||||
token_file: None,
|
||||
},
|
||||
poll_interval_ms,
|
||||
}]
|
||||
} else {
|
||||
@@ -262,6 +356,30 @@ async fn async_init() -> DynResult<AppState> {
|
||||
.http_json_listen
|
||||
.unwrap_or(cfg.frontends.http_json.listen);
|
||||
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
|
||||
|
||||
// Fold the CLI overrides back into the config so validation and the
|
||||
// frontends agree on what is about to be bound.
|
||||
cfg.frontends.http.enabled = frontends.iter().any(|f| f == "http");
|
||||
cfg.frontends.rigctl.enabled = frontends.iter().any(|f| f == "rigctl");
|
||||
cfg.frontends.http_json.enabled = frontends.iter().any(|f| f == "httpjson");
|
||||
cfg.frontends.http.listen = http_listen;
|
||||
cfg.frontends.http.port = http_port;
|
||||
cfg.frontends.rigctl.listen = rigctl_listen;
|
||||
cfg.frontends.http_json.listen = http_json_listen;
|
||||
cfg.frontends.http_json.port = http_json_port;
|
||||
|
||||
// Second validation phase: the per-rig maps are keyed by remote short name,
|
||||
// so they can only be checked once the remote list is final.
|
||||
if cli.url.is_none() {
|
||||
cfg.validate_resolved(&resolved_remotes)
|
||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||
} else {
|
||||
// --url replaces the configured remotes outright, so only the socket
|
||||
// checks still apply.
|
||||
trx_config::shared::check_socket_conflicts(&cfg.bound_sockets())
|
||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||
}
|
||||
|
||||
let callsign = cli
|
||||
.callsign
|
||||
.clone()
|
||||
@@ -366,6 +484,13 @@ async fn async_init() -> DynResult<AppState> {
|
||||
.map(|e| e.poll_interval_ms)
|
||||
.min()
|
||||
.unwrap_or(750);
|
||||
// Entries sharing a server share its connections, so the most frequent
|
||||
// request wins: whoever wants spectrum fastest sets the rate.
|
||||
let spectrum_interval = entries
|
||||
.iter()
|
||||
.map(|e| e.spectrum_interval_ms)
|
||||
.min()
|
||||
.unwrap_or_else(|| remote_client::DEFAULT_SPECTRUM_INTERVAL.as_millis() as u64);
|
||||
|
||||
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
||||
for entry in entries {
|
||||
@@ -379,6 +504,8 @@ async fn async_init() -> DynResult<AppState> {
|
||||
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
|
||||
rig_states: frontend_runtime.routing.rig_states.clone(),
|
||||
poll_interval: Duration::from_millis(poll_interval),
|
||||
spectrum_interval: Duration::from_millis(spectrum_interval),
|
||||
spectrum_stream_unsupported: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
spectrum: frontend_runtime.spectrum.sender.clone(),
|
||||
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
|
||||
server_connected: frontend_runtime.routing.server_connected.clone(),
|
||||
@@ -527,6 +654,9 @@ async fn async_init() -> DynResult<AppState> {
|
||||
DecodedMessage::LrptProgress(_) => {}
|
||||
DecodedMessage::Wefax(_) => {}
|
||||
DecodedMessage::WefaxProgress(_) => {}
|
||||
// Pictures replay from their own history, not this one.
|
||||
DecodedMessage::Sstv(_) => {}
|
||||
DecodedMessage::SstvProgress(_) => {}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+275
-103
@@ -20,35 +20,22 @@ use trx_core::{RigError, RigResult};
|
||||
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
|
||||
use trx_protocol::rig_command_to_client;
|
||||
use trx_protocol::types::RigEntry;
|
||||
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate};
|
||||
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, SpectrumFrame};
|
||||
|
||||
// Endpoint parsing lives in `trx-config` so config validation and the
|
||||
// connection code agree on what a URL means.
|
||||
pub use trx_config::url::{parse_audio_url, parse_remote_url, RemoteEndpoint};
|
||||
|
||||
const DEFAULT_REMOTE_PORT: u16 = 4530;
|
||||
const DEFAULT_AUDIO_PORT: u16 = 4531;
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const IO_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
|
||||
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RemoteEndpoint {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl RemoteEndpoint {
|
||||
pub fn connect_addr(&self) -> String {
|
||||
if self.host.contains(':') && !self.host.starts_with('[') {
|
||||
format!("[{}]:{}", self.host, self.port)
|
||||
} else {
|
||||
format!("{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep remote spectrum reasonably responsive without returning to the old
|
||||
// timeout churn caused by a much tighter request cadence.
|
||||
const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
// Default spectrum cadence when a config does not specify one. Both the push
|
||||
// stream and the poll fallback run at the configured rate; see
|
||||
// `[[remotes]].spectrum_interval_ms`.
|
||||
pub const DEFAULT_SPECTRUM_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteClientConfig {
|
||||
@@ -57,6 +44,12 @@ pub struct RemoteClientConfig {
|
||||
pub selected_rig_id: Arc<Mutex<Option<String>>>,
|
||||
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
||||
pub poll_interval: Duration,
|
||||
/// How often spectrum frames are wanted. Drives the poll fallback and is
|
||||
/// the rate the client asks the server to push at.
|
||||
pub spectrum_interval: Duration,
|
||||
/// Set once a server has rejected `SubscribeSpectrum`, so later
|
||||
/// connections to it go straight to polling instead of asking again.
|
||||
pub spectrum_stream_unsupported: Arc<AtomicBool>,
|
||||
/// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe.
|
||||
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
|
||||
/// Shared flag: `true` while a TCP connection to trx-server is active.
|
||||
@@ -505,6 +498,100 @@ async fn send_get_sat_passes_on(
|
||||
))
|
||||
}
|
||||
|
||||
/// What ended a spectrum stream attempt.
|
||||
enum SpectrumStreamOutcome {
|
||||
/// The stream ran and is over; the connection is spent.
|
||||
Finished,
|
||||
/// The server rejected the subscription. The connection is still usable,
|
||||
/// so the caller can poll on it.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// Subscribe to the server's spectrum push for one rig and publish frames as
|
||||
/// they arrive.
|
||||
///
|
||||
/// The server answers either with frames or, when it is too old to know the
|
||||
/// command, with an error response — which leaves the connection usable for
|
||||
/// polling, so falling back costs no reconnect.
|
||||
async fn run_spectrum_stream(
|
||||
config: &RemoteClientConfig,
|
||||
writer: &mut (impl AsyncWriteExt + Unpin),
|
||||
reader: &mut (impl AsyncBufRead + Unpin),
|
||||
short_name: &str,
|
||||
shutdown_rx: &mut watch::Receiver<bool>,
|
||||
) -> RigResult<SpectrumStreamOutcome> {
|
||||
let envelope = build_envelope(
|
||||
config,
|
||||
ClientCommand::SubscribeSpectrum,
|
||||
Some(short_name.to_string()),
|
||||
);
|
||||
let mut payload = serde_json::to_string(&envelope)
|
||||
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
|
||||
payload.push('\n');
|
||||
time::timeout(SPECTRUM_IO_TIMEOUT, writer.write_all(payload.as_bytes()))
|
||||
.await
|
||||
.map_err(|_| RigError::communication("spectrum subscribe write timed out".to_string()))?
|
||||
.map_err(|e| RigError::communication(format!("spectrum subscribe write failed: {e}")))?;
|
||||
time::timeout(SPECTRUM_IO_TIMEOUT, writer.flush())
|
||||
.await
|
||||
.map_err(|_| RigError::communication("spectrum subscribe flush timed out".to_string()))?
|
||||
.map_err(|e| RigError::communication(format!("spectrum subscribe flush failed: {e}")))?;
|
||||
|
||||
// Re-check what the UI wants: switching rigs has to end this stream so the
|
||||
// connection can be rebuilt for the new one.
|
||||
let mut supervisor = time::interval(Duration::from_millis(500));
|
||||
supervisor.tick().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
changed = shutdown_rx.changed() => {
|
||||
match changed {
|
||||
Ok(()) if *shutdown_rx.borrow() => return Ok(SpectrumStreamOutcome::Finished),
|
||||
Ok(()) => {}
|
||||
Err(_) => return Ok(SpectrumStreamOutcome::Finished),
|
||||
}
|
||||
}
|
||||
_ = supervisor.tick() => {
|
||||
let wanted = active_spectrum_rig_ids(config);
|
||||
if wanted.len() != 1 || wanted[0] != short_name {
|
||||
return Ok(SpectrumStreamOutcome::Finished);
|
||||
}
|
||||
}
|
||||
line = read_limited_line(reader, MAX_JSON_LINE_BYTES) => {
|
||||
let line = line
|
||||
.map_err(|e| RigError::communication(format!("spectrum read failed: {e}")))?
|
||||
.ok_or_else(|| {
|
||||
RigError::communication("spectrum connection closed".to_string())
|
||||
})?;
|
||||
let trimmed = line.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<SpectrumFrame>(trimmed) {
|
||||
Ok(frame) => publish_spectrum_frame(config, short_name, frame),
|
||||
// Anything that is not a frame means the server would rather
|
||||
// answer than stream: an older build rejecting the command.
|
||||
Err(_) => return Ok(SpectrumStreamOutcome::Unsupported),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish one pushed frame to the per-rig and selected-rig watch channels.
|
||||
fn publish_spectrum_frame(config: &RemoteClientConfig, short_name: &str, frame: SpectrumFrame) {
|
||||
if let Ok(map) = config.rig_spectrums.read() {
|
||||
if let Some(tx) = map.get(short_name) {
|
||||
tx.send_modify(|s| s.set(Some(frame.spectrum.clone()), frame.vchan_rds.clone()));
|
||||
}
|
||||
}
|
||||
if selected_rig_id(config).as_deref() == Some(short_name) {
|
||||
config
|
||||
.spectrum
|
||||
.send_modify(|s| s.set(Some(frame.spectrum), frame.vchan_rds));
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_spectrum_connection(
|
||||
config: &RemoteClientConfig,
|
||||
stream: TcpStream,
|
||||
@@ -512,7 +599,33 @@ async fn handle_spectrum_connection(
|
||||
) -> RigResult<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut interval = time::interval(SPECTRUM_POLL_INTERVAL);
|
||||
|
||||
// Prefer the push stream: polling costs a round trip per frame, so on a
|
||||
// high-latency link the frame rate is 1/RTT no matter what interval is
|
||||
// configured. It only works for one rig per connection, and only against
|
||||
// a server new enough to understand the command.
|
||||
let streamable = active_spectrum_rig_ids(config);
|
||||
if streamable.len() == 1 && !config.spectrum_stream_unsupported.load(Ordering::Relaxed) {
|
||||
match run_spectrum_stream(
|
||||
config,
|
||||
&mut writer,
|
||||
&mut reader,
|
||||
&streamable[0],
|
||||
shutdown_rx,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SpectrumStreamOutcome::Finished => return Ok(()),
|
||||
SpectrumStreamOutcome::Unsupported => {
|
||||
info!("Server does not support spectrum streaming; falling back to polling");
|
||||
config
|
||||
.spectrum_stream_unsupported
|
||||
.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut interval = time::interval(config.spectrum_interval);
|
||||
// Cache the token outside the poll loop to avoid cloning it every 50ms.
|
||||
let cached_token = config.token.clone();
|
||||
|
||||
@@ -1195,92 +1308,13 @@ async fn read_limited_line<R: AsyncBufRead + Unpin>(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
|
||||
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
|
||||
}
|
||||
|
||||
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
|
||||
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
|
||||
}
|
||||
|
||||
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
|
||||
let trimmed = url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("{kind} url is empty"));
|
||||
}
|
||||
|
||||
let addr = trimmed
|
||||
.strip_prefix("tcp://")
|
||||
.or_else(|| trimmed.strip_prefix("http-json://"))
|
||||
.or_else(|| trimmed.strip_prefix("audio://"))
|
||||
.unwrap_or(trimmed);
|
||||
|
||||
parse_host_port(addr, default_port, kind)
|
||||
}
|
||||
|
||||
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
|
||||
if let Some(rest) = input.strip_prefix('[') {
|
||||
let closing = rest
|
||||
.find(']')
|
||||
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
|
||||
let host = &rest[..closing];
|
||||
let remainder = &rest[closing + 1..];
|
||||
if host.is_empty() {
|
||||
return Err(format!("invalid {kind} url: host is empty"));
|
||||
}
|
||||
let port = if remainder.is_empty() {
|
||||
default_port
|
||||
} else if let Some(port_str) = remainder.strip_prefix(':') {
|
||||
parse_port(port_str, kind)?
|
||||
} else {
|
||||
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
|
||||
};
|
||||
return Ok(RemoteEndpoint {
|
||||
host: host.to_string(),
|
||||
port,
|
||||
});
|
||||
}
|
||||
|
||||
if input.contains(':') {
|
||||
if input.matches(':').count() > 1 {
|
||||
return Err(format!(
|
||||
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
|
||||
));
|
||||
}
|
||||
let (host, port_str) = input
|
||||
.rsplit_once(':')
|
||||
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
|
||||
if host.is_empty() {
|
||||
return Err(format!("invalid {kind} url: host is empty"));
|
||||
}
|
||||
return Ok(RemoteEndpoint {
|
||||
host: host.to_string(),
|
||||
port: parse_port(port_str, kind)?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RemoteEndpoint {
|
||||
host: input.to_string(),
|
||||
port: default_port,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
|
||||
let port: u16 = port_str
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
|
||||
if port == 0 {
|
||||
return Err(format!("invalid {kind} port: 0"));
|
||||
}
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[allow(unused_imports)]
|
||||
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
|
||||
use super::{
|
||||
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
|
||||
DEFAULT_SPECTRUM_INTERVAL,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -1475,6 +1509,8 @@ mod tests {
|
||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||
poll_interval: Duration::from_millis(100),
|
||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||
spectrum: Arc::new(spectrum_tx),
|
||||
server_connected: Arc::new(AtomicBool::new(false)),
|
||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -1520,6 +1556,8 @@ mod tests {
|
||||
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||
poll_interval: Duration::from_millis(500),
|
||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||
spectrum: Arc::new(spectrum_tx),
|
||||
server_connected: Arc::new(AtomicBool::new(false)),
|
||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -1535,6 +1573,130 @@ mod tests {
|
||||
assert_eq!(envelope.rig_id.as_deref(), Some("sdr"));
|
||||
}
|
||||
|
||||
fn stream_test_config(spectrum_tx: watch::Sender<SharedSpectrum>) -> super::RemoteClientConfig {
|
||||
super::RemoteClientConfig {
|
||||
addr: "127.0.0.1:4530".to_string(),
|
||||
token: None,
|
||||
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||
poll_interval: Duration::from_millis(500),
|
||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||
spectrum: Arc::new(spectrum_tx),
|
||||
server_connected: Arc::new(AtomicBool::new(false)),
|
||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||
rig_states: Arc::new(RwLock::new(HashMap::new())),
|
||||
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
|
||||
rig_id_to_short_name: HashMap::new(),
|
||||
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
|
||||
sat_passes: Arc::new(RwLock::new(None)),
|
||||
rig_meters: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A pushed frame reaches the watch channel the UI reads, with no request
|
||||
/// from the client beyond the initial subscribe.
|
||||
#[tokio::test]
|
||||
async fn spectrum_stream_publishes_pushed_frames() {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
let (spectrum_tx, mut spectrum_rx) = watch::channel(SharedSpectrum::default());
|
||||
let config = stream_test_config(spectrum_tx);
|
||||
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
|
||||
|
||||
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
|
||||
let (client_read, mut client_write) = tokio::io::split(client_io);
|
||||
let mut client_read = BufReader::new(client_read);
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
super::run_spectrum_stream(
|
||||
&config,
|
||||
&mut client_write,
|
||||
&mut client_read,
|
||||
"sdr",
|
||||
&mut shutdown_rx,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
// The server sees the subscribe, then pushes without being asked.
|
||||
let mut server = BufReader::new(&mut server_io);
|
||||
let mut subscribe = String::new();
|
||||
server.read_line(&mut subscribe).await.expect("subscribe");
|
||||
assert!(
|
||||
subscribe.contains("subscribe_spectrum"),
|
||||
"unexpected command: {subscribe}"
|
||||
);
|
||||
|
||||
let frame = trx_protocol::SpectrumFrame {
|
||||
rig_id: "sdr".to_string(),
|
||||
spectrum: trx_core::rig::state::SpectrumData {
|
||||
bins: vec![-70.0, -30.0],
|
||||
center_hz: 14_200_000,
|
||||
sample_rate: 1_920_000,
|
||||
rds: None,
|
||||
},
|
||||
vchan_rds: None,
|
||||
};
|
||||
let mut line = serde_json::to_string(&frame).unwrap();
|
||||
line.push('\n');
|
||||
server_io.write_all(line.as_bytes()).await.expect("push");
|
||||
server_io.flush().await.expect("flush");
|
||||
|
||||
spectrum_rx.changed().await.expect("spectrum published");
|
||||
let published = spectrum_rx.borrow().clone();
|
||||
let spectrum = published.frame.expect("spectrum present");
|
||||
assert_eq!(spectrum.center_hz, 14_200_000);
|
||||
assert_eq!(spectrum.bins, vec![-70.0, -30.0]);
|
||||
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = task.await;
|
||||
}
|
||||
|
||||
/// An older server answers the unknown command with an error instead of
|
||||
/// frames. That has to read as "poll instead", not as a dead connection.
|
||||
#[tokio::test]
|
||||
async fn spectrum_stream_falls_back_when_unsupported() {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
||||
let config = stream_test_config(spectrum_tx);
|
||||
let (_shutdown_tx, mut shutdown_rx) = watch::channel(false);
|
||||
|
||||
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
|
||||
let (client_read, mut client_write) = tokio::io::split(client_io);
|
||||
let mut client_read = BufReader::new(client_read);
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
super::run_spectrum_stream(
|
||||
&config,
|
||||
&mut client_write,
|
||||
&mut client_read,
|
||||
"sdr",
|
||||
&mut shutdown_rx,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
let mut server = BufReader::new(&mut server_io);
|
||||
let mut subscribe = String::new();
|
||||
server.read_line(&mut subscribe).await.expect("subscribe");
|
||||
|
||||
server_io
|
||||
.write_all(
|
||||
b"{\"success\":false,\"state\":null,\"error\":\"Invalid JSON: unknown variant\"}\n",
|
||||
)
|
||||
.await
|
||||
.expect("error response");
|
||||
server_io.flush().await.expect("flush");
|
||||
|
||||
let outcome = task.await.expect("join").expect("stream result");
|
||||
assert!(
|
||||
matches!(outcome, super::SpectrumStreamOutcome::Unsupported),
|
||||
"an error response should fall back to polling"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_envelope_translates_short_name_to_server_rig_id() {
|
||||
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
||||
@@ -1548,6 +1710,8 @@ mod tests {
|
||||
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
|
||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||
poll_interval: Duration::from_millis(500),
|
||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||
spectrum: Arc::new(spectrum_tx),
|
||||
server_connected: Arc::new(AtomicBool::new(false)),
|
||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -1580,6 +1744,8 @@ mod tests {
|
||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||
poll_interval: Duration::from_millis(500),
|
||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||
spectrum: Arc::new(spectrum_tx),
|
||||
server_connected: Arc::new(AtomicBool::new(false)),
|
||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -1604,6 +1770,8 @@ mod tests {
|
||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||
poll_interval: Duration::from_millis(500),
|
||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||
spectrum: Arc::new(spectrum_tx),
|
||||
server_connected: Arc::new(AtomicBool::new(false)),
|
||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -1644,6 +1812,8 @@ mod tests {
|
||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||
known_rigs: known_rigs.clone(),
|
||||
poll_interval: Duration::from_millis(500),
|
||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||
spectrum: Arc::new(spectrum_tx),
|
||||
server_connected: Arc::new(AtomicBool::new(false)),
|
||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -1717,6 +1887,8 @@ mod tests {
|
||||
selected_rig_id,
|
||||
known_rigs,
|
||||
poll_interval: Duration::from_millis(500),
|
||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||
spectrum: Arc::new(spectrum_tx),
|
||||
server_connected: Arc::new(AtomicBool::new(false)),
|
||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||
|
||||
@@ -17,8 +17,8 @@ use uuid::Uuid;
|
||||
|
||||
use trx_core::audio::AudioStreamInfo;
|
||||
use trx_core::decode::{
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
||||
WsprMessage,
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
|
||||
WefaxMessage, WsprMessage,
|
||||
};
|
||||
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
||||
use trx_core::{DynResult, RigRequest, RigState};
|
||||
@@ -233,6 +233,7 @@ pub struct DecodeHistoryContext {
|
||||
pub ft2: DecodeHistory<Ft8Message>,
|
||||
pub wspr: DecodeHistory<WsprMessage>,
|
||||
pub wefax: DecodeHistory<WefaxMessage>,
|
||||
pub sstv: DecodeHistory<SstvMessage>,
|
||||
}
|
||||
|
||||
impl Default for DecodeHistoryContext {
|
||||
@@ -248,6 +249,7 @@ impl Default for DecodeHistoryContext {
|
||||
ft2: Arc::new(Mutex::new(VecDeque::new())),
|
||||
wspr: Arc::new(Mutex::new(VecDeque::new())),
|
||||
wefax: Arc::new(Mutex::new(VecDeque::new())),
|
||||
sstv: Arc::new(Mutex::new(VecDeque::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/ais.ts
|
||||
var aisWindow = window;
|
||||
var escapeAisHtml = (input) => aisWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||
var escapeAisHtml = (input) => hostCore.escapeMapHtml(input);
|
||||
var aisStatus = document.getElementById("ais-status");
|
||||
var aisMessagesEl = document.getElementById("ais-messages");
|
||||
var aisFilterInput = document.getElementById("ais-filter");
|
||||
@@ -119,10 +124,10 @@ function aisRouteText(msg) {
|
||||
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
||||
}
|
||||
function aisDistanceText(msg) {
|
||||
if (aisWindow.serverLat == null || aisWindow.serverLon == null || msg.lat == null || msg.lon == null || !aisWindow.haversineKm) {
|
||||
if (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) {
|
||||
return "";
|
||||
}
|
||||
const distKm = aisWindow.haversineKm(aisWindow.serverLat, aisWindow.serverLon, msg.lat, msg.lon);
|
||||
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, msg.lat, msg.lon);
|
||||
if (!Number.isFinite(distKm)) return "";
|
||||
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||
return `${distKm.toFixed(1)} km from TRX`;
|
||||
@@ -155,8 +160,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) {
|
||||
const row = document.createElement("div");
|
||||
const row = document.createElement("details");
|
||||
row.className = "ais-message";
|
||||
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
@@ -169,7 +184,8 @@ function renderAisRow(msg) {
|
||||
const motion = aisMotionText(msg);
|
||||
const route = aisRouteText(msg);
|
||||
const distance = aisDistanceText(msg);
|
||||
const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>` : "";
|
||||
const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>` : "";
|
||||
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
||||
row.dataset.filterText = [
|
||||
name,
|
||||
msg.mmsi,
|
||||
@@ -180,7 +196,16 @@ function renderAisRow(msg) {
|
||||
msg.destination,
|
||||
aisTypeLabel(msg.message_type)
|
||||
].filter(Boolean).join(" ").toUpperCase();
|
||||
row.innerHTML = `<div class="ais-row-head"><span class="ais-time">${ts}</span><span class="ais-call">${nameHtml}</span><span class="${channel.badgeClass}">${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);
|
||||
return row;
|
||||
}
|
||||
@@ -260,9 +285,11 @@ function addAisMessage(msg) {
|
||||
pruneAisMessageHistory();
|
||||
scheduleAisBarUpdate();
|
||||
scheduleAisHistoryRender();
|
||||
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
|
||||
aisWindow.aisMapAddVessel(msg);
|
||||
}
|
||||
plotAisMessage(msg);
|
||||
}
|
||||
function plotAisMessage(msg) {
|
||||
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
|
||||
aisWindow.aisMapAddVessel(msg);
|
||||
}
|
||||
function normalizeServerAisMessage(msg) {
|
||||
return {
|
||||
@@ -283,9 +310,7 @@ function onServerAisBatch(messages) {
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
});
|
||||
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
|
||||
aisWindow.aisMapAddVessel(next);
|
||||
}
|
||||
plotAisMessage(next);
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
@@ -303,7 +328,7 @@ document.getElementById("settings-clear-ais-history")?.addEventListener("click",
|
||||
void (async () => {
|
||||
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await aisWindow.postPath?.("/clear_ais_decode");
|
||||
await hostCore.postPath("/clear_ais_decode");
|
||||
resetAisHistoryView();
|
||||
} catch (e) {
|
||||
console.error("AIS history clear failed", e);
|
||||
@@ -327,5 +352,9 @@ window.trxPluginRuntime.registerDecoder({
|
||||
onBatch: onServerAisBatch,
|
||||
restore: onServerAisBatch,
|
||||
reset: resetAisHistoryView,
|
||||
prune: pruneAisHistoryView
|
||||
prune: pruneAisHistoryView,
|
||||
// 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,19 +1,20 @@
|
||||
import {
|
||||
aprsAgeText,
|
||||
aprsCategoryLabel,
|
||||
aprsHexBytes,
|
||||
aprsPacketCategory,
|
||||
collapseAprsDuplicates,
|
||||
normalizeAprsPacket,
|
||||
renderAprsInfo,
|
||||
renderLocalAprsSymbol
|
||||
} from "./chunk-M2I6DH4X.js";
|
||||
renderAprsPacketRow
|
||||
} from "./chunk-OPEIVJGD.js";
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/aprs.ts
|
||||
var aprsWindow = window;
|
||||
var escapeAprsHtml = (input) => aprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||
var escapeAprsHtml = (input) => hostCore.escapeMapHtml(input);
|
||||
var showAprsHint = (message, durationMs) => {
|
||||
aprsWindow.showHint?.(message, durationMs);
|
||||
hostCore.showHint(message, durationMs);
|
||||
};
|
||||
var aprsStatus = document.getElementById("aprs-status");
|
||||
var aprsPacketsEl = document.getElementById("aprs-packets");
|
||||
@@ -58,8 +59,8 @@ function scheduleAprsBarUpdate() {
|
||||
});
|
||||
}
|
||||
function aprsDistanceText(pkt) {
|
||||
if (aprsWindow.serverLat == null || aprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !aprsWindow.haversineKm) return "";
|
||||
const distKm = aprsWindow.haversineKm(aprsWindow.serverLat, aprsWindow.serverLon, pkt.lat, pkt.lon);
|
||||
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
|
||||
if (!Number.isFinite(distKm)) return "";
|
||||
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||
return `${distKm.toFixed(1)} km from TRX`;
|
||||
@@ -110,51 +111,27 @@ function updateAprsChipState() {
|
||||
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
|
||||
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||
}
|
||||
function renderAprsRow(pkt, isFresh) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "aprs-packet";
|
||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||
if (isFresh) row.classList.add("aprs-packet-new");
|
||||
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const age = aprsAgeText(pkt._tsMs);
|
||||
const category = aprsPacketCategory(pkt);
|
||||
const categoryLabel = aprsCategoryLabel(category);
|
||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${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);
|
||||
}
|
||||
})();
|
||||
});
|
||||
async function copyAprsCoords(text) {
|
||||
try {
|
||||
const clipboard = Reflect.get(navigator, "clipboard");
|
||||
if (!clipboard) return;
|
||||
await clipboard.writeText(text);
|
||||
showAprsHint("Coordinates copied", 1200);
|
||||
} catch {
|
||||
showAprsHint("Copy failed", 1500);
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
function renderAprsHistory() {
|
||||
pruneAprsPacketHistory();
|
||||
@@ -219,15 +196,17 @@ function pruneAprsHistoryView() {
|
||||
updateAprsBar();
|
||||
renderAprsHistory();
|
||||
}
|
||||
function plotAprsPacket(pkt) {
|
||||
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
|
||||
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
||||
}
|
||||
function addAprsPacket(pkt) {
|
||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||
pkt._tsMs = tsMs;
|
||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
aprsPacketHistory.unshift(pkt);
|
||||
pruneAprsPacketHistory();
|
||||
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
|
||||
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
||||
}
|
||||
plotAprsPacket(pkt);
|
||||
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||
scheduleAprsHistoryRender();
|
||||
}
|
||||
@@ -244,9 +223,7 @@ function onServerAprsBatch(packets) {
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
|
||||
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
|
||||
}
|
||||
plotAprsPacket(next);
|
||||
if (next.crcOk) hasCrcOk = true;
|
||||
normalized.push(next);
|
||||
}
|
||||
@@ -260,7 +237,7 @@ document.getElementById("settings-clear-aprs-history")?.addEventListener("click"
|
||||
void (async () => {
|
||||
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await aprsWindow.postPath?.("/clear_aprs_decode");
|
||||
await hostCore.postPath("/clear_aprs_decode");
|
||||
resetAprsHistoryView();
|
||||
} catch (e) {
|
||||
console.error("APRS history clear failed", e);
|
||||
@@ -310,5 +287,9 @@ window.trxPluginRuntime.registerDecoder({
|
||||
onBatch: onServerAprsBatch,
|
||||
restore: onServerAprsBatch,
|
||||
reset: resetAprsHistoryView,
|
||||
prune: pruneAprsHistoryView
|
||||
prune: pruneAprsHistoryView,
|
||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||
syncMap: () => {
|
||||
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
|
||||
}
|
||||
});
|
||||
|
||||
+5
-1
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
hostState
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/background-decode.ts
|
||||
var bgdWindow = window;
|
||||
(function() {
|
||||
@@ -104,7 +108,7 @@ var bgdWindow = window;
|
||||
}
|
||||
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||
renderBookmarkChecklist();
|
||||
const isControl = backgroundDecodeRole === "control" || bgdWindow.authEnabled === false;
|
||||
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||
const panel = document.getElementById("background-decode-panel");
|
||||
if (panel) {
|
||||
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/bookmarks.ts
|
||||
var bridge = window;
|
||||
function bmEl(id) {
|
||||
@@ -5,6 +10,9 @@ function bmEl(id) {
|
||||
if (!element) throw new Error(`Missing bookmark element #${id}`);
|
||||
return element;
|
||||
}
|
||||
function bmOptionalEl(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
function errorMessage(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -34,7 +42,7 @@ function bmEsc(str) {
|
||||
return d.innerHTML;
|
||||
}
|
||||
function bmCanControl() {
|
||||
return typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled || typeof bridge.authRole !== "undefined" && bridge.authRole === "control";
|
||||
return !hostState.authEnabled || hostState.authRole === "control";
|
||||
}
|
||||
function bmSyncAccess() {
|
||||
const canCtrl = bmCanControl();
|
||||
@@ -44,8 +52,7 @@ function bmSyncAccess() {
|
||||
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
||||
}
|
||||
function bmListScope() {
|
||||
const rig = typeof bridge.lastActiveRigId !== "undefined" ? bridge.lastActiveRigId : null;
|
||||
return rig || "general";
|
||||
return hostState.lastActiveRigId || "general";
|
||||
}
|
||||
async function bmFetchOverlay() {
|
||||
const overlayScope = bmListScope();
|
||||
@@ -61,7 +68,7 @@ async function bmFetchOverlay() {
|
||||
if (typeof bridge.syncBookmarkMapLocators === "function") {
|
||||
bridge.syncBookmarkMapLocators(bmOverlayList);
|
||||
}
|
||||
if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw();
|
||||
hostCore.scheduleSpectrumDraw();
|
||||
}
|
||||
async function bmFetch(categoryFilter) {
|
||||
let url = "/bookmarks";
|
||||
@@ -184,12 +191,12 @@ function bmChangePage(delta) {
|
||||
bmRender(bmFilteredList);
|
||||
}
|
||||
function bmReadDecoders() {
|
||||
return (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => bmEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
|
||||
return hostState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
|
||||
}
|
||||
function bmWriteDecoders(decoders) {
|
||||
const set = new Set(decoders || []);
|
||||
(bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||
const el = bmEl("bm-dec-" + d.id);
|
||||
hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||
const el = bmOptionalEl("bm-dec-" + d.id);
|
||||
if (el) el.checked = set.has(d.id);
|
||||
});
|
||||
}
|
||||
@@ -197,7 +204,7 @@ function bmBuildDecoderCheckboxes() {
|
||||
const container = bmEl("bm-decoder-checkboxes");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
(bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||
hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||
const label = document.createElement("label");
|
||||
label.className = "bm-decoder-check";
|
||||
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
|
||||
@@ -227,19 +234,17 @@ function bmCloseForm() {
|
||||
if (wrap) wrap.style.display = "none";
|
||||
}
|
||||
function bmPrefillFromStatus() {
|
||||
if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) {
|
||||
bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz));
|
||||
const freqHz = hostState.lastFreqHz;
|
||||
if (freqHz != null && Number.isFinite(freqHz)) {
|
||||
bmEl("bm-freq").value = String(Math.round(freqHz));
|
||||
}
|
||||
if (typeof bridge.lastModeName === "string" && bridge.lastModeName) {
|
||||
bmEl("bm-mode").value = bridge.lastModeName;
|
||||
if (hostState.lastModeName) {
|
||||
bmEl("bm-mode").value = hostState.lastModeName;
|
||||
}
|
||||
if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) {
|
||||
bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz));
|
||||
if (hostState.currentBandwidthHz > 0) {
|
||||
bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
|
||||
}
|
||||
const activeDecoders = (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => {
|
||||
const btn = bmEl(d.id + "-decode-toggle-btn");
|
||||
return btn && btn.dataset.enabled === "true";
|
||||
}).map((d) => d.id);
|
||||
const activeDecoders = hostState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true").map((d) => d.id);
|
||||
bmWriteDecoders(activeDecoders);
|
||||
}
|
||||
async function bmSave(e) {
|
||||
@@ -320,55 +325,44 @@ async function bmDelete(id) {
|
||||
}
|
||||
function bmApply(bm) {
|
||||
try {
|
||||
if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) {
|
||||
bridge.modeEl.value = (bm.mode || "").toUpperCase();
|
||||
const modeEl = document.getElementById("mode");
|
||||
if (modeEl) {
|
||||
modeEl.value = (bm.mode || "").toUpperCase();
|
||||
hostCore.syncModePicker();
|
||||
}
|
||||
if (bm.bandwidth_hz) {
|
||||
if (typeof bridge.currentBandwidthHz !== "undefined") {
|
||||
bridge.currentBandwidthHz = bm.bandwidth_hz;
|
||||
}
|
||||
bridge.currentBandwidthHz = bm.bandwidth_hz;
|
||||
if (typeof bridge.syncBandwidthInput === "function") {
|
||||
bridge.syncBandwidthInput(bm.bandwidth_hz);
|
||||
}
|
||||
hostState.currentBandwidthHz = bm.bandwidth_hz;
|
||||
hostCore.syncBandwidthInput(bm.bandwidth_hz);
|
||||
}
|
||||
if (typeof bridge.applyLocalTunedFrequency === "function") {
|
||||
if (typeof bridge._freqOptimisticSeq !== "undefined") {
|
||||
++bridge._freqOptimisticSeq;
|
||||
bridge._freqOptimisticHz = bm.freq_hz;
|
||||
}
|
||||
bridge.applyLocalTunedFrequency(bm.freq_hz, true);
|
||||
}
|
||||
if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) {
|
||||
bridge.scheduleSpectrumDraw();
|
||||
hostCore.armOptimisticFrequency(bm.freq_hz);
|
||||
hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
|
||||
if (hostState.lastSpectrumData) {
|
||||
hostCore.scheduleSpectrumDraw();
|
||||
}
|
||||
const tunePromise = (async () => {
|
||||
await bridge.trx?.modules?.vchan?.takeSchedulerControl();
|
||||
const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false;
|
||||
await bridge.trx.modules.vchan?.takeSchedulerControl();
|
||||
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
|
||||
if (!onVirtual) {
|
||||
await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||
}
|
||||
if (bm.bandwidth_hz) {
|
||||
const bwHandledByVchan = await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
||||
const bwHandledByVchan = await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
||||
if (!bwHandledByVchan) {
|
||||
await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
||||
await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
||||
}
|
||||
}
|
||||
if (typeof bridge.setRigFrequency === "function") {
|
||||
await bridge.setRigFrequency(bm.freq_hz);
|
||||
} else {
|
||||
await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`);
|
||||
}
|
||||
hostCore.setRigFrequency(bm.freq_hz);
|
||||
})();
|
||||
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||
const modeUp = (bm.mode || "").toUpperCase();
|
||||
const allToggleDecoders = (bridge.decoderRegistry || []).filter(
|
||||
const allToggleDecoders = hostState.decoderRegistry.filter(
|
||||
(d) => d.activation === "toggle"
|
||||
);
|
||||
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||
let statusUrl = "/status";
|
||||
if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) {
|
||||
statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId);
|
||||
const rigId = hostState.lastActiveRigId;
|
||||
if (rigId) {
|
||||
statusUrl += "?remote=" + encodeURIComponent(rigId);
|
||||
}
|
||||
const statusResp = await fetch(statusUrl);
|
||||
if (!statusResp.ok) return;
|
||||
@@ -387,7 +381,7 @@ function bmApply(bm) {
|
||||
wanted = currentlyOn;
|
||||
}
|
||||
if (wanted !== currentlyOn) {
|
||||
toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||
toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||
}
|
||||
}
|
||||
if (toggles.length) await Promise.all(toggles);
|
||||
@@ -399,8 +393,6 @@ function bmApply(bm) {
|
||||
console.error("Failed to apply bookmark:", err);
|
||||
}
|
||||
}
|
||||
bridge.trx ??= {};
|
||||
bridge.trx.modules ??= {};
|
||||
bridge.trx.modules.bookmarks = {
|
||||
get overlayList() {
|
||||
return bmOverlayList;
|
||||
@@ -439,8 +431,8 @@ function bmUpdateSelectionUi() {
|
||||
function bmPopulateMoveTarget() {
|
||||
const sel = bmEl("bm-move-target");
|
||||
if (!sel) return;
|
||||
const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : [];
|
||||
const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {};
|
||||
const rigIds = hostState.lastRigIds;
|
||||
const displayNames = hostState.lastRigDisplayNames;
|
||||
const prev = sel.value;
|
||||
sel.innerHTML = "";
|
||||
if (bmScope !== "general") {
|
||||
@@ -545,8 +537,8 @@ async function bmDeleteSelected() {
|
||||
function bmPopulateScopePicker() {
|
||||
const picker = bmEl("bm-scope-picker");
|
||||
if (!picker) return;
|
||||
const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : [];
|
||||
const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {};
|
||||
const rigIds = hostState.lastRigIds;
|
||||
const displayNames = hostState.lastRigDisplayNames;
|
||||
const prev = picker.value;
|
||||
while (picker.options.length > 1) picker.remove(1);
|
||||
rigIds.forEach((id) => {
|
||||
@@ -565,9 +557,7 @@ function bmPopulateScopePicker() {
|
||||
(function initBookmarks() {
|
||||
bmSyncAccess();
|
||||
bmBuildDecoderCheckboxes();
|
||||
if (typeof bridge.onDecoderRegistryReady === "function") {
|
||||
bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||
}
|
||||
hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||
bmPopulateScopePicker();
|
||||
const scopePicker = bmEl("bm-scope-picker");
|
||||
if (scopePicker) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// src/plugins/host.ts
|
||||
var host = window;
|
||||
var hostState = host.trx.state;
|
||||
var hostCore = host.trx.core;
|
||||
|
||||
export {
|
||||
hostState,
|
||||
hostCore
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
+11
-3
@@ -1,5 +1,13 @@
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/ftx-family.ts
|
||||
var bridge = window;
|
||||
function formatBarTime(timestampMs) {
|
||||
if (!timestampMs) return "--:--:--";
|
||||
return new Date(timestampMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
function finiteNumber(value) {
|
||||
const number = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
@@ -198,7 +206,7 @@ function initializeFtxDecoder(config) {
|
||||
let html = "";
|
||||
for (const message of recent) {
|
||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${bridge.fmtTime?.(timestamp) ?? ""}</span>`;
|
||||
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${formatBarTime(timestamp)}</span>`;
|
||||
const snr = finiteNumber(message.snr_db);
|
||||
const delta = finiteNumber(message.dt_s);
|
||||
const frequency = displayFrequency(message.freq_hz);
|
||||
@@ -243,7 +251,7 @@ function initializeFtxDecoder(config) {
|
||||
void (async () => {
|
||||
try {
|
||||
await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
|
||||
await bridge.postPath?.(`/toggle_${id}_decode`);
|
||||
await hostCore.postPath(`/toggle_${id}_decode`);
|
||||
} catch (error) {
|
||||
console.error(`${label} toggle failed`, error);
|
||||
}
|
||||
@@ -253,7 +261,7 @@ function initializeFtxDecoder(config) {
|
||||
void (async () => {
|
||||
if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await bridge.postPath?.(`/clear_${id}_decode`);
|
||||
await hostCore.postPath(`/clear_${id}_decode`);
|
||||
reset();
|
||||
} catch (error) {
|
||||
console.error(`${label} history clear failed`, error);
|
||||
@@ -0,0 +1,254 @@
|
||||
// src/plugins/aprs-shared.ts
|
||||
function escapeAprsHtml(value) {
|
||||
return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||
}
|
||||
function aprsPacketCategory(packet) {
|
||||
const type = (packet.type ?? "").toLowerCase();
|
||||
const info = (packet.info ?? "").toLowerCase();
|
||||
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
|
||||
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||
return "other";
|
||||
}
|
||||
function aprsCategoryLabel(category) {
|
||||
switch (category) {
|
||||
case "position":
|
||||
return "Position";
|
||||
case "message":
|
||||
return "Message";
|
||||
case "weather":
|
||||
return "Weather";
|
||||
case "telemetry":
|
||||
return "Telemetry";
|
||||
default:
|
||||
return "Other";
|
||||
}
|
||||
}
|
||||
function aprsAgeText(timestampMs) {
|
||||
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
|
||||
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${String(seconds)}s ago`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${String(minutes)}m ago`;
|
||||
return `${String(Math.round(minutes / 60))}h ago`;
|
||||
}
|
||||
function aprsPacketSignature(packet) {
|
||||
return [
|
||||
packet.srcCall ?? "",
|
||||
packet.destCall ?? "",
|
||||
packet.path ?? "",
|
||||
packet.info ?? "",
|
||||
packet.type ?? "",
|
||||
packet.lat?.toFixed(4) ?? "",
|
||||
packet.lon?.toFixed(4) ?? ""
|
||||
].join("|");
|
||||
}
|
||||
function collapseAprsDuplicates(packets) {
|
||||
const seen = /* @__PURE__ */ new Set();
|
||||
return packets.filter((packet) => {
|
||||
const signature = aprsPacketSignature(packet);
|
||||
if (seen.has(signature)) return false;
|
||||
seen.add(signature);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
function aprsHexBytes(bytes) {
|
||||
if (!bytes?.length) return "--";
|
||||
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||
}
|
||||
function renderAprsInfo(packet) {
|
||||
if (packet.info_bytes?.length) {
|
||||
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
|
||||
}
|
||||
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
|
||||
}
|
||||
function renderAprsByte(byte) {
|
||||
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||
}
|
||||
function renderAprsCharacter(character) {
|
||||
const code = character.charCodeAt(0);
|
||||
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||
}
|
||||
function escapeAprsCharacter(character) {
|
||||
if (character === "<") return "<";
|
||||
if (character === ">") return ">";
|
||||
if (character === "&") return "&";
|
||||
if (character === '"') return """;
|
||||
return character;
|
||||
}
|
||||
var APRS_SPRITE_COLUMNS = 16;
|
||||
var APRS_SPRITE_CELL_PX = 24;
|
||||
var APRS_SPRITE_FIRST_CODE = 33;
|
||||
var APRS_SPRITE_LAST_CODE = 126;
|
||||
function aprsSpriteOffset(code) {
|
||||
if (code.length !== 1) return null;
|
||||
const point = code.charCodeAt(0);
|
||||
if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
|
||||
const index = point - APRS_SPRITE_FIRST_CODE;
|
||||
const column = index % APRS_SPRITE_COLUMNS;
|
||||
const row = Math.floor(index / APRS_SPRITE_COLUMNS);
|
||||
return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
|
||||
}
|
||||
function aprsSymbolSprite(symbolTable, symbolCode) {
|
||||
if (!symbolTable || !symbolCode) return null;
|
||||
const symbolOffset = aprsSpriteOffset(symbolCode);
|
||||
if (!symbolOffset) return null;
|
||||
if (symbolTable === "/") {
|
||||
return {
|
||||
className: "aprs-symbol-primary",
|
||||
backgroundPosition: symbolOffset,
|
||||
label: `Primary APRS symbol ${symbolTable}${symbolCode}`
|
||||
};
|
||||
}
|
||||
if (symbolTable === "\\") {
|
||||
return {
|
||||
className: "aprs-symbol-alternate",
|
||||
backgroundPosition: symbolOffset,
|
||||
label: `Alternate APRS symbol ${symbolTable}${symbolCode}`
|
||||
};
|
||||
}
|
||||
const overlayOffset = aprsSpriteOffset(symbolTable);
|
||||
if (!overlayOffset) return null;
|
||||
return {
|
||||
className: "aprs-symbol-overlaid",
|
||||
backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
|
||||
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`
|
||||
};
|
||||
}
|
||||
function renderAprsSymbolSlot(packet, escapeHtml) {
|
||||
return renderLocalAprsSymbol(packet, escapeHtml) || '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
|
||||
}
|
||||
function renderLocalAprsSymbol(packet, escapeHtml) {
|
||||
if (!packet.symbolTable || !packet.symbolCode) return "";
|
||||
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
|
||||
if (sprite) {
|
||||
return `<span class="aprs-symbol ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
|
||||
}
|
||||
const symbol = escapeHtml(packet.symbolCode);
|
||||
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
|
||||
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
||||
}
|
||||
function normalizeAprsPacket(packet, receiver) {
|
||||
return {
|
||||
rig_id: packet.rig_id || null,
|
||||
receiver,
|
||||
srcCall: packet.src_call ?? "",
|
||||
destCall: packet.dest_call ?? "",
|
||||
path: packet.path ?? "",
|
||||
info: packet.info ?? "",
|
||||
info_bytes: packet.info_bytes ?? [],
|
||||
type: packet.packet_type ?? "",
|
||||
crcOk: packet.crc_ok ?? false,
|
||||
ts_ms: packet.ts_ms ?? null,
|
||||
lat: packet.lat ?? null,
|
||||
lon: packet.lon ?? null,
|
||||
symbolTable: packet.symbol_table ?? null,
|
||||
symbolCode: packet.symbol_code ?? null
|
||||
};
|
||||
}
|
||||
function fahrenheitToCelsius(fahrenheit) {
|
||||
return Math.round((fahrenheit - 32) * 5 / 9 * 10) / 10;
|
||||
}
|
||||
function summarizeAprsWeather(info) {
|
||||
const parts = [];
|
||||
const temperature = /t(-?\d{2,3})/.exec(info);
|
||||
if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`);
|
||||
const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info);
|
||||
if (wind) {
|
||||
const gust = /g(\d{3})/.exec(info);
|
||||
const knots = Number(wind[2]);
|
||||
parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`);
|
||||
}
|
||||
const humidity = /h(\d{2})/.exec(info);
|
||||
if (humidity) {
|
||||
const value = Number(humidity[1]);
|
||||
parts.push(`${value === 0 ? 100 : value}% RH`);
|
||||
}
|
||||
const pressure = /b(\d{5})/.exec(info);
|
||||
if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`);
|
||||
const rain = /r(\d{3})/.exec(info);
|
||||
if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`);
|
||||
return parts.length ? parts.join(" · ") : null;
|
||||
}
|
||||
function summarizeAprsTelemetry(info) {
|
||||
const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim());
|
||||
if (!match?.[2]) return null;
|
||||
const channels = match[2].split(",").filter((value) => value.length > 0);
|
||||
const bits = match[3] ? ` · bits ${match[3]}` : "";
|
||||
return `#${match[1]} · ${channels.join(" ")}${bits}`;
|
||||
}
|
||||
function summarizeAprsMessage(info) {
|
||||
const match = /^:([^:]{9}):(.*)$/.exec(info);
|
||||
if (!match?.[1] || match[2] == null) return null;
|
||||
const addressee = match[1].trim();
|
||||
const text = match[2].replace(/\{\d+\s*$/, "").trim();
|
||||
return `→ ${addressee}: ${text}`;
|
||||
}
|
||||
function summarizeAprsCourseSpeed(info) {
|
||||
const match = /(\d{3})\/(\d{3})/.exec(info);
|
||||
if (!match) return null;
|
||||
const knots = Number(match[2]);
|
||||
if (knots === 0) return null;
|
||||
return `${Number(match[1])}° ${knots} kt`;
|
||||
}
|
||||
function summarizeAprsPayload(packet) {
|
||||
const info = packet.info ?? "";
|
||||
if (!info) return null;
|
||||
const category = aprsPacketCategory(packet);
|
||||
if (category === "message") return summarizeAprsMessage(info);
|
||||
if (category === "weather") return summarizeAprsWeather(info);
|
||||
if (category === "telemetry") return summarizeAprsTelemetry(info);
|
||||
if (category === "position") {
|
||||
const parts = [];
|
||||
if (packet.lat != null && packet.lon != null) {
|
||||
parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`);
|
||||
}
|
||||
const courseSpeed = summarizeAprsCourseSpeed(info);
|
||||
if (courseSpeed) parts.push(courseSpeed);
|
||||
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
|
||||
if (comment && comment.length <= 60) parts.push(comment);
|
||||
return parts.length ? parts.join(" · ") : null;
|
||||
}
|
||||
const text = info.replace(/^[>;<?]/, "").trim();
|
||||
return text.length ? text : null;
|
||||
}
|
||||
function renderAprsPacketRow(packet, options = {}) {
|
||||
const row = document.createElement("details");
|
||||
row.className = "aprs-packet";
|
||||
if (!packet.crcOk) row.classList.add("aprs-packet-crc");
|
||||
if (options.fresh) row.classList.add("aprs-packet-new");
|
||||
const time = packet._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const category = aprsPacketCategory(packet);
|
||||
const summary = summarizeAprsPayload(packet);
|
||||
const hasPosition = packet.lat != null && packet.lon != null;
|
||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
|
||||
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>>${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div></div>`;
|
||||
row.querySelectorAll("[data-aprs-map]").forEach((element) => {
|
||||
element.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon);
|
||||
});
|
||||
});
|
||||
const copyButton = row.querySelector("[data-aprs-copy]");
|
||||
if (copyButton) {
|
||||
copyButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
export {
|
||||
aprsPacketCategory,
|
||||
aprsAgeText,
|
||||
collapseAprsDuplicates,
|
||||
aprsSymbolSprite,
|
||||
normalizeAprsPacket,
|
||||
renderAprsPacketRow
|
||||
};
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/cw.ts
|
||||
var cwWindow = window;
|
||||
var cwStatusEl = document.getElementById("cw-status");
|
||||
@@ -25,7 +29,7 @@ var cwBarCurrentLine = null;
|
||||
var cwBarDismissedAtMs = 0;
|
||||
var cwAutoLocalOverride = null;
|
||||
function escapeCwHtml(input) {
|
||||
return cwWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||
return hostCore.escapeMapHtml(input);
|
||||
}
|
||||
function applyCwAutoUi(enabled) {
|
||||
if (cwAutoInput) cwAutoInput.checked = enabled;
|
||||
@@ -246,7 +250,7 @@ async function setCwTone(tone, { syncInput = true } = {}) {
|
||||
cwToneInput.value = String(clamped);
|
||||
}
|
||||
try {
|
||||
await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||
await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||
} catch (e) {
|
||||
console.error("CW tone set failed", e);
|
||||
}
|
||||
@@ -259,7 +263,7 @@ if (cwAutoInput) {
|
||||
cwAutoLocalOverride = enabled;
|
||||
applyCwAutoUi(enabled);
|
||||
try {
|
||||
await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||
await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||
drawCwTonePicker();
|
||||
} catch (error) {
|
||||
console.error("CW auto toggle failed", error);
|
||||
@@ -276,7 +280,7 @@ if (cwWpmInput) {
|
||||
const wpm = clampCwWpm(cwWpmInput.value);
|
||||
cwWpmInput.value = String(wpm);
|
||||
try {
|
||||
await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
|
||||
await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
|
||||
} catch (error) {
|
||||
console.error("CW WPM set failed", error);
|
||||
}
|
||||
@@ -312,7 +316,7 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
||||
void (async () => {
|
||||
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await cwWindow.postPath?.("/clear_cw_decode");
|
||||
await hostCore.postPath("/clear_cw_decode");
|
||||
resetCwHistoryView();
|
||||
} catch (error) {
|
||||
console.error("CW history clear failed", error);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
(() => {
|
||||
// src/decode-history-worker.ts
|
||||
var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"];
|
||||
var workerScope = self;
|
||||
function decodeCborUint(view, bytes, state, additional) {
|
||||
const offset = state.offset;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
initializeFtxDecoder
|
||||
} from "./chunk-SGMG5LG2.js";
|
||||
} from "./chunk-O2Y7YEVQ.js";
|
||||
import "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/ft2.ts
|
||||
initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
initializeFtxDecoder
|
||||
} from "./chunk-SGMG5LG2.js";
|
||||
} from "./chunk-O2Y7YEVQ.js";
|
||||
import "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/ft4.ts
|
||||
initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 });
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
initializeFt8FamilyBar,
|
||||
initializeFtxDecoder,
|
||||
installFtxCompatibilityHelpers
|
||||
} from "./chunk-SGMG5LG2.js";
|
||||
} from "./chunk-O2Y7YEVQ.js";
|
||||
import "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/ft8.ts
|
||||
installFtxCompatibilityHelpers();
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import {
|
||||
aprsAgeText,
|
||||
aprsCategoryLabel,
|
||||
aprsHexBytes,
|
||||
aprsPacketCategory,
|
||||
collapseAprsDuplicates,
|
||||
normalizeAprsPacket,
|
||||
renderAprsInfo,
|
||||
renderLocalAprsSymbol
|
||||
} from "./chunk-M2I6DH4X.js";
|
||||
renderAprsPacketRow
|
||||
} from "./chunk-OPEIVJGD.js";
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/hf-aprs.ts
|
||||
var hfAprsWindow = window;
|
||||
var escapeHfAprsHtml = (input) => hfAprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||
var hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
||||
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
||||
@@ -44,8 +44,8 @@ function scheduleHfAprsHistoryRender() {
|
||||
renderHfAprsHistory();
|
||||
}
|
||||
function hfAprsDistanceText(pkt) {
|
||||
if (hfAprsWindow.serverLat == null || hfAprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !hfAprsWindow.haversineKm) return "";
|
||||
const distKm = hfAprsWindow.haversineKm(hfAprsWindow.serverLat, hfAprsWindow.serverLon, pkt.lat, pkt.lon);
|
||||
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
|
||||
if (!Number.isFinite(distKm)) return "";
|
||||
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||
return `${distKm.toFixed(1)} km from TRX`;
|
||||
@@ -98,51 +98,27 @@ function updateHfAprsChipState() {
|
||||
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
||||
}
|
||||
function renderHfAprsRow(pkt, isFresh) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "aprs-packet";
|
||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||
if (isFresh) row.classList.add("aprs-packet-new");
|
||||
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const age = aprsAgeText(pkt._tsMs);
|
||||
const category = aprsPacketCategory(pkt);
|
||||
const categoryLabel = aprsCategoryLabel(category);
|
||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${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);
|
||||
}
|
||||
});
|
||||
return renderAprsPacketRow(pkt, {
|
||||
fresh: isFresh,
|
||||
badge: "HF",
|
||||
distance: hfAprsDistanceText(pkt),
|
||||
onMap: (lat, lon) => {
|
||||
hfAprsWindow.navigateToAprsMap?.(lat, lon);
|
||||
},
|
||||
onCopy: (text) => {
|
||||
void copyHfAprsCoords(text);
|
||||
}
|
||||
});
|
||||
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);
|
||||
hfAprsWindow.showHint?.("Coordinates copied", 1200);
|
||||
}
|
||||
} catch {
|
||||
hfAprsWindow.showHint?.("Copy failed", 1500);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
async function copyHfAprsCoords(text) {
|
||||
try {
|
||||
const clipboard = Reflect.get(navigator, "clipboard");
|
||||
if (!clipboard) return;
|
||||
await clipboard.writeText(text);
|
||||
hostCore.showHint("Coordinates copied", 1200);
|
||||
} catch {
|
||||
hostCore.showHint("Copy failed", 1500);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
function renderHfAprsHistory() {
|
||||
pruneHfAprsPacketHistory();
|
||||
@@ -201,7 +177,7 @@ hfAprsDecodeToggleBtn?.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
|
||||
await hfAprsWindow.postPath?.("/toggle_hf_aprs_decode");
|
||||
await hostCore.postPath("/toggle_hf_aprs_decode");
|
||||
} catch (e) {
|
||||
console.error("HF APRS toggle failed", e);
|
||||
}
|
||||
@@ -211,7 +187,7 @@ document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("cli
|
||||
void (async () => {
|
||||
if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await hfAprsWindow.postPath?.("/clear_hf_aprs_decode");
|
||||
await hostCore.postPath("/clear_hf_aprs_decode");
|
||||
resetHfAprsHistoryView();
|
||||
} catch (e) {
|
||||
console.error("HF APRS history clear failed", e);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
aprsSymbolSprite
|
||||
} from "./chunk-OPEIVJGD.js";
|
||||
|
||||
// src/map-core.ts
|
||||
function mapEl(id) {
|
||||
const element = document.querySelector(`#${CSS.escape(id)}`);
|
||||
@@ -58,6 +62,7 @@ var mapWindow = window;
|
||||
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 mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||
const MAP_FILTER_ALL_KEY = "__all";
|
||||
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
|
||||
let mapSearchFilter = "";
|
||||
let mapRigFilter = "";
|
||||
@@ -834,38 +839,36 @@ var mapWindow = window;
|
||||
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
|
||||
return;
|
||||
}
|
||||
let helperText = "";
|
||||
const noun = kind === "band" ? "bands" : "sources";
|
||||
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : [];
|
||||
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
|
||||
if (kind === "source") {
|
||||
if (noneSelected) {
|
||||
helperText = "All sources visible — click to filter";
|
||||
}
|
||||
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
|
||||
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
|
||||
}
|
||||
const showingAll = kind === "source" ? sourceKeys.every((k) => !mapFilter[k]) : !(selectedSet instanceof Set) || selectedSet.size === 0;
|
||||
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) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "map-locator-chip";
|
||||
const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key);
|
||||
if (kind === "source" && noneSelected) {
|
||||
if (showingAll) {
|
||||
btn.classList.add("is-default");
|
||||
} else if (!isActive) {
|
||||
btn.classList.add("is-inactive");
|
||||
}
|
||||
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
|
||||
btn.dataset.filterKind = kind;
|
||||
btn.dataset.filterKey = item.key;
|
||||
btn.style.setProperty("--chip-color", item.color);
|
||||
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
|
||||
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) {
|
||||
if (!container) return;
|
||||
@@ -973,11 +976,10 @@ var mapWindow = window;
|
||||
renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems);
|
||||
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
|
||||
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
|
||||
choiceLabelEl.textContent = "Show";
|
||||
if (mapLocatorFilter.phase === "band") {
|
||||
choiceLabelEl.textContent = "Visible Bands";
|
||||
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
|
||||
} else {
|
||||
choiceLabelEl.textContent = "Visible Sources";
|
||||
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
|
||||
}
|
||||
syncLocatorMarkerStyles();
|
||||
@@ -1331,7 +1333,8 @@ var mapWindow = window;
|
||||
function applyMapOverlayPanelVisibility() {
|
||||
const panel = document.querySelector("#map-stage .map-overlay-panel");
|
||||
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() {
|
||||
const btn = mapEl("map-overlay-toggle-btn");
|
||||
@@ -1528,7 +1531,13 @@ var mapWindow = window;
|
||||
const kind = String(chip.dataset.filterKind || "");
|
||||
const key = String(chip.dataset.filterKey || "");
|
||||
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;
|
||||
mapFilter[sourceKey] = !mapFilter[sourceKey];
|
||||
const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER);
|
||||
@@ -1643,42 +1652,29 @@ var mapWindow = window;
|
||||
return;
|
||||
}
|
||||
const mapRect = mapContainer.getBoundingClientRect();
|
||||
const width = mapContainer.clientWidth || mapRect.width;
|
||||
const footer = document.querySelector(".footer");
|
||||
let bottom = mapIsFullscreen() && stage ? stage.getBoundingClientRect().bottom : window.innerHeight;
|
||||
if (!mapIsFullscreen() && footer) {
|
||||
let bottom = window.innerHeight;
|
||||
if (footer) {
|
||||
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 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));
|
||||
const target = Math.max(0, Math.floor(bottom - mapRect.top - 8));
|
||||
mapContainer.style.height = `${target}px`;
|
||||
if (aprsMap) aprsMap.invalidateSize();
|
||||
}
|
||||
function aprsSymbolIcon(symbolTable, symbolCode) {
|
||||
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({
|
||||
className: "",
|
||||
html: `<div class="aprs-symbol-local" title="${table} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`,
|
||||
html,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
popupAnchor: [0, -12]
|
||||
});
|
||||
}
|
||||
mapWindow.navigateToAprsMap = function(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 = "";
|
||||
function focusMapPosition(lat, lon) {
|
||||
initAprsMap();
|
||||
sizeAprsMapToViewport();
|
||||
if (aprsMap) {
|
||||
@@ -1690,19 +1686,10 @@ var mapWindow = window;
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
mapWindow.navigateToMapLocator = function(grid, preferredType = null) {
|
||||
}
|
||||
function focusMapLocator(grid, preferredType = null) {
|
||||
const normalizedGrid = String(grid || "").trim().toUpperCase();
|
||||
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();
|
||||
sizeAprsMapToViewport();
|
||||
if (!aprsMap) return false;
|
||||
@@ -1747,7 +1734,7 @@ var mapWindow = window;
|
||||
requestAnimationFrame(focusMarker);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
}
|
||||
function buildReceiverPopupHtml(rigIds) {
|
||||
const call = T.serverCallsign || T.ownerCallsign || "Receiver";
|
||||
let meta = "";
|
||||
@@ -2165,14 +2152,16 @@ var mapWindow = window;
|
||||
function updateMapContactPathsToggle() {
|
||||
const btn = mapEl("map-contact-paths-toggle");
|
||||
if (!btn) return;
|
||||
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
|
||||
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() {
|
||||
const btn = mapEl("map-p2p-paths-toggle");
|
||||
if (!btn) return;
|
||||
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
|
||||
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() {
|
||||
if (C.decodeHistoryMapRenderingDeferred()) {
|
||||
@@ -2332,7 +2321,7 @@ var mapWindow = window;
|
||||
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
|
||||
syncDecodeContactPathVisibility();
|
||||
if (selectedMapQsoKey && entry.sourceGrid) {
|
||||
mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType);
|
||||
focusMapLocator(entry.sourceGrid, entry.sourceType);
|
||||
}
|
||||
});
|
||||
const head = document.createElement("div");
|
||||
@@ -2438,7 +2427,7 @@ var mapWindow = window;
|
||||
card.className = "map-qso-card";
|
||||
if (entry.grid) {
|
||||
card.addEventListener("click", () => {
|
||||
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
|
||||
focusMapLocator(entry.grid ?? "", entry.sourceType);
|
||||
});
|
||||
}
|
||||
const head = document.createElement("div");
|
||||
@@ -2544,7 +2533,7 @@ var mapWindow = window;
|
||||
card.className = "map-qso-card";
|
||||
if (entry.grid) {
|
||||
card.addEventListener("click", () => {
|
||||
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
|
||||
focusMapLocator(entry.grid ?? "", entry.sourceType);
|
||||
});
|
||||
}
|
||||
const head = document.createElement("div");
|
||||
@@ -3049,6 +3038,8 @@ var mapWindow = window;
|
||||
}
|
||||
modules.map = {
|
||||
initAprsMap,
|
||||
focusMapPosition,
|
||||
focusMapLocator,
|
||||
sizeAprsMapToViewport,
|
||||
syncAprsReceiverMarker,
|
||||
updateMapRigFilter,
|
||||
@@ -3109,5 +3100,6 @@ var mapWindow = window;
|
||||
bandForHz,
|
||||
reverseGeocodeLocation
|
||||
};
|
||||
window.trxPluginRuntime.syncMapAll();
|
||||
autoInitIfVisible();
|
||||
})();
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/sat.ts
|
||||
var satWindow = window;
|
||||
var satDom = {
|
||||
status: document.getElementById("sat-status"),
|
||||
liveView: document.getElementById("sat-live-view"),
|
||||
historyView: document.getElementById("sat-history-view"),
|
||||
predictionsView: document.getElementById("sat-predictions-view"),
|
||||
liveLatest: document.getElementById("sat-live-latest"),
|
||||
historyList: document.getElementById("sat-history-list"),
|
||||
historyCount: document.getElementById("sat-history-count"),
|
||||
@@ -13,29 +16,12 @@ var satDom = {
|
||||
typeFilter: document.getElementById("sat-type-filter"),
|
||||
lrptState: document.getElementById("sat-lrpt-state"),
|
||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||
viewPredBtn: document.getElementById("sat-view-predictions"),
|
||||
predFilter: document.getElementById("sat-pred-filter"),
|
||||
predMinEl: document.getElementById("sat-pred-min-el"),
|
||||
predCategory: document.getElementById("sat-pred-category"),
|
||||
predCurrentList: document.getElementById("sat-pred-current-list"),
|
||||
predUpcomingList: document.getElementById("sat-pred-list"),
|
||||
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
||||
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
||||
predStatus: document.getElementById("sat-pred-status")
|
||||
viewHistoryBtn: document.getElementById("sat-view-history")
|
||||
};
|
||||
var satImageHistory = [];
|
||||
var SAT_MAX_IMAGES = 100;
|
||||
var SAT_PRED_PAGE_SIZE = 50;
|
||||
var satPredShowAll = false;
|
||||
var satFilterText = "";
|
||||
var satActiveView = "live";
|
||||
var satPredData = [];
|
||||
var satPredFilterText = "";
|
||||
var satPredMinEl = 0;
|
||||
var satPredCategory = "all";
|
||||
var satPredSatCount = 0;
|
||||
var satPredCountdownTimer = null;
|
||||
function scheduleSatUi(key, job) {
|
||||
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
||||
satWindow.trxScheduleUiFrameJob(key, job);
|
||||
@@ -44,37 +30,19 @@ function scheduleSatUi(key, job) {
|
||||
job();
|
||||
}
|
||||
function switchSatView(view) {
|
||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||
satActiveView = view;
|
||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
||||
if (leavingPredictions) clearPredictionDom();
|
||||
if (view === "history") {
|
||||
renderSatHistoryTable();
|
||||
} else if (view === "predictions") {
|
||||
satPredShowAll = false;
|
||||
void loadSatPredictions();
|
||||
}
|
||||
if (view === "history") renderSatHistoryTable();
|
||||
}
|
||||
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.viewHistoryBtn?.addEventListener("click", () => {
|
||||
switchSatView("history");
|
||||
});
|
||||
satDom.viewPredBtn?.addEventListener("click", () => {
|
||||
switchSatView("predictions");
|
||||
});
|
||||
var lastSatLrptOn = null;
|
||||
satWindow.updateSatLiveState = function(update) {
|
||||
if (!satDom.lrptState) return;
|
||||
@@ -243,7 +211,7 @@ lrptDecodeToggleBtn?.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
|
||||
await satWindow.postPath?.("/toggle_lrpt_decode");
|
||||
await hostCore.postPath("/toggle_lrpt_decode");
|
||||
} catch (e) {
|
||||
console.error("LRPT toggle failed", e);
|
||||
}
|
||||
@@ -264,207 +232,13 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click",
|
||||
void (async () => {
|
||||
if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await satWindow.postPath?.("/clear_lrpt_decode");
|
||||
await hostCore.postPath("/clear_lrpt_decode");
|
||||
resetSatHistoryView();
|
||||
} catch (e) {
|
||||
console.error("Weather satellite history clear failed", e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
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) {
|
||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||
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();
|
||||
};
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
hostState
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/scheduler.ts
|
||||
var schedulerWindow = window;
|
||||
var wiredElements = /* @__PURE__ */ new WeakSet();
|
||||
@@ -6,6 +10,9 @@ function schedulerEl(id) {
|
||||
if (!element) throw new Error(`Missing scheduler element #${id}`);
|
||||
return element;
|
||||
}
|
||||
function schedulerOptionalEl(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
(function() {
|
||||
"use strict";
|
||||
let schedulerRole = null;
|
||||
@@ -359,8 +366,8 @@ function schedulerEl(id) {
|
||||
renderSatelliteSection();
|
||||
if (mode === "grayline" && currentConfig && currentConfig.grayline) {
|
||||
const gl = currentConfig.grayline;
|
||||
const lat = gl.lat ?? schedulerWindow.serverLat ?? "";
|
||||
const lon = gl.lon ?? schedulerWindow.serverLon ?? "";
|
||||
const lat = gl.lat ?? hostState.serverLat ?? "";
|
||||
const lon = gl.lon ?? hostState.serverLon ?? "";
|
||||
setInputValue("scheduler-gl-lat", lat != null ? lat : "");
|
||||
setInputValue("scheduler-gl-lon", lon != null ? lon : "");
|
||||
const gridEl = schedulerEl("scheduler-gl-grid");
|
||||
@@ -373,8 +380,8 @@ function schedulerEl(id) {
|
||||
renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id);
|
||||
renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id);
|
||||
} else if (mode === "grayline") {
|
||||
const lat = schedulerWindow.serverLat ?? "";
|
||||
const lon = schedulerWindow.serverLon ?? "";
|
||||
const lat = hostState.serverLat ?? "";
|
||||
const lon = hostState.serverLon ?? "";
|
||||
setInputValue("scheduler-gl-lat", lat != null ? lat : "");
|
||||
setInputValue("scheduler-gl-lon", lon != null ? lon : "");
|
||||
const gridEl2 = schedulerEl("scheduler-gl-grid");
|
||||
@@ -624,7 +631,7 @@ function schedulerEl(id) {
|
||||
return '<line class="sch-timeline-needle" x1="' + x.toFixed(1) + '" y1="2" x2="' + x.toFixed(1) + '" y2="38" /><polygon class="sch-timeline-needle-head" points="' + (x - 3).toFixed(1) + ",2 " + (x + 3).toFixed(1) + ",2 " + x.toFixed(1) + ',6" />';
|
||||
}
|
||||
function renderTimelineNeedle() {
|
||||
const g = schedulerEl("sch-timeline-needle-g");
|
||||
const g = schedulerOptionalEl("sch-timeline-needle-g");
|
||||
if (g) g.innerHTML = timelineNeedleSvg();
|
||||
}
|
||||
function schInlineEdit(tr, entry, idx) {
|
||||
@@ -1213,8 +1220,8 @@ function schedulerEl(id) {
|
||||
markDirty: markSchedulerDirty
|
||||
};
|
||||
schedulerWindow.trx.modules.scheduler = schedulerService;
|
||||
if (schedulerWindow.authRole != null) {
|
||||
initScheduler(schedulerWindow.lastActiveRigId ?? null, schedulerWindow.authRole);
|
||||
if (hostState.authRole != null) {
|
||||
initScheduler(hostState.lastActiveRigId, hostState.authRole);
|
||||
wireSchedulerEvents();
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
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 (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 (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 renderLatestCard() {
|
||||
if (!sstvDom.liveLatest) return;
|
||||
const latest = sstvHistory[sstvHistory.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 text = filterText.trim().toLowerCase();
|
||||
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice();
|
||||
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
|
||||
});
|
||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "sstv_progress",
|
||||
onMessage: onProgress
|
||||
});
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/vchan.ts
|
||||
var vchanWindow = window;
|
||||
var vchanSessionId = null;
|
||||
@@ -63,7 +68,7 @@ function vchanStartSchedulerReleasePolling() {
|
||||
}
|
||||
async function vchanToggleSchedulerRelease() {
|
||||
if (!vchanSessionId) return;
|
||||
const rigId = vchanRigId || vchanWindow.lastActiveRigId || null;
|
||||
const rigId = vchanRigId || hostState.lastActiveRigId || null;
|
||||
try {
|
||||
const resp = await fetch("/scheduler-control", {
|
||||
method: "PUT",
|
||||
@@ -161,14 +166,12 @@ function vchanRender() {
|
||||
});
|
||||
picker.appendChild(addBtn);
|
||||
vchanSyncAccentUI();
|
||||
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
|
||||
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
|
||||
}
|
||||
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
|
||||
vchanRenderSchedulerRelease();
|
||||
}
|
||||
async function vchanAllocate() {
|
||||
if (!vchanSessionId || !vchanRigId) return;
|
||||
const freqHz = typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0 ? vchanWindow.lastFreqHz : 0;
|
||||
const freqHz = typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0 ? hostState.lastFreqHz : 0;
|
||||
const modeEl = document.getElementById("mode");
|
||||
const mode = modeEl ? modeEl.value || "USB" : "USB";
|
||||
try {
|
||||
@@ -251,11 +254,11 @@ async function vchanSubscribe(channelId) {
|
||||
}
|
||||
function vchanReconnectAudio() {
|
||||
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
|
||||
vchanWindow._audioChannelOverride = ch?.id ?? null;
|
||||
if (!vchanWindow.rxActive) return;
|
||||
vchanWindow.stopRxAudio?.();
|
||||
hostState.audioChannelOverride = ch?.id ?? null;
|
||||
if (!hostState.rxActive) return;
|
||||
hostCore.stopRxAudio();
|
||||
setTimeout(() => {
|
||||
vchanWindow.startRxAudio?.();
|
||||
hostCore.startRxAudio();
|
||||
}, 300);
|
||||
}
|
||||
function vchanApplyCapabilities(caps) {
|
||||
@@ -276,35 +279,34 @@ function vchanUpdateFreqDisplay() {
|
||||
if (!ch) return;
|
||||
const el = document.getElementById("freq");
|
||||
if (!el) return;
|
||||
if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") {
|
||||
el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit);
|
||||
} else {
|
||||
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
|
||||
}
|
||||
el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit);
|
||||
}
|
||||
function vchanSyncModeDisplay() {
|
||||
const modeEl = document.getElementById("mode");
|
||||
if (!modeEl) return;
|
||||
if (vchanIsOnVirtual()) {
|
||||
const ch = vchanActiveChannel();
|
||||
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
|
||||
if (ch && ch.mode) {
|
||||
modeEl.value = ch.mode.toUpperCase();
|
||||
hostCore.syncModePicker();
|
||||
}
|
||||
}
|
||||
const modeUpper = (modeEl.value || "").toUpperCase();
|
||||
if (typeof vchanWindow.lastModeName === "string") {
|
||||
if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") {
|
||||
vchanWindow.setJogDivisor?.(10);
|
||||
vchanWindow.resetRdsDisplay?.();
|
||||
} else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") {
|
||||
vchanWindow.resetRdsDisplay?.();
|
||||
if (typeof hostState.lastModeName === "string") {
|
||||
if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") {
|
||||
hostCore.setJogDivisor(10);
|
||||
hostCore.resetRdsDisplay();
|
||||
} else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") {
|
||||
hostCore.resetRdsDisplay();
|
||||
}
|
||||
vchanWindow.lastModeName = modeUpper;
|
||||
hostState.lastModeName = modeUpper;
|
||||
}
|
||||
vchanWindow.updateWfmControls?.();
|
||||
vchanWindow.updateSdrSquelchControlVisibility?.();
|
||||
hostCore.updateWfmControls();
|
||||
hostCore.updateSdrSquelchControlVisibility();
|
||||
if (vchanWindow.refreshRdsUi) {
|
||||
vchanWindow.refreshRdsUi();
|
||||
} else {
|
||||
vchanWindow.positionRdsPsOverlay?.();
|
||||
hostCore.positionRdsPsOverlay();
|
||||
}
|
||||
}
|
||||
function vchanSyncBwDisplay() {
|
||||
@@ -314,12 +316,12 @@ function vchanSyncBwDisplay() {
|
||||
const bwEl = document.getElementById("spectrum-bw-input");
|
||||
if (!bwEl) return;
|
||||
let bwHz = ch.bandwidth_hz || 0;
|
||||
if (bwHz === 0 && vchanWindow.mwDefaultsForMode) {
|
||||
bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0;
|
||||
if (bwHz === 0) {
|
||||
bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0;
|
||||
}
|
||||
if (bwHz > 0) {
|
||||
bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, "");
|
||||
vchanWindow.currentBandwidthHz = bwHz;
|
||||
hostState.currentBandwidthHz = bwHz;
|
||||
}
|
||||
}
|
||||
function vchanSyncAccentUI() {
|
||||
@@ -333,25 +335,20 @@ function vchanSyncAccentUI() {
|
||||
vchanSyncModeDisplay();
|
||||
vchanSyncBwDisplay();
|
||||
} else {
|
||||
origRefreshFreqDisplay?.();
|
||||
}
|
||||
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
|
||||
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
|
||||
hostCore.refreshFreqDisplay();
|
||||
}
|
||||
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
|
||||
}
|
||||
var origRefreshFreqDisplay = null;
|
||||
function vchanSetChannelFreq(freqHz) {
|
||||
if (!vchanRigId || !vchanActiveId) return;
|
||||
if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) {
|
||||
const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2;
|
||||
const center = vchanWindow.lastSpectrumData.center_hz;
|
||||
if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) {
|
||||
const halfSpan = hostState.lastSpectrumData.sample_rate / 2;
|
||||
const center = hostState.lastSpectrumData.center_hz;
|
||||
if (Math.abs(freqHz - center) > halfSpan) {
|
||||
if (vchanWindow.showHint) {
|
||||
vchanWindow.showHint(
|
||||
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
||||
3e3
|
||||
);
|
||||
}
|
||||
hostCore.showHint(
|
||||
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
||||
3e3
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -411,6 +408,19 @@ async function vchanInterceptBandwidth(bwHz) {
|
||||
await vchanSetChannelBandwidth(bwHz);
|
||||
return true;
|
||||
}
|
||||
function vchanInterceptFrequency(freqHz) {
|
||||
if (!vchanIsOnVirtual()) return false;
|
||||
const targetHz = Math.round(freqHz);
|
||||
hostCore.armOptimisticFrequency(targetHz);
|
||||
hostCore.applyLocalTunedFrequency(targetHz);
|
||||
vchanSetChannelFreq(freqHz);
|
||||
return true;
|
||||
}
|
||||
function vchanInterceptFreqDisplay() {
|
||||
if (!vchanIsOnVirtual()) return false;
|
||||
vchanUpdateFreqDisplay();
|
||||
return true;
|
||||
}
|
||||
vchanWindow.trx ??= {};
|
||||
vchanWindow.trx.modules ??= {};
|
||||
vchanWindow.trx.modules.vchan = {
|
||||
@@ -427,27 +437,11 @@ vchanWindow.trx.modules.vchan = {
|
||||
isOnVirtual: vchanIsOnVirtual,
|
||||
interceptMode: vchanInterceptMode,
|
||||
interceptBandwidth: vchanInterceptBandwidth,
|
||||
interceptFrequency: vchanInterceptFrequency,
|
||||
interceptFreqDisplay: vchanInterceptFreqDisplay,
|
||||
takeSchedulerControl: vchanTakeSchedulerControl,
|
||||
releaseToScheduler: vchanToggleSchedulerRelease
|
||||
};
|
||||
(function() {
|
||||
const original = vchanWindow.setRigFrequency;
|
||||
vchanWindow.setRigFrequency = function(freqHz) {
|
||||
if (vchanIsOnVirtual()) {
|
||||
if (vchanWindow.applyLocalTunedFrequency) {
|
||||
if (typeof vchanWindow._freqOptimisticSeq === "number") {
|
||||
vchanWindow._freqOptimisticSeq += 1;
|
||||
vchanWindow._freqOptimisticHz = Math.round(freqHz);
|
||||
}
|
||||
vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
|
||||
}
|
||||
vchanSetChannelFreq(freqHz);
|
||||
return;
|
||||
}
|
||||
void vchanTakeSchedulerControl();
|
||||
original?.(freqHz);
|
||||
};
|
||||
})();
|
||||
(function initSchedulerReleaseControl() {
|
||||
const btn = document.getElementById("scheduler-release-btn");
|
||||
if (btn) {
|
||||
@@ -458,13 +452,3 @@ vchanWindow.trx.modules.vchan = {
|
||||
vchanStartSchedulerReleasePolling();
|
||||
vchanRenderSchedulerRelease();
|
||||
})();
|
||||
(function() {
|
||||
origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null;
|
||||
vchanWindow.refreshFreqDisplay = function() {
|
||||
if (vchanIsOnVirtual()) {
|
||||
vchanUpdateFreqDisplay();
|
||||
return;
|
||||
}
|
||||
origRefreshFreqDisplay?.();
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/vdes.ts
|
||||
var vdesWindow = window;
|
||||
var escapeVdesHtml = (input) => vdesWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||
var escapeVdesHtml = (input) => hostCore.escapeMapHtml(input);
|
||||
var vdesStatus = document.getElementById("vdes-status");
|
||||
var vdesMessagesEl = document.getElementById("vdes-messages");
|
||||
var vdesFilterInput = document.getElementById("vdes-filter");
|
||||
@@ -216,9 +220,7 @@ function onServerVdesBatch(messages) {
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
});
|
||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
||||
vdesWindow.vdesMapAddPoint(next);
|
||||
}
|
||||
plotVdesMessage(next);
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
@@ -231,7 +233,7 @@ document.getElementById("settings-clear-vdes-history")?.addEventListener("click"
|
||||
void (async () => {
|
||||
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await vdesWindow.postPath?.("/clear_vdes_decode");
|
||||
await hostCore.postPath("/clear_vdes_decode");
|
||||
resetVdesHistoryView();
|
||||
} catch (e) {
|
||||
console.error("VDES history clear failed", e);
|
||||
@@ -244,13 +246,15 @@ if (vdesFilterInput) {
|
||||
renderVdesHistory();
|
||||
});
|
||||
}
|
||||
function plotVdesMessage(msg) {
|
||||
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
|
||||
vdesWindow.vdesMapAddPoint(msg);
|
||||
}
|
||||
function onServerVdes(msg) {
|
||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||
const next = normalizeServerVdesMessage(msg);
|
||||
addVdesMessage(next);
|
||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
||||
vdesWindow.vdesMapAddPoint(next);
|
||||
}
|
||||
plotVdesMessage(next);
|
||||
}
|
||||
function pruneVdesHistoryView() {
|
||||
pruneVdesMessageHistory();
|
||||
@@ -264,5 +268,9 @@ window.trxPluginRuntime.registerDecoder({
|
||||
onBatch: onServerVdesBatch,
|
||||
restore: onServerVdesBatch,
|
||||
reset: resetVdesHistoryView,
|
||||
prune: pruneVdesHistoryView
|
||||
prune: pruneVdesHistoryView,
|
||||
// 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 {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/wefax.ts
|
||||
var wefaxWindow = window;
|
||||
var wefaxDom = {
|
||||
@@ -299,7 +303,7 @@ if (wefaxDom.toggleBtn) {
|
||||
if (wefaxWindow.takeSchedulerControlForDecoderDisable) {
|
||||
await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton);
|
||||
}
|
||||
await wefaxWindow.postPath?.("/toggle_wefax_decode");
|
||||
await hostCore.postPath("/toggle_wefax_decode");
|
||||
} catch (e) {
|
||||
console.error("WEFAX toggle failed", e);
|
||||
}
|
||||
@@ -310,7 +314,7 @@ if (wefaxDom.clearBtn) {
|
||||
wefaxDom.clearBtn.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await wefaxWindow.postPath?.("/clear_wefax_decode");
|
||||
await hostCore.postPath("/clear_wefax_decode");
|
||||
resetWefaxHistoryView();
|
||||
} catch (e) {
|
||||
console.error("WEFAX clear failed", e);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/wspr.ts
|
||||
var wsprWindow = window;
|
||||
var wsprStatus = document.getElementById("wspr-status");
|
||||
@@ -229,7 +233,7 @@ wsprDecodeToggleBtn?.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
|
||||
await wsprWindow.postPath?.("/toggle_wspr_decode");
|
||||
await hostCore.postPath("/toggle_wspr_decode");
|
||||
} catch (error) {
|
||||
console.error("WSPR toggle failed", error);
|
||||
}
|
||||
@@ -239,7 +243,7 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
||||
void (async () => {
|
||||
if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await wsprWindow.postPath?.("/clear_wspr_decode");
|
||||
await hostCore.postPath("/clear_wspr_decode");
|
||||
resetWsprHistoryView();
|
||||
} catch (error) {
|
||||
console.error("WSPR history clear failed", error);
|
||||
|
||||
@@ -22,8 +22,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||
<symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol>
|
||||
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
|
||||
<symbol id="icon-signal" viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="11" width="2.5" height="4" rx="0.5"/><rect x="4.75" y="8" width="2.5" height="7" rx="0.5"/><rect x="8.5" y="5" width="2.5" height="10" rx="0.5"/><rect x="12.25" y="2" width="2.5" height="13" rx="0.5"/></symbol>
|
||||
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
|
||||
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
|
||||
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
|
||||
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
|
||||
@@ -46,20 +47,27 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="tab-bar-nav" aria-label="Primary navigation">
|
||||
<button class="tab active" data-tab="main">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg>
|
||||
<span class="tab-label">Main</span>
|
||||
<span class="tab-label">Radio</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="bookmarks">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
||||
<span class="tab-label">Bookmarks</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="digital-modes">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-signal"/></svg>
|
||||
<button class="tab" data-tab="digital-modes" aria-label="Digital modes">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
|
||||
<span class="tab-label">Digital modes</span>
|
||||
<!-- A fifth of a phone screen does not hold "Digital modes"; the
|
||||
button keeps the full name for assistive tech. -->
|
||||
<span class="tab-label-short" aria-hidden="true">Digital</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="map">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
||||
<span class="tab-label">Map</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="satellites">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-satellite"/></svg>
|
||||
<span class="tab-label">Satellites</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="statistics">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
||||
<span class="tab-label">Statistics</span>
|
||||
@@ -85,7 +93,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<button id="header-rec-btn" class="header-bar-btn header-rec-btn" type="button" aria-label="Toggle recording" title="Toggle recording">REC</button>
|
||||
<div class="header-rig-switch">
|
||||
<select id="header-rig-switch-select" aria-label="Select active rig"></select>
|
||||
<span id="header-rig-summary" class="header-rig-summary" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="header-style-pick">
|
||||
<select id="header-style-pick-select" aria-label="Select UI style">
|
||||
@@ -100,6 +107,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<option value="phosphor">Phosphor</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="header-share-btn" class="header-bar-btn header-share-btn" type="button" aria-label="Copy a link to this frequency" title="Copy a link to this frequency">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M6.5 9.5a2.6 2.6 0 0 0 3.9.3l2.2-2.2a2.6 2.6 0 0 0-3.7-3.7l-1 1"/><path d="M9.5 6.5a2.6 2.6 0 0 0-3.9-.3L3.4 8.4a2.6 2.6 0 0 0 3.7 3.7l1-1"/></svg>
|
||||
</button>
|
||||
<button id="theme-toggle" class="header-bar-btn" type="button" aria-label="Toggle dark or light theme">Light</button>
|
||||
<button id="header-auth-btn" class="header-bar-btn" type="button" style="display:none;" aria-label="Login or Logout">Login</button>
|
||||
</div>
|
||||
@@ -143,6 +153,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>
|
||||
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display" aria-describedby="spectrum-text-summary"></canvas>
|
||||
<p id="spectrum-text-summary" class="visually-hidden">Spectrum data is waiting for the receiver.</p>
|
||||
<div id="spectrum-squelch-line" class="spectrum-squelch-line" style="display:none;" data-state="closed">
|
||||
<span class="spectrum-squelch-grip" id="spectrum-squelch-grip" role="slider" tabindex="0"
|
||||
aria-label="Squelch threshold" aria-valuemin="-120" aria-valuemax="-30" aria-valuenow="-95">SQL <span id="spectrum-squelch-label">-95</span> dB</span>
|
||||
</div>
|
||||
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
|
||||
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
|
||||
<div id="spectrum-db-axis" aria-hidden="true"></div>
|
||||
@@ -157,13 +171,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div id="spectrum-size-grip" title="Drag to resize spectrum height" aria-label="Resize spectrum height"></div>
|
||||
<div id="spectrum-controls">
|
||||
<div id="spectrum-bw-row">
|
||||
<label id="spectrum-bw-label">Bandwidth <input type="number" id="spectrum-bw-input" value="" step="0.1" min="0.1" /> kHz</label>
|
||||
<button id="spectrum-bw-set-btn" type="button">Set</button>
|
||||
<button id="spectrum-bw-auto-btn" type="button">Auto BW</button>
|
||||
<button id="spectrum-bw-sweet-btn" type="button">Sweet-spot</button>
|
||||
<label id="spectrum-bw-label" class="spectrum-field"><span class="spectrum-field-name">Bandwidth</span><input type="number" id="spectrum-bw-input" value="" step="0.1" min="0.1" /><span class="spectrum-field-unit">kHz</span></label>
|
||||
<button id="spectrum-bw-set-btn" class="spectrum-btn" type="button">Set</button>
|
||||
<button id="spectrum-bw-auto-btn" class="spectrum-btn" type="button">Auto BW</button>
|
||||
<button id="spectrum-bw-sweet-btn" class="spectrum-btn" type="button">Sweet-spot</button>
|
||||
</div>
|
||||
<div class="spectrum-controls-spacer" aria-hidden="true"></div>
|
||||
<div id="spectrum-level-row">
|
||||
<label class="overview-control" id="spectrum-peak-hold-label">Peak Hold
|
||||
<label class="overview-control spectrum-field" id="spectrum-peak-hold-label"><span class="spectrum-field-name">Peak Hold</span>
|
||||
<select id="overview-peak-hold" class="status-input">
|
||||
<option value="0">Off</option>
|
||||
<option value="500">0.5 s</option>
|
||||
@@ -176,10 +191,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<option value="60000">60 s</option>
|
||||
</select>
|
||||
</label>
|
||||
<label id="spectrum-floor-label">Floor <input type="number" id="spectrum-floor-input" value="-115" step="5" /> dB</label>
|
||||
<label id="spectrum-range-label">Range <input type="number" id="spectrum-range-input" value="90" step="10" min="10" /> dB</label>
|
||||
<button id="spectrum-auto-btn" type="button">Auto</button>
|
||||
<label id="spectrum-gamma-label">Contrast <input type="range" id="spectrum-gamma-input" min="0.2" max="3.0" step="0.1" value="1.0" /><span id="spectrum-gamma-value">1.0</span></label>
|
||||
<label id="spectrum-floor-label" class="spectrum-field"><span class="spectrum-field-name">Floor</span><input type="number" id="spectrum-floor-input" value="-115" step="5" /><span class="spectrum-field-unit">dB</span></label>
|
||||
<label id="spectrum-range-label" class="spectrum-field"><span class="spectrum-field-name">Range</span><input type="number" id="spectrum-range-input" value="90" step="10" min="10" /><span class="spectrum-field-unit">dB</span></label>
|
||||
<button id="spectrum-auto-btn" class="spectrum-btn" type="button">Auto</button>
|
||||
<label id="spectrum-gamma-label" class="spectrum-field"><span class="spectrum-field-name">Contrast</span><input type="range" id="spectrum-gamma-input" min="0.2" max="3.0" step="0.1" value="1.0" /><span id="spectrum-gamma-value" class="spectrum-field-value">1.0</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spectrum-hint" class="spectrum-hint-mouse">Scroll to zoom · Ctrl+Scroll to tune · Drag to pan · Drag BW edges to resize · +/- zoom · Arrows pan · 0 reset</div>
|
||||
@@ -206,38 +221,27 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="label"><span>Signal strength</span></div>
|
||||
</div>
|
||||
<div class="freq-field frequency-col">
|
||||
<input class="status-input" id="freq" type="text" value="--" 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>
|
||||
<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>
|
||||
<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="full-row controls-tray-shell">
|
||||
<div class="controls-tray-scroll">
|
||||
<div class="controls-tray">
|
||||
<div class="controls-row full-row">
|
||||
<div class="controls-col label-below-col">
|
||||
<div class="controls-col controls-col-mode label-below-col">
|
||||
<div class="label"><span>Mode</span></div>
|
||||
<div class="inline">
|
||||
<select class="status-input" id="mode" 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 class="controls-col controls-col-center">
|
||||
@@ -249,6 +253,35 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<button id="jog-up" type="button" class="jog-btn">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls-col controls-col-step">
|
||||
<div class="inline step-controls-inline">
|
||||
<div class="freq-field unit-col">
|
||||
<div class="jog-step" id="jog-step">
|
||||
<button type="button" data-step="1000000">MHz</button>
|
||||
<button type="button" data-step="1000" class="active">kHz</button>
|
||||
<button type="button" data-step="1">Hz</button>
|
||||
</div>
|
||||
<div class="label"><span>Unit</span></div>
|
||||
</div>
|
||||
<div class="freq-field mult-col">
|
||||
<div class="jog-mult" id="jog-mult">
|
||||
<button type="button" data-mult="1" class="active" aria-label="Use full tune step">1x</button>
|
||||
<button type="button" data-mult="10" aria-label="Use one tenth tune step">0.1x</button>
|
||||
</div>
|
||||
<div class="label"><span>Step Scale</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
|
||||
<div class="label"><span>Transmit / Power</span></div>
|
||||
<div class="btn-grid">
|
||||
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
|
||||
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
|
||||
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls-row controls-row-mode full-row" id="mode-controls-row" style="display:none">
|
||||
<div class="controls-col controls-col-wfm label-below-col" id="wfm-controls-col" style="display:none;">
|
||||
<div class="inline wfm-controls-inline">
|
||||
<label class="wfm-control">
|
||||
@@ -306,14 +339,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
<div class="label"><span>SAM</span></div>
|
||||
</div>
|
||||
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
|
||||
<div class="label"><span>Transmit / Power</span></div>
|
||||
<div class="btn-grid">
|
||||
<button id="ptt-btn" type="button" 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="full-row label-below-row" id="vfo-row">
|
||||
<div class="label"><span>VFO</span></div>
|
||||
@@ -363,32 +388,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="vchan-picker" id="vchan-picker"></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="label"><span>Signal</span></div>
|
||||
<div class="signal" style="gap: 1rem;">
|
||||
@@ -426,13 +425,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
|
||||
<button id="rx-audio-btn" type="button">Play Audio</button>
|
||||
<button id="tx-audio-btn" type="button">Transmit Audio</button>
|
||||
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
|
||||
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
|
||||
<label class="vol-label" id="sdr-squelch-wrap" style="display:none;">SQL<input type="range" id="sdr-squelch" min="0" max="100" value="0" class="vol-slider" /><small class="vol-pct" id="sdr-squelch-pct">Open</small><button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set squelch to current noise level">Auto</button></label>
|
||||
<div id="audio-level">
|
||||
<div id="audio-level-fill"></div>
|
||||
<span class="audio-group audio-volume-group">
|
||||
<span class="audio-group-label">Volume</span>
|
||||
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
|
||||
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
|
||||
</span>
|
||||
<span class="sql-control" id="sdr-squelch-wrap" style="display:none;">
|
||||
<button id="sdr-squelch-toggle" type="button" class="sql-toggle" aria-pressed="false" title="Turn the squelch on or off"><span class="sql-state" id="sdr-squelch-state" data-state="off" aria-hidden="true"></span>SQL</button>
|
||||
<label class="sql-db"><input type="number" id="sdr-squelch-db" min="-120" max="-30" step="1" value="-95" inputmode="numeric" aria-label="Squelch threshold in dB" /><span class="sql-db-unit">dB</span></label>
|
||||
<button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set the threshold just above the noise floor">Auto</button>
|
||||
</span>
|
||||
<span class="audio-group audio-level-group">
|
||||
<span class="audio-group-label">Level</span>
|
||||
<div id="audio-level">
|
||||
<div id="audio-level-fill"></div>
|
||||
</div>
|
||||
<small id="audio-status">Off</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
@@ -556,6 +593,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<button class="sub-tab" data-subtab="rds">RDS</button>
|
||||
<button class="sub-tab" data-subtab="sat">SAT</button>
|
||||
<button class="sub-tab" data-subtab="wefax">WEFAX</button>
|
||||
<button class="sub-tab" data-subtab="sstv">SSTV</button>
|
||||
</div>
|
||||
<div id="subtab-overview" class="sub-tab-panel">
|
||||
<div class="plugin-item" data-decoder="ais">
|
||||
@@ -618,6 +656,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-item" data-decoder="sstv">
|
||||
<strong>SSTV Decoder</strong>
|
||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||
Slow-Scan Television — pictures in Martin, Scottie, Robot, PD and Wraase modes
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-item" data-decoder="wefax">
|
||||
<strong>WEFAX Decoder</strong>
|
||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||
@@ -625,6 +669,56 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subtab-sstv" class="sub-tab-panel" style="display:none;">
|
||||
<div class="ft8-controls">
|
||||
<button id="sstv-decode-toggle-btn" type="button">Enable SSTV</button>
|
||||
<small id="sstv-status" style="color:var(--text-muted);">Idle</small>
|
||||
</div>
|
||||
<!-- View selector -->
|
||||
<div class="sat-view-bar">
|
||||
<button id="sstv-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
||||
<button id="sstv-view-history" class="sat-view-btn" type="button">History</button>
|
||||
</div>
|
||||
<!-- Live view -->
|
||||
<div id="sstv-live-view">
|
||||
<div style="margin:0 0 0.5rem;">
|
||||
<div style="color:var(--text-muted); font-size:0.82rem; line-height:1.5;">
|
||||
<strong>Slow-Scan Television</strong> — pictures sent as tones, in Martin,
|
||||
Scottie, Robot, PD and Wraase modes. Tune a sideband or FM signal and enable the
|
||||
decoder; the mode is read from the header the sender opens with.
|
||||
</div>
|
||||
</div>
|
||||
<div id="sstv-live-container" style="display:none; margin:0.5rem 0;">
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.3rem;">
|
||||
<strong>Receiving</strong>
|
||||
<small id="sstv-live-info" style="color:var(--text-muted);"></small>
|
||||
</div>
|
||||
<canvas id="sstv-live-canvas" width="320" height="256"
|
||||
style="width:100%; max-width:640px; image-rendering:pixelated; background:#111;"></canvas>
|
||||
</div>
|
||||
<div id="sstv-live-latest" style="margin-top:0.5rem;"></div>
|
||||
</div>
|
||||
<!-- History view -->
|
||||
<div id="sstv-history-view" style="display:none;">
|
||||
<div class="sat-history-controls">
|
||||
<input id="sstv-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. Martin, PD120)" />
|
||||
<select id="sstv-sort" class="sat-sort-select">
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
</select>
|
||||
<button id="sstv-clear-btn" type="button" style="font-size:0.8rem;">Clear All</button>
|
||||
</div>
|
||||
<div class="sat-history-header">
|
||||
<span class="sat-col-time">Time</span>
|
||||
<span class="sat-col-type">Mode</span>
|
||||
<span class="sat-col-sat">Size</span>
|
||||
<span class="sat-col-lines">Lines</span>
|
||||
<span class="sat-col-link">Image</span>
|
||||
</div>
|
||||
<div id="sstv-history-list"></div>
|
||||
<small id="sstv-history-count" style="color:var(--text-muted);font-size:0.75rem;">No pictures yet</small>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subtab-rds" class="sub-tab-panel" style="display:none;">
|
||||
<div class="rds-grid">
|
||||
<div class="rds-field"><span class="rds-label">Status</span><span id="rds-status" class="rds-value rds-no-signal">No signal</span></div>
|
||||
@@ -655,19 +749,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<input id="ais-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. MMSI, vessel, A)" />
|
||||
<small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||
</div>
|
||||
<div class="ais-summary">
|
||||
<div class="ais-summary-card">
|
||||
<span class="ais-summary-label">Channels</span>
|
||||
<span id="ais-channel-summary" class="ais-summary-value">A 161.975 MHz · B 162.025 MHz</span>
|
||||
</div>
|
||||
<div class="ais-summary-card">
|
||||
<span class="ais-summary-label">Tracked</span>
|
||||
<span id="ais-vessel-count" class="ais-summary-value">0 vessels</span>
|
||||
</div>
|
||||
<div class="ais-summary-card">
|
||||
<span class="ais-summary-label">Latest</span>
|
||||
<span id="ais-latest-seen" class="ais-summary-value">No traffic yet</span>
|
||||
</div>
|
||||
<div class="aprs-filter-row">
|
||||
<span class="aprs-counts">
|
||||
<span id="ais-vessel-count" class="aprs-counts-value">0 vessels</span>
|
||||
<span id="ais-latest-seen" class="aprs-counts-value">No traffic yet</span>
|
||||
<span id="ais-channel-summary" class="aprs-counts-value">A 161.975 MHz · B 162.025 MHz</span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="ais-messages"></div>
|
||||
</div>
|
||||
@@ -697,20 +784,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<input id="aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
|
||||
<small id="aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||
</div>
|
||||
<div class="aprs-summary">
|
||||
<div class="aprs-summary-card">
|
||||
<span class="aprs-summary-label">Frames</span>
|
||||
<span id="aprs-total-count" class="aprs-summary-value">0 total</span>
|
||||
</div>
|
||||
<div class="aprs-summary-card">
|
||||
<span class="aprs-summary-label">Visible</span>
|
||||
<span id="aprs-visible-count" class="aprs-summary-value">0 shown</span>
|
||||
</div>
|
||||
<div class="aprs-summary-card">
|
||||
<span class="aprs-summary-label">Latest</span>
|
||||
<span id="aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aprs-filter-row">
|
||||
<button id="aprs-type-all" class="aprs-chip active" type="button">All</button>
|
||||
<button id="aprs-type-position" class="aprs-chip" type="button">Pos</button>
|
||||
@@ -718,11 +791,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<button id="aprs-type-weather" class="aprs-chip" type="button">Wx</button>
|
||||
<button id="aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
|
||||
<button id="aprs-type-other" class="aprs-chip" type="button">Other</button>
|
||||
</div>
|
||||
<div class="aprs-filter-row">
|
||||
<span class="aprs-filter-sep" aria-hidden="true"></span>
|
||||
<button id="aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
|
||||
<button id="aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
|
||||
<button id="aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
|
||||
<span class="aprs-counts">
|
||||
<span id="aprs-visible-count" class="aprs-counts-value">0 shown</span>
|
||||
<span id="aprs-total-count" class="aprs-counts-value">0 total</span>
|
||||
<span id="aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="aprs-packets"></div>
|
||||
</div>
|
||||
@@ -732,20 +809,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<input id="hf-aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
|
||||
<small id="hf-aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||
</div>
|
||||
<div class="aprs-summary">
|
||||
<div class="aprs-summary-card">
|
||||
<span class="aprs-summary-label">Frames</span>
|
||||
<span id="hf-aprs-total-count" class="aprs-summary-value">0 total</span>
|
||||
</div>
|
||||
<div class="aprs-summary-card">
|
||||
<span class="aprs-summary-label">Visible</span>
|
||||
<span id="hf-aprs-visible-count" class="aprs-summary-value">0 shown</span>
|
||||
</div>
|
||||
<div class="aprs-summary-card">
|
||||
<span class="aprs-summary-label">Latest</span>
|
||||
<span id="hf-aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aprs-filter-row">
|
||||
<button id="hf-aprs-type-all" class="aprs-chip active" type="button">All</button>
|
||||
<button id="hf-aprs-type-position" class="aprs-chip" type="button">Pos</button>
|
||||
@@ -753,11 +816,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<button id="hf-aprs-type-weather" class="aprs-chip" type="button">Wx</button>
|
||||
<button id="hf-aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
|
||||
<button id="hf-aprs-type-other" class="aprs-chip" type="button">Other</button>
|
||||
</div>
|
||||
<div class="aprs-filter-row">
|
||||
<span class="aprs-filter-sep" aria-hidden="true"></span>
|
||||
<button id="hf-aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
|
||||
<button id="hf-aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
|
||||
<button id="hf-aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
|
||||
<span class="aprs-counts">
|
||||
<span id="hf-aprs-visible-count" class="aprs-counts-value">0 shown</span>
|
||||
<span id="hf-aprs-total-count" class="aprs-counts-value">0 total</span>
|
||||
<span id="hf-aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="hf-aprs-packets"></div>
|
||||
</div>
|
||||
@@ -856,7 +923,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="sat-view-bar">
|
||||
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
||||
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
|
||||
<button id="sat-view-predictions" class="sat-view-btn" type="button">Predictions</button>
|
||||
</div>
|
||||
<!-- Live view -->
|
||||
<div id="sat-live-view">
|
||||
@@ -898,50 +964,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div id="sat-history-list"></div>
|
||||
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
||||
</div>
|
||||
<!-- Predictions view -->
|
||||
<div id="sat-predictions-view" style="display:none;">
|
||||
<div class="ft8-controls">
|
||||
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
||||
<select id="sat-pred-category" class="sat-sort-select">
|
||||
<option value="all">All</option>
|
||||
<option value="weather">Weather</option>
|
||||
<option value="amateur">Ham Radio</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<select id="sat-pred-min-el" class="sat-sort-select">
|
||||
<option value="0">All passes</option>
|
||||
<option value="10">Min 10°</option>
|
||||
<option value="20">Min 20°</option>
|
||||
<option value="45">Min 45°</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Current passes -->
|
||||
<div id="sat-pred-current-section">
|
||||
<div class="sat-pred-section-title">Currently receivable</div>
|
||||
<div class="sat-pred-header sat-pred-header-current">
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
<span class="sat-pred-col-el">Max El</span>
|
||||
<span class="sat-pred-col-time">AOS Start</span>
|
||||
<span class="sat-pred-col-time">AOS End</span>
|
||||
<span class="sat-pred-col-countdown">Time left</span>
|
||||
<span class="sat-pred-col-dir">Direction</span>
|
||||
</div>
|
||||
<div id="sat-pred-current-list"></div>
|
||||
</div>
|
||||
<!-- Upcoming passes -->
|
||||
<div id="sat-pred-upcoming-section">
|
||||
<div class="sat-pred-section-title">Upcoming passes</div>
|
||||
<div class="sat-pred-header">
|
||||
<span class="sat-pred-col-time">AOS (UTC)</span>
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
<span class="sat-pred-col-el">Max El</span>
|
||||
<span class="sat-pred-col-dur">Duration</span>
|
||||
<span class="sat-pred-col-dir">Direction</span>
|
||||
</div>
|
||||
<div id="sat-pred-list"></div>
|
||||
</div>
|
||||
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
|
||||
<div class="ft8-controls">
|
||||
@@ -1003,55 +1025,105 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<template id="tmpl-map">
|
||||
<div id="map-stage">
|
||||
<div class="map-overlay-panel">
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Filter by</span>
|
||||
<div id="map-locator-phase" class="map-locator-phase-row"></div>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
|
||||
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Rig</span>
|
||||
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
|
||||
<option value="">All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Search</span>
|
||||
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">History</span>
|
||||
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
|
||||
<option value="15">15 min</option>
|
||||
<option value="30">30 min</option>
|
||||
<option value="60">1 hr</option>
|
||||
<option value="180">3 hrs</option>
|
||||
<option value="360">6 hrs</option>
|
||||
<option value="720">12 hrs</option>
|
||||
<option value="1440">24 hrs</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Paths</span>
|
||||
<div class="map-locator-phase-row">
|
||||
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn">TRX Paths On</button>
|
||||
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn">Contact Paths On</button>
|
||||
<span class="map-locator-empty">TRX paths on popup, directed decode paths when target locator is known</span>
|
||||
<div class="map-overlay-filters">
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Filter</span>
|
||||
<div id="map-locator-phase" class="map-locator-phase-row"></div>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
|
||||
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Rig</span>
|
||||
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
|
||||
<option value="">All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">History</span>
|
||||
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
|
||||
<option value="15">15 min</option>
|
||||
<option value="30">30 min</option>
|
||||
<option value="60">1 hr</option>
|
||||
<option value="180">3 hrs</option>
|
||||
<option value="360">6 hrs</option>
|
||||
<option value="720">12 hrs</option>
|
||||
<option value="1440">24 hrs</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Paths</span>
|
||||
<div class="map-locator-phase-row">
|
||||
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="TRX paths are drawn from a station popup">TRX</button>
|
||||
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="Directed decode paths are drawn when the target locator is known">Contact</button>
|
||||
<span class="map-paths-hint">TRX paths on popup, directed decode paths when target locator is known</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Last, so the search field takes whatever the fixed-width
|
||||
groups leave on the bar's final row rather than a sliver. -->
|
||||
<div class="map-locator-filter-group map-filter-grow">
|
||||
<span class="map-locator-filter-label">Search</span>
|
||||
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-corner-controls">
|
||||
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
|
||||
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
|
||||
<div class="map-overlay-actions">
|
||||
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
|
||||
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div>
|
||||
<div id="aprs-map"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div id="tab-satellites" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">Satellites</h2>
|
||||
<div class="ft8-controls">
|
||||
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
||||
<select id="sat-pred-category" class="sat-sort-select">
|
||||
<option value="all">All</option>
|
||||
<option value="weather">Weather</option>
|
||||
<option value="amateur">Ham Radio</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<select id="sat-pred-min-el" class="sat-sort-select">
|
||||
<option value="0">All passes</option>
|
||||
<option value="10">Min 10°</option>
|
||||
<option value="20">Min 20°</option>
|
||||
<option value="45">Min 45°</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Current passes -->
|
||||
<div id="sat-pred-current-section">
|
||||
<div class="sat-pred-section-title">Currently receivable</div>
|
||||
<div class="sat-pred-header sat-pred-header-current">
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
<span class="sat-pred-col-el">Max El</span>
|
||||
<span class="sat-pred-col-time">AOS Start</span>
|
||||
<span class="sat-pred-col-time">AOS End</span>
|
||||
<span class="sat-pred-col-countdown">Time left</span>
|
||||
<span class="sat-pred-col-dir">Direction</span>
|
||||
</div>
|
||||
<div id="sat-pred-current-list"></div>
|
||||
</div>
|
||||
<!-- Upcoming passes -->
|
||||
<div id="sat-pred-upcoming-section">
|
||||
<div class="sat-pred-section-title">Upcoming passes</div>
|
||||
<div class="sat-pred-header">
|
||||
<span class="sat-pred-col-time">AOS (UTC)</span>
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
<span class="sat-pred-col-el">Max El</span>
|
||||
<span class="sat-pred-col-dur">Duration</span>
|
||||
<span class="sat-pred-col-dir">Direction</span>
|
||||
</div>
|
||||
<div id="sat-pred-list"></div>
|
||||
</div>
|
||||
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
||||
</div>
|
||||
|
||||
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">Statistics</h2>
|
||||
<template id="tmpl-statistics">
|
||||
<div class="stats-controls">
|
||||
<div class="stats-control-group">
|
||||
@@ -1192,6 +1264,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</section>
|
||||
</div>
|
||||
<div id="tab-settings" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">Settings</h2>
|
||||
<div class="sub-tab-bar">
|
||||
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
|
||||
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
|
||||
@@ -1487,6 +1560,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-about" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">About</h2>
|
||||
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
|
||||
<template id="tmpl-about">
|
||||
<div class="sub-tab-bar">
|
||||
@@ -1592,10 +1666,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</template>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="copyright">
|
||||
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · <span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span> — <span id="copyright-year"></span>
|
||||
<div class="footer-meta">
|
||||
<span class="copyright">
|
||||
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · © <span id="copyright-year"></span>
|
||||
</span>
|
||||
<span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span>
|
||||
</div>
|
||||
<div class="hint" id="power-hint" aria-live="polite">Connecting…</div>
|
||||
<div class="hint" id="power-hint" data-state="busy" aria-live="polite">Connecting…</div>
|
||||
</div>
|
||||
<div id="conn-lost-overlay" class="decode-history-overlay content-overlay is-hidden" aria-live="assertive" aria-atomic="true">
|
||||
<div class="decode-history-overlay-card">
|
||||
@@ -1629,11 +1706,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>
|
||||
</div>
|
||||
<div id="decode-history-overlay" class="decode-history-overlay is-hidden" aria-live="polite" aria-atomic="true">
|
||||
<div class="decode-history-overlay-card">
|
||||
<div id="decode-history-overlay-title" class="decode-history-overlay-title">Loading decode history…</div>
|
||||
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
|
||||
<div id="decode-history-overlay" class="history-progress is-hidden" role="status" aria-live="polite" aria-atomic="true">
|
||||
<div class="history-progress-text">
|
||||
<span id="decode-history-overlay-title" class="history-progress-title">Loading decode history…</span>
|
||||
<span id="decode-history-overlay-sub" class="history-progress-sub">Preparing recent decodes for the UI</span>
|
||||
</div>
|
||||
<span class="history-progress-track"><span id="decode-history-progress-bar" class="history-progress-bar"></span></span>
|
||||
</div>
|
||||
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></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,12 +23,14 @@ await build({
|
||||
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
|
||||
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
||||
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
||||
sstv: path.join(sourceDir, "plugins", "sstv.ts"),
|
||||
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
||||
ais: path.join(sourceDir, "plugins", "ais.ts"),
|
||||
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
|
||||
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
||||
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
||||
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
|
||||
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
|
||||
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
||||
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
||||
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||
"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",
|
||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -42,6 +42,9 @@ source change.
|
||||
- `core/` contains dependency-light shared helpers.
|
||||
- `features/` contains application behavior grouped by responsibility.
|
||||
- `plugins/` contains lazy decoder and high-coupling feature entries.
|
||||
- `plugins/host.ts` declares the typed `window.trx` state and services that
|
||||
feature bundles read; feature entries import it rather than reaching for bare
|
||||
`window` properties, which the module graph does not publish.
|
||||
- Files under `assets/web/vendor/` are vendored JavaScript and are not part of
|
||||
the TypeScript migration.
|
||||
|
||||
|
||||
@@ -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 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";
|
||||
|
||||
@@ -78,6 +78,10 @@ export type RdsData = { pi?: number | null, program_service?: string | null, rad
|
||||
export type SpectrumData = {
|
||||
/**
|
||||
* 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>,
|
||||
/**
|
||||
@@ -111,7 +115,7 @@ export type RigSnapshot = { info: RigInfo, status: RigStatus, band: string | nul
|
||||
/**
|
||||
* 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, };
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
export {};
|
||||
|
||||
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 CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
|
||||
interface DecodeState { offset: number }
|
||||
|
||||
+65
-1
@@ -3,7 +3,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
export const TAB_ORDER = [
|
||||
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
|
||||
"main", "bookmarks", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
|
||||
] as const;
|
||||
|
||||
export type TabName = typeof TAB_ORDER[number];
|
||||
@@ -13,6 +13,7 @@ export const TAB_PATHS: Readonly<Record<TabName, string>> = {
|
||||
bookmarks: "/bookmarks",
|
||||
"digital-modes": "/digital-modes",
|
||||
map: "/map",
|
||||
satellites: "/satellites",
|
||||
statistics: "/statistics",
|
||||
recorder: "/recorder",
|
||||
settings: "/settings",
|
||||
@@ -37,3 +38,66 @@ export function updateTabHistory(name: TabName, replace = false): void {
|
||||
if (replace) window.history.replaceState({}, "", 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
|
||||
|
||||
import type * as Leaflet from "leaflet";
|
||||
import { aprsSymbolSprite } from "./plugins/aprs-shared";
|
||||
import type { PluginRuntimeWindow } from "./plugins/runtime-contract";
|
||||
|
||||
export {};
|
||||
|
||||
@@ -228,6 +230,8 @@ const mapWindow = window as unknown as MapWindow;
|
||||
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 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() };
|
||||
let mapSearchFilter = "";
|
||||
let mapRigFilter = ""; // "" = all rigs
|
||||
@@ -1077,38 +1081,42 @@ const mapWindow = window as unknown as MapWindow;
|
||||
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
|
||||
return;
|
||||
}
|
||||
let helperText = "";
|
||||
const noun = kind === "band" ? "bands" : "sources";
|
||||
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[] : [];
|
||||
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
|
||||
if (kind === "source") {
|
||||
if (noneSelected) {
|
||||
helperText = "All sources visible \u2014 click to filter";
|
||||
}
|
||||
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
|
||||
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
|
||||
}
|
||||
// Selecting nothing selects everything, for both kinds.
|
||||
const showingAll = kind === "source"
|
||||
? sourceKeys.every((k) => !mapFilter[k])
|
||||
: !(selectedSet instanceof Set) || selectedSet.size === 0;
|
||||
// An "All" chip carries what a sentence of helper text used to say, in a
|
||||
// width the bar can afford, and gives the selection somewhere to be undone.
|
||||
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) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "map-locator-chip";
|
||||
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");
|
||||
} else if (!isActive) {
|
||||
btn.classList.add("is-inactive");
|
||||
}
|
||||
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
|
||||
btn.dataset.filterKind = kind;
|
||||
btn.dataset.filterKey = item.key;
|
||||
btn.style.setProperty("--chip-color", item.color);
|
||||
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
|
||||
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 {
|
||||
@@ -1232,11 +1240,12 @@ const mapWindow = window as unknown as MapWindow;
|
||||
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
|
||||
|
||||
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") {
|
||||
choiceLabelEl.textContent = "Visible Bands";
|
||||
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
|
||||
} else {
|
||||
choiceLabelEl.textContent = "Visible Sources";
|
||||
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
|
||||
}
|
||||
syncLocatorMarkerStyles();
|
||||
@@ -1635,7 +1644,10 @@ const mapWindow = window as unknown as MapWindow;
|
||||
function applyMapOverlayPanelVisibility() {
|
||||
const panel = document.querySelector("#map-stage .map-overlay-panel");
|
||||
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() {
|
||||
@@ -1858,7 +1870,14 @@ const mapWindow = window as unknown as MapWindow;
|
||||
const kind = String(chip.dataset.filterKind || "");
|
||||
const key = String(chip.dataset.filterKey || "");
|
||||
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
|
||||
const sourceKey = key as MapFilterKey;
|
||||
mapFilter[sourceKey] = !mapFilter[sourceKey];
|
||||
@@ -1979,48 +1998,43 @@ const mapWindow = window as unknown as MapWindow;
|
||||
if (aprsMap) aprsMap.invalidateSize();
|
||||
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 width = mapContainer.clientWidth || mapRect.width;
|
||||
const footer = document.querySelector(".footer");
|
||||
let bottom = mapIsFullscreen() && stage
|
||||
? stage.getBoundingClientRect().bottom
|
||||
: window.innerHeight;
|
||||
if (!mapIsFullscreen() && footer) {
|
||||
let bottom = window.innerHeight;
|
||||
if (footer) {
|
||||
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 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));
|
||||
const target = Math.max(0, Math.floor(bottom - mapRect.top - 8));
|
||||
mapContainer.style.height = `${target}px`;
|
||||
if (aprsMap) aprsMap.invalidateSize();
|
||||
}
|
||||
|
||||
function aprsSymbolIcon(symbolTable: string, symbolCode: string): Leaflet.DivIcon | 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({
|
||||
className: "",
|
||||
html: `<div class="aprs-symbol-local" title="${table} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`,
|
||||
html,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
popupAnchor: [0, -12]
|
||||
});
|
||||
}
|
||||
|
||||
mapWindow.navigateToAprsMap = function(lat, lon) {
|
||||
// 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 = "";
|
||||
function focusMapPosition(lat: number, lon: number) {
|
||||
initAprsMap();
|
||||
sizeAprsMapToViewport();
|
||||
if (aprsMap) {
|
||||
@@ -2032,20 +2046,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();
|
||||
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();
|
||||
sizeAprsMapToViewport();
|
||||
if (!aprsMap) return false;
|
||||
@@ -2095,7 +2101,7 @@ const mapWindow = window as unknown as MapWindow;
|
||||
requestAnimationFrame(focusMarker);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2592,18 +2598,26 @@ const mapWindow = window as unknown as MapWindow;
|
||||
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() {
|
||||
const btn = mapEl("map-contact-paths-toggle");
|
||||
if (!btn) return;
|
||||
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
|
||||
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() {
|
||||
const btn = mapEl("map-p2p-paths-toggle");
|
||||
if (!btn) return;
|
||||
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
|
||||
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() {
|
||||
@@ -2806,7 +2820,7 @@ const mapWindow = window as unknown as MapWindow;
|
||||
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
|
||||
syncDecodeContactPathVisibility();
|
||||
if (selectedMapQsoKey && entry.sourceGrid) {
|
||||
mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType);
|
||||
focusMapLocator(entry.sourceGrid, entry.sourceType);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2933,7 +2947,7 @@ const mapWindow = window as unknown as MapWindow;
|
||||
card.className = "map-qso-card";
|
||||
if (entry.grid) {
|
||||
card.addEventListener("click", () => {
|
||||
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
|
||||
focusMapLocator(entry.grid ?? "", entry.sourceType);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3059,7 +3073,7 @@ const mapWindow = window as unknown as MapWindow;
|
||||
card.className = "map-qso-card";
|
||||
if (entry.grid) {
|
||||
card.addEventListener("click", () => {
|
||||
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
|
||||
focusMapLocator(entry.grid ?? "", entry.sourceType);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3623,6 +3637,8 @@ const mapWindow = window as unknown as MapWindow;
|
||||
// Register module API for core to call
|
||||
modules.map = {
|
||||
initAprsMap,
|
||||
focusMapPosition,
|
||||
focusMapLocator,
|
||||
sizeAprsMapToViewport,
|
||||
syncAprsReceiverMarker,
|
||||
updateMapRigFilter,
|
||||
@@ -3672,6 +3688,18 @@ const mapWindow = window as unknown as MapWindow;
|
||||
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.
|
||||
autoInitIfVisible();
|
||||
})();
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
//
|
||||
// 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" | "recorder" | "settings";
|
||||
|
||||
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: ["/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"],
|
||||
bookmarks: ["/bookmarks.js"],
|
||||
recorder: [],
|
||||
|
||||
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
|
||||
plugin.prune();
|
||||
return true;
|
||||
},
|
||||
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
|
||||
clearQueued() { queued.clear(); },
|
||||
hasDecoder: (id) => decoders.has(id),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostCore, hostState } from "./host.js";
|
||||
|
||||
export {};
|
||||
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||
@@ -25,24 +27,18 @@ interface AisMessage {
|
||||
}
|
||||
interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
|
||||
interface AisBridge {
|
||||
navigateToAprsMap?: (lat: number, lon: number) => void;
|
||||
getDecodeHistoryRetentionMs?: () => number;
|
||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||
escapeMapHtml?: (input: string) => string;
|
||||
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
|
||||
serverLat?: number | null;
|
||||
serverLon?: number | null;
|
||||
haversineKm?: (lat1: number, lon1: number, lat2: number, lon2: number) => number;
|
||||
aisMapAddVessel?: (message: AisMessage) => void;
|
||||
clearMapMarkersByType?: (type: string) => void;
|
||||
postPath?: (path: string) => Promise<unknown>;
|
||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||
updateAisBar?: () => void;
|
||||
clearAisBar?: () => void;
|
||||
}
|
||||
const aisWindow = window as unknown as AisBridge;
|
||||
const escapeAisHtml = (input: string): string => aisWindow.escapeMapHtml?.(input) ?? input
|
||||
.replaceAll("&", "&").replaceAll("<", "<")
|
||||
.replaceAll(">", ">").replaceAll('"', """);
|
||||
const escapeAisHtml = (input: string): string => hostCore.escapeMapHtml(input);
|
||||
|
||||
// --- AIS Decoder Plugin (server-side decode) ---
|
||||
const aisStatus = document.getElementById("ais-status");
|
||||
@@ -176,10 +172,10 @@ function aisRouteText(msg: AisMessage): string {
|
||||
}
|
||||
|
||||
function aisDistanceText(msg: AisMessage): string {
|
||||
if (aisWindow.serverLat == null || aisWindow.serverLon == null || msg.lat == null || msg.lon == null || !aisWindow.haversineKm) {
|
||||
if (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) {
|
||||
return "";
|
||||
}
|
||||
const distKm = aisWindow.haversineKm(aisWindow.serverLat, aisWindow.serverLon, msg.lat, msg.lon);
|
||||
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, msg.lat, msg.lon);
|
||||
if (!Number.isFinite(distKm)) return "";
|
||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||
return `${distKm.toFixed(1)} km from TRX`;
|
||||
@@ -217,8 +213,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 {
|
||||
const row = document.createElement("div");
|
||||
const row = document.createElement("details");
|
||||
row.className = "ais-message";
|
||||
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
@@ -232,8 +241,9 @@ function renderAisRow(msg: AisMessage): HTMLElement {
|
||||
const route = aisRouteText(msg);
|
||||
const distance = aisDistanceText(msg);
|
||||
const pos = msg.lat != null && msg.lon != null
|
||||
? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>`
|
||||
? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>`
|
||||
: "";
|
||||
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
||||
row.dataset.filterText = [
|
||||
name,
|
||||
msg.mmsi,
|
||||
@@ -248,23 +258,43 @@ function renderAisRow(msg: AisMessage): HTMLElement {
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
row.innerHTML =
|
||||
`<div class="ais-row-head">` +
|
||||
`<span class="ais-time">${ts}</span>` +
|
||||
`<summary class="decode-line">` +
|
||||
`<span class="ais-time">${escapeAisHtml(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>` +
|
||||
`<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<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);
|
||||
return row;
|
||||
}
|
||||
@@ -359,9 +389,13 @@ function addAisMessage(msg: AisMessage): void {
|
||||
scheduleAisBarUpdate();
|
||||
scheduleAisHistoryRender();
|
||||
|
||||
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
|
||||
aisWindow.aisMapAddVessel(msg);
|
||||
}
|
||||
plotAisMessage(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 {
|
||||
@@ -384,9 +418,7 @@ function onServerAisBatch(messages: AisMessage[]): void {
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
|
||||
aisWindow.aisMapAddVessel(next);
|
||||
}
|
||||
plotAisMessage(next);
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
@@ -405,7 +437,7 @@ function pruneAisHistoryView(): void {
|
||||
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => { void (async () => {
|
||||
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await aisWindow.postPath?.("/clear_ais_decode");
|
||||
await hostCore.postPath("/clear_ais_decode");
|
||||
resetAisHistoryView();
|
||||
} catch (e) {
|
||||
console.error("AIS history clear failed", e);
|
||||
@@ -432,4 +464,6 @@ updateAisSummary();
|
||||
restore: onServerAisBatch,
|
||||
reset: resetAisHistoryView,
|
||||
prune: pruneAisHistoryView,
|
||||
// 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;
|
||||
}
|
||||
|
||||
function escapeAprsHtml(value: string): string {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
export function aprsPacketCategory(packet: AprsPacket): AprsCategory {
|
||||
const type = (packet.type ?? "").toLowerCase();
|
||||
const info = (packet.info ?? "").toLowerCase();
|
||||
@@ -108,8 +116,87 @@ function escapeAprsCharacter(character: string): string {
|
||||
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 {
|
||||
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>`;
|
||||
@@ -133,3 +220,174 @@ export function normalizeAprsPacket(packet: AprsPacket, receiver: unknown): Aprs
|
||||
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;
|
||||
}
|
||||
|
||||
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>` +
|
||||
`</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 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;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostCore, hostState } from "./host.js";
|
||||
|
||||
import {
|
||||
aprsAgeText,
|
||||
aprsCategoryLabel,
|
||||
aprsHexBytes,
|
||||
aprsPacketCategory,
|
||||
collapseAprsDuplicates,
|
||||
normalizeAprsPacket,
|
||||
renderAprsInfo,
|
||||
renderLocalAprsSymbol,
|
||||
renderAprsPacketRow,
|
||||
type AprsPacket,
|
||||
type AprsTypeFilter,
|
||||
} from "./aprs-shared";
|
||||
@@ -18,26 +17,18 @@ import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||
interface AprsBridge {
|
||||
getDecodeHistoryRetentionMs?: () => number;
|
||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||
serverLat?: number | null;
|
||||
serverLon?: number | null;
|
||||
haversineKm?: (lat1: number, lon1: number, lat2: number, lon2: number) => number;
|
||||
escapeMapHtml?: (input: string) => string;
|
||||
navigateToAprsMap?: (lat: number, lon: number) => void;
|
||||
showHint?: (message: string, durationMs: number) => void;
|
||||
clearMapMarkersByType?: (type: string) => void;
|
||||
aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
|
||||
getDecodeRigMeta?: () => unknown;
|
||||
postPath?: (path: string) => Promise<unknown>;
|
||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||
updateAprsBar?: () => void;
|
||||
clearAprsBar?: () => void;
|
||||
closeAprsBar?: () => void;
|
||||
}
|
||||
const aprsWindow = window as unknown as AprsBridge;
|
||||
const escapeAprsHtml = (input: string): string => aprsWindow.escapeMapHtml?.(input) ?? input
|
||||
.replaceAll("&", "&").replaceAll("<", "<")
|
||||
.replaceAll(">", ">").replaceAll('"', """);
|
||||
const showAprsHint = (message: string, durationMs: number): void => { aprsWindow.showHint?.(message, durationMs); };
|
||||
const escapeAprsHtml = (input: string): string => hostCore.escapeMapHtml(input);
|
||||
const showAprsHint = (message: string, durationMs: number): void => { hostCore.showHint(message, durationMs); };
|
||||
|
||||
// --- APRS Decoder Plugin (server-side decode) ---
|
||||
const aprsStatus = document.getElementById("aprs-status");
|
||||
@@ -87,8 +78,8 @@ function scheduleAprsBarUpdate() {
|
||||
}
|
||||
|
||||
function aprsDistanceText(pkt: AprsPacket): string {
|
||||
if (aprsWindow.serverLat == null || aprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !aprsWindow.haversineKm) return "";
|
||||
const distKm = aprsWindow.haversineKm(aprsWindow.serverLat, aprsWindow.serverLon, pkt.lat, pkt.lon);
|
||||
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
|
||||
if (!Number.isFinite(distKm)) return "";
|
||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||
return `${distKm.toFixed(1)} km from TRX`;
|
||||
@@ -147,93 +138,24 @@ function updateAprsChipState() {
|
||||
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||
}
|
||||
|
||||
function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "aprs-packet";
|
||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||
if (isFresh) row.classList.add("aprs-packet-new");
|
||||
|
||||
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const age = aprsAgeText(pkt._tsMs);
|
||||
const category = aprsPacketCategory(pkt);
|
||||
const categoryLabel = aprsCategoryLabel(category);
|
||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${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);
|
||||
}
|
||||
})(); });
|
||||
async function copyAprsCoords(text: string): Promise<void> {
|
||||
try {
|
||||
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
|
||||
if (!clipboard) return;
|
||||
await clipboard.writeText(text);
|
||||
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); },
|
||||
});
|
||||
}
|
||||
|
||||
function renderAprsHistory() {
|
||||
@@ -307,6 +229,12 @@ function pruneAprsHistoryView(): void {
|
||||
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 {
|
||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||
pkt._tsMs = tsMs;
|
||||
@@ -315,9 +243,7 @@ function addAprsPacket(pkt: AprsPacket): void {
|
||||
aprsPacketHistory.unshift(pkt);
|
||||
pruneAprsPacketHistory();
|
||||
|
||||
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
|
||||
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
||||
}
|
||||
plotAprsPacket(pkt);
|
||||
|
||||
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||
|
||||
@@ -338,9 +264,7 @@ function onServerAprsBatch(packets: AprsPacket[]): void {
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
|
||||
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
|
||||
}
|
||||
plotAprsPacket(next);
|
||||
if (next.crcOk) hasCrcOk = true;
|
||||
normalized.push(next);
|
||||
}
|
||||
@@ -354,7 +278,7 @@ function onServerAprsBatch(packets: AprsPacket[]): void {
|
||||
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => { void (async () => {
|
||||
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await aprsWindow.postPath?.("/clear_aprs_decode");
|
||||
await hostCore.postPath("/clear_aprs_decode");
|
||||
resetAprsHistoryView();
|
||||
} catch (e) {
|
||||
console.error("APRS history clear failed", e);
|
||||
@@ -412,4 +336,6 @@ renderAprsHistory();
|
||||
restore: onServerAprsBatch,
|
||||
reset: resetAprsHistoryView,
|
||||
prune: pruneAprsHistoryView,
|
||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
|
||||
});
|
||||
|
||||
+3
-2
@@ -2,6 +2,8 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostState } from "./host.js";
|
||||
|
||||
export {};
|
||||
|
||||
interface DecoderDescriptor {
|
||||
@@ -38,7 +40,6 @@ interface BackgroundDecodeStatus {
|
||||
}
|
||||
interface BackgroundBridge {
|
||||
decoderRegistry?: DecoderDescriptor[];
|
||||
authEnabled?: boolean;
|
||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
|
||||
}
|
||||
@@ -170,7 +171,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||
renderBookmarkChecklist();
|
||||
|
||||
const isControl = backgroundDecodeRole === "control" || bgdWindow.authEnabled === false;
|
||||
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||
const panel = document.getElementById("background-decode-panel");
|
||||
if (panel) {
|
||||
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostCore, hostState } from "./host.js";
|
||||
|
||||
export {};
|
||||
|
||||
/* DOM IDs in the server-owned page are required by this feature; bmEl throws
|
||||
@@ -19,14 +21,6 @@ interface Bookmark {
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
interface DecoderDescriptor {
|
||||
id: string;
|
||||
label: string;
|
||||
activation?: string;
|
||||
active_modes?: string[];
|
||||
bookmark_selectable?: boolean;
|
||||
}
|
||||
|
||||
interface BookmarkService {
|
||||
readonly overlayList: readonly Bookmark[];
|
||||
readonly overlayRevision: number;
|
||||
@@ -44,31 +38,12 @@ interface VirtualChannelService {
|
||||
}
|
||||
|
||||
interface BookmarkBridge {
|
||||
authEnabled?: boolean;
|
||||
authRole?: string | null;
|
||||
lastActiveRigId?: string | null;
|
||||
lastRigIds?: string[];
|
||||
lastRigDisplayNames?: Record<string, string>;
|
||||
lastFreqHz?: number;
|
||||
lastModeName?: string;
|
||||
lastSpectrumData?: unknown;
|
||||
currentBandwidthHz?: number;
|
||||
modeEl?: HTMLSelectElement | null;
|
||||
decoderRegistry?: DecoderDescriptor[];
|
||||
trx?: { modules?: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } };
|
||||
trx: { modules: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } };
|
||||
trxUi: {
|
||||
confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>;
|
||||
notify?(message: string, options: { kind: "error" }): void;
|
||||
};
|
||||
syncBookmarkMapLocators?(bookmarks: readonly Bookmark[]): void;
|
||||
scheduleSpectrumDraw?(): void;
|
||||
syncBandwidthInput?(bandwidthHz: number): void;
|
||||
applyLocalTunedFrequency?(frequencyHz: number, force?: boolean): void;
|
||||
setRigFrequency?(frequencyHz: number): Promise<unknown>;
|
||||
postPath(path: string): Promise<unknown>;
|
||||
onDecoderRegistryReady?(callback: () => void): void;
|
||||
_freqOptimisticSeq?: number;
|
||||
_freqOptimisticHz?: number;
|
||||
}
|
||||
|
||||
type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement;
|
||||
@@ -79,6 +54,12 @@ function bmEl(id: string): BookmarkElement {
|
||||
return element as BookmarkElement;
|
||||
}
|
||||
|
||||
/* Decoder checkboxes and decoder toggle buttons are built from the runtime
|
||||
* registry, so their elements are legitimately absent for unbuilt decoders. */
|
||||
function bmOptionalEl(id: string): BookmarkElement | null {
|
||||
return document.getElementById(id) as BookmarkElement | null;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -119,10 +100,7 @@ function bmEsc(str: unknown): string {
|
||||
}
|
||||
|
||||
function bmCanControl() {
|
||||
return (
|
||||
(typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled) ||
|
||||
(typeof bridge.authRole !== "undefined" && bridge.authRole === "control")
|
||||
);
|
||||
return !hostState.authEnabled || hostState.authRole === "control";
|
||||
}
|
||||
|
||||
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
||||
@@ -136,8 +114,7 @@ function bmSyncAccess() {
|
||||
|
||||
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
|
||||
function bmListScope() {
|
||||
const rig = (typeof bridge.lastActiveRigId !== "undefined") ? bridge.lastActiveRigId : null;
|
||||
return rig || "general";
|
||||
return hostState.lastActiveRigId || "general";
|
||||
}
|
||||
|
||||
async function bmFetchOverlay() {
|
||||
@@ -154,7 +131,7 @@ async function bmFetchOverlay() {
|
||||
if (typeof bridge.syncBookmarkMapLocators === "function") {
|
||||
bridge.syncBookmarkMapLocators(bmOverlayList);
|
||||
}
|
||||
if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw();
|
||||
hostCore.scheduleSpectrumDraw();
|
||||
}
|
||||
|
||||
async function bmFetch(categoryFilter: string): Promise<void> {
|
||||
@@ -310,19 +287,19 @@ function bmChangePage(delta: number): void {
|
||||
|
||||
// Read decoder checkboxes and return an array of selected decoder names.
|
||||
function bmReadDecoders(): string[] {
|
||||
return (bridge.decoderRegistry || [])
|
||||
return hostState.decoderRegistry
|
||||
.filter(d => d.bookmark_selectable)
|
||||
.filter(d => bmEl("bm-dec-" + d.id)?.checked)
|
||||
.filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked)
|
||||
.map(d => d.id);
|
||||
}
|
||||
|
||||
// Set decoder checkboxes to match the given array.
|
||||
function bmWriteDecoders(decoders: readonly string[]): void {
|
||||
const set = new Set(decoders || []);
|
||||
(bridge.decoderRegistry || [])
|
||||
hostState.decoderRegistry
|
||||
.filter(d => d.bookmark_selectable)
|
||||
.forEach(d => {
|
||||
const el = bmEl("bm-dec-" + d.id);
|
||||
const el = bmOptionalEl("bm-dec-" + d.id);
|
||||
if (el) el.checked = set.has(d.id);
|
||||
});
|
||||
}
|
||||
@@ -332,7 +309,7 @@ function bmBuildDecoderCheckboxes() {
|
||||
const container = bmEl("bm-decoder-checkboxes");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
(bridge.decoderRegistry || [])
|
||||
hostState.decoderRegistry
|
||||
.filter(d => d.bookmark_selectable)
|
||||
.forEach(d => {
|
||||
const label = document.createElement("label");
|
||||
@@ -372,23 +349,21 @@ function bmCloseForm() {
|
||||
}
|
||||
|
||||
function bmPrefillFromStatus() {
|
||||
// Use globals maintained by app.js (updated by SSE stream)
|
||||
if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) {
|
||||
bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz));
|
||||
// Read live rig state from the host contract (updated by the SSE stream).
|
||||
const freqHz = hostState.lastFreqHz;
|
||||
if (freqHz != null && Number.isFinite(freqHz)) {
|
||||
bmEl("bm-freq").value = String(Math.round(freqHz));
|
||||
}
|
||||
if (typeof bridge.lastModeName === "string" && bridge.lastModeName) {
|
||||
bmEl("bm-mode").value = bridge.lastModeName;
|
||||
if (hostState.lastModeName) {
|
||||
bmEl("bm-mode").value = hostState.lastModeName;
|
||||
}
|
||||
if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) {
|
||||
bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz));
|
||||
if (hostState.currentBandwidthHz > 0) {
|
||||
bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
|
||||
}
|
||||
// Prefill decoder checkboxes from current toggle button state.
|
||||
const activeDecoders = (bridge.decoderRegistry || [])
|
||||
const activeDecoders = hostState.decoderRegistry
|
||||
.filter(d => d.bookmark_selectable && d.activation === "toggle")
|
||||
.filter(d => {
|
||||
const btn = bmEl(d.id + "-decode-toggle-btn");
|
||||
return btn && btn.dataset.enabled === "true";
|
||||
})
|
||||
.filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true")
|
||||
.map(d => d.id);
|
||||
bmWriteDecoders(activeDecoders);
|
||||
}
|
||||
@@ -478,58 +453,45 @@ async function bmDelete(id: string): Promise<void> {
|
||||
function bmApply(bm: Bookmark): void {
|
||||
try {
|
||||
// --- Optimistic UI updates (instant, before any network round-trips) ---
|
||||
if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) {
|
||||
bridge.modeEl.value = (bm.mode || "").toUpperCase();
|
||||
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
|
||||
if (modeEl) {
|
||||
modeEl.value = (bm.mode || "").toUpperCase();
|
||||
hostCore.syncModePicker();
|
||||
}
|
||||
if (bm.bandwidth_hz) {
|
||||
if (typeof bridge.currentBandwidthHz !== "undefined") {
|
||||
bridge.currentBandwidthHz = bm.bandwidth_hz;
|
||||
}
|
||||
bridge.currentBandwidthHz = bm.bandwidth_hz;
|
||||
if (typeof bridge.syncBandwidthInput === "function") {
|
||||
bridge.syncBandwidthInput(bm.bandwidth_hz);
|
||||
}
|
||||
hostState.currentBandwidthHz = bm.bandwidth_hz;
|
||||
hostCore.syncBandwidthInput(bm.bandwidth_hz);
|
||||
}
|
||||
if (typeof bridge.applyLocalTunedFrequency === "function") {
|
||||
// Set optimistic guard before applying so SSE cannot snap back.
|
||||
if (typeof bridge._freqOptimisticSeq !== "undefined") {
|
||||
++bridge._freqOptimisticSeq;
|
||||
bridge._freqOptimisticHz = bm.freq_hz;
|
||||
}
|
||||
// Force display so the BW overlay is repositioned even when freq is unchanged.
|
||||
bridge.applyLocalTunedFrequency(bm.freq_hz, true);
|
||||
}
|
||||
if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) {
|
||||
bridge.scheduleSpectrumDraw();
|
||||
// Set optimistic guard before applying so SSE cannot snap back.
|
||||
hostCore.armOptimisticFrequency(bm.freq_hz);
|
||||
// Force display so the BW overlay is repositioned even when freq is unchanged.
|
||||
hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
|
||||
if (hostState.lastSpectrumData) {
|
||||
hostCore.scheduleSpectrumDraw();
|
||||
}
|
||||
|
||||
// Take scheduler control up front, then apply mode before bandwidth so a
|
||||
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
|
||||
const tunePromise = (async () => {
|
||||
await bridge.trx?.modules?.vchan?.takeSchedulerControl();
|
||||
await bridge.trx.modules.vchan?.takeSchedulerControl();
|
||||
|
||||
const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false;
|
||||
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
|
||||
if (!onVirtual) {
|
||||
await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||
}
|
||||
|
||||
if (bm.bandwidth_hz) {
|
||||
const bwHandledByVchan =
|
||||
await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
||||
await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
||||
if (!bwHandledByVchan) {
|
||||
await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
||||
await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
||||
}
|
||||
}
|
||||
|
||||
// bridge.setRigFrequency is wrapped by vchan.js to redirect to the channel API
|
||||
// when on a virtual channel, so this call works correctly in both cases.
|
||||
// It also does its own optimistic update (bridge.applyLocalTunedFrequency) but
|
||||
// that's a no-op since we already set the same value above.
|
||||
if (typeof bridge.setRigFrequency === "function") {
|
||||
await bridge.setRigFrequency(bm.freq_hz);
|
||||
} else {
|
||||
await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`);
|
||||
}
|
||||
// setRigFrequency redirects to the channel API when a virtual channel is
|
||||
// active. It repeats the optimistic update applied above, which is a
|
||||
// no-op because the value is unchanged.
|
||||
hostCore.setRigFrequency(bm.freq_hz);
|
||||
})();
|
||||
// Decoder toggles — fire-and-forget.
|
||||
// - Decoders incompatible with the new mode are always turned off
|
||||
@@ -539,13 +501,14 @@ function bmApply(bm: Bookmark): void {
|
||||
// alone.
|
||||
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||
const modeUp = (bm.mode || "").toUpperCase();
|
||||
const allToggleDecoders = (bridge.decoderRegistry || []).filter(d =>
|
||||
const allToggleDecoders = hostState.decoderRegistry.filter(d =>
|
||||
d.activation === "toggle"
|
||||
);
|
||||
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||
let statusUrl = "/status";
|
||||
if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) {
|
||||
statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId);
|
||||
const rigId = hostState.lastActiveRigId;
|
||||
if (rigId) {
|
||||
statusUrl += "?remote=" + encodeURIComponent(rigId);
|
||||
}
|
||||
const statusResp = await fetch(statusUrl);
|
||||
if (!statusResp.ok) return;
|
||||
@@ -567,7 +530,7 @@ function bmApply(bm: Bookmark): void {
|
||||
wanted = currentlyOn;
|
||||
}
|
||||
if (wanted !== currentlyOn) {
|
||||
toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||
toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||
}
|
||||
}
|
||||
if (toggles.length) await Promise.all(toggles);
|
||||
@@ -582,8 +545,6 @@ function bmApply(bm: Bookmark): void {
|
||||
}
|
||||
}
|
||||
|
||||
bridge.trx ??= {};
|
||||
bridge.trx.modules ??= {};
|
||||
bridge.trx.modules.bookmarks = {
|
||||
get overlayList() { return bmOverlayList; },
|
||||
get overlayRevision() { return bmOverlayRevision; },
|
||||
@@ -619,8 +580,8 @@ function bmUpdateSelectionUi() {
|
||||
function bmPopulateMoveTarget() {
|
||||
const sel = bmEl("bm-move-target");
|
||||
if (!sel) return;
|
||||
const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : [];
|
||||
const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {};
|
||||
const rigIds = hostState.lastRigIds;
|
||||
const displayNames = hostState.lastRigDisplayNames;
|
||||
const prev = sel.value;
|
||||
sel.innerHTML = "";
|
||||
if (bmScope !== "general") {
|
||||
@@ -728,8 +689,8 @@ async function bmDeleteSelected() {
|
||||
function bmPopulateScopePicker() {
|
||||
const picker = bmEl("bm-scope-picker");
|
||||
if (!picker) return;
|
||||
const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : [];
|
||||
const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {};
|
||||
const rigIds = hostState.lastRigIds;
|
||||
const displayNames = hostState.lastRigDisplayNames;
|
||||
// Preserve current selection if still valid.
|
||||
const prev = picker.value;
|
||||
while (picker.options.length > 1) picker.remove(1);
|
||||
@@ -756,9 +717,7 @@ function bmPopulateScopePicker() {
|
||||
// Build decoder checkboxes from registry. The registry is fetched async
|
||||
// so we rebuild once it arrives to ensure checkboxes are present.
|
||||
bmBuildDecoderCheckboxes();
|
||||
if (typeof bridge.onDecoderRegistryReady === "function") {
|
||||
bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||
}
|
||||
hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||
|
||||
// Scope picker
|
||||
bmPopulateScopePicker();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostCore } from "./host.js";
|
||||
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||
|
||||
export {};
|
||||
@@ -30,8 +32,6 @@ interface CwBridge {
|
||||
lastFreqHz?: number;
|
||||
currentBandwidthHz?: number;
|
||||
lastSpectrumData?: CwSpectrum;
|
||||
escapeMapHtml?: (input: string) => string;
|
||||
postPath?: (path: string) => Promise<unknown>;
|
||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||
applyCwAutoUi?: (enabled: boolean) => void;
|
||||
applyCwAutoUiFromServer?: (enabled: boolean) => void;
|
||||
@@ -75,9 +75,7 @@ let cwBarDismissedAtMs = 0;
|
||||
let cwAutoLocalOverride: boolean | null = null;
|
||||
|
||||
function escapeCwHtml(input: string): string {
|
||||
return cwWindow.escapeMapHtml?.(input) ?? input
|
||||
.replaceAll("&", "&").replaceAll("<", "<")
|
||||
.replaceAll(">", ">").replaceAll('"', """);
|
||||
return hostCore.escapeMapHtml(input);
|
||||
}
|
||||
|
||||
function applyCwAutoUi(enabled: boolean): void {
|
||||
@@ -344,7 +342,7 @@ async function setCwTone(tone: unknown, { syncInput = true }: { syncInput?: bool
|
||||
cwToneInput.value = String(clamped);
|
||||
}
|
||||
try {
|
||||
await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||
await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||
} catch (e) {
|
||||
console.error("CW tone set failed", e);
|
||||
}
|
||||
@@ -358,7 +356,7 @@ if (cwAutoInput) {
|
||||
cwAutoLocalOverride = enabled;
|
||||
applyCwAutoUi(enabled);
|
||||
try {
|
||||
await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||
await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||
drawCwTonePicker();
|
||||
} catch (error: unknown) {
|
||||
console.error("CW auto toggle failed", error);
|
||||
@@ -375,7 +373,7 @@ if (cwWpmInput) {
|
||||
if (cwAutoInput?.checked) return;
|
||||
const wpm = clampCwWpm(cwWpmInput.value);
|
||||
cwWpmInput.value = String(wpm);
|
||||
try { await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
||||
try { await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
||||
catch (error: unknown) { console.error("CW WPM set failed", error); }
|
||||
})();
|
||||
});
|
||||
@@ -413,7 +411,7 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
||||
void (async () => {
|
||||
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await cwWindow.postPath?.("/clear_cw_decode");
|
||||
await hostCore.postPath("/clear_cw_decode");
|
||||
resetCwHistoryView();
|
||||
} catch (error: unknown) {
|
||||
console.error("CW history clear failed", error);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostCore } from "./host.js";
|
||||
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||
|
||||
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
|
||||
@@ -45,8 +47,6 @@ interface FtxBridge {
|
||||
updateFt8Bar?: () => void;
|
||||
registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
|
||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
|
||||
postPath?: (path: string) => Promise<unknown>;
|
||||
fmtTime?: (timestampMs: number) => string;
|
||||
trxUi: ConfirmApi;
|
||||
clearFt8Bar?: () => void;
|
||||
closeFt8Bar?: () => void;
|
||||
@@ -62,6 +62,14 @@ interface FtxConfig {
|
||||
|
||||
const bridge = window as unknown as FtxBridge & PluginRuntimeWindow;
|
||||
|
||||
// Legacy ft8.js owned this formatter locally; the shared module kept reading it
|
||||
// off `window`, where nothing published it, so bar timestamps rendered empty.
|
||||
function formatBarTime(timestampMs: number): string {
|
||||
if (!timestampMs) return "--:--:--";
|
||||
return new Date(timestampMs)
|
||||
.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
const number = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
@@ -274,7 +282,7 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
||||
let html = "";
|
||||
for (const message of recent) {
|
||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${bridge.fmtTime?.(timestamp) ?? ""}</span>`;
|
||||
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${formatBarTime(timestamp)}</span>`;
|
||||
const snr = finiteNumber(message.snr_db);
|
||||
const delta = finiteNumber(message.dt_s);
|
||||
const frequency = displayFrequency(message.freq_hz);
|
||||
@@ -309,12 +317,12 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
||||
toggle?.addEventListener("click", () => { void (async () => {
|
||||
try {
|
||||
await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
|
||||
await bridge.postPath?.(`/toggle_${id}_decode`);
|
||||
await hostCore.postPath(`/toggle_${id}_decode`);
|
||||
} catch (error: unknown) { console.error(`${label} toggle failed`, error); }
|
||||
})(); });
|
||||
document.getElementById(`settings-clear-${id}-history`)?.addEventListener("click", () => { void (async () => {
|
||||
if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return;
|
||||
try { await bridge.postPath?.(`/clear_${id}_decode`); reset(); }
|
||||
try { await hostCore.postPath(`/clear_${id}_decode`); reset(); }
|
||||
catch (error: unknown) { console.error(`${label} history clear failed`, error); }
|
||||
})(); });
|
||||
}
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostCore, hostState } from "./host.js";
|
||||
|
||||
import {
|
||||
aprsAgeText,
|
||||
aprsCategoryLabel,
|
||||
aprsHexBytes,
|
||||
aprsPacketCategory,
|
||||
collapseAprsDuplicates,
|
||||
normalizeAprsPacket,
|
||||
renderAprsInfo,
|
||||
renderLocalAprsSymbol,
|
||||
renderAprsPacketRow,
|
||||
type AprsPacket,
|
||||
type AprsTypeFilter,
|
||||
} from "./aprs-shared";
|
||||
@@ -19,21 +18,12 @@ import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||
interface HfAprsBridge {
|
||||
getDecodeHistoryRetentionMs?: () => number;
|
||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||
serverLat?: number | null;
|
||||
serverLon?: number | null;
|
||||
haversineKm?: (lat1: number, lon1: number, lat2: number, lon2: number) => number;
|
||||
escapeMapHtml?: (input: string) => string;
|
||||
navigateToAprsMap?: (lat: number, lon: number) => void;
|
||||
showHint?: (message: string, durationMs: number) => void;
|
||||
getDecodeRigMeta?: () => unknown;
|
||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
||||
postPath?: (path: string) => Promise<unknown>;
|
||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||
}
|
||||
const hfAprsWindow = window as unknown as HfAprsBridge;
|
||||
const escapeHfAprsHtml = (input: string): string => hfAprsWindow.escapeMapHtml?.(input) ?? input
|
||||
.replaceAll("&", "&").replaceAll("<", "<")
|
||||
.replaceAll(">", ">").replaceAll('"', """);
|
||||
|
||||
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
|
||||
const hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||
@@ -72,8 +62,8 @@ function scheduleHfAprsHistoryRender() {
|
||||
}
|
||||
|
||||
function hfAprsDistanceText(pkt: AprsPacket): string {
|
||||
if (hfAprsWindow.serverLat == null || hfAprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !hfAprsWindow.haversineKm) return "";
|
||||
const distKm = hfAprsWindow.haversineKm(hfAprsWindow.serverLat, hfAprsWindow.serverLon, pkt.lat, pkt.lon);
|
||||
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
|
||||
if (!Number.isFinite(distKm)) return "";
|
||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||
return `${distKm.toFixed(1)} km from TRX`;
|
||||
@@ -134,95 +124,27 @@ function updateHfAprsChipState() {
|
||||
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 {
|
||||
const row = document.createElement("div");
|
||||
row.className = "aprs-packet";
|
||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||
if (isFresh) row.classList.add("aprs-packet-new");
|
||||
|
||||
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const age = aprsAgeText(pkt._tsMs);
|
||||
const category = aprsPacketCategory(pkt);
|
||||
const categoryLabel = aprsCategoryLabel(category);
|
||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${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);
|
||||
}
|
||||
});
|
||||
return renderAprsPacketRow(pkt, {
|
||||
fresh: isFresh,
|
||||
badge: "HF",
|
||||
distance: hfAprsDistanceText(pkt),
|
||||
onMap: (lat: number, lon: number) => { hfAprsWindow.navigateToAprsMap?.(lat, lon); },
|
||||
onCopy: (text: string) => { void copyHfAprsCoords(text); },
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
hfAprsWindow.showHint?.("Coordinates copied", 1200);
|
||||
}
|
||||
} catch {
|
||||
hfAprsWindow.showHint?.("Copy failed", 1500);
|
||||
}
|
||||
})(); });
|
||||
async function copyHfAprsCoords(text: string): Promise<void> {
|
||||
try {
|
||||
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
|
||||
if (!clipboard) return;
|
||||
await clipboard.writeText(text);
|
||||
hostCore.showHint("Coordinates copied", 1200);
|
||||
} catch {
|
||||
hostCore.showHint("Copy failed", 1500);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderHfAprsHistory() {
|
||||
@@ -289,7 +211,7 @@ const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn
|
||||
hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => {
|
||||
try {
|
||||
await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
|
||||
await hfAprsWindow.postPath?.("/toggle_hf_aprs_decode");
|
||||
await hostCore.postPath("/toggle_hf_aprs_decode");
|
||||
} catch (e) {
|
||||
console.error("HF APRS toggle failed", e);
|
||||
}
|
||||
@@ -298,7 +220,7 @@ hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => {
|
||||
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", () => { void (async () => {
|
||||
if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await hfAprsWindow.postPath?.("/clear_hf_aprs_decode");
|
||||
await hostCore.postPath("/clear_hf_aprs_decode");
|
||||
resetHfAprsHistoryView();
|
||||
} catch (e) {
|
||||
console.error("HF APRS history clear failed", e);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// Typed view of the `window.trx` host contract for lazy feature bundles.
|
||||
//
|
||||
// Feature entries are separate esbuild bundles, so they cannot share module
|
||||
// instances with the application entry and must reach application state and
|
||||
// services through the host namespace documented in
|
||||
// `docs/frontend-architecture.md`. Declaring that contract once here keeps the
|
||||
// feature bundles from re-deriving it — and from drifting back to bare `window`
|
||||
// properties, which the module graph no longer publishes.
|
||||
|
||||
export interface HostDecoderDescriptor {
|
||||
id: string;
|
||||
label: string;
|
||||
activation?: string;
|
||||
active_modes?: string[];
|
||||
bookmark_selectable?: boolean;
|
||||
}
|
||||
|
||||
export interface HostState {
|
||||
readonly serverLat: number | null;
|
||||
readonly serverLon: number | null;
|
||||
readonly authEnabled: boolean;
|
||||
readonly authRole: string | null;
|
||||
readonly lastActiveRigId: string | null;
|
||||
readonly lastRigIds: string[];
|
||||
readonly lastRigDisplayNames: Record<string, string>;
|
||||
readonly lastFreqHz: number | null;
|
||||
readonly lastSpectrumData: { sample_rate: number; center_hz: number } | null;
|
||||
readonly jogUnit: number;
|
||||
readonly rxActive: boolean;
|
||||
readonly decoderRegistry: readonly HostDecoderDescriptor[];
|
||||
lastModeName: string;
|
||||
currentBandwidthHz: number;
|
||||
audioChannelOverride: string | null;
|
||||
}
|
||||
|
||||
export interface HostCore {
|
||||
postPath(path: string): Promise<unknown>;
|
||||
applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void;
|
||||
armOptimisticFrequency(frequencyHz: number): void;
|
||||
escapeMapHtml(value: string): string;
|
||||
haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
||||
showHint(message: string, durationMs?: number): void;
|
||||
formatFreqForStep(frequencyHz: number, stepHz: number): string;
|
||||
refreshFreqDisplay(): void;
|
||||
setJogDivisor(divisor: number): void;
|
||||
mwDefaultsForMode(mode: string): [number, number, number, number];
|
||||
resetRdsDisplay(): void;
|
||||
positionRdsPsOverlay(): void;
|
||||
updateWfmControls(): void;
|
||||
updateSdrSquelchControlVisibility(): void;
|
||||
updateDocumentTitle(rds: unknown): void;
|
||||
activeChannelRds(): unknown;
|
||||
startRxAudio(): void;
|
||||
stopRxAudio(): void;
|
||||
setRigFrequency(frequencyHz: number): void;
|
||||
syncBandwidthInput(bandwidthHz: number): void;
|
||||
scheduleSpectrumDraw(): void;
|
||||
/** Repaints the mode buttons from #mode after writing to it. */
|
||||
syncModePicker(): void;
|
||||
onDecoderRegistryReady(callback: () => void): void;
|
||||
}
|
||||
|
||||
interface HostWindow {
|
||||
trx: { state: HostState; core: HostCore };
|
||||
}
|
||||
|
||||
const host = window as unknown as HostWindow;
|
||||
|
||||
export const hostState: HostState = host.trx.state;
|
||||
export const hostCore: HostCore = host.trx.core;
|
||||
@@ -9,6 +9,9 @@ export interface DecoderPlugin<TMessage = unknown> {
|
||||
restore?(messages: TMessage[]): void;
|
||||
reset?(): void;
|
||||
prune?(): 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 {
|
||||
@@ -19,6 +22,7 @@ export interface TrxPluginRuntime {
|
||||
reset(id: string): boolean;
|
||||
resetAll(): void;
|
||||
prune(id: string): boolean;
|
||||
syncMapAll(): void;
|
||||
clearQueued(): void;
|
||||
hasDecoder(id: string): boolean;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
|
||||
import { hostCore } from "./host.js";
|
||||
|
||||
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate } from "./satellite-types";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||
|
||||
type SatelliteView = "live" | "history" | "predictions";
|
||||
type SatelliteView = "live" | "history";
|
||||
interface SatelliteBridge {
|
||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||
clearSatPredictionDom?: () => void;
|
||||
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
||||
addSatMapOverlay?: (image: SatelliteImage) => void;
|
||||
clearSatMapOverlays?: () => void;
|
||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
||||
postPath?: (path: string) => Promise<unknown>;
|
||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||
satShowOnMap?: (south: number, west: number, north: number, east: number) => void;
|
||||
enableMapSourceFilter?: (source: string) => void;
|
||||
@@ -24,14 +24,14 @@ const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow;
|
||||
// --- SAT Plugin ---
|
||||
// Live view: decoder state, latest image card
|
||||
// History view: filterable table of all decoded images
|
||||
// Predictions view: next 24 h passes for ham satellites
|
||||
//
|
||||
// Pass predictions live on their own page; see satellite-predictions.ts.
|
||||
|
||||
// ── DOM references (cached once) ───────────────────────────────────
|
||||
const satDom = {
|
||||
status: document.getElementById("sat-status"),
|
||||
liveView: document.getElementById("sat-live-view"),
|
||||
historyView: document.getElementById("sat-history-view"),
|
||||
predictionsView: document.getElementById("sat-predictions-view"),
|
||||
liveLatest: document.getElementById("sat-live-latest"),
|
||||
historyList: document.getElementById("sat-history-list"),
|
||||
historyCount: document.getElementById("sat-history-count"),
|
||||
@@ -41,30 +41,13 @@ const satDom = {
|
||||
lrptState: document.getElementById("sat-lrpt-state"),
|
||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||
viewPredBtn: document.getElementById("sat-view-predictions"),
|
||||
predFilter: document.getElementById("sat-pred-filter") 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 ───────────────────────────────────────────────────────────
|
||||
let satImageHistory: SatelliteImage[] = [];
|
||||
const SAT_MAX_IMAGES = 100;
|
||||
const SAT_PRED_PAGE_SIZE = 50;
|
||||
let satPredShowAll = false;
|
||||
let satFilterText = "";
|
||||
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 ─────────────────────────────────────────────
|
||||
function scheduleSatUi(key: string, job: () => void): void {
|
||||
@@ -77,33 +60,16 @@ function scheduleSatUi(key: string, job: () => void): void {
|
||||
|
||||
// ── View switching ──────────────────────────────────────────────────
|
||||
function switchSatView(view: SatelliteView): void {
|
||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||
satActiveView = view;
|
||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
||||
if (leavingPredictions) clearPredictionDom();
|
||||
if (view === "history") {
|
||||
renderSatHistoryTable();
|
||||
} else if (view === "predictions") {
|
||||
satPredShowAll = false;
|
||||
void loadSatPredictions();
|
||||
}
|
||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||
if (view === "history") renderSatHistoryTable();
|
||||
}
|
||||
|
||||
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.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
|
||||
satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); });
|
||||
|
||||
// ── Live view: decoder state ────────────────────────────────────────
|
||||
let lastSatLrptOn: boolean | null = null;
|
||||
@@ -305,7 +271,7 @@ const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
||||
lrptDecodeToggleBtn?.addEventListener("click", () => { void (async () => {
|
||||
try {
|
||||
await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
|
||||
await satWindow.postPath?.("/toggle_lrpt_decode");
|
||||
await hostCore.postPath("/toggle_lrpt_decode");
|
||||
} catch (e) {
|
||||
console.error("LRPT toggle failed", e);
|
||||
}
|
||||
@@ -327,241 +293,13 @@ document
|
||||
?.addEventListener("click", () => { void (async () => {
|
||||
if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await satWindow.postPath?.("/clear_lrpt_decode");
|
||||
await hostCore.postPath("/clear_lrpt_decode");
|
||||
resetSatHistoryView();
|
||||
} catch (e) {
|
||||
console.error("Weather satellite history clear failed", e);
|
||||
}
|
||||
})(); });
|
||||
|
||||
// ── 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 ──────────────
|
||||
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
|
||||
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();
|
||||
};
|
||||
@@ -80,9 +80,5 @@ export interface SchedulerWindow {
|
||||
confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>;
|
||||
notify?(message: string, options: { kind: "error" }): void;
|
||||
};
|
||||
authRole?: string | null;
|
||||
lastActiveRigId?: string | null;
|
||||
serverLat?: number | null;
|
||||
serverLon?: number | null;
|
||||
satScheduler?: SatelliteSchedulerApi;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostState } from "./host.js";
|
||||
|
||||
import type {
|
||||
ScheduleEntry,
|
||||
SchedulerBookmark,
|
||||
@@ -27,6 +29,12 @@ function schedulerEl(id: string): SchedulerElement {
|
||||
return element as SchedulerElement;
|
||||
}
|
||||
|
||||
/* The timeline needle group is drawn by this feature's own SVG markup, so it is
|
||||
* absent until the timeline has rendered at least once. */
|
||||
function schedulerOptionalEl(id: string): SchedulerElement | null {
|
||||
return document.getElementById(id) as SchedulerElement | null;
|
||||
}
|
||||
|
||||
// Background Decoding Scheduler UI
|
||||
|
||||
(function () {
|
||||
@@ -481,8 +489,8 @@ function schedulerEl(id: string): SchedulerElement {
|
||||
if (mode === "grayline" && currentConfig && currentConfig.grayline) {
|
||||
const gl = currentConfig.grayline;
|
||||
// Prefer saved value; fall back to server coordinates from app.js globals.
|
||||
const lat = gl.lat ?? schedulerWindow.serverLat ?? "";
|
||||
const lon = gl.lon ?? schedulerWindow.serverLon ?? "";
|
||||
const lat = gl.lat ?? hostState.serverLat ?? "";
|
||||
const lon = gl.lon ?? hostState.serverLon ?? "";
|
||||
setInputValue("scheduler-gl-lat", lat != null ? lat : "");
|
||||
setInputValue("scheduler-gl-lon", lon != null ? lon : "");
|
||||
const gridEl = schedulerEl("scheduler-gl-grid");
|
||||
@@ -496,8 +504,8 @@ function schedulerEl(id: string): SchedulerElement {
|
||||
renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id);
|
||||
} else if (mode === "grayline") {
|
||||
// No saved grayline config yet — pre-fill coords from server if available.
|
||||
const lat = schedulerWindow.serverLat ?? "";
|
||||
const lon = schedulerWindow.serverLon ?? "";
|
||||
const lat = hostState.serverLat ?? "";
|
||||
const lon = hostState.serverLon ?? "";
|
||||
setInputValue("scheduler-gl-lat", lat != null ? lat : "");
|
||||
setInputValue("scheduler-gl-lon", lon != null ? lon : "");
|
||||
const gridEl2 = schedulerEl("scheduler-gl-grid");
|
||||
@@ -821,7 +829,7 @@ function schedulerEl(id: string): SchedulerElement {
|
||||
}
|
||||
|
||||
function renderTimelineNeedle() {
|
||||
const g = schedulerEl("sch-timeline-needle-g");
|
||||
const g = schedulerOptionalEl("sch-timeline-needle-g");
|
||||
if (g) g.innerHTML = timelineNeedleSvg();
|
||||
}
|
||||
|
||||
@@ -1566,8 +1574,8 @@ function schedulerEl(id: string): SchedulerElement {
|
||||
// When loaded eagerly, initSettingsUI() in app.js calls initScheduler();
|
||||
// when loaded lazily (e.g. settings tab click after boot), the app has
|
||||
// already passed that point, so we must self-initialize here.
|
||||
if (schedulerWindow.authRole != null) {
|
||||
initScheduler(schedulerWindow.lastActiveRigId ?? null, schedulerWindow.authRole);
|
||||
if (hostState.authRole != null) {
|
||||
initScheduler(hostState.lastActiveRigId, hostState.authRole);
|
||||
wireSchedulerEvents();
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
// 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 type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||
|
||||
export {};
|
||||
|
||||
interface SstvImage {
|
||||
ts_ms?: number;
|
||||
vis?: number;
|
||||
mode?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
lines?: number;
|
||||
complete?: boolean;
|
||||
path?: string;
|
||||
_tsMs?: number;
|
||||
_ts?: string;
|
||||
}
|
||||
|
||||
interface SstvProgress {
|
||||
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 {
|
||||
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();
|
||||
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
function renderLatestCard(): void {
|
||||
if (!sstvDom.liveLatest) return;
|
||||
const latest = sstvHistory[sstvHistory.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[] {
|
||||
const text = filterText.trim().toLowerCase();
|
||||
const matching = text
|
||||
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
|
||||
: sstvHistory.slice();
|
||||
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,
|
||||
});
|
||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "sstv_progress",
|
||||
onMessage: onProgress as (msg: unknown) => void,
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostCore, hostState } from "./host.js";
|
||||
|
||||
export {};
|
||||
|
||||
interface VirtualChannel {
|
||||
@@ -21,35 +23,12 @@ interface SchedulerReleaseState {
|
||||
all_released?: boolean;
|
||||
current_session_released?: boolean;
|
||||
}
|
||||
interface SpectrumSnapshot { sample_rate: number; center_hz: number }
|
||||
// Callbacks the application publishes as documented transitional window
|
||||
// properties; all other application state and services arrive through the
|
||||
// typed host contract in ./host.ts.
|
||||
interface VirtualChannelBridge {
|
||||
lastActiveRigId?: string | null;
|
||||
lastFreqHz?: number;
|
||||
lastModeName?: string;
|
||||
lastSpectrumData?: SpectrumSnapshot | null;
|
||||
currentBandwidthHz?: number;
|
||||
jogUnit?: number;
|
||||
rxActive?: boolean;
|
||||
_audioChannelOverride?: string | null;
|
||||
_freqOptimisticSeq?: number;
|
||||
_freqOptimisticHz?: number;
|
||||
renderRdsOverlays?: () => void;
|
||||
updateDocumentTitle?: (rds: unknown) => void;
|
||||
activeChannelRds?: () => unknown;
|
||||
stopRxAudio?: () => void;
|
||||
startRxAudio?: () => void;
|
||||
formatFreqForStep?: (frequencyHz: number, jogUnit: number) => string;
|
||||
setJogDivisor?: (divisor: number) => void;
|
||||
resetRdsDisplay?: () => void;
|
||||
updateWfmControls?: () => void;
|
||||
updateSdrSquelchControlVisibility?: () => void;
|
||||
refreshRdsUi?: () => void;
|
||||
positionRdsPsOverlay?: () => void;
|
||||
mwDefaultsForMode?: (mode: string) => [number, ...unknown[]];
|
||||
showHint?: (message: string, durationMs: number) => void;
|
||||
applyLocalTunedFrequency?: (frequencyHz: number) => void;
|
||||
setRigFrequency?: (frequencyHz: number) => void;
|
||||
refreshFreqDisplay?: () => void;
|
||||
trx?: { modules?: { vchan?: VirtualChannelService } };
|
||||
}
|
||||
|
||||
@@ -63,6 +42,8 @@ interface VirtualChannelService {
|
||||
isOnVirtual(): boolean;
|
||||
interceptMode(mode: string): Promise<boolean>;
|
||||
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
|
||||
interceptFrequency(frequencyHz: number): boolean;
|
||||
interceptFreqDisplay(): boolean;
|
||||
takeSchedulerControl(): Promise<void>;
|
||||
releaseToScheduler(): Promise<void>;
|
||||
}
|
||||
@@ -146,7 +127,7 @@ function vchanStartSchedulerReleasePolling() {
|
||||
|
||||
async function vchanToggleSchedulerRelease() {
|
||||
if (!vchanSessionId) return;
|
||||
const rigId = vchanRigId || vchanWindow.lastActiveRigId || null;
|
||||
const rigId = vchanRigId || hostState.lastActiveRigId || null;
|
||||
try {
|
||||
const resp = await fetch("/scheduler-control", {
|
||||
method: "PUT",
|
||||
@@ -261,9 +242,7 @@ function vchanRender() {
|
||||
picker.appendChild(addBtn);
|
||||
|
||||
vchanSyncAccentUI();
|
||||
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
|
||||
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
|
||||
}
|
||||
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
|
||||
vchanRenderSchedulerRelease();
|
||||
}
|
||||
|
||||
@@ -271,8 +250,8 @@ async function vchanAllocate() {
|
||||
if (!vchanSessionId || !vchanRigId) return;
|
||||
|
||||
// Use the last known rig frequency and mode as the starting point.
|
||||
const freqHz = (typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0)
|
||||
? vchanWindow.lastFreqHz
|
||||
const freqHz = (typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0)
|
||||
? hostState.lastFreqHz
|
||||
: 0;
|
||||
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
|
||||
const mode = modeEl ? (modeEl.value || "USB") : "USB";
|
||||
@@ -374,14 +353,14 @@ function vchanReconnectAudio(): void {
|
||||
// Always update the override so startRxAudio picks up the right URL,
|
||||
// even when audio isn't currently running.
|
||||
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
|
||||
vchanWindow._audioChannelOverride = ch?.id ?? null;
|
||||
if (!vchanWindow.rxActive) return;
|
||||
vchanWindow.stopRxAudio?.();
|
||||
hostState.audioChannelOverride = ch?.id ?? null;
|
||||
if (!hostState.rxActive) return;
|
||||
hostCore.stopRxAudio();
|
||||
// Delay so the server has time to set up the per-channel encoder.
|
||||
// The server-side audio_ws handler also polls for up to 2 s, so this
|
||||
// just needs to be long enough for the WS upgrade to reach the server.
|
||||
setTimeout(() => {
|
||||
vchanWindow.startRxAudio?.();
|
||||
hostCore.startRxAudio();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
@@ -414,11 +393,7 @@ function vchanUpdateFreqDisplay() {
|
||||
if (!ch) return;
|
||||
const el = document.getElementById("freq") as HTMLInputElement | null;
|
||||
if (!el) return;
|
||||
if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") {
|
||||
el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit);
|
||||
} else {
|
||||
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
|
||||
}
|
||||
el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit);
|
||||
}
|
||||
|
||||
// Sync the mode picker to the active virtual channel's mode.
|
||||
@@ -428,25 +403,28 @@ function vchanSyncModeDisplay() {
|
||||
if (!modeEl) return;
|
||||
if (vchanIsOnVirtual()) {
|
||||
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.
|
||||
const modeUpper = (modeEl.value || "").toUpperCase();
|
||||
if (typeof vchanWindow.lastModeName === "string") {
|
||||
if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") {
|
||||
vchanWindow.setJogDivisor?.(10);
|
||||
vchanWindow.resetRdsDisplay?.();
|
||||
} else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") {
|
||||
vchanWindow.resetRdsDisplay?.();
|
||||
if (typeof hostState.lastModeName === "string") {
|
||||
if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") {
|
||||
hostCore.setJogDivisor(10);
|
||||
hostCore.resetRdsDisplay();
|
||||
} else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") {
|
||||
hostCore.resetRdsDisplay();
|
||||
}
|
||||
vchanWindow.lastModeName = modeUpper;
|
||||
hostState.lastModeName = modeUpper;
|
||||
}
|
||||
vchanWindow.updateWfmControls?.();
|
||||
vchanWindow.updateSdrSquelchControlVisibility?.();
|
||||
hostCore.updateWfmControls();
|
||||
hostCore.updateSdrSquelchControlVisibility();
|
||||
if (vchanWindow.refreshRdsUi) {
|
||||
vchanWindow.refreshRdsUi();
|
||||
} else {
|
||||
vchanWindow.positionRdsPsOverlay?.();
|
||||
hostCore.positionRdsPsOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,12 +437,12 @@ function vchanSyncBwDisplay() {
|
||||
if (!bwEl) return;
|
||||
// bandwidth_hz == 0 means mode-default; derive it from the channel mode.
|
||||
let bwHz = ch.bandwidth_hz || 0;
|
||||
if (bwHz === 0 && vchanWindow.mwDefaultsForMode) {
|
||||
bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0;
|
||||
if (bwHz === 0) {
|
||||
bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0;
|
||||
}
|
||||
if (bwHz > 0) {
|
||||
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
|
||||
vchanWindow.currentBandwidthHz = bwHz;
|
||||
hostState.currentBandwidthHz = bwHz;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,29 +458,22 @@ function vchanSyncAccentUI() {
|
||||
vchanSyncModeDisplay();
|
||||
vchanSyncBwDisplay();
|
||||
} else {
|
||||
origRefreshFreqDisplay?.();
|
||||
}
|
||||
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
|
||||
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
|
||||
hostCore.refreshFreqDisplay();
|
||||
}
|
||||
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
|
||||
}
|
||||
|
||||
// Saved reference to the original refreshFreqDisplay from app.js.
|
||||
let origRefreshFreqDisplay: (() => void) | null = null;
|
||||
|
||||
function vchanSetChannelFreq(freqHz: number): void {
|
||||
if (!vchanRigId || !vchanActiveId) return;
|
||||
// Validate against current SDR capture window.
|
||||
if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) {
|
||||
const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2;
|
||||
const center = vchanWindow.lastSpectrumData.center_hz;
|
||||
if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) {
|
||||
const halfSpan = hostState.lastSpectrumData.sample_rate / 2;
|
||||
const center = hostState.lastSpectrumData.center_hz;
|
||||
if (Math.abs(freqHz - center) > halfSpan) {
|
||||
if (vchanWindow.showHint) {
|
||||
vchanWindow.showHint(
|
||||
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
||||
3000
|
||||
);
|
||||
}
|
||||
hostCore.showHint(
|
||||
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
||||
3000
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -571,6 +542,26 @@ async function vchanInterceptBandwidth(bwHz: number): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Called by setRigFrequency before it posts /set_freq. When a non-primary
|
||||
// channel is active the change belongs to the channel API instead of the rig,
|
||||
// so this applies the optimistic local update and reports the tune as handled.
|
||||
function vchanInterceptFrequency(freqHz: number): boolean {
|
||||
if (!vchanIsOnVirtual()) return false;
|
||||
const targetHz = Math.round(freqHz);
|
||||
hostCore.armOptimisticFrequency(targetHz);
|
||||
hostCore.applyLocalTunedFrequency(targetHz);
|
||||
vchanSetChannelFreq(freqHz);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Called by the application's refreshFreqDisplay. While a virtual channel is
|
||||
// active the field belongs to that channel, so report the render as handled.
|
||||
function vchanInterceptFreqDisplay(): boolean {
|
||||
if (!vchanIsOnVirtual()) return false;
|
||||
vchanUpdateFreqDisplay();
|
||||
return true;
|
||||
}
|
||||
|
||||
vchanWindow.trx ??= {};
|
||||
vchanWindow.trx.modules ??= {};
|
||||
vchanWindow.trx.modules.vchan = {
|
||||
@@ -583,34 +574,12 @@ vchanWindow.trx.modules.vchan = {
|
||||
isOnVirtual: vchanIsOnVirtual,
|
||||
interceptMode: vchanInterceptMode,
|
||||
interceptBandwidth: vchanInterceptBandwidth,
|
||||
interceptFrequency: vchanInterceptFrequency,
|
||||
interceptFreqDisplay: vchanInterceptFreqDisplay,
|
||||
takeSchedulerControl: vchanTakeSchedulerControl,
|
||||
releaseToScheduler: vchanToggleSchedulerRelease,
|
||||
};
|
||||
|
||||
// Wrap setRigFrequency (defined in app.js, loaded before this file) so that
|
||||
// frequency changes are redirected to the active virtual channel instead of
|
||||
// the server when on a non-primary channel.
|
||||
(function() {
|
||||
const original = vchanWindow.setRigFrequency;
|
||||
vchanWindow.setRigFrequency = function(freqHz: number) {
|
||||
if (vchanIsOnVirtual()) {
|
||||
// Optimistic local update first, then fire-and-forget channel API.
|
||||
if (vchanWindow.applyLocalTunedFrequency) {
|
||||
if (typeof vchanWindow._freqOptimisticSeq === "number") {
|
||||
vchanWindow._freqOptimisticSeq += 1;
|
||||
vchanWindow._freqOptimisticHz = Math.round(freqHz);
|
||||
}
|
||||
vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
|
||||
}
|
||||
vchanSetChannelFreq(freqHz);
|
||||
return;
|
||||
}
|
||||
// Scheduler control is fire-and-forget — don't block the freq change.
|
||||
void vchanTakeSchedulerControl();
|
||||
original?.(freqHz);
|
||||
};
|
||||
})();
|
||||
|
||||
(function initSchedulerReleaseControl() {
|
||||
const btn = document.getElementById("scheduler-release-btn");
|
||||
if (btn) {
|
||||
@@ -622,16 +591,3 @@ vchanWindow.trx.modules.vchan = {
|
||||
vchanRenderSchedulerRelease();
|
||||
})();
|
||||
|
||||
// Wrap refreshFreqDisplay so the main freq field stays in sync with the
|
||||
// active virtual channel's frequency (SSE rig-state updates would otherwise
|
||||
// constantly overwrite it with channel 0's freq).
|
||||
(function() {
|
||||
origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null;
|
||||
vchanWindow.refreshFreqDisplay = function() {
|
||||
if (vchanIsOnVirtual()) {
|
||||
vchanUpdateFreqDisplay();
|
||||
return;
|
||||
}
|
||||
origRefreshFreqDisplay?.();
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostCore } from "./host.js";
|
||||
|
||||
export {};
|
||||
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||
@@ -38,8 +40,6 @@ interface VdesMessage {
|
||||
interface VdesBridge {
|
||||
getDecodeHistoryRetentionMs?: () => number;
|
||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||
escapeMapHtml?: (input: string) => string;
|
||||
postPath?: (path: string) => Promise<unknown>;
|
||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||
vdesMapAddPoint?: (message: VdesMessage) => void;
|
||||
updateVdesBar?: () => void;
|
||||
@@ -47,9 +47,7 @@ interface VdesBridge {
|
||||
}
|
||||
|
||||
const vdesWindow = window as unknown as VdesBridge;
|
||||
const escapeVdesHtml = (input: string): string => vdesWindow.escapeMapHtml?.(input) ?? input
|
||||
.replaceAll("&", "&").replaceAll("<", "<")
|
||||
.replaceAll(">", ">").replaceAll('"', """);
|
||||
const escapeVdesHtml = (input: string): string => hostCore.escapeMapHtml(input);
|
||||
|
||||
// --- VDES Decoder Plugin (server-side decode) ---
|
||||
const vdesStatus = document.getElementById("vdes-status");
|
||||
@@ -322,9 +320,7 @@ function onServerVdesBatch(messages: VdesMessage[]): void {
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
||||
vdesWindow.vdesMapAddPoint(next);
|
||||
}
|
||||
plotVdesMessage(next);
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
@@ -337,7 +333,7 @@ function onServerVdesBatch(messages: VdesMessage[]): void {
|
||||
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => { void (async () => {
|
||||
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await vdesWindow.postPath?.("/clear_vdes_decode");
|
||||
await hostCore.postPath("/clear_vdes_decode");
|
||||
resetVdesHistoryView();
|
||||
} catch (e) {
|
||||
console.error("VDES history clear failed", e);
|
||||
@@ -351,13 +347,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 {
|
||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||
const next = normalizeServerVdesMessage(msg);
|
||||
addVdesMessage(next);
|
||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
||||
vdesWindow.vdesMapAddPoint(next);
|
||||
}
|
||||
plotVdesMessage(next);
|
||||
}
|
||||
|
||||
function pruneVdesHistoryView(): void {
|
||||
@@ -374,4 +374,6 @@ updateVdesSummary();
|
||||
restore: onServerVdesBatch,
|
||||
reset: resetVdesHistoryView,
|
||||
prune: pruneVdesHistoryView,
|
||||
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user