Initial commit
Sync docs to Wiki / wiki (push) Has been cancelled

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-05-17 23:25:14 +02:00
commit ba48de2d30
237 changed files with 105505 additions and 0 deletions
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
[package]
name = "trx-frontend-http"
version.workspace = true
edition = "2021"
build = "build.rs"
[dependencies]
trx-core = { path = "../../../trx-core" }
trx-frontend = { path = ".." }
trx-protocol = { path = "../../../../src/trx-protocol" }
tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true }
base64 = "0.22"
actix-web = "4.4"
actix-ws = "0.3"
tokio-stream = { version = "0.1", features = ["sync"] }
futures-util = "0.3"
bytes = "1"
flate2 = { workspace = true }
brotli = "7"
rand = "0.8"
hex = "0.4"
pickledb = "0.5"
dirs = "6"
uuid = { workspace = true }
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,493 @@
{
"iaru_r1": {
"name": "IARU Region 1",
"bands": [
{
"name": "2200m", "low_hz": 135700, "high_hz": 137800,
"segments": [
{ "low_hz": 135700, "high_hz": 137800, "mode": "CW", "label": "CW/Narrow" }
]
},
{
"name": "630m", "low_hz": 472000, "high_hz": 479000,
"segments": [
{ "low_hz": 472000, "high_hz": 479000, "mode": "CW", "label": "CW/Narrow" }
]
},
{
"name": "160m", "low_hz": 1810000, "high_hz": 2000000,
"segments": [
{ "low_hz": 1810000, "high_hz": 1838000, "mode": "CW", "label": "CW" },
{ "low_hz": 1838000, "high_hz": 1840000, "mode": "Narrow", "label": "Narrow" },
{ "low_hz": 1840000, "high_hz": 2000000, "mode": "All", "label": "All Modes" }
]
},
{
"name": "80m", "low_hz": 3500000, "high_hz": 3800000,
"segments": [
{ "low_hz": 3500000, "high_hz": 3570000, "mode": "CW", "label": "CW" },
{ "low_hz": 3570000, "high_hz": 3600000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 3600000, "high_hz": 3620000, "mode": "All", "label": "All Modes" },
{ "low_hz": 3620000, "high_hz": 3800000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "60m", "low_hz": 5351500, "high_hz": 5366500,
"segments": [
{ "low_hz": 5351500, "high_hz": 5354000, "mode": "CW", "label": "CW" },
{ "low_hz": 5354000, "high_hz": 5366500, "mode": "All", "label": "All Modes" }
]
},
{
"name": "40m", "low_hz": 7000000, "high_hz": 7200000,
"segments": [
{ "low_hz": 7000000, "high_hz": 7040000, "mode": "CW", "label": "CW" },
{ "low_hz": 7040000, "high_hz": 7060000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 7060000, "high_hz": 7100000, "mode": "All", "label": "All Modes" },
{ "low_hz": 7100000, "high_hz": 7200000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "30m", "low_hz": 10100000, "high_hz": 10150000,
"segments": [
{ "low_hz": 10100000, "high_hz": 10140000, "mode": "CW", "label": "CW" },
{ "low_hz": 10140000, "high_hz": 10150000, "mode": "Narrow", "label": "Narrow/Digi" }
]
},
{
"name": "20m", "low_hz": 14000000, "high_hz": 14350000,
"segments": [
{ "low_hz": 14000000, "high_hz": 14070000, "mode": "CW", "label": "CW" },
{ "low_hz": 14070000, "high_hz": 14099000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 14099000, "high_hz": 14101000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 14101000, "high_hz": 14112000, "mode": "All", "label": "All Modes" },
{ "low_hz": 14112000, "high_hz": 14350000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "17m", "low_hz": 18068000, "high_hz": 18168000,
"segments": [
{ "low_hz": 18068000, "high_hz": 18095000, "mode": "CW", "label": "CW" },
{ "low_hz": 18095000, "high_hz": 18109000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 18109000, "high_hz": 18111000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 18111000, "high_hz": 18168000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "15m", "low_hz": 21000000, "high_hz": 21450000,
"segments": [
{ "low_hz": 21000000, "high_hz": 21070000, "mode": "CW", "label": "CW" },
{ "low_hz": 21070000, "high_hz": 21149000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 21149000, "high_hz": 21151000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 21151000, "high_hz": 21450000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "12m", "low_hz": 24890000, "high_hz": 24990000,
"segments": [
{ "low_hz": 24890000, "high_hz": 24915000, "mode": "CW", "label": "CW" },
{ "low_hz": 24915000, "high_hz": 24929000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 24929000, "high_hz": 24931000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 24931000, "high_hz": 24990000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "10m", "low_hz": 28000000, "high_hz": 29700000,
"segments": [
{ "low_hz": 28000000, "high_hz": 28070000, "mode": "CW", "label": "CW" },
{ "low_hz": 28070000, "high_hz": 28190000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 28190000, "high_hz": 28225000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 28225000, "high_hz": 28320000, "mode": "All", "label": "All Modes" },
{ "low_hz": 28320000, "high_hz": 29100000, "mode": "Phone", "label": "Phone" },
{ "low_hz": 29100000, "high_hz": 29510000, "mode": "FM", "label": "FM" },
{ "low_hz": 29510000, "high_hz": 29700000, "mode": "Satellite", "label": "Satellite" }
]
},
{
"name": "6m", "low_hz": 50000000, "high_hz": 54000000,
"segments": [
{ "low_hz": 50000000, "high_hz": 50100000, "mode": "CW", "label": "CW/Beacon" },
{ "low_hz": 50100000, "high_hz": 50500000, "mode": "Phone", "label": "SSB" },
{ "low_hz": 50500000, "high_hz": 51000000, "mode": "All", "label": "All Modes" },
{ "low_hz": 51000000, "high_hz": 52000000, "mode": "FM", "label": "FM Repeaters" },
{ "low_hz": 52000000, "high_hz": 54000000, "mode": "All", "label": "All Modes" }
]
},
{
"name": "4m", "low_hz": 70000000, "high_hz": 70500000,
"segments": [
{ "low_hz": 70000000, "high_hz": 70100000, "mode": "CW", "label": "CW/Beacon" },
{ "low_hz": 70100000, "high_hz": 70250000, "mode": "Phone", "label": "SSB" },
{ "low_hz": 70250000, "high_hz": 70300000, "mode": "All", "label": "All Modes" },
{ "low_hz": 70300000, "high_hz": 70500000, "mode": "FM", "label": "FM" }
]
},
{
"name": "FM Broadcast", "low_hz": 87500000, "high_hz": 108000000,
"segments": [
{ "low_hz": 87500000, "high_hz": 108000000, "mode": "FM", "label": "FM Broadcasting" }
]
},
{
"name": "2m", "low_hz": 144000000, "high_hz": 146000000,
"segments": [
{ "low_hz": 144000000, "high_hz": 144150000, "mode": "CW", "label": "CW/EME" },
{ "low_hz": 144150000, "high_hz": 144400000, "mode": "Phone", "label": "SSB" },
{ "low_hz": 144400000, "high_hz": 144490000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 144490000, "high_hz": 144500000, "mode": "Beacon", "label": "NCDXF Beacon" },
{ "low_hz": 144500000, "high_hz": 144794000, "mode": "All", "label": "All Modes" },
{ "low_hz": 144794000, "high_hz": 144990000, "mode": "Narrow", "label": "Digital/APRS" },
{ "low_hz": 144990000, "high_hz": 145194000, "mode": "FM", "label": "FM Simplex" },
{ "low_hz": 145194000, "high_hz": 145806000, "mode": "FM", "label": "FM Repeaters" },
{ "low_hz": 145806000, "high_hz": 146000000, "mode": "Satellite", "label": "Satellite" }
]
},
{
"name": "70cm", "low_hz": 430000000, "high_hz": 440000000,
"segments": [
{ "low_hz": 430000000, "high_hz": 432000000, "mode": "FM", "label": "FM Repeaters" },
{ "low_hz": 432000000, "high_hz": 432150000, "mode": "CW", "label": "CW/EME" },
{ "low_hz": 432150000, "high_hz": 432500000, "mode": "Phone", "label": "SSB" },
{ "low_hz": 432500000, "high_hz": 432800000, "mode": "All", "label": "All Modes" },
{ "low_hz": 432800000, "high_hz": 433000000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 433000000, "high_hz": 435000000, "mode": "FM", "label": "FM" },
{ "low_hz": 435000000, "high_hz": 438000000, "mode": "Satellite", "label": "Satellite" },
{ "low_hz": 438000000, "high_hz": 440000000, "mode": "FM", "label": "FM" }
]
},
{
"name": "23cm", "low_hz": 1240000000, "high_hz": 1300000000,
"segments": [
{ "low_hz": 1240000000, "high_hz": 1243000000, "mode": "All", "label": "All Modes" },
{ "low_hz": 1243000000, "high_hz": 1260000000, "mode": "Narrow", "label": "Digital/ATV" },
{ "low_hz": 1260000000, "high_hz": 1270000000, "mode": "Satellite", "label": "Satellite" },
{ "low_hz": 1270000000, "high_hz": 1300000000, "mode": "All", "label": "All Modes" }
]
}
]
},
"iaru_r2": {
"name": "IARU Region 2",
"bands": [
{
"name": "2200m", "low_hz": 135700, "high_hz": 137800,
"segments": [
{ "low_hz": 135700, "high_hz": 137800, "mode": "CW", "label": "CW/Narrow" }
]
},
{
"name": "630m", "low_hz": 472000, "high_hz": 479000,
"segments": [
{ "low_hz": 472000, "high_hz": 479000, "mode": "CW", "label": "CW/Narrow" }
]
},
{
"name": "160m", "low_hz": 1800000, "high_hz": 2000000,
"segments": [
{ "low_hz": 1800000, "high_hz": 1840000, "mode": "CW", "label": "CW" },
{ "low_hz": 1840000, "high_hz": 1850000, "mode": "Narrow", "label": "CW/Digi" },
{ "low_hz": 1850000, "high_hz": 2000000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "80m", "low_hz": 3500000, "high_hz": 4000000,
"segments": [
{ "low_hz": 3500000, "high_hz": 3570000, "mode": "CW", "label": "CW" },
{ "low_hz": 3570000, "high_hz": 3600000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 3600000, "high_hz": 3700000, "mode": "All", "label": "All Modes" },
{ "low_hz": 3700000, "high_hz": 4000000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "60m", "low_hz": 5330500, "high_hz": 5403500,
"segments": [
{ "low_hz": 5330500, "high_hz": 5403500, "mode": "All", "label": "All Modes" }
]
},
{
"name": "40m", "low_hz": 7000000, "high_hz": 7300000,
"segments": [
{ "low_hz": 7000000, "high_hz": 7040000, "mode": "CW", "label": "CW" },
{ "low_hz": 7040000, "high_hz": 7060000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 7060000, "high_hz": 7100000, "mode": "All", "label": "All Modes" },
{ "low_hz": 7100000, "high_hz": 7125000, "mode": "All", "label": "All Modes" },
{ "low_hz": 7125000, "high_hz": 7300000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "30m", "low_hz": 10100000, "high_hz": 10150000,
"segments": [
{ "low_hz": 10100000, "high_hz": 10140000, "mode": "CW", "label": "CW" },
{ "low_hz": 10140000, "high_hz": 10150000, "mode": "Narrow", "label": "Narrow/Digi" }
]
},
{
"name": "20m", "low_hz": 14000000, "high_hz": 14350000,
"segments": [
{ "low_hz": 14000000, "high_hz": 14070000, "mode": "CW", "label": "CW" },
{ "low_hz": 14070000, "high_hz": 14099000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 14099000, "high_hz": 14101000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 14101000, "high_hz": 14112000, "mode": "All", "label": "All Modes" },
{ "low_hz": 14112000, "high_hz": 14350000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "17m", "low_hz": 18068000, "high_hz": 18168000,
"segments": [
{ "low_hz": 18068000, "high_hz": 18095000, "mode": "CW", "label": "CW" },
{ "low_hz": 18095000, "high_hz": 18109000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 18109000, "high_hz": 18111000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 18111000, "high_hz": 18168000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "15m", "low_hz": 21000000, "high_hz": 21450000,
"segments": [
{ "low_hz": 21000000, "high_hz": 21070000, "mode": "CW", "label": "CW" },
{ "low_hz": 21070000, "high_hz": 21149000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 21149000, "high_hz": 21151000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 21151000, "high_hz": 21450000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "12m", "low_hz": 24890000, "high_hz": 24990000,
"segments": [
{ "low_hz": 24890000, "high_hz": 24915000, "mode": "CW", "label": "CW" },
{ "low_hz": 24915000, "high_hz": 24929000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 24929000, "high_hz": 24931000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 24931000, "high_hz": 24990000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "10m", "low_hz": 28000000, "high_hz": 29700000,
"segments": [
{ "low_hz": 28000000, "high_hz": 28070000, "mode": "CW", "label": "CW" },
{ "low_hz": 28070000, "high_hz": 28190000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 28190000, "high_hz": 28225000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 28225000, "high_hz": 28300000, "mode": "All", "label": "All Modes" },
{ "low_hz": 28300000, "high_hz": 29100000, "mode": "Phone", "label": "Phone" },
{ "low_hz": 29100000, "high_hz": 29510000, "mode": "FM", "label": "FM" },
{ "low_hz": 29510000, "high_hz": 29700000, "mode": "Satellite", "label": "Satellite" }
]
},
{
"name": "6m", "low_hz": 50000000, "high_hz": 54000000,
"segments": [
{ "low_hz": 50000000, "high_hz": 50100000, "mode": "CW", "label": "CW/Beacon" },
{ "low_hz": 50100000, "high_hz": 50300000, "mode": "Phone", "label": "SSB" },
{ "low_hz": 50300000, "high_hz": 50600000, "mode": "All", "label": "All Modes" },
{ "low_hz": 50600000, "high_hz": 51000000, "mode": "Narrow", "label": "Digital" },
{ "low_hz": 51000000, "high_hz": 54000000, "mode": "FM", "label": "FM" }
]
},
{
"name": "FM Broadcast", "low_hz": 87500000, "high_hz": 108000000,
"segments": [
{ "low_hz": 87500000, "high_hz": 108000000, "mode": "FM", "label": "FM Broadcasting" }
]
},
{
"name": "2m", "low_hz": 144000000, "high_hz": 148000000,
"segments": [
{ "low_hz": 144000000, "high_hz": 144100000, "mode": "CW", "label": "CW/EME" },
{ "low_hz": 144100000, "high_hz": 144275000, "mode": "Phone", "label": "SSB" },
{ "low_hz": 144275000, "high_hz": 144400000, "mode": "Beacon", "label": "Beacon/Packet" },
{ "low_hz": 144400000, "high_hz": 145500000, "mode": "FM", "label": "FM Simplex" },
{ "low_hz": 145500000, "high_hz": 146000000, "mode": "FM", "label": "FM Repeaters" },
{ "low_hz": 146000000, "high_hz": 148000000, "mode": "FM", "label": "FM Repeaters" }
]
},
{
"name": "1.25m", "low_hz": 222000000, "high_hz": 225000000,
"segments": [
{ "low_hz": 222000000, "high_hz": 222150000, "mode": "CW", "label": "CW/EME" },
{ "low_hz": 222150000, "high_hz": 222250000, "mode": "Phone", "label": "SSB" },
{ "low_hz": 222250000, "high_hz": 223380000, "mode": "All", "label": "All Modes" },
{ "low_hz": 223380000, "high_hz": 223520000, "mode": "Narrow", "label": "Digital" },
{ "low_hz": 223520000, "high_hz": 225000000, "mode": "FM", "label": "FM Repeaters" }
]
},
{
"name": "70cm", "low_hz": 420000000, "high_hz": 450000000,
"segments": [
{ "low_hz": 420000000, "high_hz": 426000000, "mode": "All", "label": "All Modes" },
{ "low_hz": 426000000, "high_hz": 432000000, "mode": "FM", "label": "FM Repeaters" },
{ "low_hz": 432000000, "high_hz": 432100000, "mode": "CW", "label": "CW/EME" },
{ "low_hz": 432100000, "high_hz": 433000000, "mode": "Phone", "label": "SSB/All" },
{ "low_hz": 433000000, "high_hz": 435000000, "mode": "FM", "label": "FM/Links" },
{ "low_hz": 435000000, "high_hz": 438000000, "mode": "Satellite", "label": "Satellite" },
{ "low_hz": 438000000, "high_hz": 444000000, "mode": "FM", "label": "FM Repeaters" },
{ "low_hz": 444000000, "high_hz": 450000000, "mode": "FM", "label": "FM Repeaters" }
]
},
{
"name": "33cm", "low_hz": 902000000, "high_hz": 928000000,
"segments": [
{ "low_hz": 902000000, "high_hz": 903000000, "mode": "Narrow", "label": "Narrowband/Digital" },
{ "low_hz": 903000000, "high_hz": 906000000, "mode": "Narrow", "label": "Digital/Spread Spectrum" },
{ "low_hz": 906000000, "high_hz": 909000000, "mode": "FM", "label": "FM Repeaters" },
{ "low_hz": 909000000, "high_hz": 915000000, "mode": "All", "label": "All Modes" },
{ "low_hz": 915000000, "high_hz": 921000000, "mode": "All", "label": "All Modes" },
{ "low_hz": 921000000, "high_hz": 927000000, "mode": "FM", "label": "FM Repeaters" },
{ "low_hz": 927000000, "high_hz": 928000000, "mode": "FM", "label": "FM Simplex" }
]
},
{
"name": "23cm", "low_hz": 1240000000, "high_hz": 1300000000,
"segments": [
{ "low_hz": 1240000000, "high_hz": 1260000000, "mode": "All", "label": "All Modes/ATV" },
{ "low_hz": 1260000000, "high_hz": 1270000000, "mode": "Satellite", "label": "Satellite" },
{ "low_hz": 1270000000, "high_hz": 1295000000, "mode": "FM", "label": "FM Repeaters" },
{ "low_hz": 1295000000, "high_hz": 1300000000, "mode": "Narrow", "label": "Narrowband" }
]
}
]
},
"iaru_r3": {
"name": "IARU Region 3",
"bands": [
{
"name": "2200m", "low_hz": 135700, "high_hz": 137800,
"segments": [
{ "low_hz": 135700, "high_hz": 137800, "mode": "CW", "label": "CW/Narrow" }
]
},
{
"name": "630m", "low_hz": 472000, "high_hz": 479000,
"segments": [
{ "low_hz": 472000, "high_hz": 479000, "mode": "CW", "label": "CW/Narrow" }
]
},
{
"name": "160m", "low_hz": 1800000, "high_hz": 2000000,
"segments": [
{ "low_hz": 1800000, "high_hz": 1840000, "mode": "CW", "label": "CW" },
{ "low_hz": 1840000, "high_hz": 1850000, "mode": "Narrow", "label": "CW/Digi" },
{ "low_hz": 1850000, "high_hz": 2000000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "80m", "low_hz": 3500000, "high_hz": 3900000,
"segments": [
{ "low_hz": 3500000, "high_hz": 3570000, "mode": "CW", "label": "CW" },
{ "low_hz": 3570000, "high_hz": 3600000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 3600000, "high_hz": 3620000, "mode": "All", "label": "All Modes" },
{ "low_hz": 3620000, "high_hz": 3900000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "40m", "low_hz": 7000000, "high_hz": 7300000,
"segments": [
{ "low_hz": 7000000, "high_hz": 7040000, "mode": "CW", "label": "CW" },
{ "low_hz": 7040000, "high_hz": 7060000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 7060000, "high_hz": 7100000, "mode": "All", "label": "All Modes" },
{ "low_hz": 7100000, "high_hz": 7300000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "30m", "low_hz": 10100000, "high_hz": 10150000,
"segments": [
{ "low_hz": 10100000, "high_hz": 10140000, "mode": "CW", "label": "CW" },
{ "low_hz": 10140000, "high_hz": 10150000, "mode": "Narrow", "label": "Narrow/Digi" }
]
},
{
"name": "20m", "low_hz": 14000000, "high_hz": 14350000,
"segments": [
{ "low_hz": 14000000, "high_hz": 14070000, "mode": "CW", "label": "CW" },
{ "low_hz": 14070000, "high_hz": 14099000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 14099000, "high_hz": 14101000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 14101000, "high_hz": 14112000, "mode": "All", "label": "All Modes" },
{ "low_hz": 14112000, "high_hz": 14350000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "17m", "low_hz": 18068000, "high_hz": 18168000,
"segments": [
{ "low_hz": 18068000, "high_hz": 18095000, "mode": "CW", "label": "CW" },
{ "low_hz": 18095000, "high_hz": 18109000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 18109000, "high_hz": 18111000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 18111000, "high_hz": 18168000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "15m", "low_hz": 21000000, "high_hz": 21450000,
"segments": [
{ "low_hz": 21000000, "high_hz": 21070000, "mode": "CW", "label": "CW" },
{ "low_hz": 21070000, "high_hz": 21149000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 21149000, "high_hz": 21151000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 21151000, "high_hz": 21450000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "12m", "low_hz": 24890000, "high_hz": 24990000,
"segments": [
{ "low_hz": 24890000, "high_hz": 24915000, "mode": "CW", "label": "CW" },
{ "low_hz": 24915000, "high_hz": 24929000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 24929000, "high_hz": 24931000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 24931000, "high_hz": 24990000, "mode": "Phone", "label": "Phone" }
]
},
{
"name": "10m", "low_hz": 28000000, "high_hz": 29700000,
"segments": [
{ "low_hz": 28000000, "high_hz": 28070000, "mode": "CW", "label": "CW" },
{ "low_hz": 28070000, "high_hz": 28190000, "mode": "Narrow", "label": "Narrow/Digi" },
{ "low_hz": 28190000, "high_hz": 28225000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 28225000, "high_hz": 28300000, "mode": "All", "label": "All Modes" },
{ "low_hz": 28300000, "high_hz": 29100000, "mode": "Phone", "label": "Phone" },
{ "low_hz": 29100000, "high_hz": 29510000, "mode": "FM", "label": "FM" },
{ "low_hz": 29510000, "high_hz": 29700000, "mode": "Satellite", "label": "Satellite" }
]
},
{
"name": "6m", "low_hz": 50000000, "high_hz": 54000000,
"segments": [
{ "low_hz": 50000000, "high_hz": 50100000, "mode": "CW", "label": "CW/Beacon" },
{ "low_hz": 50100000, "high_hz": 50300000, "mode": "Phone", "label": "SSB" },
{ "low_hz": 50300000, "high_hz": 50600000, "mode": "All", "label": "All Modes" },
{ "low_hz": 50600000, "high_hz": 51000000, "mode": "Narrow", "label": "Digital" },
{ "low_hz": 51000000, "high_hz": 54000000, "mode": "FM", "label": "FM" }
]
},
{
"name": "FM Broadcast", "low_hz": 87500000, "high_hz": 108000000,
"segments": [
{ "low_hz": 87500000, "high_hz": 108000000, "mode": "FM", "label": "FM Broadcasting" }
]
},
{
"name": "2m", "low_hz": 144000000, "high_hz": 148000000,
"segments": [
{ "low_hz": 144000000, "high_hz": 144100000, "mode": "CW", "label": "CW/EME" },
{ "low_hz": 144100000, "high_hz": 144400000, "mode": "Phone", "label": "SSB" },
{ "low_hz": 144400000, "high_hz": 144500000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 144500000, "high_hz": 145000000, "mode": "All", "label": "All Modes" },
{ "low_hz": 145000000, "high_hz": 146000000, "mode": "FM", "label": "FM Simplex" },
{ "low_hz": 146000000, "high_hz": 148000000, "mode": "FM", "label": "FM Repeaters" }
]
},
{
"name": "70cm", "low_hz": 430000000, "high_hz": 450000000,
"segments": [
{ "low_hz": 430000000, "high_hz": 432000000, "mode": "FM", "label": "FM Repeaters" },
{ "low_hz": 432000000, "high_hz": 432100000, "mode": "CW", "label": "CW/EME" },
{ "low_hz": 432100000, "high_hz": 432400000, "mode": "Phone", "label": "SSB" },
{ "low_hz": 432400000, "high_hz": 432500000, "mode": "Beacon", "label": "Beacon" },
{ "low_hz": 432500000, "high_hz": 435000000, "mode": "All", "label": "All Modes" },
{ "low_hz": 435000000, "high_hz": 438000000, "mode": "Satellite", "label": "Satellite" },
{ "low_hz": 438000000, "high_hz": 440000000, "mode": "FM", "label": "FM" },
{ "low_hz": 440000000, "high_hz": 450000000, "mode": "FM", "label": "FM Repeaters" }
]
},
{
"name": "23cm", "low_hz": 1240000000, "high_hz": 1300000000,
"segments": [
{ "low_hz": 1240000000, "high_hz": 1260000000, "mode": "All", "label": "All Modes" },
{ "low_hz": 1260000000, "high_hz": 1270000000, "mode": "Satellite", "label": "Satellite" },
{ "low_hz": 1270000000, "high_hz": 1300000000, "mode": "FM", "label": "FM/ATV" }
]
}
]
}
}
@@ -0,0 +1,180 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
function decodeCborUint(view, bytes, state, additional) {
const offset = state.offset;
if (additional < 24) return additional;
if (additional === 24) {
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
state.offset += 1;
return bytes[offset];
}
if (additional === 25) {
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
state.offset += 2;
return view.getUint16(offset);
}
if (additional === 26) {
if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
state.offset += 4;
return view.getUint32(offset);
}
if (additional === 27) {
if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
const value = view.getBigUint64(offset);
state.offset += 8;
const numeric = Number(value);
if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range");
return numeric;
}
throw new Error("Unsupported CBOR additional info");
}
function decodeCborFloat16(bits) {
const sign = (bits & 0x8000) ? -1 : 1;
const exponent = (bits >> 10) & 0x1f;
const fraction = bits & 0x03ff;
if (exponent === 0) {
return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024);
}
if (exponent === 0x1f) {
return fraction === 0 ? sign * Infinity : Number.NaN;
}
return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024));
}
function decodeCborItem(view, bytes, state) {
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
const initial = bytes[state.offset++];
const major = initial >> 5;
const additional = initial & 0x1f;
if (major === 0) return decodeCborUint(view, bytes, state, additional);
if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional);
if (major === 2) {
const length = decodeCborUint(view, bytes, state, additional);
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
const chunk = bytes.slice(state.offset, state.offset + length);
state.offset += length;
return Array.from(chunk);
}
if (major === 3) {
const length = decodeCborUint(view, bytes, state, additional);
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
const chunk = bytes.subarray(state.offset, state.offset + length);
state.offset += length;
return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk);
}
if (major === 4) {
const length = decodeCborUint(view, bytes, state, additional);
const items = new Array(length);
for (let i = 0; i < length; i += 1) {
items[i] = decodeCborItem(view, bytes, state);
}
return items;
}
if (major === 5) {
const length = decodeCborUint(view, bytes, state, additional);
const value = {};
for (let i = 0; i < length; i += 1) {
const key = decodeCborItem(view, bytes, state);
value[String(key)] = decodeCborItem(view, bytes, state);
}
return value;
}
if (major === 6) {
decodeCborUint(view, bytes, state, additional);
return decodeCborItem(view, bytes, state);
}
if (major === 7) {
if (additional === 20) return false;
if (additional === 21) return true;
if (additional === 22) return null;
if (additional === 23) return undefined;
if (additional === 25) {
if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
const bits = view.getUint16(state.offset);
state.offset += 2;
return decodeCborFloat16(bits);
}
if (additional === 26) {
if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
const value = view.getFloat32(state.offset);
state.offset += 4;
return value;
}
if (additional === 27) {
if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
const value = view.getFloat64(state.offset);
state.offset += 8;
return value;
}
}
throw new Error("Unsupported CBOR major type");
}
function decodeCborPayload(buffer) {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const state = { offset: 0 };
const value = decodeCborItem(view, bytes, state);
if (state.offset !== bytes.length) {
throw new Error("Unexpected trailing bytes in decode history payload");
}
return value;
}
async function fetchAndDecodeHistory(url, batchLimit) {
self.postMessage({ type: "status", phase: "fetching" });
const resp = await fetch(url, { credentials: "same-origin" });
if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`);
const payload = await resp.arrayBuffer();
if (!payload || payload.byteLength === 0) {
self.postMessage({ type: "start", total: 0 });
self.postMessage({ type: "done", total: 0 });
return;
}
self.postMessage({ type: "status", phase: "decoding" });
const history = decodeCborPayload(payload);
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
const items = history && Array.isArray(history[key]) ? history[key] : [];
return sum + items.length;
}, 0);
self.postMessage({ type: "start", total });
let processed = 0;
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
for (const kind of HISTORY_GROUP_KEYS) {
const items = history && Array.isArray(history[kind]) ? history[kind] : [];
if (items.length === 0) continue;
for (let index = 0; index < items.length; index += safeLimit) {
const messages = items.slice(index, index + safeLimit);
processed += messages.length;
self.postMessage({
type: "group",
kind,
messages,
processed,
total,
});
}
}
self.postMessage({ type: "done", total });
}
self.onmessage = (event) => {
const data = event?.data || {};
if (data?.type !== "fetch-history") return;
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit)
.catch((err) => {
self.postMessage({
type: "error",
message: err && err.message ? err.message : String(err || "unknown worker failure"),
});
});
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,120 @@
(function() {
if (typeof L === "undefined") return;
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function finiteAngle(value) {
if (!Number.isFinite(value)) return null;
const normalized = ((Number(value) % 360) + 360) % 360;
return normalized;
}
function svgColor(value, fallback) {
const text = String(value || fallback || "");
return text.replace(/"/g, "&quot;");
}
function buildSymbolHtml(options, zoom) {
const heading = finiteAngle(options.heading);
const course = finiteAngle(options.course);
const angle = heading != null ? heading : course;
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
const sizeBase = Number.isFinite(options.size) ? Number(options.size) : 22;
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
const size = clamp(sizeBase + zoomBoost, 16, 32);
const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
const color = svgColor(options.color, "#ff7559");
const outline = svgColor(options.outline, "#6b2118");
const body = angle != null
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})">` +
`<path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />` +
`</g>`
: `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
const courseLine = course != null
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})">` +
`<line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" />` +
`</g>`
: "";
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` +
courseLine +
body +
`</svg>`
);
}
L.TrxAisTrackSymbol = L.Marker.extend({
options: {
heading: null,
course: null,
speed: null,
color: "#ff7559",
outline: "#6b2118",
size: 22,
interactive: true,
keyboard: true,
riseOnHover: true,
},
initialize: function(latlng, options) {
const merged = L.Util.extend({}, this.options, options || {});
merged.icon = L.divIcon({
className: "trx-ais-track-symbol-icon",
html: "",
iconSize: [merged.size, merged.size],
iconAnchor: [merged.size / 2, merged.size / 2],
});
L.Marker.prototype.initialize.call(this, latlng, merged);
},
onAdd: function(map) {
L.Marker.prototype.onAdd.call(this, map);
this._refreshIcon();
this._boundZoomRefresh = this._refreshIcon.bind(this);
map.on("zoomend", this._boundZoomRefresh);
},
onRemove: function(map) {
if (this._boundZoomRefresh) {
map.off("zoomend", this._boundZoomRefresh);
this._boundZoomRefresh = null;
}
L.Marker.prototype.onRemove.call(this, map);
},
setAisState: function(next) {
if (next && typeof next === "object") {
if ("heading" in next) this.options.heading = next.heading;
if ("course" in next) this.options.course = next.course;
if ("speed" in next) this.options.speed = next.speed;
if ("color" in next) this.options.color = next.color;
if ("outline" in next) this.options.outline = next.outline;
}
this._refreshIcon();
return this;
},
_refreshIcon: function() {
if (!this._icon) return;
const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
const html = buildSymbolHtml(this.options, zoom);
this._icon.innerHTML = html;
const sizeBase = Number.isFinite(this.options.size) ? Number(this.options.size) : 22;
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
const size = clamp(sizeBase + zoomBoost, 16, 32);
this._icon.style.width = `${size}px`;
this._icon.style.height = `${size}px`;
this._icon.style.marginLeft = `${-size / 2}px`;
this._icon.style.marginTop = `${-size / 2}px`;
},
});
L.trxAisTrackSymbol = function(latlng, options) {
return new L.TrxAisTrackSymbol(latlng, options);
};
})();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,407 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- AIS Decoder Plugin (server-side decode) ---
const aisStatus = document.getElementById("ais-status");
const aisMessagesEl = document.getElementById("ais-messages");
const aisFilterInput = document.getElementById("ais-filter");
const aisBarOverlay = document.getElementById("ais-bar-overlay");
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
const aisVesselCountEl = document.getElementById("ais-vessel-count");
const aisLatestSeenEl = document.getElementById("ais-latest-seen");
const AIS_BAR_WINDOW_MS = 15 * 60 * 1000;
const AIS_DEFAULT_A_HZ = 161_975_000;
const AIS_CHANNEL_SPACING_HZ = 50_000;
let aisFilterText = "";
let aisMessageHistory = [];
function currentAisHistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneAisMessageHistory() {
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
}
function scheduleAisUi(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleAisHistoryRender() {
scheduleAisUi("ais-history", () => renderAisHistory());
}
function scheduleAisBarUpdate() {
scheduleAisUi("ais-bar", () => updateAisBar());
}
function formatAisMhz(freqHz) {
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
}
function currentAisChannelPlan() {
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
return {
aHz: safeAHz,
bHz: safeAHz + AIS_CHANNEL_SPACING_HZ,
};
}
function aisChannelInfo(channel) {
const plan = currentAisChannelPlan();
const ch = String(channel || "").trim().toUpperCase();
if (ch === "B") {
return {
label: "AIS-B",
badgeClass: "ais-badge ais-badge-channel-b",
freqText: formatAisMhz(plan.bHz),
};
}
return {
label: "AIS-A",
badgeClass: "ais-badge ais-badge-channel-a",
freqText: formatAisMhz(plan.aHz),
};
}
function aisDisplayName(msg) {
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
}
function aisDisplayNameHtml(msg) {
const label = escapeMapHtml(aisDisplayName(msg));
const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
if (!url) return label;
return `<a class="title-link" href="${escapeMapHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
}
function aisTypeLabel(type) {
switch (Number(type)) {
case 1:
case 2:
case 3:
return "Class A Position";
case 4:
return "Base Station";
case 5:
return "Static/Voyage";
case 18:
return "Class B Position";
case 19:
return "Class B Extended";
case 21:
return "Aid to Nav";
case 24:
return "Class B Static";
default:
return `Type ${type ?? "--"}`;
}
}
function aisAgeText(tsMs) {
if (!Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
return `${hours}h ago`;
}
function aisMotionText(msg) {
const parts = [
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null,
msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}° HDG` : null,
].filter(Boolean);
return parts.join(" · ");
}
function aisRouteText(msg) {
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
}
function aisDistanceText(msg) {
if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) {
return "";
}
const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon);
if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`;
}
function aisLatestByVessel(messages) {
const byMmsi = new Map();
for (const msg of messages) {
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
if (!byMmsi.has(key)) byMmsi.set(key, msg);
}
return Array.from(byMmsi.values());
}
function updateAisSummary() {
const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
}
const vessels = aisLatestByVessel(aisMessageHistory);
if (aisVesselCountEl) {
const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
}
if (aisLatestSeenEl) {
const latest = aisMessageHistory[0];
if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet";
} else {
const channel = aisChannelInfo(latest.channel);
aisLatestSeenEl.textContent = `${channel.label} ${aisAgeText(latest._tsMs)}`;
}
}
}
function renderAisRow(msg) {
const row = document.createElement("div");
row.className = "ais-message";
const ts = msg._ts || new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const name = aisDisplayName(msg);
const nameHtml = aisDisplayNameHtml(msg);
const channel = aisChannelInfo(msg.channel);
const motion = aisMotionText(msg);
const route = aisRouteText(msg);
const distance = aisDistanceText(msg);
const pos = msg.lat != null && msg.lon != null
? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>`
: "";
row.dataset.filterText = [
name,
msg.mmsi,
msg.channel,
channel.label,
msg.vessel_name,
msg.callsign,
msg.destination,
aisTypeLabel(msg.message_type),
]
.filter(Boolean)
.join(" ")
.toUpperCase();
row.innerHTML =
`<div class="ais-row-head">` +
`<span class="ais-time">${ts}</span>` +
`<span class="ais-call">${nameHtml}</span>` +
`<span class="${channel.badgeClass}">${escapeMapHtml(channel.label)}</span>` +
`<span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span>` +
`</div>` +
`<div class="ais-row-meta">` +
`<span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` +
(route ? `<span class="ais-meta-text">${escapeMapHtml(route)}</span>` : "") +
`<span class="ais-meta-text">${escapeMapHtml(channel.freqText)}</span>` +
`</div>` +
`<div class="ais-row-detail">` +
(motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) +
(distance ? `<span>${escapeMapHtml(distance)}</span>` : "") +
(pos ? `<span>${pos}</span>` : "") +
`<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span>` +
`</div>`;
applyAisFilterToRow(row);
return row;
}
function applyAisFilterToRow(row) {
if (!aisFilterText) {
row.style.display = "";
return;
}
const message = row.dataset.filterText || "";
row.style.display = message.includes(aisFilterText) ? "" : "none";
}
function applyAisFilterToAll() {
if (!aisMessagesEl) return;
const rows = aisMessagesEl.querySelectorAll(".ais-message");
rows.forEach((row) => applyAisFilterToRow(row));
}
function updateAisBar() {
if (!aisBarOverlay) return;
updateAisSummary();
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
const messages = aisLatestByVessel(recent).slice(0, 8);
if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none";
aisBarOverlay.innerHTML = "";
return;
}
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">AIS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAisBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAisBar();}" aria-label="Clear AIS overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>';
for (const msg of messages) {
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
const pin = msg.lat != null && msg.lon != null
? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>`
: "";
const name = `<span class="ais-call">${aisDisplayNameHtml(msg)}</span>`;
const channel = aisChannelInfo(msg.channel);
const distance = aisDistanceText(msg);
const details = [
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
escapeMapHtml(channel.label),
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
distance ? escapeMapHtml(distance) : null,
escapeMapHtml(aisAgeText(msg._tsMs)),
]
.filter(Boolean)
.join(" · ");
html += `<div class="aprs-bar-frame">` +
`<div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div>` +
`</div>`;
}
aisBarOverlay.innerHTML = html;
aisBarOverlay.style.display = "flex";
}
window.updateAisBar = updateAisBar;
window.clearAisBar = function() {
window.resetAisHistoryView();
};
window.resetAisHistoryView = function() {
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
aisMessageHistory = [];
updateAisBar();
renderAisHistory();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
};
function renderAisHistory() {
pruneAisMessageHistory();
if (!aisMessagesEl) {
updateAisSummary();
return;
}
const fragment = document.createDocumentFragment();
for (let i = 0; i < aisMessageHistory.length; i += 1) {
fragment.appendChild(renderAisRow(aisMessageHistory[i]));
}
aisMessagesEl.replaceChildren(fragment);
updateAisSummary();
}
function addAisMessage(msg) {
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
msg._tsMs = tsMs;
msg._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
aisMessageHistory.unshift(msg);
pruneAisMessageHistory();
scheduleAisBarUpdate();
scheduleAisHistoryRender();
if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) {
window.aisMapAddVessel(msg);
}
}
function normalizeServerAisMessage(msg) {
return {
rig_id: msg.rig_id || null,
channel: msg.channel,
message_type: msg.message_type,
mmsi: msg.mmsi,
lat: msg.lat,
lon: msg.lon,
sog_knots: msg.sog_knots,
cog_deg: msg.cog_deg,
heading_deg: msg.heading_deg,
vessel_name: msg.vessel_name,
callsign: msg.callsign,
destination: msg.destination,
ts_ms: msg.ts_ms,
};
}
window.onServerAisBatch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerAisMessage(msg);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
if (next.lat != null && next.lon != null && window.aisMapAddVessel) {
window.aisMapAddVessel(next);
}
normalized.push(next);
}
normalized.reverse();
aisMessageHistory = normalized.concat(aisMessageHistory);
pruneAisMessageHistory();
scheduleAisBarUpdate();
scheduleAisHistoryRender();
};
window.restoreAisHistory = function(messages) {
window.onServerAisBatch(messages);
};
window.pruneAisHistoryView = function() {
pruneAisMessageHistory();
updateAisBar();
renderAisHistory();
};
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
if (!confirm("Clear all AIS decode history? This cannot be undone.")) return;
try {
await postPath("/clear_ais_decode");
window.resetAisHistoryView();
} catch (e) {
console.error("AIS history clear failed", e);
}
});
if (aisFilterInput) {
aisFilterInput.addEventListener("input", () => {
aisFilterText = aisFilterInput.value.trim().toUpperCase();
renderAisHistory();
});
}
window.onServerAis = function(msg) {
if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg));
};
updateAisSummary();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais");
@@ -0,0 +1,498 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- APRS Decoder Plugin (server-side decode) ---
const aprsStatus = document.getElementById("aprs-status");
const aprsPacketsEl = document.getElementById("aprs-packets");
const aprsFilterInput = document.getElementById("aprs-filter");
const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
const aprsTotalCountEl = document.getElementById("aprs-total-count");
const aprsVisibleCountEl = document.getElementById("aprs-visible-count");
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
let aprsFilterText = "";
let aprsPacketHistory = [];
let aprsBarDismissedAtMs = 0;
let aprsOnlyPos = false;
let aprsHideCrc = false;
let aprsCollapseDup = false;
let aprsTypeFilter = "all";
function currentAprsHistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneAprsPacketHistory() {
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
}
function scheduleAprsUi(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleAprsHistoryRender() {
scheduleAprsUi("aprs-history", () => renderAprsHistory());
}
function scheduleAprsBarUpdate() {
scheduleAprsUi("aprs-bar", () => updateAprsBar());
}
function renderAprsInfo(pkt) {
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
if (bytes && bytes.length > 0) {
let out = "";
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i];
if (b >= 0x20 && b <= 0x7e) {
const ch = String.fromCharCode(b);
if (ch === "<") out += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
else out += ch;
} else {
const hex = b.toString(16).toUpperCase().padStart(2, "0");
out += `<span class="aprs-byte">0x${hex}</span>`;
}
}
return out;
}
const str = pkt.info || "";
let out = "";
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code >= 0x20 && code <= 0x7e) {
const ch = str[i];
if (ch === "<") out += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
else out += ch;
} else {
const hex = code.toString(16).toUpperCase().padStart(2, "0");
out += `<span class="aprs-byte">0x${hex}</span>`;
}
}
return out;
}
function aprsPacketCategory(pkt) {
const type = String(pkt.type || "").toLowerCase();
const info = String(pkt.info || "").toLowerCase();
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather";
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
return "other";
}
function aprsCategoryLabel(category) {
switch (category) {
case "position": return "Position";
case "message": return "Message";
case "weather": return "Weather";
case "telemetry": return "Telemetry";
default: return "Other";
}
}
function aprsAgeText(tsMs) {
if (!Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
return `${hours}h ago`;
}
function aprsDistanceText(pkt) {
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`;
}
function aprsPacketSignature(pkt) {
return [
pkt.srcCall || "",
pkt.destCall || "",
pkt.path || "",
pkt.info || "",
pkt.type || "",
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
].join("|");
}
function aprsHexBytes(bytes) {
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
}
function aprsFilterMatch(pkt) {
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
if (aprsHideCrc && !pkt.crcOk) return false;
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
if (!aprsFilterText) return true;
const haystack = [
pkt.srcCall,
pkt.destCall,
pkt.path,
pkt.info,
pkt.type,
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
aprsPacketCategory(pkt),
]
.filter(Boolean)
.join(" ")
.toUpperCase();
return haystack.includes(aprsFilterText);
}
function aprsVisiblePackets() {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
return packets.filter(aprsFilterMatch);
}
function collapseAprsDuplicates(packets) {
const seen = new Set();
const out = [];
for (const pkt of packets) {
const key = aprsPacketSignature(pkt);
if (seen.has(key)) continue;
seen.add(key);
out.push(pkt);
}
return out;
}
function updateAprsSummary() {
const visible = aprsVisiblePackets();
if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
}
if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0];
if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet";
} else {
aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
}
}
}
function updateAprsChipState() {
document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => {
btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`);
});
aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos);
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
}
function renderAprsRow(pkt, isFresh) {
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = aprsAgeText(pkt._tsMs);
const category = aprsPacketCategory(pkt);
const categoryLabel = aprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
let symbolHtml = "";
if (pkt.symbolTable && pkt.symbolCode) {
const sheet = pkt.symbolTable === "/" ? 0 : 1;
const code = pkt.symbolCode.charCodeAt(0) - 33;
const col = code % 16;
const row2 = Math.floor(code / 16);
const bgX = -(col * 24);
const bgY = -(row2 * 24);
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
}
const posLink = pkt.lat != null && pkt.lon != null
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
: "";
const distance = aprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML =
`<div class="aprs-row-head">` +
`<span class="aprs-time">${ts}</span>` +
symbolHtml +
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
`<span>&gt;${escapeMapHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
pathBadge +
crcBadge +
`</div>` +
`<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
`</div>` +
`<div class="aprs-row-detail">` +
`<span title="${escapeMapHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
(posLink ? `<span>${posLink}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`<details class="aprs-details">` +
`<summary>Details</summary>` +
`<div class="aprs-details-grid">` +
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` +
`</details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = String(el.dataset.aprsMap || "");
const [lat, lon] = raw.split(",").map(Number);
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
window.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
const raw = String(copyBtn.dataset.aprsCopy || "");
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(raw);
showHint("Coordinates copied", 1200);
}
} catch (_e) {
showHint("Copy failed", 1500);
}
});
}
return row;
}
function renderAprsHistory() {
pruneAprsPacketHistory();
if (!aprsPacketsEl) {
updateAprsSummary();
updateAprsChipState();
return;
}
const visible = aprsVisiblePackets();
const fragment = document.createDocumentFragment();
for (let i = 0; i < visible.length; i++) {
fragment.appendChild(renderAprsRow(visible[i], i === 0));
}
aprsPacketsEl.replaceChildren(fragment);
updateAprsSummary();
updateAprsChipState();
}
function updateAprsBar() {
if (!aprsBarOverlay) return;
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
aprsBarOverlay.style.display = "none";
aprsBarOverlay.innerHTML = "";
return;
}
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">&times;</button></span></div>';
for (const pkt of frames) {
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
const call = `<span class="aprs-bar-call">${escapeMapHtml(pkt.srcCall)}</span>`;
const dest = escapeMapHtml(pkt.destCall || "");
const info = escapeMapHtml(pkt.info || "");
const pin = pkt.lat != null && pkt.lon != null
? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>`
: "";
html += `<div class="aprs-bar-frame">` +
`<div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div>` +
`</div>`;
}
aprsBarOverlay.innerHTML = html;
aprsBarOverlay.style.display = "flex";
}
window.updateAprsBar = updateAprsBar;
window.clearAprsBar = function() {
window.resetAprsHistoryView();
};
window.closeAprsBar = function() {
aprsBarDismissedAtMs = Date.now();
if (aprsBarOverlay) {
aprsBarOverlay.style.display = "none";
aprsBarOverlay.innerHTML = "";
}
};
window.resetAprsHistoryView = function() {
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
aprsPacketHistory = [];
updateAprsBar();
renderAprsHistory();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs");
};
window.pruneAprsHistoryView = function() {
pruneAprsPacketHistory();
updateAprsBar();
renderAprsHistory();
};
function addAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory();
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt);
}
if (pkt.crcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
}
function normalizeServerAprsPacket(pkt) {
return {
rig_id: pkt.rig_id || null,
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
srcCall: pkt.src_call,
destCall: pkt.dest_call,
path: pkt.path,
info: pkt.info,
info_bytes: pkt.info_bytes,
type: pkt.packet_type,
crcOk: pkt.crc_ok,
ts_ms: pkt.ts_ms,
lat: pkt.lat,
lon: pkt.lon,
symbolTable: pkt.symbol_table,
symbolCode: pkt.symbol_code,
};
}
window.onServerAprsBatch = function(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
aprsStatus.textContent = "Receiving";
const normalized = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
if (next.lat != null && next.lon != null && window.aprsMapAddStation) {
window.aprsMapAddStation(next.srcCall, next.lat, next.lon, next.info, next.symbolTable, next.symbolCode, next);
}
if (next.crcOk) hasCrcOk = true;
normalized.push(next);
}
normalized.reverse();
aprsPacketHistory = normalized.concat(aprsPacketHistory);
pruneAprsPacketHistory();
if (hasCrcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
};
window.restoreAprsHistory = function(packets) {
window.onServerAprsBatch(packets);
};
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
if (!confirm("Clear all APRS decode history? This cannot be undone.")) return;
try {
await postPath("/clear_aprs_decode");
window.resetAprsHistoryView();
} catch (e) {
console.error("APRS history clear failed", e);
}
});
if (aprsOnlyPosBtn) {
aprsOnlyPosBtn.addEventListener("click", () => {
aprsOnlyPos = !aprsOnlyPos;
renderAprsHistory();
});
}
if (aprsHideCrcBtn) {
aprsHideCrcBtn.addEventListener("click", () => {
aprsHideCrc = !aprsHideCrc;
renderAprsHistory();
});
}
if (aprsCollapseDupBtn) {
aprsCollapseDupBtn.addEventListener("click", () => {
aprsCollapseDup = !aprsCollapseDup;
renderAprsHistory();
});
}
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
const btn = document.getElementById(`aprs-type-${type}`);
if (!btn) return;
btn.addEventListener("click", () => {
aprsTypeFilter = type;
renderAprsHistory();
});
});
if (aprsFilterInput) {
aprsFilterInput.addEventListener("input", () => {
aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
renderAprsHistory();
});
}
// --- Server-side APRS decode handler ---
window.onServerAprs = function(pkt) {
aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
};
renderAprsHistory();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs");
@@ -0,0 +1,410 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
(function () {
"use strict";
function bgdSupportedIds() {
return (window.decoderRegistry || [])
.filter(function (d) { return d.background_decode; })
.map(function (d) { return d.id; });
}
let backgroundDecodeRole = null;
let currentRigId = null;
let currentConfig = null;
let bookmarkList = [];
let statusInterval = null;
let bgdDirty = false;
function initBackgroundDecode(rigId, role) {
backgroundDecodeRole = role;
currentRigId = rigId || null;
if (currentRigId) loadBackgroundDecode();
startStatusPolling();
}
function setBackgroundDecodeRig(rigId) {
const nextRigId = rigId || null;
if (nextRigId === currentRigId) return;
currentRigId = nextRigId;
if (!currentRigId) return;
loadBackgroundDecode();
}
function apiGetConfig(rigId) {
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function apiPutConfig(rigId, config) {
return fetch("/background-decode/" + encodeURIComponent(rigId), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
}).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function apiResetConfig(rigId) {
return fetch("/background-decode/" + encodeURIComponent(rigId), {
method: "DELETE",
}).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function apiGetStatus(rigId) {
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function apiGetBookmarks() {
return fetch("/bookmarks").then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function loadBackgroundDecode() {
const rigId = currentRigId;
if (!rigId) return;
Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
.then(function ([config, bookmarks]) {
currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] };
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
renderBackgroundDecode();
clearBgdDirty();
pollBackgroundDecodeStatus();
})
.catch(function (err) {
console.error("background decode load failed", err);
});
}
function supportedBookmarks() {
return bookmarkList.filter(function (bookmark) {
return bookmarkDecoderKinds(bookmark).length > 0;
});
}
function bookmarkDecoderKinds(bookmark) {
var ids = bgdSupportedIds();
var decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : [];
var explicit = decoders
.map(function (item) { return String(item || "").trim().toLowerCase(); })
.filter(function (item, index, arr) {
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
});
if (explicit.length > 0) return explicit;
// Fall back: infer from mode via mode-bound entries in the registry.
var mode = String(bookmark && bookmark.mode || "").trim().toUpperCase();
return (window.decoderRegistry || [])
.filter(function (d) {
return d.activation === "mode_bound" && d.background_decode
&& d.active_modes.indexOf(mode) >= 0;
})
.map(function (d) { return d.id; });
}
function renderBackgroundDecode() {
if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
setCheckbox("background-decode-enabled", !!currentConfig.enabled);
renderBookmarkChecklist();
const isControl = backgroundDecodeRole === "control" || (typeof authEnabled !== "undefined" && !authEnabled);
const panel = document.getElementById("background-decode-panel");
if (panel) {
panel.querySelectorAll("input, select, button.sch-write").forEach(function (el) {
el.disabled = !isControl;
});
}
const saveBtn = document.getElementById("background-decode-save-btn");
const resetBtn = document.getElementById("background-decode-reset-btn");
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
}
function renderBookmarkChecklist(filterText) {
const container = document.getElementById("bgd-bookmark-checklist");
if (!container) return;
container.innerHTML = "";
const selectedIds = new Set(
currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []
);
const all = supportedBookmarks();
const filter = (filterText || "").trim().toLowerCase();
const filtered = filter
? all.filter(function (bm) {
var text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
return text.indexOf(filter) >= 0;
})
: all;
if (filtered.length === 0) {
container.innerHTML = '<div class="bgd-checklist-empty">' +
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
'</div>';
return;
}
filtered.forEach(function (bookmark) {
var row = document.createElement("label");
row.className = "bgd-checklist-row";
var decoders = bookmarkDecoderKinds(bookmark);
var checked = selectedIds.has(bookmark.id) ? " checked" : "";
row.innerHTML =
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
row.querySelector("input").addEventListener("change", function (e) {
onChecklistToggle(bookmark.id, e.target.checked);
});
container.appendChild(row);
});
}
function onChecklistToggle(bookmarkId, checked) {
if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = [];
if (checked && !currentConfig.bookmark_ids.includes(bookmarkId)) {
currentConfig.bookmark_ids.push(bookmarkId);
} else if (!checked) {
currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function (id) { return id !== bookmarkId; });
}
markBgdDirty();
}
function saveBackgroundDecode() {
const rigId = currentRigId;
if (!rigId) return;
const payload = {
remote: rigId,
enabled: !!document.getElementById("background-decode-enabled").checked,
bookmark_ids: Array.isArray(currentConfig && currentConfig.bookmark_ids) ? currentConfig.bookmark_ids.slice() : [],
};
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.disabled = true;
apiPutConfig(rigId, payload)
.then(function (saved) {
currentConfig = saved;
renderBackgroundDecode();
clearBgdDirty();
pollBackgroundDecodeStatus();
showToast("Background decode saved.");
})
.catch(function (err) {
showToast("Save failed: " + err.message, true);
})
.finally(function () {
if (btn) btn.disabled = false;
});
}
function resetBackgroundDecode() {
const rigId = currentRigId;
if (!rigId) return;
if (!confirm("Reset background decode configuration? This cannot be undone.")) return;
apiResetConfig(rigId)
.then(function (saved) {
currentConfig = saved;
renderBackgroundDecode();
clearBgdDirty();
pollBackgroundDecodeStatus();
showToast("Background decode reset.");
})
.catch(function (err) {
showToast("Reset failed: " + err.message, true);
});
}
function startStatusPolling() {
if (statusInterval) clearInterval(statusInterval);
statusInterval = setInterval(pollBackgroundDecodeStatus, 15000);
}
function pollBackgroundDecodeStatus() {
const rigId = currentRigId;
if (!rigId) return;
apiGetStatus(rigId)
.then(renderStatus)
.catch(function () {});
}
function renderStatus(status) {
const card = document.getElementById("background-decode-status-card");
if (!card) return;
const entries = Array.isArray(status && status.entries) ? status.entries : [];
if (!entries.length) {
card.textContent = "No background decode bookmarks configured.";
return;
}
const summary = [];
if (status.active_rig) {
if (Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
if (Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
} else {
summary.push("This rig is not currently selected for audio.");
}
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
html += '<div class="bgd-status-list">';
entries.forEach(function (entry) {
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
const parts = [];
if (Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
if (entry.mode) parts.push(entry.mode);
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
parts.push(entry.decoder_kinds.join("/").toUpperCase());
}
html +=
'<div class="bgd-status-row">' +
'<div>' +
'<div class="bgd-status-name">' + escHtml(name) + '</div>' +
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
'</div>' +
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' +
'<svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' +
escHtml(prettyState(entry.state)) + '</div>' +
'</div>';
});
html += "</div>";
card.innerHTML = html;
}
function prettyState(state) {
switch (state) {
case "active": return "\u2713 Active";
case "out_of_span": return "\u25B3 Out of span";
case "waiting_for_spectrum": return "\u25B3 Waiting";
case "waiting_for_user": return "\u25B3 No user";
case "missing_bookmark": return "\u2717 Missing";
case "no_supported_decoders": return "\u2717 Unsupported";
case "disabled": return "\u25B3 Disabled";
case "handled_by_scheduler": return "\u25B3 Scheduler";
case "scheduler_has_control": return "\u25B3 Scheduler";
case "handled_by_virtual_channel": return "\u25B3 VChan";
default: return "\u25B3 Inactive";
}
}
function setCheckbox(id, value) {
const el = document.getElementById(id);
if (el) el.checked = !!value;
}
function formatFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
return hz + " Hz";
}
function escHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function markBgdDirty() {
if (bgdDirty) return;
bgdDirty = true;
var btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.add("sch-dirty");
}
function clearBgdDirty() {
bgdDirty = false;
var btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.remove("sch-dirty");
}
function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast");
if (!el) return;
el.textContent = msg;
el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
el.style.display = "block";
setTimeout(function () {
el.style.display = "none";
}, 3000);
}
function selectAllBookmarks() {
if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
var ids = supportedBookmarks().map(function (bm) { return bm.id; });
currentConfig.bookmark_ids = ids;
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
markBgdDirty();
}
function deselectAllBookmarks() {
if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
currentConfig.bookmark_ids = [];
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
markBgdDirty();
}
function wireBackgroundDecodeEvents() {
const filterInput = document.getElementById("bgd-bookmark-filter");
if (filterInput && !filterInput._wired) {
filterInput._wired = true;
filterInput.addEventListener("input", function () {
renderBookmarkChecklist(filterInput.value);
});
}
const enabledCb = document.getElementById("background-decode-enabled");
if (enabledCb && !enabledCb._wired) {
enabledCb._wired = true;
enabledCb.addEventListener("change", function () { markBgdDirty(); });
}
const selectAllBtn = document.getElementById("bgd-select-all-btn");
if (selectAllBtn && !selectAllBtn._wired) {
selectAllBtn._wired = true;
selectAllBtn.addEventListener("click", selectAllBookmarks);
}
const deselectAllBtn = document.getElementById("bgd-deselect-all-btn");
if (deselectAllBtn && !deselectAllBtn._wired) {
deselectAllBtn._wired = true;
deselectAllBtn.addEventListener("click", deselectAllBookmarks);
}
const saveBtn = document.getElementById("background-decode-save-btn");
if (saveBtn && !saveBtn._wired) {
saveBtn._wired = true;
saveBtn.addEventListener("click", saveBackgroundDecode);
}
const resetBtn = document.getElementById("background-decode-reset-btn");
if (resetBtn && !resetBtn._wired) {
resetBtn._wired = true;
resetBtn.addEventListener("click", resetBackgroundDecode);
}
}
window.initBackgroundDecode = initBackgroundDecode;
window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
window.setBackgroundDecodeRig = setBackgroundDecodeRig;
})();
@@ -0,0 +1,792 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- Bookmarks Tab ---
/** Current bookmark scope: "general" or a rig remote name. */
let bmScope = "general";
/** Build the ?scope= query string for a given or current bookmark scope. */
function bmScopeParam(prefix, scope) {
const sep = prefix ? "&" : "?";
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
}
var bmList = [];
var bmRevision = 0;
/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
var bmOverlayList = [];
var bmOverlayRevision = 0;
let bmFilteredList = [];
let bmEditId = null;
let bmEditScope = null;
let bmCurrentPage = 1;
const BM_PAGE_SIZE = 25;
const bmSelected = new Set();
function bmFmtFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
return hz + "\u202fHz";
}
function bmEsc(str) {
const d = document.createElement("div");
d.appendChild(document.createTextNode(String(str)));
return d.innerHTML;
}
function bmCanControl() {
return (
(typeof authEnabled !== "undefined" && !authEnabled) ||
(typeof authRole !== "undefined" && authRole === "control")
);
}
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
function bmSyncAccess() {
const canCtrl = bmCanControl();
const addBtn = document.getElementById("bm-add-btn");
const selectAllBtn = document.getElementById("bm-select-all-btn");
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
}
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
function bmListScope() {
const rig = (typeof lastActiveRigId !== "undefined") ? lastActiveRigId : null;
return rig || "general";
}
async function bmFetchOverlay() {
const overlayScope = bmListScope();
try {
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
if (!resp.ok) throw new Error("HTTP " + resp.status);
bmOverlayList = await resp.json();
} catch (e) {
console.error("Failed to fetch overlay bookmarks:", e);
bmOverlayList = [];
}
bmOverlayRevision++;
if (typeof window.syncBookmarkMapLocators === "function") {
window.syncBookmarkMapLocators(bmOverlayList);
}
if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw();
}
async function bmFetch(categoryFilter) {
let url = "/bookmarks";
let hasQuery = false;
if (categoryFilter && categoryFilter !== "") {
url += "?category=" + encodeURIComponent(categoryFilter);
hasQuery = true;
}
url += bmScopeParam(hasQuery);
const overlayPromise = bmFetchOverlay();
try {
const resp = await fetch(url);
if (!resp.ok) throw new Error("HTTP " + resp.status);
bmList = await resp.json();
} catch (e) {
console.error("Failed to fetch bookmarks:", e);
bmList = [];
}
bmRevision++;
bmSelected.clear();
bmUpdateSelectionUi();
bmSyncAccess();
bmApplyFilters();
bmRefreshCategoryFilter(categoryFilter);
await overlayPromise;
}
function bmApplyFilters() {
const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase();
const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase();
let filtered = modeFilter
? bmList.filter((bm) => String(bm.mode || "").toUpperCase() === modeFilter)
: bmList;
filtered = text
? filtered.filter((bm) =>
(bm.name || "").toLowerCase().includes(text) ||
(bm.locator || "").toLowerCase().includes(text) ||
(bm.category || "").toLowerCase().includes(text) ||
(bm.comment || "").toLowerCase().includes(text)
)
: filtered;
bmFilteredList = filtered;
bmCurrentPage = 1;
bmRender(filtered);
}
async function bmRefreshCategoryFilter(keepValue) {
const sel = document.getElementById("bm-category-filter");
const modeSel = document.getElementById("bm-mode-filter");
if (!sel && !modeSel) return;
try {
const resp = await fetch("/bookmarks" + bmScopeParam(false));
if (!resp.ok) return;
const all = await resp.json();
if (sel) {
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
while (sel.options.length > 1) sel.remove(1);
cats.forEach((cat) => {
const opt = document.createElement("option");
opt.value = cat;
opt.textContent = cat;
sel.add(opt);
});
if (keepValue && cats.includes(keepValue)) sel.value = keepValue;
}
if (modeSel) {
const keepMode = modeSel.value;
const modes = [...new Set(all.map((b) => String(b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
while (modeSel.options.length > 1) modeSel.remove(1);
modes.forEach((mode) => {
const opt = document.createElement("option");
opt.value = mode;
opt.textContent = mode;
modeSel.add(opt);
});
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
}
} catch (_) {}
}
function bmRender(list) {
const tbody = document.getElementById("bm-tbody");
const emptyEl = document.getElementById("bm-empty");
const paginatorEl = document.getElementById("bm-paginator");
const pageSummaryEl = document.getElementById("bm-page-summary");
const pageIndicatorEl = document.getElementById("bm-page-indicator");
const prevBtn = document.getElementById("bm-page-prev");
const nextBtn = document.getElementById("bm-page-next");
if (!tbody) return;
tbody.innerHTML = "";
if (list.length === 0) {
if (emptyEl) emptyEl.style.display = "";
if (paginatorEl) paginatorEl.style.display = "none";
return;
}
if (emptyEl) emptyEl.style.display = "none";
const canControl = bmCanControl();
const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE));
const page = Math.min(Math.max(bmCurrentPage, 1), totalPages);
bmCurrentPage = page;
const startIndex = (page - 1) * BM_PAGE_SIZE;
const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length);
const pageItems = list.slice(startIndex, endIndex);
const showScope = bmScope !== "general";
pageItems.forEach((bm) => {
const tr = document.createElement("tr");
tr.dataset.bmId = bm.id;
const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
const locatorCell = bm.locator || "--";
const catCell = bm.category || "Uncategorised";
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
const commentCell = bm.comment || "";
const checked = bmSelected.has(bm.id) ? " checked" : "";
const scopeBadge = showScope && bm.scope === "general" ? ' <span class="bm-scope-badge">G</span>' : "";
tr.innerHTML =
`<td class="bm-col-sel"><input type="checkbox" class="bm-row-sel" data-bm-id="${bmEsc(bm.id)}"${checked} aria-label="Select ${bmEsc(bm.name)}" /></td>` +
`<td class="bm-col-name">${bmEsc(bm.name)}${scopeBadge}</td>` +
`<td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td>` +
`<td class="bm-col-mode">${bmEsc(bm.mode)}</td>` +
`<td class="bm-col-bw">${bwCell}</td>` +
`<td class="bm-col-loc">${bmEsc(locatorCell)}</td>` +
`<td class="bm-col-cat">${bmEsc(catCell)}</td>` +
`<td class="bm-col-dec">${bmEsc(decoderCell)}</td>` +
`<td class="bm-col-cmt">${bmEsc(commentCell)}</td>` +
`<td class="bm-col-act">` +
`<button class="bm-tune-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Tune</button>` +
(canControl
? `<button class="bm-edit-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Edit</button>` +
`<button class="bm-del-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Delete</button>`
: "") +
`</td>`;
tbody.appendChild(tr);
});
bmSyncSelectAllCheckbox();
if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`;
if (prevBtn) prevBtn.disabled = page <= 1;
if (nextBtn) nextBtn.disabled = page >= totalPages;
}
function bmChangePage(delta) {
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
if (nextPage === bmCurrentPage) return;
bmCurrentPage = nextPage;
bmRender(bmFilteredList);
}
// Read decoder checkboxes and return an array of selected decoder names.
function bmReadDecoders() {
return (window.decoderRegistry || [])
.filter(d => d.bookmark_selectable)
.filter(d => document.getElementById("bm-dec-" + d.id)?.checked)
.map(d => d.id);
}
// Set decoder checkboxes to match the given array.
function bmWriteDecoders(decoders) {
const set = new Set(decoders || []);
(window.decoderRegistry || [])
.filter(d => d.bookmark_selectable)
.forEach(d => {
const el = document.getElementById("bm-dec-" + d.id);
if (el) el.checked = set.has(d.id);
});
}
// Build decoder checkboxes dynamically from the registry.
function bmBuildDecoderCheckboxes() {
const container = document.getElementById("bm-decoder-checkboxes");
if (!container) return;
container.innerHTML = "";
(window.decoderRegistry || [])
.filter(d => d.bookmark_selectable)
.forEach(d => {
const label = document.createElement("label");
label.className = "bm-decoder-check";
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
container.appendChild(label);
});
}
function bmOpenForm(bm) {
const wrap = document.getElementById("bm-form-wrap");
if (!wrap) return;
bmEditId = bm ? bm.id : null;
bmEditScope = bm ? (bm.scope || bmScope) : null;
// Rebuild decoder checkboxes from registry (handles race where registry
// loaded after initial build).
bmBuildDecoderCheckboxes();
document.getElementById("bm-id").value = bm ? bm.id : "";
document.getElementById("bm-name").value = bm ? bm.name : "";
document.getElementById("bm-freq").value = bm ? bm.freq_hz : "";
document.getElementById("bm-mode").value = bm ? bm.mode : "";
document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
document.getElementById("bm-locator").value = bm ? (bm.locator || "") : "";
document.getElementById("bm-category-input").value = bm ? (bm.category || "") : "";
document.getElementById("bm-comment").value = bm ? (bm.comment || "") : "";
bmWriteDecoders(bm ? bm.decoders : []);
document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex";
document.getElementById("bm-name").focus();
}
function bmCloseForm() {
const wrap = document.getElementById("bm-form-wrap");
if (wrap) wrap.style.display = "none";
bmEditId = null;
}
function bmPrefillFromStatus() {
// Use globals maintained by app.js (updated by SSE stream)
if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) {
document.getElementById("bm-freq").value = Math.round(lastFreqHz);
}
if (typeof lastModeName === "string" && lastModeName) {
document.getElementById("bm-mode").value = lastModeName;
}
if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) {
document.getElementById("bm-bw").value = Math.round(currentBandwidthHz);
}
// Prefill decoder checkboxes from current toggle button state.
const activeDecoders = (window.decoderRegistry || [])
.filter(d => d.bookmark_selectable && d.activation === "toggle")
.filter(d => {
const btn = document.getElementById(d.id + "-decode-toggle-btn");
return btn && btn.dataset.enabled === "true";
})
.map(d => d.id);
bmWriteDecoders(activeDecoders);
}
async function bmSave(e) {
e.preventDefault();
const id = document.getElementById("bm-id").value;
const name = document.getElementById("bm-name").value.trim();
const freqStr = document.getElementById("bm-freq").value;
const freq_hz = parseInt(freqStr, 10);
const mode = document.getElementById("bm-mode").value.trim();
const bwStr = document.getElementById("bm-bw").value;
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
const locator = document.getElementById("bm-locator").value.trim().toUpperCase();
const category = document.getElementById("bm-category-input").value.trim();
const comment = document.getElementById("bm-comment").value.trim();
const decoders = bmReadDecoders();
if (!name || !Number.isFinite(freq_hz) || !mode) {
alert("Name, Frequency, and Mode are required.");
return;
}
const body = {
name,
freq_hz,
mode,
bandwidth_hz,
locator: locator || null,
category,
comment,
decoders,
};
try {
let resp;
if (id) {
resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
} else {
resp = await fetch("/bookmarks" + bmScopeParam(false), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
if (!resp.ok) {
const text = await resp.text();
if (resp.status === 409) {
throw new Error("A bookmark for that frequency already exists.");
}
throw new Error(text || "HTTP " + resp.status);
}
bmCloseForm();
await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) {
console.error("Failed to save bookmark:", err);
alert("Failed to save bookmark: " + err.message);
}
}
async function bmDelete(id) {
if (!confirm("Delete this bookmark?")) return;
const bm = bmList.find((b) => b.id === id);
const scope = bm ? bm.scope : undefined;
try {
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
method: "DELETE",
});
if (!resp.ok) throw new Error("HTTP " + resp.status);
await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) {
console.error("Failed to delete bookmark:", err);
alert("Failed to delete bookmark: " + err.message);
}
}
async function bmApply(bm) {
try {
// --- Optimistic UI updates (instant, before any network round-trips) ---
if (typeof modeEl !== "undefined" && modeEl) {
modeEl.value = String(bm.mode || "").toUpperCase();
}
if (bm.bandwidth_hz) {
if (typeof currentBandwidthHz !== "undefined") {
currentBandwidthHz = bm.bandwidth_hz;
}
window.currentBandwidthHz = bm.bandwidth_hz;
if (typeof syncBandwidthInput === "function") {
syncBandwidthInput(bm.bandwidth_hz);
}
}
if (typeof applyLocalTunedFrequency === "function") {
// Set optimistic guard before applying so SSE cannot snap back.
if (typeof _freqOptimisticSeq !== "undefined") {
++_freqOptimisticSeq;
_freqOptimisticHz = bm.freq_hz;
}
// Force display so the BW overlay is repositioned even when freq is unchanged.
applyLocalTunedFrequency(bm.freq_hz, true);
}
if (typeof scheduleSpectrumDraw === "function" && typeof lastSpectrumData !== "undefined" && lastSpectrumData) {
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 () => {
if (typeof vchanTakeSchedulerControl === "function") {
await vchanTakeSchedulerControl();
}
const onVirtual = typeof vchanInterceptMode === "function"
&& await vchanInterceptMode(bm.mode);
if (!onVirtual) {
await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
}
if (bm.bandwidth_hz) {
const bwHandledByVchan = typeof vchanInterceptBandwidth === "function"
&& await vchanInterceptBandwidth(bm.bandwidth_hz);
if (!bwHandledByVchan) {
await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz);
}
}
// 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 (applyLocalTunedFrequency) but
// that's a no-op since we already set the same value above.
if (typeof setRigFrequency === "function") {
await setRigFrequency(bm.freq_hz);
} else {
await postPath("/set_freq?hz=" + bm.freq_hz);
}
})();
// Decoder toggles — fire-and-forget.
// - Decoders incompatible with the new mode are always turned off
// (even when the bookmark has no explicit decoder selection).
// - For compatible decoders, if the bookmark specifies a set, the
// toggles are driven to match that set; otherwise they're left
// alone.
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const modeUp = (bm.mode || "").toUpperCase();
const allToggleDecoders = (window.decoderRegistry || []).filter(d =>
d.activation === "toggle"
);
const decoderPromise = allToggleDecoders.length ? (async () => {
let statusUrl = "/status";
if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) {
statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId);
}
const statusResp = await fetch(statusUrl);
if (!statusResp.ok) return;
const st = await statusResp.json();
const toggles = [];
for (const d of allToggleDecoders) {
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
const currentlyOn = !!st[statusKey];
const compatible = Array.isArray(d.active_modes)
&& d.active_modes.includes(modeUp);
let wanted;
if (!compatible) {
// Always disable decoders that don't apply to the new mode.
wanted = false;
} else if (hasDecoders) {
wanted = bm.decoders.includes(d.id);
} else {
// Mode-compatible and no bookmark selection: leave as-is.
wanted = currentlyOn;
}
if (wanted !== currentlyOn) {
toggles.push(postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
}
}
if (toggles.length) await Promise.all(toggles);
})() : Promise.resolve();
// Don't await — let the network calls settle in the background.
// Errors are logged but don't block the UI.
Promise.all([tunePromise, decoderPromise]).catch(
(err) => console.error("Bookmark apply background error:", err)
);
} catch (err) {
console.error("Failed to apply bookmark:", err);
}
}
function bmUpdateSelectionUi() {
const count = bmSelected.size;
const canCtrl = bmCanControl();
const visible = count > 0 && canCtrl;
const btn = document.getElementById("bm-del-selected-btn");
const countEl = document.getElementById("bm-del-selected-count");
if (btn) btn.style.display = visible ? "" : "none";
if (countEl) countEl.textContent = count;
const moveWrap = document.getElementById("bm-move-selected-wrap");
const moveCountEl = document.getElementById("bm-move-selected-count");
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
if (moveCountEl) moveCountEl.textContent = count;
if (visible) bmPopulateMoveTarget();
const selectAllBtn = document.getElementById("bm-select-all-btn");
if (selectAllBtn && bmCanControl()) {
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
}
}
/** Populate the move-target dropdown with all scopes except the current one. */
function bmPopulateMoveTarget() {
const sel = document.getElementById("bm-move-target");
if (!sel) return;
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
const prev = sel.value;
sel.innerHTML = "";
if (bmScope !== "general") {
const opt = document.createElement("option");
opt.value = "general";
opt.textContent = "General";
sel.appendChild(opt);
}
rigIds.forEach((id) => {
if (id === bmScope) return;
const opt = document.createElement("option");
opt.value = id;
opt.textContent = displayNames[id] || id;
sel.appendChild(opt);
});
if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) {
sel.value = prev;
}
}
async function bmMoveSelected() {
const ids = Array.from(bmSelected);
if (ids.length === 0) return;
const target = document.getElementById("bm-move-target")?.value;
if (!target) return;
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
if (!confirm(`Move ${ids.length} bookmark${ids.length > 1 ? "s" : ""} to "${targetLabel}"?`)) return;
try {
// Group selected IDs by their owning scope (skip if already in target).
const byScope = {};
for (const id of ids) {
const bm = bmList.find((b) => b.id === id);
const scope = bm?.scope || bmScope;
if (scope === target) continue;
(byScope[scope] ||= []).push(id);
}
await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: scopeIds, to: target }),
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
));
bmSelected.clear();
bmUpdateSelectionUi();
await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) {
console.error("Failed to move bookmarks:", err);
alert("Failed to move bookmarks: " + err.message);
}
}
function bmSyncSelectAllCheckbox() {
const selectAll = document.getElementById("bm-select-all");
if (!selectAll) return;
const checkboxes = document.querySelectorAll(".bm-row-sel");
if (checkboxes.length === 0) {
selectAll.checked = false;
selectAll.indeterminate = false;
return;
}
const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length;
selectAll.checked = checkedCount === checkboxes.length;
selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
}
async function bmDeleteSelected() {
const ids = Array.from(bmSelected);
if (ids.length === 0) return;
if (!confirm(`Delete ${ids.length} selected bookmark${ids.length > 1 ? "s" : ""}?`)) return;
try {
// Group selected IDs by their owning scope.
const byScope = {};
for (const id of ids) {
const bm = bmList.find((b) => b.id === id);
const scope = bm?.scope || bmScope;
(byScope[scope] ||= []).push(id);
}
await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: scopeIds }),
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
));
bmSelected.clear();
bmUpdateSelectionUi();
await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) {
console.error("Failed to delete bookmarks:", err);
alert("Failed to delete bookmarks: " + err.message);
}
}
/** Populate the scope picker with "General" + one option per rig. */
function bmPopulateScopePicker() {
const picker = document.getElementById("bm-scope-picker");
if (!picker) return;
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
// Preserve current selection if still valid.
const prev = picker.value;
while (picker.options.length > 1) picker.remove(1);
rigIds.forEach((id) => {
const opt = document.createElement("option");
opt.value = id;
opt.textContent = displayNames[id] || id;
picker.appendChild(opt);
});
if (prev && (prev === "general" || rigIds.includes(prev))) {
picker.value = prev;
} else {
picker.value = "general";
}
bmScope = picker.value;
}
// --- Event wiring ---
(function initBookmarks() {
// Set initial button visibility (auth may already be resolved by the time
// scripts run if auth is disabled; otherwise bmFetch() will sync it).
bmSyncAccess();
// Build decoder checkboxes from registry. The registry is fetched async
// so we rebuild once it arrives to ensure checkboxes are present.
bmBuildDecoderCheckboxes();
if (typeof window.onDecoderRegistryReady === "function") {
window.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
}
// Scope picker
bmPopulateScopePicker();
const scopePicker = document.getElementById("bm-scope-picker");
if (scopePicker) {
scopePicker.addEventListener("change", (e) => {
bmScope = e.target.value;
bmFetch(document.getElementById("bm-category-filter")?.value || "");
});
}
// Refresh list and sync access when the Bookmarks tab is activated
document.querySelector(".tab-bar").addEventListener("click", (e) => {
const btn = e.target.closest('.tab[data-tab="bookmarks"]');
if (!btn) return;
bmFetch(document.getElementById("bm-category-filter").value);
});
// Add Bookmark button — open form and prefill from current rig state
document.getElementById("bm-add-btn").addEventListener("click", () => {
bmOpenForm(null);
bmPrefillFromStatus();
});
// Category filter dropdown
document.getElementById("bm-category-filter").addEventListener("change", (e) => {
bmFetch(e.target.value);
});
// Mode filter dropdown (client-side, no re-fetch)
document.getElementById("bm-mode-filter").addEventListener("change", () => {
bmApplyFilters();
});
// Text search filter (client-side, no re-fetch)
document.getElementById("bm-text-filter").addEventListener("input", () => {
bmApplyFilters();
});
document.getElementById("bm-page-prev").addEventListener("click", () => {
bmChangePage(-1);
});
document.getElementById("bm-page-next").addEventListener("click", () => {
bmChangePage(1);
});
// Form submit
document.getElementById("bm-form").addEventListener("submit", bmSave);
// Form cancel
document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm);
const formWrap = document.getElementById("bm-form-wrap");
if (formWrap) {
formWrap.addEventListener("click", (e) => {
if (e.target === formWrap) bmCloseForm();
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") {
bmCloseForm();
}
});
// Select-all checkbox
document.getElementById("bm-select-all").addEventListener("change", (e) => {
const checked = e.target.checked;
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
cb.checked = checked;
if (checked) bmSelected.add(cb.dataset.bmId);
else bmSelected.delete(cb.dataset.bmId);
});
bmUpdateSelectionUi();
});
// Select All (across all pages) button
document.getElementById("bm-select-all-btn").addEventListener("click", () => {
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
if (allSelected) {
bmSelected.clear();
} else {
bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
}
// Sync visible page checkboxes
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
cb.checked = bmSelected.has(cb.dataset.bmId);
});
bmSyncSelectAllCheckbox();
bmUpdateSelectionUi();
});
// Delete Selected button
document.getElementById("bm-del-selected-btn").addEventListener("click", () => {
bmDeleteSelected();
});
// Move Selected button
document.getElementById("bm-move-selected-btn").addEventListener("click", () => {
bmMoveSelected();
});
// Table action buttons and row checkboxes (event delegation)
document.getElementById("bm-tbody").addEventListener("click", async (e) => {
const checkbox = e.target.closest(".bm-row-sel");
if (checkbox) {
if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId);
else bmSelected.delete(checkbox.dataset.bmId);
bmSyncSelectAllCheckbox();
bmUpdateSelectionUi();
return;
}
const tuneBtn = e.target.closest(".bm-tune-btn");
const editBtn = e.target.closest(".bm-edit-btn");
const delBtn = e.target.closest(".bm-del-btn");
if (tuneBtn) {
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
if (bm) await bmApply(bm);
} else if (editBtn) {
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
if (bm) bmOpenForm(bm);
} else if (delBtn) {
await bmDelete(delBtn.dataset.bmId);
}
});
// Pre-load bookmarks so spectrum markers are visible immediately.
bmFetch("");
})();
@@ -0,0 +1,451 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- CW (Morse) Decoder Plugin (server-side decode) ---
const cwStatusEl = document.getElementById("cw-status");
const cwOutputEl = document.getElementById("cw-output");
const cwAutoInput = document.getElementById("cw-auto");
const cwWpmInput = document.getElementById("cw-wpm");
const cwToneInput = document.getElementById("cw-tone");
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
const cwToneGl = typeof createTrxWebGlRenderer === "function"
? createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
: null;
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
const cwToneRangeEl = document.getElementById("cw-tone-range");
const cwBarOverlay = document.getElementById("cw-bar-overlay");
const CW_MAX_LINES = 200;
const CW_TONE_MIN_HZ = 100;
const CW_TONE_MAX_HZ = 10_000;
const CW_WPM_MIN = 5;
const CW_WPM_MAX = 40;
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
const CW_BAR_LINE_GAP_MS = 5000;
let cwLastAppendTime = 0;
let cwTonePickerRaf = null;
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}]
let cwBarCurrentLine = null; // accumulates chars until gap/newline
let cwBarDismissedAtMs = 0;
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
// acknowledged). While set, server-state updates must not override the
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
// does not immediately undo the user's choice.
let cwAutoLocalOverride = null;
function applyCwAutoUi(enabled) {
if (cwAutoInput) cwAutoInput.checked = enabled;
if (cwWpmInput) {
cwWpmInput.disabled = enabled;
cwWpmInput.readOnly = enabled;
}
if (cwToneInput) {
cwToneInput.disabled = enabled;
cwToneInput.readOnly = enabled;
}
if (cwTonePickerEl) {
cwTonePickerEl.classList.toggle("is-auto", enabled);
}
}
window.applyCwAutoUi = applyCwAutoUi;
// Called by app.js render() when a server-state snapshot arrives. Ignores
// the update while cwAutoLocalOverride is set (user change still in-flight).
window.applyCwAutoUiFromServer = function(enabled) {
if (cwAutoLocalOverride !== null) return;
applyCwAutoUi(enabled);
};
function cwBarFlushCurrentLine() {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine);
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
}
cwBarCurrentLine = null;
}
function updateCwBar() {
if (!cwBarOverlay) return;
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
const isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
// Prepend the in-progress line so characters appear immediately
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none";
cwBarOverlay.innerHTML = "";
return;
}
let html =
'<div class="aprs-bar-header">' +
'<span class="aprs-bar-title"><span class="aprs-bar-title-word">CW</span><span class="aprs-bar-title-word">Live</span></span>' +
'<span class="aprs-bar-actions">' +
'<span class="aprs-bar-window">Last 15 minutes</span>' +
'<span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0"' +
' onclick="window.clearCwBar()"' +
' onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearCwBar();}"' +
' aria-label="Clear CW overlay">Clear</span></span>' +
'<button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">&times;</button>' +
'</span>' +
'</div>';
for (const line of liveLines.slice(0, 8)) {
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
const meta = [
line.wpm ? `${line.wpm} WPM` : null,
line.tone_hz ? `${line.tone_hz} Hz` : null,
].filter(Boolean).join(" · ");
html += `<div class="aprs-bar-frame">` +
`<div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` +
(meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") +
`</div></div>`;
}
cwBarOverlay.innerHTML = html;
cwBarOverlay.style.display = "flex";
}
window.updateCwBar = updateCwBar;
window.clearCwBar = function() {
window.resetCwHistoryView();
};
window.closeCwBar = function() {
cwBarDismissedAtMs = Date.now();
if (cwBarOverlay) {
cwBarOverlay.style.display = "none";
cwBarOverlay.innerHTML = "";
}
};
function clampCwWpm(wpm) {
const numeric = Number(wpm);
if (!Number.isFinite(numeric)) return 15;
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
}
function clampCwTone(tone) {
const numeric = Number(tone);
if (!Number.isFinite(numeric)) return 700;
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
}
function currentCwToneRange() {
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
return null;
}
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
const lowerSideband = mode === "CWR";
const upperSideband = mode === "CW";
if (!lowerSideband && !upperSideband) return null;
const toneMinHz = CW_TONE_MIN_HZ;
const toneMaxHz = CW_TONE_MAX_HZ;
if (toneMaxHz < toneMinHz) {
return null;
}
return {
tunedHz,
bandwidthHz,
toneMinHz,
toneMaxHz,
toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
lowerSideband,
mode,
};
}
function cwToneToRfHz(range, toneHz) {
if (!range) return NaN;
return range.lowerSideband
? range.tunedHz - toneHz
: range.tunedHz + toneHz;
}
function toneClampForRange(tone, range) {
const clamped = clampCwTone(tone);
if (!range) return clamped;
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
}
function ensureCwToneCanvasResolution() {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
const rect = cwToneCanvas.getBoundingClientRect();
const cssWidth = Math.round(rect.width);
const cssHeight = Math.round(rect.height);
if (cssWidth < 8 || cssHeight < 8) {
return false;
}
const dpr = window.devicePixelRatio || 1;
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
}
function drawCwTonePicker() {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
ensureCwToneCanvasResolution();
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
const width = cwToneCanvas.width;
const height = cwToneCanvas.height;
cwToneGl.clear([0, 0, 0, 0]);
const range = currentCwToneRange();
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
if (cwToneRangeEl) {
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
if (mode !== "CW" && mode !== "CWR") {
cwToneRangeEl.textContent = "CW/CWR mode required";
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
cwToneRangeEl.textContent = "Waiting for spectrum";
}
}
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
return;
}
if (cwToneRangeEl) {
const side = range.lowerSideband ? "Lower side" : "Upper side";
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
}
const bins = window.lastSpectrumData.bins;
const sampleRate = Number(window.lastSpectrumData.sample_rate);
const centerHz = Number(window.lastSpectrumData.center_hz);
const maxIdx = Math.max(1, bins.length - 1);
const fullLoHz = centerHz - sampleRate / 2;
const tones = new Array(width).fill(-140);
for (let x = 0; x < width; x += 1) {
const frac = width <= 1 ? 0 : x / (width - 1);
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
const rfHz = cwToneToRfHz(range, toneHz);
const idx = Math.max(0, Math.min(maxIdx, Math.round((((rfHz - fullLoHz) / sampleRate) * maxIdx))));
const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140;
tones[x] = power;
}
const smoothed = new Array(width).fill(-140);
const smoothRadius = Math.max(1, Math.round(width / 180));
for (let x = 0; x < width; x += 1) {
let sum = 0;
let count = 0;
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
if (i < 0 || i >= width) continue;
sum += tones[i];
count += 1;
}
smoothed[x] = count > 0 ? sum / count : tones[x];
}
const sorted = smoothed.slice().sort((a, b) => a - b);
const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
const floorDb = Math.min(q20 - 2, q95 - 10);
const ceilDb = Math.max(floorDb + 18, q95 + 2);
const dbSpan = Math.max(1, ceilDb - floorDb);
const yForDb = (db) => {
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
return Math.round((1 - n) * (height - 1));
};
const rootStyle = getComputedStyle(document.documentElement);
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
const parseColor = typeof window.trxParseCssColor === "function"
? window.trxParseCssColor
: null;
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
const hGridCount = 4;
const gridSegments = [];
for (let i = 1; i <= hGridCount; i += 1) {
const y = Math.round((i / (hGridCount + 1)) * (height - 1));
gridSegments.push(0, y, width, y);
}
cwToneGl.drawSegments(gridSegments, axisColor, 1);
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
const tickSegments = [];
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
tickSegments.push(x, 0, x, height);
}
cwToneGl.drawSegments(tickSegments, axisColor, 1);
const linePoints = [];
for (let x = 0; x < width; x += 1) {
linePoints.push(x, yForDb(smoothed[x]));
}
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
if (cwAutoInput?.checked) {
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
}
}
async function setCwTone(tone, { syncInput = true } = {}) {
const range = currentCwToneRange();
const clamped = toneClampForRange(tone, range);
if (cwToneInput && syncInput) {
cwToneInput.value = clamped;
}
try {
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
} catch (e) {
console.error("CW tone set failed", e);
}
drawCwTonePicker();
}
if (cwAutoInput) {
cwAutoInput.addEventListener("change", async () => {
const enabled = cwAutoInput.checked;
cwAutoLocalOverride = enabled;
applyCwAutoUi(enabled);
try {
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
drawCwTonePicker();
} catch (e) {
console.error("CW auto toggle failed", e);
} finally {
cwAutoLocalOverride = null;
}
});
}
if (cwWpmInput) {
cwWpmInput.addEventListener("change", async () => {
if (cwAutoInput && cwAutoInput.checked) return;
const wpm = clampCwWpm(cwWpmInput.value);
cwWpmInput.value = wpm;
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
catch (e) { console.error("CW WPM set failed", e); }
});
}
if (cwToneInput) {
cwToneInput.addEventListener("change", async () => {
if (cwAutoInput?.checked) return;
await setCwTone(cwToneInput.value);
});
}
if (cwToneCanvas) {
cwToneCanvas.addEventListener("click", async (event) => {
if (cwAutoInput?.checked) return;
const rect = cwToneCanvas.getBoundingClientRect();
if (rect.width <= 0) return;
const range = currentCwToneRange();
if (!range) return;
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const tone = range.toneMinHz + frac * range.toneSpanHz;
await setCwTone(tone);
});
}
window.resetCwHistoryView = function() {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBarHistory = [];
cwBarCurrentLine = null;
updateCwBar();
drawCwTonePicker();
};
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
if (!confirm("Clear all CW decode history? This cannot be undone.")) return;
try {
await postPath("/clear_cw_decode");
window.resetCwHistoryView();
} catch (e) {
console.error("CW history clear failed", e);
}
});
// --- Server-side CW decode handler ---
window.onServerCw = function(evt) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
// Append decoded text to output
const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
const line = document.createElement("div");
line.className = "cw-line";
cwOutputEl.appendChild(line);
}
cwLastAppendTime = now;
const lastLine = cwOutputEl.lastElementChild;
if (lastLine) {
lastLine.textContent += evt.text;
}
while (cwOutputEl.children.length > CW_MAX_LINES) {
cwOutputEl.removeChild(cwOutputEl.firstChild);
}
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
}
// Bar history accumulation (regardless of pause state)
if (evt.text) {
const now = Date.now();
if (evt.text === "\n") {
cwBarFlushCurrentLine();
} else {
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine();
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
}
cwBarCurrentLine.text += evt.text;
cwBarCurrentLine.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
}
updateCwBar();
}
if (cwSignalIndicator) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
}
if (!cwAutoInput || cwAutoInput.checked) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = clampCwWpm(evt.wpm);
}
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
}
}
if (cwTonePickerRaf != null) return;
cwTonePickerRaf = requestAnimationFrame(() => {
cwTonePickerRaf = null;
drawCwTonePicker();
});
};
window.restoreCwHistory = function(events) {
if (!Array.isArray(events) || events.length === 0) return;
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
for (const evt of events) {
window.onServerCw(evt);
}
};
window.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution();
drawCwTonePicker();
};
window.addEventListener("resize", () => {
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
});
applyCwAutoUi(!!cwAutoInput?.checked);
updateCwBar();
ensureCwToneCanvasResolution();
drawCwTonePicker();
@@ -0,0 +1,207 @@
// --- FT2 Decoder Plugin (server-side decode) ---
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
// SPDX-License-Identifier: GPL-2.0-or-later
function ft8RenderMessageFt2(message) {
if (typeof renderFt8Message === "function") return renderFt8Message(message);
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
return message;
}
const ft2Status = document.getElementById("ft2-status");
const ft2PeriodEl = document.getElementById("ft2-period");
const ft2MessagesEl = document.getElementById("ft2-messages");
const ft2FilterInput = document.getElementById("ft2-filter");
const FT2_PERIOD_MS = 3750;
const FT2_MAX_DOM_ROWS = 200;
let ft2FilterText = "";
let ft2MessageHistory = [];
function currentFt2HistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneFt2MessageHistory() {
const cutoffMs = Date.now() - currentFt2HistoryRetentionMs();
ft2MessageHistory = ft2MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
}
function scheduleFt2Ui(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleFt2HistoryRender() { scheduleFt2Ui("ft2-history", () => renderFt2History()); }
function normalizeFt2DisplayFreqHz(freqHz) {
const rawHz = Number(freqHz);
if (!Number.isFinite(rawHz)) return null;
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
return baseHz + rawHz;
}
return rawHz;
}
function updateFt2PeriodTimer() {
if (!ft2PeriodEl) return;
const nowMs = Date.now();
const remaining = (FT2_PERIOD_MS - nowMs % FT2_PERIOD_MS) / 1000;
ft2PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
}
updateFt2PeriodTimer();
setInterval(updateFt2PeriodTimer, 250);
function renderFt2Row(msg) {
const row = document.createElement("div");
row.className = "ft8-row";
const rawMessage = (msg.message || "").toString();
row.dataset.message = rawMessage.toUpperCase();
row.dataset.decoder = "ft2";
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
const renderedMessage = ft8RenderMessageFt2(rawMessage);
const tsMs = msg._tsMs ?? msg.ts_ms;
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
return row;
}
function renderFt2History() {
pruneFt2MessageHistory();
if (!ft2MessagesEl) return;
const filter = ft2FilterText;
const fragment = document.createDocumentFragment();
let rendered = 0;
for (let i = 0; i < ft2MessageHistory.length && rendered < FT2_MAX_DOM_ROWS; i++) {
const msg = ft2MessageHistory[i];
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
fragment.appendChild(renderFt2Row(msg));
rendered++;
}
ft2MessagesEl.replaceChildren(fragment);
}
function addFt2Message(msg) {
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
ft2MessageHistory.unshift(msg);
pruneFt2MessageHistory();
window.setFt8FamilyBarDecoder?.("ft2");
window.updateFt8Bar?.();
scheduleFt2HistoryRender();
}
function normalizeServerFt2Message(msg) {
const raw = (msg.message || "").toString();
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
const grids = locatorDetails.length > 0
? locatorDetails.map((d) => d.grid)
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
const rfHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
return {
raw, grids, station, rfHz, locatorDetails,
history: {
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
message: msg.message,
},
};
}
window.onServerFt2Batch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (ft2Status) ft2Status.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerFt2Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
}
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
normalized.reverse();
ft2MessageHistory = normalized.concat(ft2MessageHistory);
pruneFt2MessageHistory();
window.setFt8FamilyBarDecoder?.("ft2");
window.updateFt8Bar?.();
scheduleFt2HistoryRender();
};
window.restoreFt2History = function(messages) { window.onServerFt2Batch(messages); };
window.pruneFt2HistoryView = function() { pruneFt2MessageHistory(); renderFt2History(); };
window.resetFt2HistoryView = function() {
if (ft2MessagesEl) ft2MessagesEl.innerHTML = "";
ft2MessageHistory = [];
window.updateFt8Bar?.();
renderFt2History();
};
function buildFt2BarFrames() {
const cutoffMs = Date.now() - 15 * 60 * 1000;
const messages = ft2MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
if (messages.length === 0) {
return { count: 0, newestTsMs: 0, html: "" };
}
let html = "";
for (const msg of messages) {
const tsMs = msg._tsMs ?? msg.ts_ms;
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
const text = ft8RenderMessageFt2((msg.message || "").toString());
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
}
return { count: messages.length, newestTsMs, html };
}
window.registerFt8FamilyBarRenderer?.("ft2", buildFt2BarFrames);
if (ft2FilterInput) {
ft2FilterInput.addEventListener("input", () => {
ft2FilterText = ft2FilterInput.value.trim().toUpperCase();
renderFt2History();
});
}
const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn");
ft2DecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(ft2DecodeToggleBtn);
await postPath("/toggle_ft2_decode");
} catch (e) {
console.error("FT2 toggle failed", e);
}
});
document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => {
if (!confirm("Clear all FT2 decode history? This cannot be undone.")) return;
try {
await postPath("/clear_ft2_decode");
window.resetFt2HistoryView();
} catch (e) { console.error("FT2 history clear failed", e); }
});
window.onServerFt2 = function(msg) {
if (ft2Status) ft2Status.textContent = "Receiving";
const next = normalizeServerFt2Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
}
addFt2Message(next.history);
};
@@ -0,0 +1,207 @@
// --- FT4 Decoder Plugin (server-side decode) ---
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
// SPDX-License-Identifier: GPL-2.0-or-later
function ft8RenderMessage(message) {
if (typeof renderFt8Message === "function") return renderFt8Message(message);
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
return message;
}
const ft4Status = document.getElementById("ft4-status");
const ft4PeriodEl = document.getElementById("ft4-period");
const ft4MessagesEl = document.getElementById("ft4-messages");
const ft4FilterInput = document.getElementById("ft4-filter");
const FT4_PERIOD_MS = 7500;
const FT4_MAX_DOM_ROWS = 200;
let ft4FilterText = "";
let ft4MessageHistory = [];
function currentFt4HistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneFt4MessageHistory() {
const cutoffMs = Date.now() - currentFt4HistoryRetentionMs();
ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
}
function scheduleFt4Ui(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleFt4HistoryRender() { scheduleFt4Ui("ft4-history", () => renderFt4History()); }
function normalizeFt4DisplayFreqHz(freqHz) {
const rawHz = Number(freqHz);
if (!Number.isFinite(rawHz)) return null;
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
return baseHz + rawHz;
}
return rawHz;
}
function updateFt4PeriodTimer() {
if (!ft4PeriodEl) return;
const nowMs = Date.now();
const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1000;
ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
}
updateFt4PeriodTimer();
setInterval(updateFt4PeriodTimer, 250);
function renderFt4Row(msg) {
const row = document.createElement("div");
row.className = "ft8-row";
const rawMessage = (msg.message || "").toString();
row.dataset.message = rawMessage.toUpperCase();
row.dataset.decoder = "ft4";
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
const renderedMessage = ft8RenderMessage(rawMessage);
const tsMs = msg._tsMs ?? msg.ts_ms;
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
return row;
}
function renderFt4History() {
pruneFt4MessageHistory();
if (!ft4MessagesEl) return;
const filter = ft4FilterText;
const fragment = document.createDocumentFragment();
let rendered = 0;
for (let i = 0; i < ft4MessageHistory.length && rendered < FT4_MAX_DOM_ROWS; i++) {
const msg = ft4MessageHistory[i];
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
fragment.appendChild(renderFt4Row(msg));
rendered++;
}
ft4MessagesEl.replaceChildren(fragment);
}
function addFt4Message(msg) {
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
ft4MessageHistory.unshift(msg);
pruneFt4MessageHistory();
window.setFt8FamilyBarDecoder?.("ft4");
window.updateFt8Bar?.();
scheduleFt4HistoryRender();
}
function normalizeServerFt4Message(msg) {
const raw = (msg.message || "").toString();
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
const grids = locatorDetails.length > 0
? locatorDetails.map((d) => d.grid)
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
const rfHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
return {
raw, grids, station, rfHz, locatorDetails,
history: {
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
message: msg.message,
},
};
}
window.onServerFt4Batch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (ft4Status) ft4Status.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerFt4Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
}
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
normalized.reverse();
ft4MessageHistory = normalized.concat(ft4MessageHistory);
pruneFt4MessageHistory();
window.setFt8FamilyBarDecoder?.("ft4");
window.updateFt8Bar?.();
scheduleFt4HistoryRender();
};
window.restoreFt4History = function(messages) { window.onServerFt4Batch(messages); };
window.pruneFt4HistoryView = function() { pruneFt4MessageHistory(); renderFt4History(); };
window.resetFt4HistoryView = function() {
if (ft4MessagesEl) ft4MessagesEl.innerHTML = "";
ft4MessageHistory = [];
window.updateFt8Bar?.();
renderFt4History();
};
function buildFt4BarFrames() {
const cutoffMs = Date.now() - 15 * 60 * 1000;
const messages = ft4MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
if (messages.length === 0) {
return { count: 0, newestTsMs: 0, html: "" };
}
let html = "";
for (const msg of messages) {
const tsMs = msg._tsMs ?? msg.ts_ms;
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
const text = ft8RenderMessage((msg.message || "").toString());
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
}
return { count: messages.length, newestTsMs, html };
}
window.registerFt8FamilyBarRenderer?.("ft4", buildFt4BarFrames);
if (ft4FilterInput) {
ft4FilterInput.addEventListener("input", () => {
ft4FilterText = ft4FilterInput.value.trim().toUpperCase();
renderFt4History();
});
}
const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn");
ft4DecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(ft4DecodeToggleBtn);
await postPath("/toggle_ft4_decode");
} catch (e) {
console.error("FT4 toggle failed", e);
}
});
document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => {
if (!confirm("Clear all FT4 decode history? This cannot be undone.")) return;
try {
await postPath("/clear_ft4_decode");
window.resetFt4HistoryView();
} catch (e) { console.error("FT4 history clear failed", e); }
});
window.onServerFt4 = function(msg) {
if (ft4Status) ft4Status.textContent = "Receiving";
const next = normalizeServerFt4Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
}
addFt4Message(next.history);
};
@@ -0,0 +1,486 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- FT8 Decoder Plugin (server-side decode) ---
const ft8Status = document.getElementById("ft8-status");
const ft8PeriodEl = document.getElementById("ft8-period");
const ft8MessagesEl = document.getElementById("ft8-messages");
const ft8FilterInput = document.getElementById("ft8-filter");
const ft8BarOverlay = document.getElementById("ft8-bar-overlay");
const FT8_BAR_WINDOW_MS = 15 * 60 * 1000;
const FT8_PERIOD_SECONDS = 15;
const FT8_MAX_DOM_ROWS = 200;
const FT8_BAR_DECODER_LABELS = {
ft8: "FT8",
ft4: "FT4",
ft2: "FT2",
};
let ft8FilterText = "";
let ft8MessageHistory = [];
let ft8BarActiveDecoder = "ft8";
const ft8BarBuilders = {};
const ft8BarDismissedAtMsByDecoder = {
ft8: 0,
ft4: 0,
ft2: 0,
};
function currentFt8HistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneFt8MessageHistory() {
const cutoffMs = Date.now() - currentFt8HistoryRetentionMs();
ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
}
function scheduleFt8Ui(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleFt8HistoryRender() {
scheduleFt8Ui("ft8-history", () => renderFt8History());
}
function scheduleFt8BarUpdate() {
scheduleFt8Ui("ft8-bar", () => updateFt8Bar());
}
window.registerFt8FamilyBarRenderer = function(decoder, builder) {
if (!FT8_BAR_DECODER_LABELS[decoder] || typeof builder !== "function") return;
ft8BarBuilders[decoder] = builder;
};
window.setFt8FamilyBarDecoder = function(decoder) {
if (!FT8_BAR_DECODER_LABELS[decoder]) return;
ft8BarActiveDecoder = decoder;
scheduleFt8BarUpdate();
};
function normalizeFt8DisplayFreqHz(freqHz) {
const rawHz = Number(freqHz);
if (!Number.isFinite(rawHz)) return null;
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
return baseHz + rawHz;
}
return rawHz;
}
function fmtTime(tsMs) {
if (!tsMs) return "--:--:--";
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function updateFt8PeriodTimer() {
if (!ft8PeriodEl) return;
const nowSec = Math.floor(Date.now() / 1000);
const remaining = FT8_PERIOD_SECONDS - (nowSec % FT8_PERIOD_SECONDS);
ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`;
}
updateFt8PeriodTimer();
setInterval(updateFt8PeriodTimer, 500);
function renderFt8Row(msg) {
const row = document.createElement("div");
row.className = "ft8-row";
const rawMessage = (msg.message || "").toString();
row.dataset.message = rawMessage.toUpperCase();
row.dataset.decoder = "ft8";
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
const renderedMessage = renderFt8Message(rawMessage);
row.innerHTML = `<span class="ft8-time">${fmtTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
applyFt8FilterToRow(row);
return row;
}
function renderFt8History() {
pruneFt8MessageHistory();
if (!ft8MessagesEl) return;
const fragment = document.createDocumentFragment();
const limit = Math.min(ft8MessageHistory.length, FT8_MAX_DOM_ROWS);
for (let i = 0; i < limit; i += 1) {
fragment.appendChild(renderFt8Row(ft8MessageHistory[i]));
}
ft8MessagesEl.replaceChildren(fragment);
}
function addFt8Message(msg) {
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
ft8MessageHistory.unshift(msg);
pruneFt8MessageHistory();
ft8BarActiveDecoder = "ft8";
scheduleFt8BarUpdate();
scheduleFt8HistoryRender();
}
function normalizeServerFt8Message(msg) {
const raw = (msg.message || "").toString();
const locatorDetails = ft8ExtractLocatorDetails(raw);
const grids = locatorDetails.length > 0
? locatorDetails.map((detail) => detail.grid)
: ft8ExtractAllGrids(raw);
const station = ft8ExtractLikelyCallsign(raw);
const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
return {
raw,
grids,
station,
rfHz,
locatorDetails,
history: {
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms,
snr_db: msg.snr_db,
dt_s: msg.dt_s,
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
message: msg.message,
},
};
}
window.onServerFt8Batch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
ft8Status.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerFt8Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
...msg,
freq_hz: next.rfHz,
locator_details: next.locatorDetails,
});
}
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
normalized.reverse();
ft8MessageHistory = normalized.concat(ft8MessageHistory);
pruneFt8MessageHistory();
ft8BarActiveDecoder = "ft8";
scheduleFt8BarUpdate();
scheduleFt8HistoryRender();
};
window.restoreFt8History = function(messages) {
window.onServerFt8Batch(messages);
};
window.pruneFt8HistoryView = function() {
pruneFt8MessageHistory();
updateFt8Bar();
renderFt8History();
};
function ft8BarRfText(msg) {
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
if (!Number.isFinite(displayFreqHz)) return null;
return `${displayFreqHz.toFixed(0)} Hz`;
}
function buildFt8BarFrames() {
const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS;
const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8);
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg.ts_ms) || 0), 0);
if (messages.length === 0) {
return { count: 0, newestTsMs: 0, html: "" };
}
let html = "";
for (const msg of messages) {
const ts = msg.ts_ms ? `<span class="aprs-bar-time">${fmtTime(msg.ts_ms)}</span>` : "";
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
const rf = ft8BarRfText(msg);
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
const text = ft8EscapeHtml((msg.message || "").toString());
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
}
return { count: messages.length, newestTsMs, html };
}
function updateFt8Bar() {
if (!ft8BarOverlay) return;
const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase();
const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB";
const decoder = ft8BarActiveDecoder;
const builder = ft8BarBuilders[decoder];
const label = FT8_BAR_DECODER_LABELS[decoder] || "FT8";
const result = typeof builder === "function" ? builder() : null;
const newestTsMs = Number(result?.newestTsMs) || 0;
if (!isFt8Mode || !result || result.count === 0 || newestTsMs <= (ft8BarDismissedAtMsByDecoder[decoder] || 0)) {
ft8BarOverlay.style.display = "none";
ft8BarOverlay.innerHTML = "";
return;
}
ft8BarOverlay.innerHTML = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">&times;</button></span></div>${result.html}`;
ft8BarOverlay.style.display = "flex";
}
window.updateFt8Bar = updateFt8Bar;
window.clearFt8Bar = function() {
const decoder = ft8BarActiveDecoder;
if (decoder === "ft4") {
window.resetFt4HistoryView?.();
return;
}
if (decoder === "ft2") {
window.resetFt2HistoryView?.();
return;
}
window.resetFt8HistoryView?.();
};
window.closeFt8Bar = function() {
ft8BarDismissedAtMsByDecoder[ft8BarActiveDecoder] = Date.now();
if (ft8BarOverlay) {
ft8BarOverlay.style.display = "none";
ft8BarOverlay.innerHTML = "";
}
};
window.registerFt8FamilyBarRenderer("ft8", buildFt8BarFrames);
function renderFt8Message(message) {
let out = "";
let i = 0;
while (i < message.length) {
const ch = message[i];
if (ft8IsAlphaNum(ch)) {
let j = i + 1;
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
const token = message.slice(i, j);
const grid = token.toUpperCase();
if (ft8IsMaidenheadGridToken(grid)) {
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
} else {
out += ft8EscapeHtml(token);
}
i = j;
} else {
out += ft8EscapeHtml(ch);
i += 1;
}
}
return out;
}
function ft8TokenizeMessage(message) {
return String(message || "")
.toUpperCase()
.split(/[^A-Z0-9/]+/)
.filter(Boolean);
}
function ft8ExtractAllGrids(message) {
const out = [];
const seen = new Set();
let i = 0;
while (i < message.length) {
if (ft8IsAlphaNum(message[i])) {
let j = i + 1;
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
const token = message.slice(i, j);
const grid = token.toUpperCase();
if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) {
seen.add(grid);
out.push(grid);
}
i = j;
} else {
i += 1;
}
}
return out;
}
function ft8ExtractLocatorDetails(message) {
const tokens = ft8TokenizeMessage(message);
const grids = ft8ExtractAllGrids(String(message || ""));
if (tokens.length === 0 || grids.length === 0) return [];
const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token));
const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length;
const callsigns = [];
for (let i = 0; i < limit; i += 1) {
if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]);
}
let source = null;
let target = null;
const head = tokens[0];
if (callsigns.length > 0) {
if (head === "CQ" || head === "DE" || head === "QRZ") {
source = callsigns[0];
} else if (callsigns.length >= 2) {
target = callsigns[0];
source = callsigns[1];
} else {
source = callsigns[0];
}
}
return grids.map((grid) => ({
grid,
station: source || null,
source: source || null,
target: target || null,
}));
}
function ft8ExtractLikelyCallsign(message) {
const locatorDetails = ft8ExtractLocatorDetails(message);
if (locatorDetails.length > 0 && locatorDetails[0].station) {
return locatorDetails[0].station;
}
const tokens = ft8TokenizeMessage(message);
for (const token of tokens) {
if (ft8IsLikelyCallsignToken(token)) return token;
}
return null;
}
function ft8IsLikelyCallsignToken(token) {
if (!token) return false;
if (token.length < 3 || token.length > 12) return false;
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false;
if (ft8IsMaidenheadGridToken(token)) return false;
return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
}
function ft8IsFarewellToken(token) {
const normalized = String(token || "").trim().toUpperCase();
return normalized === "RR73" || normalized === "73" || normalized === "RR";
}
function ft8IsMaidenheadGridToken(token) {
const normalized = String(token || "").trim().toUpperCase();
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized);
}
function ft8EscapeHtml(input) {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
function ft8IsAlphaNum(ch) {
return /[A-Za-z0-9]/.test(ch);
}
function activateFt8HistoryLocator(targetEl) {
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
if (!locatorEl) return false;
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
if (!grid) return false;
if (typeof window.navigateToMapLocator === "function") {
window.navigateToMapLocator(grid, "ft8");
}
return true;
}
function applyFt8FilterToRow(row) {
if (!ft8FilterText) {
row.style.display = "";
return;
}
const message = row.dataset.message || "";
row.style.display = message.includes(ft8FilterText) ? "" : "none";
}
function applyFt8FilterToAll() {
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
rows.forEach((row) => applyFt8FilterToRow(row));
}
function updateFt8RowRf(row) {
const freqEl = row.querySelector(".ft8-freq");
if (!freqEl) return;
const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN;
const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz);
if (Number.isFinite(displayFreqHz)) {
freqEl.textContent = displayFreqHz.toFixed(0);
} else {
freqEl.textContent = "--";
}
}
window.updateFt8RfDisplay = function() {
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
rows.forEach((row) => updateFt8RowRf(row));
updateFt8Bar();
};
window.resetFt8HistoryView = function() {
ft8MessagesEl.innerHTML = "";
ft8MessageHistory = [];
updateFt8Bar();
renderFt8History();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8");
};
if (ft8FilterInput) {
ft8FilterInput.addEventListener("input", () => {
ft8FilterText = ft8FilterInput.value.trim().toUpperCase();
renderFt8History();
});
}
if (ft8MessagesEl) {
ft8MessagesEl.addEventListener("click", (event) => {
if (!activateFt8HistoryLocator(event.target)) return;
event.preventDefault();
event.stopPropagation();
});
ft8MessagesEl.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
if (!activateFt8HistoryLocator(event.target)) return;
event.preventDefault();
event.stopPropagation();
});
}
const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn");
ft8DecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn);
await postPath("/toggle_ft8_decode");
} catch (e) {
console.error("FT8 toggle failed", e);
}
});
document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
if (!confirm("Clear all FT8 decode history? This cannot be undone.")) return;
try {
await postPath("/clear_ft8_decode");
window.resetFt8HistoryView();
} catch (e) {
console.error("FT8 history clear failed", e);
}
});
// --- Server-side FT8 decode handler ---
window.onServerFt8 = function(msg) {
ft8Status.textContent = "Receiving";
const next = normalizeServerFt8Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
...msg,
freq_hz: next.rfHz,
locator_details: next.locatorDetails,
});
}
addFt8Message(next.history);
};
@@ -0,0 +1,444 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
const hfAprsStatus = document.getElementById("hf-aprs-status");
const hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
const hfAprsFilterInput = document.getElementById("hf-aprs-filter");
const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
let hfAprsFilterText = "";
let hfAprsPacketHistory = [];
let hfAprsOnlyPos = false;
let hfAprsHideCrc = false;
let hfAprsCollapseDup = false;
let hfAprsTypeFilter = "all";
function currentHfAprsHistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneHfAprsPacketHistory() {
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
}
function scheduleHfAprsHistoryRender() {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob("hf-aprs-history", () => renderHfAprsHistory());
return;
}
renderHfAprsHistory();
}
function hfAprsPacketCategory(pkt) {
const type = String(pkt.type || "").toLowerCase();
const info = String(pkt.info || "").toLowerCase();
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather";
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
return "other";
}
function hfAprsCategoryLabel(category) {
switch (category) {
case "position": return "Position";
case "message": return "Message";
case "weather": return "Weather";
case "telemetry": return "Telemetry";
default: return "Other";
}
}
function hfAprsAgeText(tsMs) {
if (!Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
return `${hours}h ago`;
}
function hfAprsDistanceText(pkt) {
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`;
}
function hfAprsPacketSignature(pkt) {
return [
pkt.srcCall || "",
pkt.destCall || "",
pkt.path || "",
pkt.info || "",
pkt.type || "",
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
].join("|");
}
function hfAprsHexBytes(bytes) {
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
}
function hfAprsFilterMatch(pkt) {
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
if (hfAprsHideCrc && !pkt.crcOk) return false;
if (hfAprsTypeFilter !== "all" && hfAprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
if (!hfAprsFilterText) return true;
const haystack = [
pkt.srcCall,
pkt.destCall,
pkt.path,
pkt.info,
pkt.type,
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
hfAprsPacketCategory(pkt),
]
.filter(Boolean)
.join(" ")
.toUpperCase();
return haystack.includes(hfAprsFilterText);
}
function hfAprsVisiblePackets() {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
return packets.filter(hfAprsFilterMatch);
}
function collapseHfAprsDuplicates(packets) {
const seen = new Set();
const out = [];
for (const pkt of packets) {
const key = hfAprsPacketSignature(pkt);
if (seen.has(key)) continue;
seen.add(key);
out.push(pkt);
}
return out;
}
function updateHfAprsSummary() {
const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
}
if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0];
if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet";
} else {
hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${hfAprsAgeText(latest._tsMs)}`;
}
}
}
function updateHfAprsChipState() {
document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => {
btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`);
});
hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos);
hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc);
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
}
function renderHfAprsInfo(pkt) {
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
if (bytes && bytes.length > 0) {
let out = "";
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i];
if (b >= 0x20 && b <= 0x7e) {
const ch = String.fromCharCode(b);
if (ch === "<") out += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
else out += ch;
} else {
const hex = b.toString(16).toUpperCase().padStart(2, "0");
out += `<span class="aprs-byte">0x${hex}</span>`;
}
}
return out;
}
const str = pkt.info || "";
let out = "";
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code >= 0x20 && code <= 0x7e) {
const ch = str[i];
if (ch === "<") out += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
else out += ch;
} else {
const hex = code.toString(16).toUpperCase().padStart(2, "0");
out += `<span class="aprs-byte">0x${hex}</span>`;
}
}
return out;
}
function renderHfAprsRow(pkt, isFresh) {
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = hfAprsAgeText(pkt._tsMs);
const category = hfAprsPacketCategory(pkt);
const categoryLabel = hfAprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
let symbolHtml = "";
if (pkt.symbolTable && pkt.symbolCode) {
const sheet = pkt.symbolTable === "/" ? 0 : 1;
const code = pkt.symbolCode.charCodeAt(0) - 33;
const col = code % 16;
const row2 = Math.floor(code / 16);
const bgX = -(col * 24);
const bgY = -(row2 * 24);
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
}
const posLink = pkt.lat != null && pkt.lon != null
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
: "";
const distance = hfAprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML =
`<div class="aprs-row-head">` +
`<span class="aprs-time">${ts}</span>` +
hfBadge +
symbolHtml +
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
`<span>&gt;${escapeMapHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
pathBadge +
crcBadge +
`</div>` +
`<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
`</div>` +
`<div class="aprs-row-detail">` +
`<span title="${escapeMapHtml(pkt.type || "")}">${renderHfAprsInfo(pkt)}</span>` +
(posLink ? `<span>${posLink}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`<details class="aprs-details">` +
`<summary>Details</summary>` +
`<div class="aprs-details-grid">` +
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(hfAprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` +
`</details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = String(el.dataset.aprsMap || "");
const [lat, lon] = raw.split(",").map(Number);
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
window.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
const raw = String(copyBtn.dataset.aprsCopy || "");
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(raw);
showHint("Coordinates copied", 1200);
}
} catch (_e) {
showHint("Copy failed", 1500);
}
});
}
return row;
}
function renderHfAprsHistory() {
pruneHfAprsPacketHistory();
if (!hfAprsPacketsEl) {
updateHfAprsSummary();
updateHfAprsChipState();
return;
}
const visible = hfAprsVisiblePackets();
const fragment = document.createDocumentFragment();
for (let i = 0; i < visible.length; i++) {
fragment.appendChild(renderHfAprsRow(visible[i], i === 0));
}
hfAprsPacketsEl.replaceChildren(fragment);
updateHfAprsSummary();
updateHfAprsChipState();
}
window.resetHfAprsHistoryView = function() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
};
window.pruneHfAprsHistoryView = function() {
pruneHfAprsPacketHistory();
renderHfAprsHistory();
};
function addHfAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory();
scheduleHfAprsHistoryRender();
}
function normalizeServerHfAprsPacket(pkt) {
return {
rig_id: pkt.rig_id || null,
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
srcCall: pkt.src_call,
destCall: pkt.dest_call,
path: pkt.path,
info: pkt.info,
info_bytes: pkt.info_bytes,
type: pkt.packet_type,
crcOk: pkt.crc_ok,
ts_ms: pkt.ts_ms,
lat: pkt.lat,
lon: pkt.lon,
symbolTable: pkt.symbol_table,
symbolCode: pkt.symbol_code,
};
}
window.onServerHfAprsBatch = function(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized = [];
for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
normalized.push(next);
}
normalized.reverse();
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
pruneHfAprsPacketHistory();
scheduleHfAprsHistoryRender();
};
window.restoreHfAprsHistory = function(packets) {
window.onServerHfAprsBatch(packets);
};
const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
await postPath("/toggle_hf_aprs_decode");
} catch (e) {
console.error("HF APRS toggle failed", e);
}
});
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => {
if (!confirm("Clear all HF APRS decode history? This cannot be undone.")) return;
try {
await postPath("/clear_hf_aprs_decode");
window.resetHfAprsHistoryView();
} catch (e) {
console.error("HF APRS history clear failed", e);
}
});
if (hfAprsOnlyPosBtn) {
hfAprsOnlyPosBtn.addEventListener("click", () => {
hfAprsOnlyPos = !hfAprsOnlyPos;
renderHfAprsHistory();
});
}
if (hfAprsHideCrcBtn) {
hfAprsHideCrcBtn.addEventListener("click", () => {
hfAprsHideCrc = !hfAprsHideCrc;
renderHfAprsHistory();
});
}
if (hfAprsCollapseDupBtn) {
hfAprsCollapseDupBtn.addEventListener("click", () => {
hfAprsCollapseDup = !hfAprsCollapseDup;
renderHfAprsHistory();
});
}
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
const btn = document.getElementById(`hf-aprs-type-${type}`);
if (!btn) return;
btn.addEventListener("click", () => {
hfAprsTypeFilter = type;
renderHfAprsHistory();
});
});
if (hfAprsFilterInput) {
hfAprsFilterInput.addEventListener("input", () => {
hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase();
renderHfAprsHistory();
});
}
// --- Server-side HF APRS decode handler ---
window.onServerHfAprs = function(pkt) {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
};
renderHfAprsHistory();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("hf_aprs");
@@ -0,0 +1,321 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// Satellite Pass Scheduling UI
// Manages the satellite overlay section within the background decoding scheduler.
// Communicates with scheduler.js via a thin window API for shared state access.
(function () {
"use strict";
// ── DOM references (cached once) ──────────────────────────────────
const dom = {
enabled: document.getElementById("scheduler-sat-enabled"),
pretune: document.getElementById("scheduler-sat-pretune"),
body: document.getElementById("scheduler-sat-body"),
tbody: document.getElementById("scheduler-sat-tbody"),
addBtn: document.getElementById("scheduler-sat-add-btn"),
passStatus: document.getElementById("scheduler-sat-pass-status"),
formWrap: document.getElementById("sch-sat-form-wrap"),
formTitle: document.getElementById("sch-sat-form-title"),
form: document.getElementById("sch-sat-form"),
formCancel: document.getElementById("sch-sat-form-cancel"),
preset: document.getElementById("scheduler-sat-preset"),
name: document.getElementById("scheduler-sat-name"),
norad: document.getElementById("scheduler-sat-norad"),
bookmark: document.getElementById("scheduler-sat-bookmark"),
minEl: document.getElementById("scheduler-sat-min-el"),
priority: document.getElementById("scheduler-sat-priority"),
centerHz: document.getElementById("scheduler-sat-center-hz"),
};
// ── Local state ───────────────────────────────────────────────────
let editIdx = null; // null = adding, number = editing
// ── Scheduler bridge ──────────────────────────────────────────────
// These accessors call into scheduler.js via window.schedulerBridge,
// which is set up by scheduler.js after it initializes.
function getBridge() {
return window.schedulerBridge || {};
}
function getConfig() {
const b = getBridge();
return typeof b.getConfig === "function" ? b.getConfig() : null;
}
function getStatus() {
const b = getBridge();
return typeof b.getStatus === "function" ? b.getStatus() : null;
}
function getBookmarks() {
const b = getBridge();
return typeof b.getBookmarks === "function" ? b.getBookmarks() : [];
}
function markDirty() {
var b = getBridge();
if (typeof b.markDirty === "function") b.markDirty();
}
function bmName(id) {
const bm = getBookmarks().find(function (b) { return b.id === id; });
return bm ? bm.name : String(id || "");
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatFreq(hz) {
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
return hz + " Hz";
}
// ── Satellite config helpers ──────────────────────────────────────
function getSatelliteEntries() {
var config = getConfig();
return (config && config.satellites && Array.isArray(config.satellites.entries))
? config.satellites.entries
: [];
}
function ensureSatelliteConfig() {
var config = getConfig();
if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
if (!config.satellites.entries) config.satellites.entries = [];
return config.satellites;
}
function collectSatelliteConfig() {
var enabled = dom.enabled ? dom.enabled.checked : false;
var pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
return {
enabled: enabled,
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
entries: getSatelliteEntries(),
};
}
// ── Render: section ───────────────────────────────────────────────
function renderSection() {
var config = getConfig();
var satCfg = (config && config.satellites) || {};
var enabled = !!satCfg.enabled;
if (dom.enabled) dom.enabled.checked = enabled;
if (dom.pretune) dom.pretune.value = satCfg.pretune_secs != null ? satCfg.pretune_secs : 60;
if (dom.body) dom.body.style.display = enabled ? "" : "none";
renderEntries();
renderPassStatus();
}
// ── Render: entries table ─────────────────────────────────────────
function renderEntries() {
if (!dom.tbody) return;
var entries = getSatelliteEntries();
var frag = document.createDocumentFragment();
entries.forEach(function (entry, idx) {
var tr = document.createElement("tr");
var tdSat = document.createElement("td");
tdSat.textContent = entry.satellite || "";
tr.appendChild(tdSat);
var tdNorad = document.createElement("td");
tdNorad.textContent = entry.norad_id || "";
tr.appendChild(tdNorad);
var tdBm = document.createElement("td");
tdBm.textContent = bmName(entry.bookmark_id);
tr.appendChild(tdBm);
var tdEl = document.createElement("td");
tdEl.textContent = (entry.min_elevation_deg != null ? entry.min_elevation_deg + "\u00B0" : "5\u00B0");
tr.appendChild(tdEl);
var tdPrio = document.createElement("td");
tdPrio.textContent = entry.priority || 0;
tr.appendChild(tdPrio);
var tdActions = document.createElement("td");
var editBtn = document.createElement("button");
editBtn.className = "sch-write";
editBtn.type = "button";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", function () {
openForm(entry, idx);
});
tdActions.appendChild(editBtn);
var removeBtn = document.createElement("button");
removeBtn.className = "sch-write";
removeBtn.type = "button";
removeBtn.textContent = "Remove";
removeBtn.addEventListener("click", function () {
removeEntry(idx);
});
tdActions.appendChild(removeBtn);
tr.appendChild(tdActions);
frag.appendChild(tr);
});
dom.tbody.replaceChildren(frag);
}
// ── Render: pass status ───────────────────────────────────────────
function renderPassStatus() {
if (!dom.passStatus) return;
var entries = getSatelliteEntries();
if (entries.length === 0) {
dom.passStatus.innerHTML = "";
return;
}
var status = getStatus();
if (status && status.active_satellite) {
dom.passStatus.innerHTML =
'<span class="sch-sat-active-badge">PASS ACTIVE: ' +
escHtml(status.active_satellite) +
'</span>';
} else {
dom.passStatus.innerHTML =
'<span style="color:var(--text-muted);font-size:0.8rem;">No satellite pass active. Predictions available in the SAT tab.</span>';
}
}
// ── Render: bookmark dropdown ─────────────────────────────────────
function renderBookmarkSelect(selectedId) {
if (!dom.bookmark) return;
dom.bookmark.innerHTML = '<option value="">— none —</option>';
getBookmarks().forEach(function (bm) {
var opt = document.createElement("option");
opt.value = bm.id;
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
if (bm.id === selectedId) opt.selected = true;
dom.bookmark.appendChild(opt);
});
}
// ── Entry management ──────────────────────────────────────────────
function removeEntry(idx) {
var sat = ensureSatelliteConfig();
sat.entries.splice(idx, 1);
renderEntries();
markDirty();
}
// ── Form: open ────────────────────────────────────────────────────
function openForm(entry, idx) {
editIdx = (idx != null) ? idx : null;
if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite";
if (dom.preset) dom.preset.value = "";
if (dom.name) dom.name.value = entry ? (entry.satellite || "") : "";
if (dom.norad) dom.norad.value = entry ? (entry.norad_id || "") : "";
if (dom.minEl) dom.minEl.value = entry && entry.min_elevation_deg != null ? entry.min_elevation_deg : 5;
if (dom.priority) dom.priority.value = entry && entry.priority != null ? entry.priority : 0;
if (dom.centerHz) dom.centerHz.value = entry && entry.center_hz ? entry.center_hz : "";
renderBookmarkSelect(entry ? entry.bookmark_id : null);
if (dom.formWrap) {
dom.formWrap.style.display = "flex";
if (dom.name) dom.name.focus();
}
}
// ── Form: close ───────────────────────────────────────────────────
function closeForm() {
if (dom.formWrap) dom.formWrap.style.display = "none";
editIdx = null;
}
// ── Form: submit ──────────────────────────────────────────────────
function onFormSubmit(e) {
e.preventDefault();
var satellite = dom.name ? dom.name.value.trim() : "";
var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
var bmId = dom.bookmark ? dom.bookmark.value : "";
if (!satellite) { alert("Please enter a satellite name."); return; }
if (isNaN(noradId) || noradId <= 0) { alert("Please enter a valid NORAD catalog number."); return; }
if (!bmId) { alert("Please select a bookmark."); return; }
var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
var centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
var sat = ensureSatelliteConfig();
var entryData = {
satellite: satellite,
norad_id: noradId,
bookmark_id: bmId,
min_elevation_deg: isNaN(minEl) ? 5 : minEl,
priority: isNaN(prio) ? 0 : prio,
center_hz: !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null,
bookmark_ids: [],
};
if (editIdx !== null) {
var existing = sat.entries[editIdx];
entryData.id = existing ? existing.id : ("sat_" + Date.now().toString(36));
sat.entries[editIdx] = entryData;
} else {
entryData.id = "sat_" + Date.now().toString(36);
sat.entries.push(entryData);
}
closeForm();
renderEntries();
markDirty();
}
// ── Preset change handler ─────────────────────────────────────────
function onPresetChange() {
if (!dom.preset || !dom.preset.value) return;
var parts = dom.preset.value.split("|");
if (dom.name) dom.name.value = parts[0] || "";
if (dom.norad) dom.norad.value = parts[1] || "";
}
// ── Wire all events ───────────────────────────────────────────────
function wireEvents() {
if (dom.enabled) {
dom.enabled.addEventListener("change", function () {
if (dom.body) dom.body.style.display = dom.enabled.checked ? "" : "none";
markDirty();
});
}
if (dom.pretune) {
dom.pretune.addEventListener("input", function () {
markDirty();
});
}
if (dom.addBtn) dom.addBtn.addEventListener("click", function () { openForm(null, null); });
if (dom.form) dom.form.addEventListener("submit", onFormSubmit);
if (dom.formCancel) dom.formCancel.addEventListener("click", closeForm);
if (dom.preset) dom.preset.addEventListener("change", onPresetChange);
}
// ── Public API ────────────────────────────────────────────────────
window.satScheduler = {
wireEvents: wireEvents,
renderSection: renderSection,
renderPassStatus: renderPassStatus,
collectSatelliteConfig: collectSatelliteConfig,
};
})();
@@ -0,0 +1,546 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- SAT Plugin ---
// Live view: decoder state, latest image card
// History view: filterable table of all decoded images
// Predictions view: next 24 h passes for ham satellites
// ── DOM references (cached once) ───────────────────────────────────
const satDom = {
status: document.getElementById("sat-status"),
liveView: document.getElementById("sat-live-view"),
historyView: document.getElementById("sat-history-view"),
predictionsView: document.getElementById("sat-predictions-view"),
liveLatest: document.getElementById("sat-live-latest"),
historyList: document.getElementById("sat-history-list"),
historyCount: document.getElementById("sat-history-count"),
filterInput: document.getElementById("sat-filter"),
sortSelect: document.getElementById("sat-sort"),
typeFilter: document.getElementById("sat-type-filter"),
lrptState: document.getElementById("sat-lrpt-state"),
viewLiveBtn: document.getElementById("sat-view-live"),
viewHistoryBtn: document.getElementById("sat-view-history"),
viewPredBtn: document.getElementById("sat-view-predictions"),
predFilter: document.getElementById("sat-pred-filter"),
predMinEl: document.getElementById("sat-pred-min-el"),
predCategory: document.getElementById("sat-pred-category"),
predCurrentList: document.getElementById("sat-pred-current-list"),
predUpcomingList: document.getElementById("sat-pred-list"),
predCurrentSec: document.getElementById("sat-pred-current-section"),
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
predStatus: document.getElementById("sat-pred-status"),
};
// ── State ───────────────────────────────────────────────────────────
let satImageHistory = [];
const SAT_MAX_IMAGES = 100;
const SAT_PRED_PAGE_SIZE = 50;
let satPredShowAll = false;
let satFilterText = "";
let satActiveView = "live"; // "live" | "history" | "predictions"
let satPredData = [];
let satPredFilterText = "";
let satPredMinEl = 0;
let satPredCategory = "all";
let satPredSatCount = 0;
let satPredCountdownTimer = null;
// ── UI scheduler helper ─────────────────────────────────────────────
function scheduleSatUi(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
// ── View switching ──────────────────────────────────────────────────
function switchSatView(view) {
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
satActiveView = view;
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
if (leavingPredictions) clearPredictionDom();
if (view === "history") {
renderSatHistoryTable();
} else if (view === "predictions") {
satPredShowAll = false;
loadSatPredictions();
}
}
function clearPredictionDom() {
stopCountdownTimer();
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
}
window.clearSatPredictionDom = clearPredictionDom;
satDom.viewLiveBtn?.addEventListener("click", () => switchSatView("live"));
satDom.viewHistoryBtn?.addEventListener("click", () => switchSatView("history"));
satDom.viewPredBtn?.addEventListener("click", () => switchSatView("predictions"));
// ── Live view: decoder state ────────────────────────────────────────
let _lastSatLrptOn = null;
window.updateSatLiveState = function (update) {
if (!satDom.lrptState) return;
const lrptOn = !!update.lrpt_decode_enabled;
if (lrptOn !== _lastSatLrptOn) {
_lastSatLrptOn = lrptOn;
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
if (satDom.status) {
if (lrptOn) {
satDom.status.textContent = "Decoder active \u2014 waiting for signal";
} else {
satDom.status.textContent = "Decoder idle";
}
}
}
};
function renderSatLatestCard() {
if (!satDom.liveLatest) return;
if (satImageHistory.length === 0) {
satDom.liveLatest.innerHTML =
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable a decoder and wait for a satellite pass.</div>';
return;
}
const img = satImageHistory[0];
const decoder = img._decoder || "unknown";
const typeName = "Meteor LRPT";
const satellite = img.satellite || "";
const channels = img.channels || img.channel_a || "";
const lines = img.mcu_count || img.line_count || 0;
const unit = "MCU rows";
const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
let meta = [typeName];
if (satellite) meta.push(satellite);
if (channels) meta.push(channels);
meta.push(`${lines} ${unit}`);
meta.push(`${date} ${ts}`);
let html = `<div class="sat-latest-card">`;
html += `<div class="sat-latest-title">Latest decoded image</div>`;
html += `<div class="sat-latest-meta">${meta.join(" &middot; ")}</div>`;
if (img.path) {
html += `<a href="${img.path}" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">Download PNG</a>`;
}
if (img.geo_bounds) {
html += ` <button type="button" class="sat-map-btn" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="font-size:0.8rem;margin-top:0.25rem;margin-left:0.5rem;cursor:pointer;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:1px 6px;">Show on Map</button>`;
}
html += `</div>`;
satDom.liveLatest.innerHTML = html;
}
// ── History view: table ─────────────────────────────────────────────
function getSatFilteredHistory() {
let items = satImageHistory;
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
if (satFilterText) {
items = items.filter((i) => {
const haystack = [
"meteor lrpt",
i.satellite || "",
i.channels || "",
i.channel_a || "",
i.channel_b || "",
].join(" ").toUpperCase();
return haystack.includes(satFilterText);
});
}
const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest";
if (sortVal === "oldest") items = items.slice().reverse();
return items;
}
function renderSatHistoryRow(img) {
const row = document.createElement("div");
row.className = "sat-history-row";
const decoder = img._decoder || "unknown";
const typeName = "Meteor LRPT";
const typeClass = "sat-type-lrpt";
const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
const satellite = img.satellite || "--";
const channels = img.channels || "--";
const lines = img.mcu_count || img.line_count || 0;
const unit = "MCU";
let link = img.path
? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>`
: "--";
if (img.geo_bounds) {
link += ` <a href="javascript:void(0)" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="color:var(--accent);">Map</a>`;
}
row.innerHTML = [
`<span>${date} ${ts}</span>`,
`<span class="sat-col-type ${typeClass}">${typeName}</span>`,
`<span>${satellite}</span>`,
`<span>${channels}</span>`,
`<span>${lines} ${unit}</span>`,
`<span>${link}</span>`,
].join("");
return row;
}
function renderSatHistoryTable() {
if (!satDom.historyList) return;
const items = getSatFilteredHistory();
const fragment = document.createDocumentFragment();
for (let i = 0; i < items.length; i += 1) {
fragment.appendChild(renderSatHistoryRow(items[i]));
}
satDom.historyList.replaceChildren(fragment);
if (satDom.historyCount) {
const total = satImageHistory.length;
const shown = items.length;
satDom.historyCount.textContent =
total === 0
? "No images yet"
: shown === total
? `${total} image${total === 1 ? "" : "s"}`
: `${shown} of ${total} images`;
}
}
// ── Add image to history ────────────────────────────────────────────
function addSatImage(img, decoder) {
const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
img._tsMs = tsMs;
img._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
img._decoder = decoder;
satImageHistory.unshift(img);
if (satImageHistory.length > SAT_MAX_IMAGES) {
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
}
scheduleSatUi("sat-latest", () => renderSatLatestCard());
if (satActiveView === "history") {
scheduleSatUi("sat-history", () => renderSatHistoryTable());
}
}
// ── Server callbacks ────────────────────────────────────────────────
window.onServerLrptProgress = function (msg) {
if (satDom.status && msg.mcu_count > 0) {
satDom.status.textContent = "Receiving \u2014 " + msg.mcu_count + " MCU rows decoded";
}
};
window.onServerLrptImage = function (msg) {
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
addSatImage(msg, "lrpt");
if (msg.geo_bounds && msg.path && window.addSatMapOverlay) {
window.addSatMapOverlay(msg);
}
};
window.resetSatHistoryView = function () {
satImageHistory = [];
if (satDom.historyList) satDom.historyList.innerHTML = "";
renderSatLatestCard();
renderSatHistoryTable();
if (window.clearSatMapOverlays) window.clearSatMapOverlays();
};
window.pruneSatHistoryView = function () {
renderSatHistoryTable();
renderSatLatestCard();
};
// ── Toggle buttons ──────────────────────────────────────────────────
const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
lrptDecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
await postPath("/toggle_lrpt_decode");
} catch (e) {
console.error("LRPT toggle failed", e);
}
});
// ── Filter / sort event listeners ───────────────────────────────────
satDom.filterInput?.addEventListener("input", () => {
satFilterText = satDom.filterInput.value.trim().toUpperCase();
renderSatHistoryTable();
});
satDom.sortSelect?.addEventListener("change", () => renderSatHistoryTable());
satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
// ── Settings: clear history ─────────────────────────────────────────
document
.getElementById("settings-clear-sat-history")
?.addEventListener("click", async () => {
if (!confirm("Clear all satellite decode history? This cannot be undone.")) return;
try {
await postPath("/clear_lrpt_decode");
window.resetSatHistoryView();
} catch (e) {
console.error("Weather satellite history clear failed", e);
}
});
// ── Predictions: helpers ────────────────────────────────────────────
function azToCardinal(deg) {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8];
}
function formatPredTime(ms) {
const d = new Date(ms);
const now = new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? dayNames[d.getUTCDay()] + " " : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s) {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms) {
const totalSec = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg) {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
// ── Predictions: countdown timer management ─────────────────────────
function stopCountdownTimer() {
if (satPredCountdownTimer) {
clearInterval(satPredCountdownTimer);
satPredCountdownTimer = null;
}
}
function startCountdownTimer(container) {
const countdownEls = container ? container.querySelectorAll(".sat-pred-col-countdown") : [];
if (countdownEls.length === 0) return;
satPredCountdownTimer = setInterval(() => {
if (satActiveView !== "predictions") {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = parseInt(el.dataset.los, 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
renderSatPredictions(getFilteredPredictions());
}
}, 1000);
}
// ── Predictions: row builders ───────────────────────────────────────
function buildCurrentPassRow(pass, now) {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
function buildUpcomingPassRow(pass) {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
// ── Predictions: filter state ───────────────────────────────────────
function getFilteredPredictions() {
let items = satPredData;
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
return items;
}
function applyPredFilters() {
renderSatPredictions(getFilteredPredictions());
}
satDom.predFilter?.addEventListener("input", () => {
satPredFilterText = satDom.predFilter.value.trim().toUpperCase();
applyPredFilters();
});
satDom.predMinEl?.addEventListener("change", () => {
satPredMinEl = parseInt(satDom.predMinEl.value, 10) || 0;
applyPredFilters();
});
satDom.predCategory?.addEventListener("change", () => {
satPredCategory = satDom.predCategory.value;
applyPredFilters();
});
// ── Predictions: main render ────────────────────────────────────────
function renderSatPredictions(passes, error) {
stopCountdownTimer();
if (error) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
// ── Current passes ──
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
if (satDom.predCurrentList) {
if (current.length === 0) {
satDom.predCurrentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
satDom.predCurrentList.replaceChildren(frag);
}
}
// ── Upcoming passes ──
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
if (satDom.predUpcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
moreRow.addEventListener("click", () => {
satPredShowAll = true;
renderSatPredictions(getFilteredPredictions());
});
frag.appendChild(moreRow);
}
satDom.predUpcomingList.replaceChildren(frag);
}
// ── Status ──
if (satDom.predStatus) {
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`;
satDom.predStatus.textContent = text;
}
// ── Countdown timer ──
if (current.length > 0 && satActiveView === "predictions") {
startCountdownTimer(satDom.predCurrentList);
}
}
// ── Predictions: data loading ───────────────────────────────────────
async function loadSatPredictions() {
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
satPredSatCount = data.satellite_count || 0;
if (data.error) {
satPredData = [];
renderSatPredictions([], data.error);
} else {
satPredData = data.passes || [];
renderSatPredictions(getFilteredPredictions());
}
} catch (e) {
renderSatPredictions([], `Failed to load predictions: ${e.message}`);
}
}
// ── Navigate to map centered on satellite image bounds ──────────────
window.satShowOnMap = function (south, west, north, east) {
if (typeof window.enableMapSourceFilter === "function") {
window.enableMapSourceFilter("sat");
}
const lat = (south + north) / 2;
const lon = (west + east) / 2;
if (window.navigateToAprsMap) {
window.navigateToAprsMap(lat, lon);
}
};
// ── Initial render ──────────────────────────────────────────────────
renderSatLatestCard();
renderSatHistoryTable();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,565 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- Virtual Channels Plugin ---
//
// Handles the `session` and `channels` SSE events emitted by /events and
// provides the channel picker UI (SDR-only, shown when filter_controls is set).
let vchanSessionId = null;
let vchanRigId = null;
let vchanChannels = [];
let vchanActiveId = null;
let schedulerReleaseState = null;
let schedulerReleasePollTimer = null;
function vchanFmtFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz";
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "\u202fkHz";
return hz + "\u202fHz";
}
function schedulerReleaseSummaryText(state) {
if (!state) return "Scheduler is controlling the rig.";
const connected = Number(state.connected_sessions) || 0;
const released = Number(state.released_sessions) || 0;
if (connected === 0) return "Scheduler can control the rig.";
if (state.all_released) {
return connected === 1
? "Scheduler is controlling the rig."
: `Scheduler is controlling the rig for all ${connected} users.`;
}
if (!state.current_session_released) {
const othersReleased = Math.max(released, 0);
return othersReleased > 0
? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.`
: "You are holding control. Release it to return control to the scheduler.";
}
const blocking = Math.max(connected - released, 0);
return blocking > 0
? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.`
: "Scheduler can control the rig.";
}
function vchanRenderSchedulerRelease() {
const btn = document.getElementById("scheduler-release-btn");
const status = document.getElementById("scheduler-release-status");
if (!btn || !status) return;
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
btn.disabled = !vchanSessionId || currentReleased;
btn.classList.toggle("active", !currentReleased);
btn.textContent = "Release to Scheduler";
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
}
async function vchanPollSchedulerRelease() {
if (!vchanSessionId) {
schedulerReleaseState = null;
vchanRenderSchedulerRelease();
return;
}
try {
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json();
vchanRenderSchedulerRelease();
} catch (e) {
console.error("scheduler release status failed", e);
}
}
function vchanStartSchedulerReleasePolling() {
if (schedulerReleasePollTimer) {
clearInterval(schedulerReleasePollTimer);
}
schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 10000);
}
async function vchanToggleSchedulerRelease() {
if (!vchanSessionId) return;
const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null);
try {
const resp = await fetch("/scheduler-control", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json();
vchanRenderSchedulerRelease();
} catch (e) {
console.error("scheduler release toggle failed", e);
}
}
async function vchanTakeSchedulerControl() {
if (!vchanSessionId) return;
if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
try {
const resp = await fetch("/scheduler-control", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId, released: false }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json();
vchanRenderSchedulerRelease();
} catch (e) {
console.error("scheduler control takeover failed", e);
}
}
window.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
// Called by app.js when the SSE `session` event arrives.
function vchanHandleSession(data) {
try {
const d = JSON.parse(data);
vchanSessionId = d.session_id || null;
vchanPollSchedulerRelease();
} catch (e) {
console.warn("vchan: bad session event", e);
}
}
// Called by app.js when the SSE `channels` event arrives.
function vchanHandleChannels(data) {
try {
const d = JSON.parse(data);
vchanRigId = d.remote || null;
vchanChannels = d.channels || [];
const ids = new Set(vchanChannels.map(c => c.id));
if (!vchanActiveId && vchanChannels.length > 0 && vchanSessionId) {
// First channels event for this session — auto-subscribe to channel 0
// so we join the same tuned channel as other users on this rig.
// Use a direct subscribe (no scheduler control takeover) to avoid
// side-effects on initial connect.
vchanAutoJoinPrimary(vchanChannels[0].id);
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
// Active channel was evicted — fall back to channel 0 and reconnect audio.
vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null;
vchanReconnectAudio();
}
vchanRender();
vchanRenderSchedulerRelease();
if (typeof renderRdsOverlays === "function") renderRdsOverlays();
} catch (e) {
console.warn("vchan: bad channels event", e);
}
}
function vchanRender() {
const picker = document.getElementById("vchan-picker");
if (!picker) return;
picker.innerHTML = "";
vchanChannels.forEach(ch => {
const btn = document.createElement("button");
btn.type = "button";
btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
if (ch.id === vchanActiveId) btn.classList.add("active");
const label = document.createElement("span");
label.className = "vchan-label";
label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
btn.appendChild(label);
if (!ch.permanent) {
const del = document.createElement("span");
del.className = "vchan-del";
del.textContent = "\u00d7";
del.title = "Delete channel";
del.addEventListener("click", e => {
e.stopPropagation();
vchanDelete(ch.id);
});
btn.appendChild(del);
}
btn.addEventListener("click", () => {
if (ch.id !== vchanActiveId) vchanSubscribe(ch.id);
});
picker.appendChild(btn);
});
// "+" button — allocate a new channel at the current VFO frequency.
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "vchan-add";
addBtn.textContent = "+";
addBtn.title = "Allocate new virtual channel at current frequency";
addBtn.addEventListener("click", vchanAllocate);
picker.appendChild(addBtn);
vchanSyncAccentUI();
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
updateDocumentTitle(activeChannelRds());
}
vchanRenderSchedulerRelease();
}
async function vchanAllocate() {
if (!vchanSessionId || !vchanRigId) return;
// Use the last known rig frequency and mode as the starting point.
const freqHz = (typeof lastFreqHz === "number" && lastFreqHz > 0)
? lastFreqHz
: 0;
const modeEl = document.getElementById("mode");
const mode = modeEl ? (modeEl.value || "USB") : "USB";
try {
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode }),
});
if (!resp.ok) {
const msg = await resp.text().catch(() => String(resp.status));
console.warn("vchan: allocate failed —", msg);
return;
}
const ch = await resp.json();
vchanActiveId = ch.id;
// The SSE `channels` event will trigger vchanRender(); optimistically
// mark active so the picker feels responsive even before the event arrives.
vchanRender();
vchanReconnectAudio();
} catch (e) {
console.error("vchan: allocate error", e);
}
}
async function vchanDelete(channelId) {
if (!vchanRigId) return;
try {
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
{ method: "DELETE" }
);
if (!resp.ok) {
console.warn("vchan: delete failed", resp.status);
}
// Channel list updates via SSE `channels` event.
} catch (e) {
console.error("vchan: delete error", e);
}
}
// Lightweight auto-join for initial connect: registers the session on
// channel 0 without taking scheduler control or reconnecting audio
// (audio isn't started yet at this point).
async function vchanAutoJoinPrimary(channelId) {
if (!vchanSessionId || !vchanRigId) return;
try {
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId }),
}
);
if (!resp.ok) {
console.warn("vchan: auto-join primary failed", resp.status);
return;
}
vchanActiveId = channelId;
vchanRender();
} catch (e) {
console.error("vchan: auto-join error", e);
}
}
async function vchanSubscribe(channelId) {
if (!vchanSessionId || !vchanRigId) return;
try {
await vchanTakeSchedulerControl();
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId }),
}
);
if (!resp.ok) {
console.warn("vchan: subscribe failed", resp.status);
return;
}
vchanActiveId = channelId;
vchanRender();
vchanSyncModeDisplay();
vchanReconnectAudio();
} catch (e) {
console.error("vchan: subscribe error", e);
}
}
// Reconnect the audio WebSocket to the appropriate endpoint:
// - virtual channel: /audio?channel_id=<uuid>
// - primary channel: /audio (no param)
// Always updates _audioChannelOverride so that starting audio later
// connects to the correct channel. Only reconnects if RX audio is active.
function vchanReconnectAudio() {
// Always update the override so startRxAudio picks up the right URL,
// even when audio isn't currently running.
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
if (typeof _audioChannelOverride !== "undefined") {
_audioChannelOverride = ch ? ch.id : null;
}
if (typeof rxActive === "undefined" || !rxActive) return;
if (typeof stopRxAudio === "function") 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(() => {
if (typeof startRxAudio === "function") startRxAudio();
}, 300);
}
// Called by app.js from applyCapabilities().
// Shows the channel picker only for SDR rigs.
function vchanApplyCapabilities(caps) {
const picker = document.getElementById("vchan-picker");
if (!picker) return;
picker.style.display = (caps && caps.filter_controls) ? "" : "none";
vchanRenderSchedulerRelease();
}
// ---------------------------------------------------------------------------
// Freq / mode interception + UI accent
// ---------------------------------------------------------------------------
// Returns true when the active channel is a non-primary (virtual) channel.
function vchanIsOnVirtual() {
if (!vchanActiveId || vchanChannels.length === 0) return false;
return vchanActiveId !== vchanChannels[0].id;
}
function vchanActiveChannel() {
return vchanChannels.find(c => c.id === vchanActiveId) || null;
}
// Update the main freq input to show the virtual channel's frequency.
function vchanUpdateFreqDisplay() {
const ch = vchanActiveChannel();
if (!ch) return;
const el = document.getElementById("freq");
if (!el) return;
if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") {
el.value = formatFreqForStep(ch.freq_hz, jogUnit);
} else {
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
}
}
// Sync the mode picker to the active virtual channel's mode.
// Called whenever the active channel changes or the channel list is refreshed.
function vchanSyncModeDisplay() {
const modeEl = document.getElementById("mode");
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
}
// When on primary channel, app.js rig-state updates handle the picker.
const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof lastModeName !== "undefined") {
if (modeUpper === "WFM" && lastModeName !== "WFM") {
if (typeof setJogDivisor === "function") setJogDivisor(10);
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
} else if (modeUpper !== "WFM" && lastModeName === "WFM") {
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
}
lastModeName = modeUpper;
}
if (typeof updateWfmControls === "function") updateWfmControls();
if (typeof updateSdrSquelchControlVisibility === "function") {
updateSdrSquelchControlVisibility();
}
if (typeof refreshRdsUi === "function") {
refreshRdsUi();
} else if (typeof positionRdsPsOverlay === "function") {
positionRdsPsOverlay();
}
}
// Sync the BW input to the active virtual channel's bandwidth.
function vchanSyncBwDisplay() {
if (!vchanIsOnVirtual()) return;
const ch = vchanActiveChannel();
if (!ch) return;
const bwEl = document.getElementById("spectrum-bw-input");
if (!bwEl) return;
// bandwidth_hz == 0 means mode-default; derive it from the channel mode.
let bwHz = ch.bandwidth_hz || 0;
if (bwHz === 0 && typeof mwDefaultsForMode === "function") {
bwHz = mwDefaultsForMode(ch.mode)[0] || 0;
}
if (bwHz > 0) {
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
if (typeof currentBandwidthHz !== "undefined") {
currentBandwidthHz = bwHz;
window.currentBandwidthHz = bwHz;
} else {
window.currentBandwidthHz = bwHz;
}
}
}
// Add / remove the vchan accent class from the freq and BW inputs.
function vchanSyncAccentUI() {
const onVirtual = vchanIsOnVirtual();
const freqEl = document.getElementById("freq");
const bwEl = document.getElementById("spectrum-bw-input");
if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual);
if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual);
if (onVirtual) {
vchanUpdateFreqDisplay();
vchanSyncModeDisplay();
vchanSyncBwDisplay();
} else if (typeof _origRefreshFreqDisplay === "function") {
_origRefreshFreqDisplay();
}
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
updateDocumentTitle(activeChannelRds());
}
}
// Saved reference to the original refreshFreqDisplay from app.js.
let _origRefreshFreqDisplay = null;
function vchanSetChannelFreq(freqHz) {
if (!vchanRigId || !vchanActiveId) return;
// Validate against current SDR capture window.
if (typeof lastSpectrumData !== "undefined" && lastSpectrumData &&
lastSpectrumData.sample_rate > 0) {
const halfSpan = Number(lastSpectrumData.sample_rate) / 2;
const center = Number(lastSpectrumData.center_hz);
if (Math.abs(freqHz - center) > halfSpan) {
if (typeof showHint === "function") {
showHint(
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
3000
);
}
return;
}
}
// Fire-and-forget: scheduler control + channel freq PUT run in background.
vchanTakeSchedulerControl();
fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ freq_hz: Math.round(freqHz) }),
}
).catch(e => console.error("vchan: set freq error", e));
}
async function vchanSetChannelBandwidth(bwHz) {
if (!vchanRigId || !vchanActiveId) return;
try {
await vchanTakeSchedulerControl();
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) }),
}
);
if (!resp.ok) console.warn("vchan: set bw failed", resp.status);
} catch (e) {
console.error("vchan: set bw error", e);
}
}
async function vchanSetChannelMode(mode) {
if (!vchanRigId || !vchanActiveId) return;
try {
await vchanTakeSchedulerControl();
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
}
);
if (!resp.ok) console.warn("vchan: set mode failed", resp.status);
} catch (e) {
console.error("vchan: set mode error", e);
}
}
// Called by app.js (applyModeFromPicker) and bookmarks.js (bmApply) before
// sending /set_mode to the server. Returns true if the change was handled
// by the virtual channel (caller should skip the server request).
window.vchanInterceptMode = async function(mode) {
if (!vchanIsOnVirtual()) return false;
await vchanSetChannelMode(mode);
return true;
};
// Called by app.js bandwidth setters before sending /set_bandwidth to the
// server. Returns true if the change was handled by the virtual channel.
window.vchanInterceptBandwidth = async function(bwHz) {
if (!vchanIsOnVirtual()) return false;
await vchanSetChannelBandwidth(bwHz);
return true;
};
// 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 _orig = window.setRigFrequency;
window.setRigFrequency = function(freqHz) {
if (vchanIsOnVirtual()) {
// Optimistic local update first, then fire-and-forget channel API.
if (typeof applyLocalTunedFrequency === "function") {
if (typeof _freqOptimisticSeq !== "undefined") {
++_freqOptimisticSeq;
_freqOptimisticHz = Math.round(freqHz);
}
applyLocalTunedFrequency(Math.round(freqHz));
}
vchanSetChannelFreq(freqHz);
return;
}
// Scheduler control is fire-and-forget — don't block the freq change.
vchanTakeSchedulerControl();
if (typeof _orig === "function") _orig(freqHz);
};
})();
(function initSchedulerReleaseControl() {
const btn = document.getElementById("scheduler-release-btn");
if (btn) {
btn.addEventListener("click", () => {
vchanToggleSchedulerRelease();
});
}
vchanStartSchedulerReleasePolling();
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 = window.refreshFreqDisplay;
window.refreshFreqDisplay = function() {
if (vchanIsOnVirtual()) {
vchanUpdateFreqDisplay();
return;
}
if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay();
};
})();
@@ -0,0 +1,352 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- VDES Decoder Plugin (server-side decode) ---
const vdesStatus = document.getElementById("vdes-status");
const vdesMessagesEl = document.getElementById("vdes-messages");
const vdesFilterInput = document.getElementById("vdes-filter");
const vdesBarOverlay = document.getElementById("vdes-bar-overlay");
const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
const vdesFrameCountEl = document.getElementById("vdes-frame-count");
const vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
const VDES_BAR_WINDOW_MS = 15 * 60 * 1000;
let vdesFilterText = "";
let vdesMessageHistory = [];
function currentVdesHistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneVdesMessageHistory() {
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
vdesMessageHistory = vdesMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
}
function scheduleVdesUi(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleVdesHistoryRender() {
scheduleVdesUi("vdes-history", () => renderVdesHistory());
}
function scheduleVdesBarUpdate() {
scheduleVdesUi("vdes-bar", () => updateVdesBar());
}
function currentVdesCenterText() {
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
const hz = raw ? Number(raw) : 0;
if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
return `100 kHz @ ${(hz / 1_000_000).toFixed(3)} MHz`;
}
function vdesAgeText(tsMs) {
if (!Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
return `${hours}h ago`;
}
function vdesHexPreview(rawBytes) {
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
return rawBytes
.slice(0, 20)
.map((value) => Number(value).toString(16).padStart(2, "0"))
.join(" ")
.toUpperCase();
}
function updateVdesSummary() {
pruneVdesMessageHistory();
if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText();
}
if (vdesFrameCountEl) {
const count = vdesMessageHistory.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
}
if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
}
}
function applyVdesFilterToRow(row) {
if (!vdesFilterText) {
row.style.display = "";
return;
}
const text = row.dataset.filterText || "";
row.style.display = text.includes(vdesFilterText) ? "" : "none";
}
function applyVdesFilterToAll() {
if (!vdesMessagesEl) return;
vdesMessagesEl.querySelectorAll(".vdes-message").forEach((row) => applyVdesFilterToRow(row));
}
function renderVdesRow(msg) {
const row = document.createElement("div");
row.className = "vdes-message";
const ts = msg._ts || new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const title = msg.vessel_name || "VDES Burst";
const label = msg.callsign || "VDES";
const info = msg.destination || "";
const labelText = msg.message_label || "";
const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : "";
const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : "";
const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : "";
const fecText = msg.fec_state || "";
const srcText = Number.isFinite(msg.source_id) ? `SRC ${Number(msg.source_id)}` : "";
const dstText = Number.isFinite(msg.destination_id) ? `DST ${Number(msg.destination_id)}` : "";
const sessionText = Number.isFinite(msg.session_id) ? `S${Number(msg.session_id)}` : "";
const asmText = Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : "";
const countText = Number.isFinite(msg.data_count) ? `${Number(msg.data_count)} data bits` : "";
const ackText = Number.isFinite(msg.ack_nack_mask) ? `ACK 0x${Number(msg.ack_nack_mask).toString(16).toUpperCase().padStart(4, "0")}` : "";
const cqiText = Number.isFinite(msg.channel_quality) ? `CQ ${Number(msg.channel_quality)}` : "";
const previewText = msg.payload_preview || "";
const rawHex = vdesHexPreview(msg.raw_bytes);
row.dataset.filterText = [
title,
label,
labelText,
info,
srcText,
dstText,
sessionText,
asmText,
countText,
ackText,
cqiText,
previewText,
linkText,
syncText,
phaseText,
fecText,
rawHex,
msg.message_type,
msg.bit_len,
]
.filter(Boolean)
.join(" ")
.toUpperCase();
row.innerHTML =
`<div class="vdes-row-head">` +
`<span class="vdes-time">${ts}</span>` +
`<span class="vdes-call">${escapeMapHtml(title)}</span>` +
`<span class="vdes-badge">${escapeMapHtml(label)}</span>` +
(labelText ? `<span class="vdes-badge">${escapeMapHtml(labelText)}</span>` : "") +
(linkText ? `<span class="vdes-badge">${escapeMapHtml(linkText)}</span>` : "") +
(srcText ? `<span class="vdes-badge">${escapeMapHtml(srcText)}</span>` : "") +
(dstText ? `<span class="vdes-badge">${escapeMapHtml(dstText)}</span>` : "") +
(syncText ? `<span class="vdes-badge">${escapeMapHtml(syncText)}</span>` : "") +
(phaseText ? `<span class="vdes-badge">${escapeMapHtml(phaseText)}</span>` : "") +
`<span class="vdes-badge">T${escapeMapHtml(String(msg.message_type ?? "--"))}</span>` +
`</div>` +
`<div class="vdes-row-meta">` +
`<span>${escapeMapHtml(currentVdesCenterText())}</span>` +
`<span>${escapeMapHtml(`${msg.bit_len || 0} bits`)}</span>` +
(sessionText ? `<span>${escapeMapHtml(sessionText)}</span>` : "") +
(asmText ? `<span>${escapeMapHtml(asmText)}</span>` : "") +
(countText ? `<span>${escapeMapHtml(countText)}</span>` : "") +
(ackText ? `<span>${escapeMapHtml(ackText)}</span>` : "") +
(cqiText ? `<span>${escapeMapHtml(cqiText)}</span>` : "") +
(info ? `<span>${escapeMapHtml(info)}</span>` : "") +
(fecText ? `<span>${escapeMapHtml(fecText)}</span>` : "") +
`<span>${escapeMapHtml(vdesAgeText(msg._tsMs))}</span>` +
`</div>` +
`<div class="vdes-row-detail">` +
(previewText ? `<span>${escapeMapHtml(previewText)}</span>` : "") +
(previewText ? `<span>·</span>` : "") +
`<span class="vdes-raw">${escapeMapHtml(rawHex)}</span>` +
`</div>`;
applyVdesFilterToRow(row);
return row;
}
function updateVdesBar() {
if (!vdesBarOverlay) return;
updateVdesSummary();
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => msg._tsMs >= cutoffMs).slice(0, 6);
if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = "";
return;
}
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>';
for (const msg of messages) {
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
const label = escapeMapHtml(msg.callsign || "VDES");
const title = escapeMapHtml(msg.vessel_name || "Burst");
const detail = [
`${msg.bit_len || 0} bits`,
msg.message_label ? escapeMapHtml(msg.message_label) : null,
Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null,
Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null,
Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null,
Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null,
Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
msg.destination ? escapeMapHtml(msg.destination) : null,
escapeMapHtml(vdesAgeText(msg._tsMs)),
]
.filter(Boolean)
.join(" · ");
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="vdes-call">${title}</span> <span class="vdes-badge">${label}</span>: ${detail}</div></div>`;
}
vdesBarOverlay.innerHTML = html;
vdesBarOverlay.style.display = "flex";
}
window.updateVdesBar = updateVdesBar;
window.clearVdesBar = function() {
window.resetVdesHistoryView();
};
window.resetVdesHistoryView = function() {
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
vdesMessageHistory = [];
updateVdesBar();
renderVdesHistory();
};
function renderVdesHistory() {
pruneVdesMessageHistory();
if (!vdesMessagesEl) {
updateVdesSummary();
return;
}
const fragment = document.createDocumentFragment();
for (let i = 0; i < vdesMessageHistory.length; i += 1) {
fragment.appendChild(renderVdesRow(vdesMessageHistory[i]));
}
vdesMessagesEl.replaceChildren(fragment);
updateVdesSummary();
}
function addVdesMessage(msg) {
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
msg._tsMs = tsMs;
msg._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
vdesMessageHistory.unshift(msg);
pruneVdesMessageHistory();
scheduleVdesBarUpdate();
scheduleVdesHistoryRender();
}
function normalizeServerVdesMessage(msg) {
return {
rig_id: msg.rig_id || null,
message_type: msg.message_type,
bit_len: msg.bit_len,
raw_bytes: msg.raw_bytes,
lat: msg.lat,
lon: msg.lon,
vessel_name: msg.vessel_name,
callsign: msg.callsign,
destination: msg.destination,
message_label: msg.message_label,
session_id: msg.session_id,
source_id: msg.source_id,
destination_id: msg.destination_id,
data_count: msg.data_count,
asm_identifier: msg.asm_identifier,
ack_nack_mask: msg.ack_nack_mask,
channel_quality: msg.channel_quality,
payload_preview: msg.payload_preview,
link_id: msg.link_id,
sync_score: msg.sync_score,
sync_errors: msg.sync_errors,
phase_rotation: msg.phase_rotation,
fec_state: msg.fec_state,
ts_ms: msg.ts_ms,
};
}
window.onServerVdesBatch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerVdesMessage(msg);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
window.vdesMapAddPoint(next);
}
normalized.push(next);
}
normalized.reverse();
vdesMessageHistory = normalized.concat(vdesMessageHistory);
pruneVdesMessageHistory();
scheduleVdesBarUpdate();
scheduleVdesHistoryRender();
};
window.restoreVdesHistory = function(messages) {
window.onServerVdesBatch(messages);
};
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => {
if (!confirm("Clear all VDES decode history? This cannot be undone.")) return;
try {
await postPath("/clear_vdes_decode");
window.resetVdesHistoryView();
} catch (e) {
console.error("VDES history clear failed", e);
}
});
if (vdesFilterInput) {
vdesFilterInput.addEventListener("input", () => {
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
renderVdesHistory();
});
}
window.onServerVdes = function(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
addVdesMessage(next);
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
window.vdesMapAddPoint(next);
}
};
window.pruneVdesHistoryView = function() {
pruneVdesMessageHistory();
updateVdesBar();
renderVdesHistory();
};
updateVdesSummary();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("vdes");
@@ -0,0 +1,386 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// ---------------------------------------------------------------------------
// wefax.js — WEFAX decoder plugin for trx-frontend-http
// Live view: decoder state, live canvas, latest image card
// History view: filterable table of all decoded images
// ---------------------------------------------------------------------------
// ── DOM references (cached once) ───────────────────────────────────
var wefaxDom = {
status: document.getElementById('wefax-status'),
liveView: document.getElementById('wefax-live-view'),
historyView: document.getElementById('wefax-history-view'),
liveContainer: document.getElementById('wefax-live-container'),
liveInfo: document.getElementById('wefax-live-info'),
liveCanvas: document.getElementById('wefax-live-canvas'),
liveLatest: document.getElementById('wefax-live-latest'),
historyList: document.getElementById('wefax-history-list'),
historyCount: document.getElementById('wefax-history-count'),
filterInput: document.getElementById('wefax-filter'),
sortSelect: document.getElementById('wefax-sort'),
toggleBtn: document.getElementById('wefax-decode-toggle-btn'),
clearBtn: document.getElementById('wefax-clear-btn'),
viewLiveBtn: document.getElementById('wefax-view-live'),
viewHistoryBtn: document.getElementById('wefax-view-history'),
};
// ── State ───────────────────────────────────────────────────────────
var wefaxImageHistory = [];
var WEFAX_MAX_IMAGES = 100;
var wefaxLiveCtx = null;
var wefaxLiveLineCount = 0;
var wefaxLivePixelsPerLine = 1809;
var wefaxActiveView = 'live';
var wefaxFilterText = '';
// ── Helpers ─────────────────────────────────────────────────────────
function currentWefaxHistoryRetentionMs() {
return window.getDecodeHistoryRetentionMs ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1000;
}
function pruneWefaxHistory() {
var cutoff = Date.now() - currentWefaxHistoryRetentionMs();
wefaxImageHistory = wefaxImageHistory.filter(function (m) { return (m._tsMs || 0) > cutoff; });
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function scheduleWefaxUi(key, job) {
if (typeof window.trxScheduleUiFrameJob === 'function') {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
// ── View switching ──────────────────────────────────────────────────
function switchWefaxView(view) {
wefaxActiveView = view;
if (wefaxDom.liveView) wefaxDom.liveView.style.display = view === 'live' ? '' : 'none';
if (wefaxDom.historyView) wefaxDom.historyView.style.display = view === 'history' ? '' : 'none';
[wefaxDom.viewLiveBtn, wefaxDom.viewHistoryBtn].forEach(function (btn) {
if (btn) btn.classList.remove('sat-view-active');
});
if (view === 'live' && wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.classList.add('sat-view-active');
if (view === 'history' && wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.classList.add('sat-view-active');
if (view === 'history') renderWefaxHistoryTable();
}
if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener('click', function () { switchWefaxView('live'); });
if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener('click', function () { switchWefaxView('history'); });
// ── Live canvas rendering ───────────────────────────────────────────
function resetLiveCanvas(pixelsPerLine) {
wefaxLivePixelsPerLine = pixelsPerLine;
wefaxLiveLineCount = 0;
wefaxDom.liveCanvas.width = pixelsPerLine;
wefaxDom.liveCanvas.height = 800;
wefaxLiveCtx = wefaxDom.liveCanvas.getContext('2d');
wefaxLiveCtx.fillStyle = '#000';
wefaxLiveCtx.fillRect(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = '';
}
function paintLine(lineBytes) {
if (!wefaxLiveCtx) return;
var y = wefaxLiveLineCount;
if (y >= wefaxDom.liveCanvas.height) {
var old = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
wefaxDom.liveCanvas.height *= 2;
wefaxLiveCtx.putImageData(old, 0, 0);
}
var w = wefaxLivePixelsPerLine;
var imgData = wefaxLiveCtx.createImageData(w, 1);
var d = imgData.data;
for (var x = 0; x < w; x++) {
var v = x < lineBytes.length ? lineBytes[x] : 0;
var i = x * 4;
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
}
wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++;
}
// ── Live view: latest image card ────────────────────────────────────
function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return;
if (wefaxImageHistory.length === 0) {
wefaxDom.liveLatest.innerHTML =
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
return;
}
var img = wefaxImageHistory[0];
var ts = img._ts || '--';
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
var meta = [
img.ioc + ' IOC',
img.lpm + ' LPM',
img.line_count + ' lines',
date + ' ' + ts,
].join(' \u00b7 ');
var imgSrc = img._dataUrl
? img._dataUrl
: img.path
? '/images/' + escapeHtml(img.path.split('/').pop())
: null;
var html = '<div class="sat-latest-card">';
html += '<div class="sat-latest-title">Latest decoded image</div>';
html += '<div class="sat-latest-meta">' + escapeHtml(meta) + '</div>';
if (imgSrc) {
html += '<a href="' + imgSrc + '" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">View full image</a>';
}
html += '</div>';
wefaxDom.liveLatest.innerHTML = html;
}
// ── History view: table ─────────────────────────────────────────────
function getWefaxFilteredHistory() {
var items = wefaxImageHistory;
if (wefaxFilterText) {
items = items.filter(function (i) {
var haystack = [
String(i.ioc || ''),
String(i.lpm || ''),
String(i.line_count || ''),
].join(' ').toUpperCase();
return haystack.indexOf(wefaxFilterText) >= 0;
});
}
var sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : 'newest';
if (sortVal === 'oldest') items = items.slice().reverse();
return items;
}
function renderWefaxHistoryRow(img) {
var row = document.createElement('div');
row.className = 'sat-history-row';
var ts = img._ts || '--';
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: 'short', day: 'numeric' }) : '';
var ioc = img.ioc || '--';
var lpm = img.lpm || '--';
var lines = img.line_count || 0;
var imgSrc = img._dataUrl
? img._dataUrl
: img.path
? '/images/' + escapeHtml(img.path.split('/').pop())
: null;
var link = imgSrc
? '<a href="' + imgSrc + '" target="_blank" style="color:var(--accent);">View</a>'
: '--';
row.innerHTML = [
'<span>' + escapeHtml(date + ' ' + ts) + '</span>',
'<span>' + escapeHtml(String(ioc)) + '</span>',
'<span>' + escapeHtml(String(lpm)) + '</span>',
'<span>' + lines + '</span>',
'<span>' + link + '</span>',
].join('');
return row;
}
function renderWefaxHistoryTable() {
if (!wefaxDom.historyList) return;
pruneWefaxHistory();
var items = getWefaxFilteredHistory();
var fragment = document.createDocumentFragment();
for (var i = 0; i < items.length; i++) {
fragment.appendChild(renderWefaxHistoryRow(items[i]));
}
wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) {
var total = wefaxImageHistory.length;
var shown = items.length;
wefaxDom.historyCount.textContent =
total === 0
? 'No images yet'
: shown === total
? total + ' image' + (total === 1 ? '' : 's')
: shown + ' of ' + total + ' images';
}
}
// ── Add image to history ────────────────────────────────────────────
function addWefaxImage(msg) {
var tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
msg._tsMs = tsMs;
msg._ts = new Date(tsMs).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
// Capture the live canvas as a data URI for thumbnails.
if (wefaxLiveCtx && wefaxLiveLineCount > 0) {
var trimmed = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxLiveLineCount);
wefaxDom.liveCanvas.height = wefaxLiveLineCount;
wefaxLiveCtx.putImageData(trimmed, 0, 0);
try { msg._dataUrl = wefaxDom.liveCanvas.toDataURL('image/png'); } catch (e) {}
}
wefaxImageHistory.unshift(msg);
if (wefaxImageHistory.length > WEFAX_MAX_IMAGES) {
wefaxImageHistory = wefaxImageHistory.slice(0, WEFAX_MAX_IMAGES);
}
scheduleWefaxUi('wefax-latest', renderWefaxLatestCard);
if (wefaxActiveView === 'history') {
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
}
}
// ── SSE event handlers (public API) ─────────────────────────────────
window.onServerWefaxProgress = function (msg) {
// State-only update (no image data): show decoder state in status.
if (msg.state && !msg.line_data) {
if (wefaxDom.status) {
wefaxDom.status.textContent = msg.state;
// Highlight active states, dim idle/scanning.
wefaxDom.status.style.color = msg.state.indexOf('Idle') === 0 ? '' : 'var(--text-accent)';
}
return;
}
if (msg.line_count <= 1 || !wefaxLiveCtx) {
resetLiveCanvas(msg.pixels_per_line || 1809);
}
if (msg.line_data) {
var binary = atob(msg.line_data);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
paintLine(bytes);
}
if (wefaxDom.liveInfo) {
wefaxDom.liveInfo.textContent =
'Line ' + msg.line_count + ' \u00b7 ' + msg.ioc + ' IOC \u00b7 ' + msg.lpm + ' LPM';
}
if (wefaxDom.status) {
wefaxDom.status.textContent = 'Receiving \u2014 line ' + msg.line_count;
wefaxDom.status.style.color = 'var(--text-accent)';
}
};
window.onServerWefax = function (msg) {
addWefaxImage(msg);
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
if (wefaxDom.status) {
wefaxDom.status.textContent = 'Complete \u2014 ' + msg.line_count + ' lines';
wefaxDom.status.style.color = '';
}
};
window.restoreWefaxHistory = function (messages) {
if (!messages || !messages.length) return;
for (var i = 0; i < messages.length; i++) {
var tsMs = Number.isFinite(messages[i].ts_ms) ? Number(messages[i].ts_ms) : Date.now();
messages[i]._tsMs = tsMs;
messages[i]._ts = new Date(tsMs).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
wefaxImageHistory = messages.concat(wefaxImageHistory);
pruneWefaxHistory();
scheduleWefaxUi('wefax-latest', renderWefaxLatestCard);
if (wefaxActiveView === 'history') {
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
}
};
window.pruneWefaxHistoryView = function () {
pruneWefaxHistory();
renderWefaxHistoryTable();
renderWefaxLatestCard();
};
window.resetWefaxHistoryView = function () {
wefaxImageHistory = [];
if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = '';
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
wefaxLiveCtx = null;
wefaxLiveLineCount = 0;
renderWefaxLatestCard();
renderWefaxHistoryTable();
if (wefaxDom.status) {
wefaxDom.status.textContent = 'Idle';
wefaxDom.status.style.color = '';
}
};
// ── Filter / sort handlers ──────────────────────────────────────────
if (wefaxDom.filterInput) {
wefaxDom.filterInput.addEventListener('input', function () {
wefaxFilterText = wefaxDom.filterInput.value.trim().toUpperCase();
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
});
}
if (wefaxDom.sortSelect) {
wefaxDom.sortSelect.addEventListener('change', function () {
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
});
}
// ── Toggle button sync ──────────────────────────────────────────────
// Sync the Enable/Disable button from the SSE state update. This is
// belt-and-suspenders alongside app.js _decoderToggles — guarantees the
// WEFAX button always reflects the server state.
window.syncWefaxToggle = function (enabled) {
if (!wefaxDom.toggleBtn) return;
wefaxDom.toggleBtn.dataset.enabled = enabled ? 'true' : 'false';
wefaxDom.toggleBtn.textContent = enabled ? 'Disable WEFAX' : 'Enable WEFAX';
wefaxDom.toggleBtn.style.borderColor = enabled ? '#00d17f' : '';
wefaxDom.toggleBtn.style.color = enabled ? '#00d17f' : '';
};
// ── Button handlers ─────────────────────────────────────────────────
if (wefaxDom.toggleBtn) {
wefaxDom.toggleBtn.addEventListener('click', async function () {
try {
if (window.takeSchedulerControlForDecoderDisable) {
await window.takeSchedulerControlForDecoderDisable(wefaxDom.toggleBtn);
}
await postPath('/toggle_wefax_decode');
} catch (e) {
console.error('WEFAX toggle failed', e);
}
});
}
if (wefaxDom.clearBtn) {
wefaxDom.clearBtn.addEventListener('click', async function () {
try {
await postPath('/clear_wefax_decode');
window.resetWefaxHistoryView();
} catch (e) {
console.error('WEFAX clear failed', e);
}
});
}
// ── Initial render ──────────────────────────────────────────────────
renderWefaxLatestCard();
@@ -0,0 +1,292 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- WSPR Decoder Plugin (server-side decode) ---
const wsprStatus = document.getElementById("wspr-status");
const wsprPeriodEl = document.getElementById("wspr-period");
const wsprMessagesEl = document.getElementById("wspr-messages");
const wsprFilterInput = document.getElementById("wspr-filter");
const WSPR_PERIOD_SECONDS = 120;
let wsprFilterText = "";
let wsprMessageHistory = [];
function currentWsprHistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneWsprMessageHistory() {
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
}
function scheduleWsprHistoryRender() {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory());
return;
}
renderWsprHistory();
}
function fmtWsprTime(tsMs) {
if (!tsMs) return "--:--:--";
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function updateWsprPeriodTimer() {
if (!wsprPeriodEl) return;
const nowSec = Math.floor(Date.now() / 1000);
const remaining = WSPR_PERIOD_SECONDS - (nowSec % WSPR_PERIOD_SECONDS);
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
const ss = String(remaining % 60).padStart(2, "0");
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
}
updateWsprPeriodTimer();
setInterval(updateWsprPeriodTimer, 500);
function renderWsprRow(msg) {
const row = document.createElement("div");
row.className = "ft8-row";
row.dataset.decoder = "wspr";
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null;
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? (baseHz + msg.freq_hz) : null;
const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--";
const message = (msg.message || "").toString();
row.dataset.message = message.toUpperCase();
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
applyWsprFilterToRow(row);
return row;
}
function renderWsprHistory() {
pruneWsprMessageHistory();
if (!wsprMessagesEl) return;
const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
fragment.appendChild(renderWsprRow(wsprMessageHistory[i]));
}
wsprMessagesEl.replaceChildren(fragment);
}
function addWsprMessage(msg) {
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
wsprMessageHistory.unshift(msg);
pruneWsprMessageHistory();
scheduleWsprHistoryRender();
}
function normalizeServerWsprMessage(msg) {
const raw = (msg.message || "").toString();
const grids = extractAllGrids(raw);
const station = extractLikelyCallsign(raw);
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz)
? (baseHz + Number(msg.freq_hz))
: (Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null);
return {
raw,
grids,
station,
rfHz,
history: {
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms,
snr_db: msg.snr_db,
dt_s: msg.dt_s,
freq_hz: msg.freq_hz,
message: raw,
},
};
}
window.onServerWsprBatch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
wsprStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
freq_hz: next.rfHz,
});
}
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
normalized.reverse();
wsprMessageHistory = normalized.concat(wsprMessageHistory);
pruneWsprMessageHistory();
scheduleWsprHistoryRender();
};
window.restoreWsprHistory = function(messages) {
window.onServerWsprBatch(messages);
};
window.pruneWsprHistoryView = function() {
pruneWsprMessageHistory();
renderWsprHistory();
};
function escapeWsprHtml(input) {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
function renderWsprMessage(message) {
let out = "";
let i = 0;
while (i < message.length) {
const ch = message[i];
if (isAlphaNum(ch)) {
let j = i + 1;
while (j < message.length && isAlphaNum(message[j])) j++;
const token = message.slice(i, j);
const grid = token.toUpperCase();
if (isMaidenheadGridToken(grid)) {
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
} else {
out += escapeWsprHtml(token);
}
i = j;
} else {
out += escapeWsprHtml(ch);
i += 1;
}
}
return out;
}
function extractAllGrids(message) {
const out = [];
const seen = new Set();
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
for (const token of parts) {
if (!token) continue;
if (isMaidenheadGridToken(token) && !seen.has(token)) {
seen.add(token);
out.push(token);
}
}
return out;
}
function extractLikelyCallsign(message) {
const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/);
for (const token of parts) {
if (!token) continue;
if (token.length < 3 || token.length > 12) continue;
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue;
if (isMaidenheadGridToken(token)) continue;
if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token;
}
return null;
}
function isFtxFarewellToken(token) {
const normalized = String(token || "").trim().toUpperCase();
return normalized === "RR73" || normalized === "73" || normalized === "RR";
}
function isMaidenheadGridToken(token) {
const normalized = String(token || "").trim().toUpperCase();
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
}
function isAlphaNum(ch) {
return /[A-Za-z0-9]/.test(ch);
}
function activateWsprHistoryLocator(targetEl) {
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
if (!locatorEl) return false;
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
if (!grid) return false;
if (typeof window.navigateToMapLocator === "function") {
window.navigateToMapLocator(grid, "wspr");
}
return true;
}
function applyWsprFilterToRow(row) {
if (!wsprFilterText) {
row.style.display = "";
return;
}
const message = row.dataset.message || "";
row.style.display = message.includes(wsprFilterText) ? "" : "none";
}
function applyWsprFilterToAll() {
const rows = wsprMessagesEl.querySelectorAll(".ft8-row");
rows.forEach((row) => applyWsprFilterToRow(row));
}
window.resetWsprHistoryView = function() {
wsprMessagesEl.innerHTML = "";
wsprMessageHistory = [];
renderWsprHistory();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr");
};
if (wsprFilterInput) {
wsprFilterInput.addEventListener("input", () => {
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
renderWsprHistory();
});
}
if (wsprMessagesEl) {
wsprMessagesEl.addEventListener("click", (event) => {
if (!activateWsprHistoryLocator(event.target)) return;
event.preventDefault();
event.stopPropagation();
});
wsprMessagesEl.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
if (!activateWsprHistoryLocator(event.target)) return;
event.preventDefault();
event.stopPropagation();
});
}
const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
wsprDecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
await postPath("/toggle_wspr_decode");
} catch (e) {
console.error("WSPR toggle failed", e);
}
});
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
if (!confirm("Clear all WSPR decode history? This cannot be undone.")) return;
try {
await postPath("/clear_wspr_decode");
window.resetWsprHistoryView();
} catch (e) {
console.error("WSPR history clear failed", e);
}
});
window.onServerWspr = function(msg) {
wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
freq_hz: next.rfHz,
});
}
addWsprMessage(next.history);
};
@@ -0,0 +1,265 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// Spectrum screenshot module (loaded on demand when user triggers screenshot).
// Communicates with app.js core via window.trx namespace.
(function () {
"use strict";
const T = window.trx;
function isVisibleForSnapshot(el) {
if (!el) return false;
const style = getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") return false;
const opacity = Number(style.opacity);
if (Number.isFinite(opacity) && opacity <= 0) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function drawRoundedRectPath(ctx, x, y, w, h, r) {
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
ctx.lineTo(x + w, y + h - radius);
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
ctx.lineTo(x + radius, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
if (!isVisibleForSnapshot(el)) return null;
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
const x = rect.left - rootRect.left;
const y = rect.top - rootRect.top;
const w = rect.width;
const h = rect.height;
const radius = parseFloat(style.borderTopLeftRadius) || 0;
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
const bgAlpha = Math.min(bg[3], maxAlpha);
if (bgAlpha > 0.01) {
drawRoundedRectPath(ctx, x, y, w, h, radius);
ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`;
ctx.fill();
}
const borderAlpha = Math.min(border[3], maxAlpha);
if (borderWidth > 0 && borderAlpha > 0.01) {
drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5));
ctx.lineWidth = borderWidth;
ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`;
ctx.stroke();
}
return { x, y, w, h, style };
}
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
const words = String(text || "").split(/\s+/).filter(Boolean);
if (!words.length) return;
let line = "";
let lineIdx = 0;
for (let i = 0; i < words.length; i += 1) {
const candidate = line ? `${line} ${words[i]}` : words[i];
if (ctx.measureText(candidate).width <= maxWidth || !line) {
line = candidate;
continue;
}
ctx.fillText(line, x, y + lineIdx * lineHeight);
lineIdx += 1;
if (lineIdx >= maxLines) return;
line = words[i];
}
if (line && lineIdx < maxLines) {
ctx.fillText(line, x, y + lineIdx * lineHeight);
}
}
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
if (!chrome) return;
const text = (fallbackText == null ? el.innerText : fallbackText) || "";
const clean = text.replace(/\s+\n/g, "\n").replace(/\n\s+/g, "\n").trim();
if (!clean) return;
const style = chrome.style;
const fontSize = parseFloat(style.fontSize) || 12;
const lineHeight = (parseFloat(style.lineHeight) || fontSize * 1.25);
const padX = 6;
const padY = 4;
const maxWidth = Math.max(20, chrome.w - padX * 2);
const maxLines = Math.max(1, Math.floor((chrome.h - padY * 2) / lineHeight));
ctx.fillStyle = style.color || "#ffffff";
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
ctx.textBaseline = "top";
const lines = clean.split(/\n+/);
let lineCursor = 0;
for (const line of lines) {
if (lineCursor >= maxLines) break;
drawWrappedText(
ctx,
line,
chrome.x + padX,
chrome.y + padY + lineCursor * lineHeight,
maxWidth,
lineHeight,
maxLines - lineCursor,
);
lineCursor += 1;
}
}
function drawAxisLabels(ctx, axisEl, rootRect) {
if (!isVisibleForSnapshot(axisEl)) return;
for (const node of axisEl.children) {
if (!(node instanceof HTMLElement)) continue;
if (!(node.matches("span") || node.matches("button"))) continue;
if (!isVisibleForSnapshot(node)) continue;
const chrome = drawElementChrome(ctx, node, rootRect);
const text = (node.textContent || "").trim();
if (!chrome || !text) continue;
const style = chrome.style;
ctx.fillStyle = style.color || "#ffffff";
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
ctx.textBaseline = "middle";
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
}
}
function buildSpectrumSnapshotCanvas() {
const rootEl = document.querySelector(".signal-visual-block");
const spectrumPanelEl = document.getElementById("spectrum-panel");
if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) {
return null;
}
for (const renderer of [T.overviewGl, T.spectrumGl, T.signalOverlayGl]) {
const gl = renderer?.gl;
if (!gl) continue;
try {
if (typeof gl.flush === "function") gl.flush();
if (typeof gl.finish === "function") gl.finish();
} catch (_) {
// Ignore transient WebGL state errors and capture the last good frame.
}
}
const rootRect = rootEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const out = document.createElement("canvas");
out.width = Math.max(1, Math.round(rootRect.width * dpr));
out.height = Math.max(1, Math.round(rootRect.height * dpr));
const ctx = out.getContext("2d");
if (!ctx) return null;
ctx.scale(dpr, dpr);
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
ctx.fillStyle = bg;
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
for (const canvas of canvases) {
if (!canvas || !isVisibleForSnapshot(canvas)) continue;
const rect = canvas.getBoundingClientRect();
ctx.drawImage(
canvas,
rect.left - rootRect.left,
rect.top - rootRect.top,
rect.width,
rect.height,
);
}
// Decoder overlays over the signal view.
// Cap background alpha to avoid opaque blocks (backdrop-filter can't be
// replicated on canvas, so frosted-glass overlays would otherwise obscure
// the spectrum).
const decoderOverlayIds = [
"ais-bar-overlay",
"vdes-bar-overlay",
"ft8-bar-overlay",
"aprs-bar-overlay",
"rds-ps-overlay",
];
for (const id of decoderOverlayIds) {
const overlayEl = document.getElementById(id);
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
}
// Spectrum axis labels and bookmark chips (includes freq bar).
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
drawAxisLabels(ctx, spectrumDbAxis, rootRect);
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
return out;
}
function clickCanvasDownload(href, fileName) {
const a = document.createElement("a");
a.href = href;
a.download = fileName;
a.rel = "noopener";
a.style.display = "none";
document.body.appendChild(a);
a.click();
requestAnimationFrame(() => a.remove());
}
function saveCanvasAsPng(canvas, fileName) {
if (!canvas) return Promise.resolve(false);
if (typeof canvas.toBlob === "function") {
return new Promise((resolve) => {
try {
canvas.toBlob((blob) => {
if (!blob) {
resolve(false);
return;
}
const url = URL.createObjectURL(blob);
clickCanvasDownload(url, fileName);
setTimeout(() => URL.revokeObjectURL(url), 1000);
resolve(true);
}, "image/png");
} catch (_) {
resolve(false);
}
});
}
try {
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
return Promise.resolve(true);
} catch (_) {
return Promise.resolve(false);
}
}
async function captureSpectrumScreenshot() {
const snapshotCanvas = buildSpectrumSnapshotCanvas();
if (!snapshotCanvas) {
T.showHint("Spectrum view not ready", 1300);
return false;
}
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
return saved;
}
// Register module API
window.trx.screenshot = {
captureSpectrumScreenshot,
buildSpectrumSnapshotCanvas,
saveCanvasAsPng,
};
})();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,478 @@
/*
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later
*/
/* ── Arctic style ─────────────────────────────────────────────────────── */
[data-style="arctic"] {
--bg: #242933;
--card-bg: #2e3440;
--input-bg: #242933;
--border: #3b4252;
--border-light: #4c566a;
--text: #d8dee9;
--text-muted: #8a9ab0;
--text-heading: #eceff4;
--btn-bg: #3b4252;
--btn-border: #5e6f88;
--accent-green: #88c0d0;
--accent-yellow: #ebcb8b;
--accent-red: #bf616a;
--jog-hi: #434c5e;
--jog-lo: #3b4252;
--jog-shadow: rgba(0,0,0,0.40);
--jog-inset: rgba(255,255,255,0.06);
--audio-level-bg: #2e3440;
--audio-level-border: #4c566a;
--audio-level-fill-start: #88c0d0;
--audio-level-fill-end: #ebcb8b;
--filter-bg: #3b4252;
--filter-fg: #d8dee9;
--filter-border: #5e6f88;
--wavelength-fg: #7a8ea8;
--spectrum-bg: #1e2530;
}
[data-style="arctic"][data-theme="light"] {
--bg: #e5e9f0;
--card-bg: #eceff4;
--input-bg: #d8dee9;
--border: #c5ccd8;
--border-light: #a8b2c0;
--text: #2e3440;
--text-muted: #4c566a;
--text-heading: #2e3440;
--btn-bg: #d8dee9;
--btn-border: #8fa3b8;
--accent-green: #5e81ac;
--accent-yellow: #c07a22;
--accent-red: #bf616a;
--jog-hi: #d8dee9;
--jog-lo: #c2cbd8;
--jog-shadow: rgba(46,52,64,0.18);
--jog-inset: rgba(255,255,255,0.70);
--audio-level-bg: #d0d6e0;
--audio-level-border: #a8b2c0;
--audio-level-fill-start: #5e81ac;
--audio-level-fill-end: #c07a22;
--filter-bg: #d8dee9;
--filter-fg: #2e3440;
--filter-border: #8fa3b8;
--wavelength-fg: #5a6a80;
--spectrum-bg: #dde1e9;
}
/* ── Lime style ───────────────────────────────────────────────────────── */
[data-style="lime"] {
--bg: #1c1c17;
--card-bg: #272822;
--input-bg: #1c1c17;
--border: #3e3d32;
--border-light: #5c5c45;
--text: #f8f8f2;
--text-muted: #908980;
--text-heading: #f8f8f2;
--btn-bg: #3e3d32;
--btn-border: #75715e;
--accent-green: #a6e22e;
--accent-yellow: #e6db74;
--accent-red: #f92672;
--jog-hi: #49483e;
--jog-lo: #3e3d32;
--jog-shadow: rgba(0,0,0,0.45);
--jog-inset: rgba(255,255,255,0.05);
--audio-level-bg: #272822;
--audio-level-border: #5c5c45;
--audio-level-fill-start: #a6e22e;
--audio-level-fill-end: #e6db74;
--filter-bg: #3e3d32;
--filter-fg: #f8f8f2;
--filter-border: #75715e;
--wavelength-fg: #9c8f78;
--spectrum-bg: #181815;
}
[data-style="lime"][data-theme="light"] {
--bg: #f5f0e4;
--card-bg: #fdf9f2;
--input-bg: #ede8d8;
--border: #d8d0bb;
--border-light: #c0b89e;
--text: #272822;
--text-muted: #6e6a56;
--text-heading: #272822;
--btn-bg: #ede8d8;
--btn-border: #b0a888;
--accent-green: #5f8700;
--accent-yellow: #9a7200;
--accent-red: #c60052;
--jog-hi: #ede8d8;
--jog-lo: #ddd8c8;
--jog-shadow: rgba(39,40,34,0.18);
--jog-inset: rgba(255,255,255,0.75);
--audio-level-bg: #ede8d8;
--audio-level-border: #c0b89e;
--audio-level-fill-start: #5f8700;
--audio-level-fill-end: #9a7200;
--filter-bg: #ede8d8;
--filter-fg: #272822;
--filter-border: #b0a888;
--wavelength-fg: #7a7260;
--spectrum-bg: #ede8d8;
}
/* ── Contrast style ───────────────────────────────────────────────────── */
[data-style="contrast"] {
--bg: #000000;
--card-bg: #0a0a0a;
--input-bg: #111111;
--border: #333333;
--border-light: #555555;
--text: #ffffff;
--text-muted: #bbbbbb;
--text-heading: #ffffff;
--btn-bg: #1a1a1a;
--btn-border: #666666;
--accent-green: #00ff88;
--accent-yellow: #ffcc00;
--accent-red: #ff3344;
--jog-hi: #2a2a2a;
--jog-lo: #1a1a1a;
--jog-shadow: rgba(0,0,0,0.60);
--jog-inset: rgba(255,255,255,0.08);
--audio-level-bg: #111111;
--audio-level-border: #555555;
--audio-level-fill-start: #00ff88;
--audio-level-fill-end: #ffcc00;
--filter-bg: #1a1a1a;
--filter-fg: #ffffff;
--filter-border: #666666;
--wavelength-fg: #aaaaaa;
--spectrum-bg: #000000;
}
[data-style="contrast"][data-theme="light"] {
--bg: #ffffff;
--card-bg: #f4f4f4;
--input-bg: #e8e8e8;
--border: #cccccc;
--border-light: #999999;
--text: #000000;
--text-muted: #333333;
--text-heading: #000000;
--btn-bg: #e0e0e0;
--btn-border: #777777;
--accent-green: #005cc5;
--accent-yellow: #cc5500;
--accent-red: #cc0000;
--jog-hi: #e0e0e0;
--jog-lo: #cccccc;
--jog-shadow: rgba(0,0,0,0.25);
--jog-inset: rgba(255,255,255,0.80);
--audio-level-bg: #e8e8e8;
--audio-level-border: #999999;
--audio-level-fill-start: #005cc5;
--audio-level-fill-end: #cc5500;
--filter-bg: #e8e8e8;
--filter-fg: #000000;
--filter-border: #999999;
--wavelength-fg: #444444;
--spectrum-bg: #f4f4f4;
}
/* ── Neon Disco style ─────────────────────────────────────────────────── */
[data-style="neon-disco"] {
--bg: #0d0015;
--card-bg: #180026;
--input-bg: #100018;
--border: #3d0060;
--border-light: #7700bb;
--text: #f5e0ff;
--text-muted: #b070d8;
--text-heading: #fce8ff;
--btn-bg: #2a0042;
--btn-border: #9900dd;
--accent-green: #ff10e0;
--accent-yellow: #39ff14;
--accent-red: #ff1460;
--jog-hi: #360058;
--jog-lo: #280042;
--jog-shadow: rgba(0,0,0,0.65);
--jog-inset: rgba(255,16,224,0.08);
--audio-level-bg: #180026;
--audio-level-border: #7700bb;
--audio-level-fill-start: #ff10e0;
--audio-level-fill-end: #39ff14;
--filter-bg: #2a0042;
--filter-fg: #f5e0ff;
--filter-border: #9900dd;
--wavelength-fg: #9055b8;
--spectrum-bg: #090010;
}
[data-style="neon-disco"][data-theme="light"] {
--bg: #faeeff;
--card-bg: #fff4ff;
--input-bg: #f2e0ff;
--border: #dda8f5;
--border-light: #cc80e8;
--text: #1a0030;
--text-muted: #7a30a0;
--text-heading: #1a0030;
--btn-bg: #f0d8ff;
--btn-border: #bb80dd;
--accent-green: #cc00a8;
--accent-yellow: #1f8800;
--accent-red: #cc0044;
--jog-hi: #f0d8ff;
--jog-lo: #e2c8f5;
--jog-shadow: rgba(60,0,100,0.18);
--jog-inset: rgba(255,255,255,0.72);
--audio-level-bg: #f0d8ff;
--audio-level-border: #cc80e8;
--audio-level-fill-start: #cc00a8;
--audio-level-fill-end: #1f8800;
--filter-bg: #f0d8ff;
--filter-fg: #1a0030;
--filter-border: #bb80dd;
--wavelength-fg: #7030a0;
--spectrum-bg: #f0d8ff;
}
/* ── Donald style ─────────────────────────────────────────────────────── */
[data-style="golden-rain"] {
--bg: #100c06;
--card-bg: #1a1209;
--input-bg: #140f08;
--border: #3f2d18;
--border-light: #6d4e23;
--text: #f3e4bf;
--text-muted: #aa9062;
--text-heading: #fff0ca;
--btn-bg: #2a1c0d;
--btn-border: #7c5928;
--accent-green: #dfac48;
--accent-yellow: #f4cd74;
--accent-red: #cf7d32;
--jog-hi: #392610;
--jog-lo: #24170b;
--jog-shadow: rgba(0,0,0,0.64);
--jog-inset: rgba(255,219,138,0.06);
--audio-level-bg: #1c130a;
--audio-level-border: #6d4e23;
--audio-level-fill-start: #dfac48;
--audio-level-fill-end: #f4cd74;
--filter-bg: #2b1d0f;
--filter-fg: #f3e4bf;
--filter-border: #7c5928;
--wavelength-fg: #ab8b52;
--spectrum-bg: #120d07;
}
[data-style="golden-rain"][data-theme="light"] {
--bg: #f7efdd;
--card-bg: #fff9ec;
--input-bg: #f0e3c6;
--border: #d4bc8a;
--border-light: #b99243;
--text: #3f2c10;
--text-muted: #7f6640;
--text-heading: #3a2609;
--btn-bg: #f0e3c6;
--btn-border: #b99243;
--accent-green: #a96d00;
--accent-yellow: #c88a16;
--accent-red: #b65316;
--jog-hi: #f2e5c8;
--jog-lo: #e3d1a8;
--jog-shadow: rgba(82,55,14,0.16);
--jog-inset: rgba(255,255,255,0.76);
--audio-level-bg: #f0e3c6;
--audio-level-border: #c5a15d;
--audio-level-fill-start: #a96d00;
--audio-level-fill-end: #d4a13a;
--filter-bg: #f0e3c6;
--filter-fg: #3f2c10;
--filter-border: #b99243;
--wavelength-fg: #87663a;
--spectrum-bg: #f5ecd9;
}
/* ── Amber style ──────────────────────────────────────────────────────── */
[data-style="amber"] {
--bg: #120706;
--card-bg: #1b0c0a;
--input-bg: #180907;
--border: #4c1a12;
--border-light: #7a2e1a;
--text: #ffe7d2;
--text-muted: #c78361;
--text-heading: #fff3e7;
--btn-bg: #2c110d;
--btn-border: #8f3a20;
--accent-green: #ff6f1f;
--accent-yellow: #ffb347;
--accent-red: #ff4a24;
--jog-hi: #381510;
--jog-lo: #24100c;
--jog-shadow: rgba(0,0,0,0.62);
--jog-inset: rgba(255,164,76,0.07);
--audio-level-bg: #1f0d0a;
--audio-level-border: #7a2e1a;
--audio-level-fill-start: #ff4a24;
--audio-level-fill-end: #ffb347;
--filter-bg: #2b120d;
--filter-fg: #ffe7d2;
--filter-border: #8f3a20;
--wavelength-fg: #d38d6a;
--spectrum-bg: #140907;
}
[data-style="amber"][data-theme="light"] {
--bg: #fff3ea;
--card-bg: #fff7f0;
--input-bg: #ffe9da;
--border: #efc7b1;
--border-light: #d9a487;
--text: #42180d;
--text-muted: #8a4b31;
--text-heading: #2f120a;
--btn-bg: #ffe2cf;
--btn-border: #cc8563;
--accent-green: #d24c12;
--accent-yellow: #d88400;
--accent-red: #c53114;
--jog-hi: #ffe2cf;
--jog-lo: #ffd5bc;
--jog-shadow: rgba(108,44,15,0.18);
--jog-inset: rgba(255,255,255,0.72);
--audio-level-bg: #ffe7d7;
--audio-level-border: #d9a487;
--audio-level-fill-start: #c53114;
--audio-level-fill-end: #d88400;
--filter-bg: #ffe2cf;
--filter-fg: #42180d;
--filter-border: #cc8563;
--wavelength-fg: #9a5a3a;
--spectrum-bg: #fff0e4;
}
/* ── Fire style ───────────────────────────────────────────────────────── */
[data-style="fire"] {
--bg: #140406;
--card-bg: #1d0708;
--input-bg: #1a0607;
--border: #551015;
--border-light: #8f1f26;
--text: #ffe6df;
--text-muted: #cf8d82;
--text-heading: #fff4ef;
--btn-bg: #2d0c0d;
--btn-border: #9d262b;
--accent-green: #d13a32;
--accent-yellow: #ff6a3d;
--accent-red: #c10f1f;
--jog-hi: #390f11;
--jog-lo: #25090b;
--jog-shadow: rgba(0,0,0,0.64);
--jog-inset: rgba(255,120,100,0.06);
--audio-level-bg: #22090b;
--audio-level-border: #8f1f26;
--audio-level-fill-start: #c10f1f;
--audio-level-fill-end: #ff6a3d;
--filter-bg: #2d0c0d;
--filter-fg: #ffe6df;
--filter-border: #9d262b;
--wavelength-fg: #d78d78;
--spectrum-bg: #150508;
}
[data-style="fire"][data-theme="light"] {
--bg: #fdf0ea;
--card-bg: #fff6f1;
--input-bg: #ffe5db;
--border: #e9b8aa;
--border-light: #d27c66;
--text: #4a110d;
--text-muted: #8a493f;
--text-heading: #340b08;
--btn-bg: #ffd9cc;
--btn-border: #c85b47;
--accent-green: #ba2d24;
--accent-yellow: #d95518;
--accent-red: #a80f1c;
--jog-hi: #ffd9cc;
--jog-lo: #ffcab9;
--jog-shadow: rgba(110,35,20,0.18);
--jog-inset: rgba(255,255,255,0.74);
--audio-level-bg: #ffe2d7;
--audio-level-border: #d27c66;
--audio-level-fill-start: #a80f1c;
--audio-level-fill-end: #d95518;
--filter-bg: #ffd9cc;
--filter-fg: #4a110d;
--filter-border: #c85b47;
--wavelength-fg: #9d5547;
--spectrum-bg: #ffede5;
}
/* ── Phosphor style ───────────────────────────────────────────────────── */
/* Classic green-phosphor CRT terminal aesthetic */
[data-style="phosphor"] {
--bg: #030a03;
--card-bg: #060e06;
--input-bg: #030a03;
--border: #0f2e0f;
--border-light: #1a4a1a;
--text: #a8e6a8;
--text-muted: #5a9a5a;
--text-heading: #c8f0c8;
--btn-bg: #0a1e0a;
--btn-border: #1e4a1e;
--accent-green: #39ff14;
--accent-yellow: #b8f060;
--accent-red: #ff4444;
--jog-hi: #0e2a0e;
--jog-lo: #081808;
--jog-shadow: rgba(0,0,0,0.65);
--jog-inset: rgba(57,255,20,0.07);
--audio-level-bg: #060e06;
--audio-level-border: #1a4a1a;
--audio-level-fill-start: #39ff14;
--audio-level-fill-end: #b8f060;
--filter-bg: #0a1e0a;
--filter-fg: #a8e6a8;
--filter-border: #1e4a1e;
--wavelength-fg: #4a8a4a;
--spectrum-bg: #010501;
}
[data-style="phosphor"] #freq {
color: #39ff14;
text-shadow: 0 0 8px rgba(57,255,20,0.55), 0 0 20px rgba(57,255,20,0.2);
}
[data-style="phosphor"] .signal-bar-fill,
[data-style="phosphor"] .meter-fill {
background: linear-gradient(90deg, #39ff14, #b8f060);
box-shadow: 0 0 6px rgba(57,255,20,0.45);
}
[data-style="phosphor"][data-theme="light"] {
--bg: #e8f5e8;
--card-bg: #f0faf0;
--input-bg: #dff0df;
--border: #b0d8b0;
--border-light: #80c080;
--text: #0a2a0a;
--text-muted: #2a6a2a;
--text-heading: #062006;
--btn-bg: #d0ebd0;
--btn-border: #4a8a4a;
--accent-green: #1a7a1a;
--accent-yellow: #4a8a00;
--accent-red: #cc2222;
--jog-hi: #d0ebd0;
--jog-lo: #bcdabc;
--jog-shadow: rgba(10,42,10,0.15);
--jog-inset: rgba(255,255,255,0.72);
--audio-level-bg: #d8edd8;
--audio-level-border: #80c080;
--audio-level-fill-start: #1a7a1a;
--audio-level-fill-end: #4a8a00;
--filter-bg: #d0ebd0;
--filter-fg: #0a2a0a;
--filter-border: #4a8a4a;
--wavelength-fg: #3a7a3a;
--spectrum-bg: #e0f0e0;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

@@ -0,0 +1,535 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
(function initTrxWebGl(global) {
"use strict";
const cssColorCache = new Map();
let cssColorProbe = null;
function clearCssColorCache() {
cssColorCache.clear();
}
function ensureCssColorProbe() {
if (cssColorProbe) return cssColorProbe;
const el = document.createElement("span");
el.style.position = "absolute";
el.style.left = "-9999px";
el.style.top = "-9999px";
el.style.pointerEvents = "none";
el.style.opacity = "0";
document.body.appendChild(el);
cssColorProbe = el;
return cssColorProbe;
}
function parseRgbString(value) {
const m = /^rgba?\(([^)]+)\)$/.exec(String(value || "").trim());
if (!m) return null;
const parts = m[1].split(",").map((p) => p.trim());
if (parts.length < 3) return null;
const r = Number(parts[0]);
const g = Number(parts[1]);
const b = Number(parts[2]);
const a = parts.length > 3 ? Number(parts[3]) : 1;
if (![r, g, b, a].every(Number.isFinite)) return null;
return [
Math.max(0, Math.min(1, r / 255)),
Math.max(0, Math.min(1, g / 255)),
Math.max(0, Math.min(1, b / 255)),
Math.max(0, Math.min(1, a)),
];
}
function parseHexColor(value) {
const raw = String(value || "").trim();
if (!/^#([0-9a-f]{3,8})$/i.test(raw)) return null;
let hex = raw.slice(1);
if (hex.length === 3 || hex.length === 4) {
hex = hex.split("").map((ch) => ch + ch).join("");
}
if (!(hex.length === 6 || hex.length === 8)) return null;
const r = parseInt(hex.slice(0, 2), 16) / 255;
const g = parseInt(hex.slice(2, 4), 16) / 255;
const b = parseInt(hex.slice(4, 6), 16) / 255;
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return [r, g, b, a];
}
function parseCssColor(value) {
const key = String(value ?? "");
if (cssColorCache.has(key)) return cssColorCache.get(key).slice();
let parsed = parseHexColor(key) || parseRgbString(key);
if (!parsed) {
const probe = ensureCssColorProbe();
probe.style.color = "";
probe.style.color = key;
const computed = getComputedStyle(probe).color;
parsed = parseRgbString(computed) || [0, 0, 0, 1];
}
cssColorCache.set(key, parsed.slice());
return parsed.slice();
}
function hslToRgba(h, s, l, a = 1) {
const hue = ((((Number(h) || 0) % 360) + 360) % 360) / 360;
const sat = Math.max(0, Math.min(1, (Number(s) || 0) / 100));
const lig = Math.max(0, Math.min(1, (Number(l) || 0) / 100));
const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat;
const p = 2 * lig - q;
const hueToRgb = (t) => {
let tt = t;
if (tt < 0) tt += 1;
if (tt > 1) tt -= 1;
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
if (tt < 1 / 2) return q;
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
return p;
};
const r = sat === 0 ? lig : hueToRgb(hue + 1 / 3);
const g = sat === 0 ? lig : hueToRgb(hue);
const b = sat === 0 ? lig : hueToRgb(hue - 1 / 3);
return [r, g, b, Math.max(0, Math.min(1, Number(a)))];
}
function normalizeColor(input, alphaMul = 1) {
let rgba;
if (Array.isArray(input)) {
const arr = input.map((v) => Number(v));
if (arr.length >= 4) {
rgba = [arr[0], arr[1], arr[2], arr[3]];
} else {
rgba = [0, 0, 0, 1];
}
} else if (typeof input === "string") {
rgba = parseCssColor(input);
} else if (input && typeof input === "object") {
rgba = [
Number(input.r) || 0,
Number(input.g) || 0,
Number(input.b) || 0,
Number(input.a ?? 1),
];
} else {
rgba = [0, 0, 0, 1];
}
const out = [
Math.max(0, Math.min(1, rgba[0])),
Math.max(0, Math.min(1, rgba[1])),
Math.max(0, Math.min(1, rgba[2])),
Math.max(0, Math.min(1, rgba[3] * alphaMul)),
];
return out;
}
function compileShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader) || "shader compile error";
gl.deleteShader(shader);
throw new Error(log);
}
return shader;
}
function createProgram(gl, vertexSrc, fragmentSrc) {
const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc);
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc);
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(program) || "program link error";
gl.deleteProgram(program);
throw new Error(log);
}
return program;
}
function pushColoredVertex(target, x, y, rgba) {
target.push(x, y, rgba[0], rgba[1], rgba[2], rgba[3]);
}
function segmentToQuadVertices(out, x0, y0, x1, y1, halfW, rgba) {
const dx = x1 - x0;
const dy = y1 - y0;
const len = Math.hypot(dx, dy);
if (!(len > 0.0001)) return;
const nx = (-dy / len) * halfW;
const ny = (dx / len) * halfW;
const ax = x0 - nx, ay = y0 - ny;
const bx = x0 + nx, by = y0 + ny;
const cx = x1 + nx, cy = y1 + ny;
const dx2 = x1 - nx, dy2 = y1 - ny;
pushColoredVertex(out, ax, ay, rgba);
pushColoredVertex(out, bx, by, rgba);
pushColoredVertex(out, cx, cy, rgba);
pushColoredVertex(out, ax, ay, rgba);
pushColoredVertex(out, cx, cy, rgba);
pushColoredVertex(out, dx2, dy2, rgba);
}
class TrxWebGlRenderer {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.options = { alpha: true, premultipliedAlpha: false, ...options };
this.gl =
canvas?.getContext("webgl", this.options) ||
canvas?.getContext("experimental-webgl", this.options) ||
null;
this.ready = !!this.gl;
this.textures = new Map();
// Reusable scratch buffers — avoids per-draw-call Float32Array allocation
// and lets us use bufferSubData instead of bufferData (no GPU realloc).
this._colorScratch = new Float32Array(4096 * 6); // grows as needed
this._colorGpuSize = 0; // current GPU buffer size (floats)
this._texScratch = new Float32Array(6 * 4); // fixed: 6 verts × (xy+uv)
if (!this.ready) return;
const gl = this.gl;
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
const colorVertexSrc =
"attribute vec2 a_pos;\n" +
"attribute vec4 a_color;\n" +
"uniform vec2 u_resolution;\n" +
"varying vec4 v_color;\n" +
"void main() {\n" +
" vec2 zeroToOne = a_pos / u_resolution;\n" +
" vec2 clip = zeroToOne * 2.0 - 1.0;\n" +
" gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" +
" v_color = a_color;\n" +
"}\n";
const colorFragmentSrc =
"precision mediump float;\n" +
"varying vec4 v_color;\n" +
"void main() {\n" +
" gl_FragColor = v_color;\n" +
"}\n";
const textureVertexSrc =
"attribute vec2 a_pos;\n" +
"attribute vec2 a_uv;\n" +
"uniform vec2 u_resolution;\n" +
"varying vec2 v_uv;\n" +
"void main() {\n" +
" vec2 zeroToOne = a_pos / u_resolution;\n" +
" vec2 clip = zeroToOne * 2.0 - 1.0;\n" +
" gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" +
" v_uv = a_uv;\n" +
"}\n";
const textureFragmentSrc =
"precision mediump float;\n" +
"varying vec2 v_uv;\n" +
"uniform sampler2D u_tex;\n" +
"uniform float u_alpha;\n" +
"void main() {\n" +
" vec4 c = texture2D(u_tex, v_uv);\n" +
" gl_FragColor = vec4(c.rgb, c.a * u_alpha);\n" +
"}\n";
this.colorProgram = createProgram(gl, colorVertexSrc, colorFragmentSrc);
this.colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
this._colorGpuSize = this._colorScratch.length;
this.colorLoc = {
pos: gl.getAttribLocation(this.colorProgram, "a_pos"),
color: gl.getAttribLocation(this.colorProgram, "a_color"),
resolution: gl.getUniformLocation(this.colorProgram, "u_resolution"),
};
this.textureProgram = createProgram(gl, textureVertexSrc, textureFragmentSrc);
this.textureBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
gl.bufferData(gl.ARRAY_BUFFER, this._texScratch, gl.DYNAMIC_DRAW);
this.textureLoc = {
pos: gl.getAttribLocation(this.textureProgram, "a_pos"),
uv: gl.getAttribLocation(this.textureProgram, "a_uv"),
resolution: gl.getUniformLocation(this.textureProgram, "u_resolution"),
alpha: gl.getUniformLocation(this.textureProgram, "u_alpha"),
tex: gl.getUniformLocation(this.textureProgram, "u_tex"),
};
}
ensureSize(cssWidth, cssHeight, dpr = (window.devicePixelRatio || 1)) {
if (!this.ready) return false;
const nextW = Math.max(1, Math.round(cssWidth * dpr));
const nextH = Math.max(1, Math.round(cssHeight * dpr));
const changed = this.canvas.width !== nextW || this.canvas.height !== nextH;
if (changed) {
this.canvas.width = nextW;
this.canvas.height = nextH;
}
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
return changed;
}
clear(color) {
if (!this.ready) return;
const gl = this.gl;
const rgba = normalizeColor(color);
gl.clearColor(rgba[0], rgba[1], rgba[2], rgba[3]);
gl.clear(gl.COLOR_BUFFER_BIT);
}
drawTriangles(vertices) {
this._drawColorGeometry(vertices, this.gl.TRIANGLES);
}
drawTriangleStrip(vertices) {
this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP);
}
_drawColorGeometry(vertices, mode) {
if (!this.ready || !vertices || vertices.length === 0) return;
const gl = this.gl;
const count = vertices.length;
// Grow scratch buffer if needed (doubles each time to amortise copies).
if (count > this._colorScratch.length) {
let newLen = this._colorScratch.length;
while (newLen < count) newLen *= 2;
this._colorScratch = new Float32Array(newLen);
}
// Copy into scratch (set() is a fast typed memcpy; avoids new allocation).
this._colorScratch.set(vertices);
const view = this._colorScratch.subarray(0, count);
gl.useProgram(this.colorProgram);
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
// Only reallocate the GPU buffer when it is too small; otherwise use
// bufferSubData which avoids a GPU reallocation (Safari is sensitive to this).
if (count > this._colorGpuSize) {
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
this._colorGpuSize = this._colorScratch.length;
} else {
gl.bufferSubData(gl.ARRAY_BUFFER, 0, view);
}
gl.enableVertexAttribArray(this.colorLoc.pos);
gl.vertexAttribPointer(this.colorLoc.pos, 2, gl.FLOAT, false, 24, 0);
gl.enableVertexAttribArray(this.colorLoc.color);
gl.vertexAttribPointer(this.colorLoc.color, 4, gl.FLOAT, false, 24, 8);
gl.uniform2f(this.colorLoc.resolution, this.canvas.width, this.canvas.height);
gl.drawArrays(mode, 0, count / 6);
}
fillRect(x, y, w, h, color) {
if (w <= 0 || h <= 0) return;
const rgba = normalizeColor(color);
const v = [];
pushColoredVertex(v, x, y, rgba);
pushColoredVertex(v, x + w, y, rgba);
pushColoredVertex(v, x + w, y + h, rgba);
pushColoredVertex(v, x, y, rgba);
pushColoredVertex(v, x + w, y + h, rgba);
pushColoredVertex(v, x, y + h, rgba);
this._drawColorGeometry(v, this.gl.TRIANGLES);
}
fillGradientRect(x, y, w, h, colorTL, colorTR, colorBR, colorBL) {
if (w <= 0 || h <= 0) return;
const tl = normalizeColor(colorTL);
const tr = normalizeColor(colorTR);
const br = normalizeColor(colorBR);
const bl = normalizeColor(colorBL);
const v = [];
pushColoredVertex(v, x, y, tl);
pushColoredVertex(v, x + w, y, tr);
pushColoredVertex(v, x + w, y + h, br);
pushColoredVertex(v, x, y, tl);
pushColoredVertex(v, x + w, y + h, br);
pushColoredVertex(v, x, y + h, bl);
this._drawColorGeometry(v, this.gl.TRIANGLES);
}
drawPolyline(points, color, width = 1) {
if (!Array.isArray(points) || points.length < 4) return;
const rgba = normalizeColor(color);
const halfW = Math.max(0.5, Number(width) || 1) / 2;
const verts = [];
for (let i = 0; i < points.length - 2; i += 2) {
segmentToQuadVertices(
verts,
points[i], points[i + 1],
points[i + 2], points[i + 3],
halfW,
rgba,
);
}
this._drawColorGeometry(verts, this.gl.TRIANGLES);
}
drawSegments(segments, color, width = 1) {
if (!Array.isArray(segments) || segments.length < 4) return;
const rgba = normalizeColor(color);
const halfW = Math.max(0.5, Number(width) || 1) / 2;
const verts = [];
for (let i = 0; i < segments.length - 3; i += 4) {
segmentToQuadVertices(
verts,
segments[i], segments[i + 1],
segments[i + 2], segments[i + 3],
halfW,
rgba,
);
}
this._drawColorGeometry(verts, this.gl.TRIANGLES);
}
drawFilledArea(points, baselineY, color) {
if (!Array.isArray(points) || points.length < 4) return;
const rgba = normalizeColor(color);
const verts = [];
for (let i = 0; i < points.length; i += 2) {
pushColoredVertex(verts, points[i], baselineY, rgba);
pushColoredVertex(verts, points[i], points[i + 1], rgba);
}
this._drawColorGeometry(verts, this.gl.TRIANGLE_STRIP);
}
drawPoints(points, size, color) {
if (!Array.isArray(points) || points.length < 2) return;
const radius = Math.max(1, Number(size) || 1);
const rgba = normalizeColor(color);
const verts = [];
for (let i = 0; i < points.length; i += 2) {
const x = points[i] - radius;
const y = points[i + 1] - radius;
const w = radius * 2;
const h = radius * 2;
pushColoredVertex(verts, x, y, rgba);
pushColoredVertex(verts, x + w, y, rgba);
pushColoredVertex(verts, x + w, y + h, rgba);
pushColoredVertex(verts, x, y, rgba);
pushColoredVertex(verts, x + w, y + h, rgba);
pushColoredVertex(verts, x, y + h, rgba);
}
this._drawColorGeometry(verts, this.gl.TRIANGLES);
}
drawDashedVerticalLine(x, y0, y1, dashLen, gapLen, color, width = 1) {
const dash = Math.max(1, Number(dashLen) || 1);
const gap = Math.max(1, Number(gapLen) || 1);
const top = Math.min(y0, y1);
const bottom = Math.max(y0, y1);
const segments = [];
for (let y = top; y < bottom; y += dash + gap) {
const segEnd = Math.min(bottom, y + dash);
segments.push(x, y, x, segEnd);
}
this.drawSegments(segments, color, width);
}
uploadRgbaTexture(name, width, height, data, filter = "linear") {
if (!this.ready || !name || !data) return null;
const gl = this.gl;
let entry = this.textures.get(name);
if (!entry) {
const texture = gl.createTexture();
entry = { texture, width: 0, height: 0 };
this.textures.set(name, entry);
}
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
const mode = filter === "nearest" ? gl.NEAREST : gl.LINEAR;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mode);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mode);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
if (entry.width !== width || entry.height !== height) {
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
width,
height,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
data,
);
entry.width = width;
entry.height = height;
} else {
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
width,
height,
gl.RGBA,
gl.UNSIGNED_BYTE,
data,
);
}
return entry.texture;
}
drawTexture(name, x, y, w, h, alpha = 1, flipY = true) {
if (!this.ready || !name || w <= 0 || h <= 0) return;
const entry = this.textures.get(name);
if (!entry) return;
const gl = this.gl;
const s = this._texScratch;
const x2 = x + w, y2 = y + h;
if (flipY) {
s[0]=x; s[1]=y; s[2]=0; s[3]=1;
s[4]=x2; s[5]=y; s[6]=1; s[7]=1;
s[8]=x2; s[9]=y2; s[10]=1;s[11]=0;
s[12]=x; s[13]=y; s[14]=0;s[15]=1;
s[16]=x2;s[17]=y2;s[18]=1;s[19]=0;
s[20]=x; s[21]=y2;s[22]=0;s[23]=0;
} else {
s[0]=x; s[1]=y; s[2]=0; s[3]=0;
s[4]=x2; s[5]=y; s[6]=1; s[7]=0;
s[8]=x2; s[9]=y2; s[10]=1;s[11]=1;
s[12]=x; s[13]=y; s[14]=0;s[15]=0;
s[16]=x2;s[17]=y2;s[18]=1;s[19]=1;
s[20]=x; s[21]=y2;s[22]=0;s[23]=1;
}
gl.useProgram(this.textureProgram);
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, s);
gl.enableVertexAttribArray(this.textureLoc.pos);
gl.vertexAttribPointer(this.textureLoc.pos, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(this.textureLoc.uv);
gl.vertexAttribPointer(this.textureLoc.uv, 2, gl.FLOAT, false, 16, 8);
gl.uniform2f(this.textureLoc.resolution, this.canvas.width, this.canvas.height);
gl.uniform1f(this.textureLoc.alpha, Math.max(0, Math.min(1, Number(alpha) || 0)));
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
gl.uniform1i(this.textureLoc.tex, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
}
function createRenderer(canvas, options) {
return new TrxWebGlRenderer(canvas, options);
}
global.trxParseCssColor = parseCssColor;
global.trxHslToRgba = hslToRgba;
global.createTrxWebGlRenderer = createRenderer;
global.trxClearCssColorCache = clearCssColorCache;
})(window);
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use std::time::{SystemTime, UNIX_EPOCH};
fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) {
let days = secs.div_euclid(86_400);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let mut 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;
let m = mp + if mp < 10 { 3 } else { -9 };
y += if m <= 2 { 1 } else { 0 };
(y as i32, m as u32, d as u32)
}
fn main() {
let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(d) => d.as_secs() as i64,
Err(_) => 0,
};
let (y, m, d) = utc_ymd_from_unix_secs(secs);
println!("cargo:rustc-env=TRX_CLIENT_BUILD_DATE={y:04}-{m:02}-{d:02}");
}
@@ -0,0 +1,398 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Static asset serving endpoints (HTML pages, JS, CSS, favicon, logo).
use actix_web::http::header;
use actix_web::web;
use actix_web::{get, HttpRequest, HttpResponse, Responder};
use std::sync::OnceLock;
use super::{gz_cache_entry, static_asset_response, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES};
use crate::server::status;
// ---------------------------------------------------------------------------
// Pre-compressed asset caches
// ---------------------------------------------------------------------------
macro_rules! define_gz_cache {
($fn_name:ident, $src:expr, $asset_name:literal) => {
fn $fn_name() -> &'static GzCacheEntry {
static CACHE: OnceLock<GzCacheEntry> = OnceLock::new();
CACHE.get_or_init(|| gz_cache_entry($src.as_bytes(), $asset_name))
}
};
}
define_gz_cache!(gz_index_html, status::index_html(), "index.html");
define_gz_cache!(gz_style_css, status::STYLE_CSS, "style.css");
define_gz_cache!(gz_themes_css, status::THEMES_CSS, "themes.css");
define_gz_cache!(gz_app_js, status::APP_JS, "app.js");
define_gz_cache!(gz_map_core_js, status::MAP_CORE_JS, "map-core.js");
define_gz_cache!(gz_screenshot_js, status::SCREENSHOT_JS, "screenshot.js");
define_gz_cache!(
gz_decode_history_worker_js,
status::DECODE_HISTORY_WORKER_JS,
"decode-history-worker.js"
);
define_gz_cache!(
gz_webgl_renderer_js,
status::WEBGL_RENDERER_JS,
"webgl-renderer.js"
);
define_gz_cache!(
gz_leaflet_ais_tracksymbol_js,
status::LEAFLET_AIS_TRACKSYMBOL_JS,
"leaflet-ais-tracksymbol.js"
);
define_gz_cache!(gz_ais_js, status::AIS_JS, "ais.js");
define_gz_cache!(gz_vdes_js, status::VDES_JS, "vdes.js");
define_gz_cache!(gz_aprs_js, status::APRS_JS, "aprs.js");
define_gz_cache!(gz_hf_aprs_js, status::HF_APRS_JS, "hf-aprs.js");
define_gz_cache!(gz_ft8_js, status::FT8_JS, "ft8.js");
define_gz_cache!(gz_ft4_js, status::FT4_JS, "ft4.js");
define_gz_cache!(gz_ft2_js, status::FT2_JS, "ft2.js");
define_gz_cache!(gz_wspr_js, status::WSPR_JS, "wspr.js");
define_gz_cache!(gz_cw_js, status::CW_JS, "cw.js");
define_gz_cache!(gz_sat_js, status::SAT_JS, "sat.js");
define_gz_cache!(gz_wefax_js, status::WEFAX_JS, "wefax.js");
define_gz_cache!(gz_bookmarks_js, status::BOOKMARKS_JS, "bookmarks.js");
define_gz_cache!(gz_scheduler_js, status::SCHEDULER_JS, "scheduler.js");
define_gz_cache!(
gz_sat_scheduler_js,
status::SAT_SCHEDULER_JS,
"sat-scheduler.js"
);
define_gz_cache!(
gz_background_decode_js,
status::BACKGROUND_DECODE_JS,
"background-decode.js"
);
define_gz_cache!(gz_vchan_js, status::VCHAN_JS, "vchan.js");
define_gz_cache!(gz_bandplan_json, status::BANDPLAN_JSON, "bandplan.json");
// Vendored DSEG14 Classic font
// (binary woff2 — served directly, not through gz_cache)
// Vendored Leaflet 1.9.4
define_gz_cache!(gz_leaflet_js, status::LEAFLET_JS, "leaflet.js");
define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css");
// ---------------------------------------------------------------------------
// HTML page routes (all serve the SPA index)
// ---------------------------------------------------------------------------
#[get("/")]
pub(crate) async fn index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/map")]
pub(crate) async fn map_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/digital-modes")]
pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/recorder")]
pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/settings")]
pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/about")]
pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
// ---------------------------------------------------------------------------
// Favicon & logo
// ---------------------------------------------------------------------------
#[get("/favicon.ico")]
pub(crate) async fn favicon() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
.body(FAVICON_BYTES)
}
#[get("/favicon.png")]
pub(crate) async fn favicon_png() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
.body(FAVICON_BYTES)
}
#[get("/logo.png")]
pub(crate) async fn logo() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
.body(LOGO_BYTES)
}
// ---------------------------------------------------------------------------
// CSS
// ---------------------------------------------------------------------------
#[get("/style.css")]
pub(crate) async fn style_css(req: HttpRequest) -> impl Responder {
let c = gz_style_css();
static_asset_response(&req, "text/css; charset=utf-8", c)
}
#[get("/themes.css")]
pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder {
let c = gz_themes_css();
static_asset_response(&req, "text/css; charset=utf-8", c)
}
// ---------------------------------------------------------------------------
// JavaScript assets
// ---------------------------------------------------------------------------
#[get("/app.js")]
pub(crate) async fn app_js(req: HttpRequest) -> impl Responder {
let c = gz_app_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/map-core.js")]
pub(crate) async fn map_core_js(req: HttpRequest) -> impl Responder {
let c = gz_map_core_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/screenshot.js")]
pub(crate) async fn screenshot_js(req: HttpRequest) -> impl Responder {
let c = gz_screenshot_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/decode-history-worker.js")]
pub(crate) async fn decode_history_worker_js(req: HttpRequest) -> impl Responder {
let c = gz_decode_history_worker_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/webgl-renderer.js")]
pub(crate) async fn webgl_renderer_js(req: HttpRequest) -> impl Responder {
let c = gz_webgl_renderer_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/leaflet-ais-tracksymbol.js")]
pub(crate) async fn leaflet_ais_tracksymbol_js(req: HttpRequest) -> impl Responder {
let c = gz_leaflet_ais_tracksymbol_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/aprs.js")]
pub(crate) async fn aprs_js(req: HttpRequest) -> impl Responder {
let c = gz_aprs_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/hf-aprs.js")]
pub(crate) async fn hf_aprs_js(req: HttpRequest) -> impl Responder {
let c = gz_hf_aprs_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/ais.js")]
pub(crate) async fn ais_js(req: HttpRequest) -> impl Responder {
let c = gz_ais_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/vdes.js")]
pub(crate) async fn vdes_js(req: HttpRequest) -> impl Responder {
let c = gz_vdes_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/ft8.js")]
pub(crate) async fn ft8_js(req: HttpRequest) -> impl Responder {
let c = gz_ft8_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/ft4.js")]
pub(crate) async fn ft4_js(req: HttpRequest) -> impl Responder {
let c = gz_ft4_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/ft2.js")]
pub(crate) async fn ft2_js(req: HttpRequest) -> impl Responder {
let c = gz_ft2_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/wspr.js")]
pub(crate) async fn wspr_js(req: HttpRequest) -> impl Responder {
let c = gz_wspr_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/cw.js")]
pub(crate) async fn cw_js(req: HttpRequest) -> impl Responder {
let c = gz_cw_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/sat.js")]
pub(crate) async fn sat_js(req: HttpRequest) -> impl Responder {
let c = gz_sat_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/wefax.js")]
pub(crate) async fn wefax_js(req: HttpRequest) -> impl Responder {
let c = gz_wefax_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/images/{filename}")]
pub(crate) async fn wefax_image(path: web::Path<String>) -> impl Responder {
let filename = path.into_inner();
// Reject path traversal attempts.
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return HttpResponse::BadRequest().body("invalid filename");
}
if !filename.ends_with(".png") {
return HttpResponse::BadRequest().body("only .png files are accessible");
}
let dir = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("wefax");
let file_path = dir.join(&filename);
match std::fs::read(&file_path) {
Ok(data) => HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.insert_header((header::CACHE_CONTROL, "public, max-age=86400"))
.body(data),
Err(_) => HttpResponse::NotFound().body("image not found"),
}
}
#[get("/bookmarks.js")]
pub(crate) async fn bookmarks_js(req: HttpRequest) -> impl Responder {
let c = gz_bookmarks_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/scheduler.js")]
pub(crate) async fn scheduler_js(req: HttpRequest) -> impl Responder {
let c = gz_scheduler_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/sat-scheduler.js")]
pub(crate) async fn sat_scheduler_js(req: HttpRequest) -> impl Responder {
let c = gz_sat_scheduler_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/background-decode.js")]
pub(crate) async fn background_decode_js(req: HttpRequest) -> impl Responder {
let c = gz_background_decode_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/vchan.js")]
pub(crate) async fn vchan_js(req: HttpRequest) -> impl Responder {
let c = gz_vchan_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/bandplan.json")]
pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder {
let c = gz_bandplan_json();
static_asset_response(&req, "application/json; charset=utf-8", c)
}
// ---------------------------------------------------------------------------
// Vendored DSEG14 Classic font
// ---------------------------------------------------------------------------
#[get("/vendor/dseg14-classic-latin-400-normal.woff2")]
pub(crate) async fn dseg14_classic_woff2() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "font/woff2"))
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
.body(status::DSEG14_CLASSIC_WOFF2)
}
// ---------------------------------------------------------------------------
// Vendored Leaflet 1.9.4
// ---------------------------------------------------------------------------
#[get("/vendor/leaflet.js")]
pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder {
let c = gz_leaflet_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/vendor/leaflet.css")]
pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder {
let c = gz_leaflet_css();
static_asset_response(&req, "text/css; charset=utf-8", c)
}
#[get("/vendor/marker-icon.png")]
pub(crate) async fn leaflet_marker_icon() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
.body(status::LEAFLET_MARKER_ICON)
}
#[get("/vendor/marker-icon-2x.png")]
pub(crate) async fn leaflet_marker_icon_2x() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
.body(status::LEAFLET_MARKER_ICON_2X)
}
#[get("/vendor/marker-shadow.png")]
pub(crate) async fn leaflet_marker_shadow() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
.body(status::LEAFLET_MARKER_SHADOW)
}
#[get("/vendor/layers.png")]
pub(crate) async fn leaflet_layers() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
.body(status::LEAFLET_LAYERS)
}
#[get("/vendor/layers-2x.png")]
pub(crate) async fn leaflet_layers_2x() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
.body(status::LEAFLET_LAYERS_2X)
}
@@ -0,0 +1,287 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Bookmark CRUD endpoints.
use std::sync::Arc;
use actix_web::Error;
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse};
use super::{no_cache_response, request_accepts_html, require_control};
use crate::server::status;
// ============================================================================
// Types
// ============================================================================
#[derive(serde::Deserialize)]
pub struct BookmarkQuery {
pub category: Option<String>,
pub scope: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct BookmarkScopeQuery {
pub scope: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct BookmarkInput {
pub name: String,
pub freq_hz: u64,
pub mode: String,
pub bandwidth_hz: Option<u64>,
pub locator: Option<String>,
pub comment: Option<String>,
pub category: Option<String>,
pub decoders: Option<Vec<String>>,
}
/// A bookmark with its owning scope tag for the list response.
#[derive(serde::Serialize)]
struct BookmarkWithScope {
#[serde(flatten)]
bm: crate::server::bookmarks::Bookmark,
scope: String,
}
#[derive(serde::Deserialize)]
struct BatchDeleteRequest {
ids: Vec<String>,
}
#[derive(serde::Deserialize)]
struct BatchMoveRequest {
ids: Vec<String>,
to: String,
}
// ============================================================================
// Helpers
// ============================================================================
/// Resolve which `BookmarkStore` to use based on the `scope` parameter.
fn resolve_bookmark_store(
scope: Option<&str>,
store_map: &crate::server::bookmarks::BookmarkStoreMap,
) -> std::sync::Arc<crate::server::bookmarks::BookmarkStore> {
match scope.filter(|s| !s.is_empty() && *s != "general") {
Some(remote) => store_map.store_for(remote),
None => store_map.general().clone(),
}
}
fn gen_bookmark_id() -> String {
hex::encode(rand::random::<[u8; 16]>())
}
fn normalize_bookmark_locator(locator: Option<String>) -> Option<String> {
locator.and_then(|value| {
let trimmed = value.trim().to_uppercase();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
}
// ============================================================================
// Endpoints
// ============================================================================
#[get("/bookmarks")]
pub async fn list_bookmarks(
req: HttpRequest,
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
query: web::Query<BookmarkQuery>,
) -> Result<HttpResponse, Error> {
if request_accepts_html(&req) {
return Ok(no_cache_response(
"text/html; charset=utf-8",
status::index_html(),
));
}
let scope = query
.scope
.as_deref()
.filter(|s| !s.is_empty() && *s != "general");
let mut list: Vec<BookmarkWithScope> = match scope {
Some(remote) => {
let mut map: std::collections::HashMap<String, BookmarkWithScope> = store_map
.general()
.list()
.into_iter()
.map(|bm| {
let id = bm.id.clone();
(
id,
BookmarkWithScope {
bm,
scope: "general".into(),
},
)
})
.collect();
for bm in store_map.store_for(remote).list() {
let id = bm.id.clone();
map.insert(
id,
BookmarkWithScope {
bm,
scope: remote.to_owned(),
},
);
}
map.into_values().collect()
}
None => store_map
.general()
.list()
.into_iter()
.map(|bm| BookmarkWithScope {
bm,
scope: "general".into(),
})
.collect(),
};
if let Some(ref cat) = query.category {
if !cat.is_empty() {
let cat_lower = cat.to_lowercase();
list.retain(|item| item.bm.category.to_lowercase() == cat_lower);
}
}
list.sort_by_key(|item| item.bm.freq_hz);
Ok(HttpResponse::Ok().json(list))
}
#[post("/bookmarks")]
pub async fn create_bookmark(
req: HttpRequest,
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
query: web::Query<BookmarkScopeQuery>,
body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
if store.freq_taken(body.freq_hz, None) {
return Err(actix_web::error::ErrorConflict(
"a bookmark for that frequency already exists",
));
}
let bm = crate::server::bookmarks::Bookmark {
id: gen_bookmark_id(),
name: body.name.clone(),
freq_hz: body.freq_hz,
mode: body.mode.clone(),
bandwidth_hz: body.bandwidth_hz,
locator: normalize_bookmark_locator(body.locator.clone()),
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
};
if store.insert(&bm) {
Ok(HttpResponse::Created().json(bm))
} else {
Err(actix_web::error::ErrorInternalServerError(
"failed to save bookmark",
))
}
}
#[put("/bookmarks/{id}")]
pub async fn update_bookmark(
req: HttpRequest,
path: web::Path<String>,
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
query: web::Query<BookmarkScopeQuery>,
body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner();
if store.freq_taken(body.freq_hz, Some(&id)) {
return Err(actix_web::error::ErrorConflict(
"a bookmark for that frequency already exists",
));
}
let bm = crate::server::bookmarks::Bookmark {
id: id.clone(),
name: body.name.clone(),
freq_hz: body.freq_hz,
mode: body.mode.clone(),
bandwidth_hz: body.bandwidth_hz,
locator: normalize_bookmark_locator(body.locator.clone()),
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
};
if store.upsert(&id, &bm) {
Ok(HttpResponse::Ok().json(bm))
} else {
Err(actix_web::error::ErrorNotFound("bookmark not found"))
}
}
#[delete("/bookmarks/{id}")]
pub async fn delete_bookmark(
req: HttpRequest,
path: web::Path<String>,
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner();
if store.remove(&id) {
Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true })))
} else {
Err(actix_web::error::ErrorNotFound("bookmark not found"))
}
}
#[post("/bookmarks/batch_delete")]
pub async fn batch_delete_bookmarks(
req: HttpRequest,
body: web::Json<BatchDeleteRequest>,
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let mut deleted = 0usize;
for id in &body.ids {
if store.remove(id) {
deleted += 1;
}
}
Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": deleted })))
}
#[post("/bookmarks/batch_move")]
pub async fn batch_move_bookmarks(
req: HttpRequest,
body: web::Json<BatchMoveRequest>,
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
let from_store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref());
let mut moved = 0usize;
for id in &body.ids {
if let Some(bm) = from_store.get(id) {
if to_store.insert(&bm) && from_store.remove(id) {
moved += 1;
}
}
}
Ok(HttpResponse::Ok().json(serde_json::json!({ "moved": moved })))
}
@@ -0,0 +1,603 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Decoder toggle/clear endpoints and decode history.
use std::sync::Arc;
use actix_web::http::header;
use actix_web::Error;
use actix_web::{get, post, web, HttpResponse, Responder};
use bytes::Bytes;
use futures_util::stream::{select, StreamExt};
use tokio::sync::{broadcast, mpsc, watch};
use tokio::time::{self, Duration};
use tokio_stream::wrappers::IntervalStream;
use trx_core::{RigCommand, RigRequest, RigState};
use trx_frontend::FrontendRuntimeContext;
use super::{gzip_bytes, send_command, RemoteQuery};
/// Resolve the rig state for a specific remote, falling back to the global
/// default when no `remote` is given or the rig is unknown.
fn resolve_rig_state(
remote: Option<&str>,
context: &FrontendRuntimeContext,
fallback: &watch::Receiver<RigState>,
) -> RigState {
remote
.filter(|s| !s.is_empty())
.and_then(|rid| context.rig_state_rx(rid))
.unwrap_or_else(|| fallback.clone())
.borrow()
.clone()
}
// ============================================================================
// Decoder registry
// ============================================================================
#[get("/decoders")]
pub async fn decoder_registry() -> impl Responder {
HttpResponse::Ok().json(trx_protocol::DECODER_REGISTRY)
}
// ============================================================================
// Decode history types and helpers
// ============================================================================
#[derive(serde::Serialize)]
struct DecodeHistoryPayload {
ais: Vec<trx_core::decode::AisMessage>,
vdes: Vec<trx_core::decode::VdesMessage>,
aprs: Vec<trx_core::decode::AprsPacket>,
hf_aprs: Vec<trx_core::decode::AprsPacket>,
cw: Vec<trx_core::decode::CwEvent>,
ft8: Vec<trx_core::decode::Ft8Message>,
ft4: Vec<trx_core::decode::Ft8Message>,
ft2: Vec<trx_core::decode::Ft8Message>,
wspr: Vec<trx_core::decode::WsprMessage>,
wefax: Vec<trx_core::decode::WefaxMessage>,
}
impl DecodeHistoryPayload {
fn total_messages(&self) -> usize {
self.ais.len()
+ self.vdes.len()
+ self.aprs.len()
+ self.hf_aprs.len()
+ self.cw.len()
+ self.ft8.len()
+ self.ft4.len()
+ self.ft2.len()
+ self.wspr.len()
+ self.wefax.len()
}
}
/// Build the grouped decode history payload from all per-decoder ring-buffers.
fn collect_decode_history(
context: &FrontendRuntimeContext,
rig_filter: Option<&str>,
) -> DecodeHistoryPayload {
DecodeHistoryPayload {
ais: crate::server::audio::snapshot_ais_history(context, rig_filter),
vdes: crate::server::audio::snapshot_vdes_history(context, rig_filter),
aprs: crate::server::audio::snapshot_aprs_history(context, rig_filter),
hf_aprs: crate::server::audio::snapshot_hf_aprs_history(context, rig_filter),
cw: crate::server::audio::snapshot_cw_history(context, rig_filter),
ft8: crate::server::audio::snapshot_ft8_history(context, rig_filter),
ft4: crate::server::audio::snapshot_ft4_history(context, rig_filter),
ft2: crate::server::audio::snapshot_ft2_history(context, rig_filter),
wspr: crate::server::audio::snapshot_wspr_history(context, rig_filter),
wefax: crate::server::audio::snapshot_wefax_history(context, rig_filter),
}
}
fn encode_cbor_length(out: &mut Vec<u8>, major: u8, value: u64) {
debug_assert!(major <= 7);
match value {
0..=23 => out.push((major << 5) | (value as u8)),
24..=0xff => {
out.push((major << 5) | 24);
out.push(value as u8);
}
0x100..=0xffff => {
out.push((major << 5) | 25);
out.extend_from_slice(&(value as u16).to_be_bytes());
}
0x1_0000..=0xffff_ffff => {
out.push((major << 5) | 26);
out.extend_from_slice(&(value as u32).to_be_bytes());
}
_ => {
out.push((major << 5) | 27);
out.extend_from_slice(&value.to_be_bytes());
}
}
}
fn encode_cbor_json_value(out: &mut Vec<u8>, value: &serde_json::Value) {
match value {
serde_json::Value::Null => out.push(0xf6),
serde_json::Value::Bool(false) => out.push(0xf4),
serde_json::Value::Bool(true) => out.push(0xf5),
serde_json::Value::Number(number) => {
if let Some(value) = number.as_u64() {
encode_cbor_length(out, 0, value);
} else if let Some(value) = number.as_i64() {
if value >= 0 {
encode_cbor_length(out, 0, value as u64);
} else {
encode_cbor_length(out, 1, value.unsigned_abs() - 1);
}
} else if let Some(value) = number.as_f64() {
out.push(0xfb);
out.extend_from_slice(&value.to_be_bytes());
} else {
out.push(0xf6);
}
}
serde_json::Value::String(text) => {
encode_cbor_length(out, 3, text.len() as u64);
out.extend_from_slice(text.as_bytes());
}
serde_json::Value::Array(items) => {
encode_cbor_length(out, 4, items.len() as u64);
for item in items {
encode_cbor_json_value(out, item);
}
}
serde_json::Value::Object(map) => {
encode_cbor_length(out, 5, map.len() as u64);
for (key, item) in map {
encode_cbor_length(out, 3, key.len() as u64);
out.extend_from_slice(key.as_bytes());
encode_cbor_json_value(out, item);
}
}
}
}
fn encode_decode_history_cbor(
history: &DecodeHistoryPayload,
) -> Result<Vec<u8>, serde_json::Error> {
let value = serde_json::to_value(history)?;
let mut out = Vec::with_capacity(history.total_messages().saturating_mul(96));
encode_cbor_json_value(&mut out, &value);
Ok(out)
}
// ============================================================================
// Decode history endpoint
// ============================================================================
/// `GET /decode/history` — returns the full decode history as gzipped CBOR.
#[get("/decode/history")]
pub async fn decode_history(
context: web::Data<Arc<FrontendRuntimeContext>>,
query: web::Query<RemoteQuery>,
) -> impl Responder {
if context.audio.decode_rx.is_none() {
return HttpResponse::NotFound().body("decode not enabled");
}
let rig_filter = query.remote.as_deref().filter(|s| !s.is_empty());
let history = collect_decode_history(context.get_ref(), rig_filter);
let cbor = match encode_decode_history_cbor(&history) {
Ok(cbor) => cbor,
Err(err) => {
tracing::error!("failed to encode decode history as CBOR: {err}");
return HttpResponse::InternalServerError().finish();
}
};
let payload = match gzip_bytes(&cbor) {
Ok(payload) => payload,
Err(err) => {
tracing::error!("failed to gzip decode history payload: {err}");
return HttpResponse::InternalServerError().finish();
}
};
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/cbor"))
.insert_header((header::CONTENT_ENCODING, "gzip"))
.body(payload)
}
// ============================================================================
// Decode SSE stream
// ============================================================================
#[get("/decode")]
pub async fn decode_events(
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
let Some(decode_rx) = crate::server::audio::subscribe_decode(context.get_ref()) else {
tracing::warn!("/decode requested but decode channel not set (audio disabled?)");
return Ok(HttpResponse::NotFound().body("decode not enabled"));
};
tracing::info!("/decode SSE client connected");
let decode_stream = futures_util::stream::unfold(decode_rx, |mut rx| async move {
loop {
match rx.recv().await {
Ok(msg) => {
if let Ok(json) = serde_json::to_string(&msg) {
return Some((
Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n"))),
rx,
));
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return None,
}
}
});
let pings = IntervalStream::new(time::interval(Duration::from_secs(15)))
.map(|_| Ok::<Bytes, Error>(Bytes::from(": ping\n\n")));
let stream = select(pings, decode_stream);
Ok(HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
.insert_header((header::CONTENT_ENCODING, "identity"))
.insert_header((header::CACHE_CONTROL, "no-cache"))
.insert_header((header::CONNECTION, "keep-alive"))
.streaming(stream))
}
// ============================================================================
// Decoder toggle endpoints
// ============================================================================
#[post("/toggle_aprs_decode")]
pub async fn toggle_aprs_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
send_command(
&rig_tx,
RigCommand::SetAprsDecodeEnabled(!rig_state.decoders.aprs_decode_enabled),
q.remote,
)
.await
}
#[post("/toggle_hf_aprs_decode")]
pub async fn toggle_hf_aprs_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
send_command(
&rig_tx,
RigCommand::SetHfAprsDecodeEnabled(!rig_state.decoders.hf_aprs_decode_enabled),
q.remote,
)
.await
}
#[post("/toggle_cw_decode")]
pub async fn toggle_cw_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
send_command(
&rig_tx,
RigCommand::SetCwDecodeEnabled(!rig_state.decoders.cw_decode_enabled),
q.remote,
)
.await
}
#[derive(serde::Deserialize)]
pub struct CwAutoQuery {
pub enabled: bool,
pub remote: Option<String>,
}
#[post("/set_cw_auto")]
pub async fn set_cw_auto(
query: web::Query<CwAutoQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetCwAuto(q.enabled), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct CwWpmQuery {
pub wpm: u32,
pub remote: Option<String>,
}
#[post("/set_cw_wpm")]
pub async fn set_cw_wpm(
query: web::Query<CwWpmQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetCwWpm(q.wpm), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct CwToneQuery {
pub tone_hz: u32,
pub remote: Option<String>,
}
#[post("/set_cw_tone")]
pub async fn set_cw_tone(
query: web::Query<CwToneQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetCwToneHz(q.tone_hz), q.remote).await
}
#[post("/toggle_ft8_decode")]
pub async fn toggle_ft8_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
send_command(
&rig_tx,
RigCommand::SetFt8DecodeEnabled(!rig_state.decoders.ft8_decode_enabled),
q.remote,
)
.await
}
#[post("/toggle_ft4_decode")]
pub async fn toggle_ft4_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
send_command(
&rig_tx,
RigCommand::SetFt4DecodeEnabled(!rig_state.decoders.ft4_decode_enabled),
q.remote,
)
.await
}
#[post("/toggle_ft2_decode")]
pub async fn toggle_ft2_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
send_command(
&rig_tx,
RigCommand::SetFt2DecodeEnabled(!rig_state.decoders.ft2_decode_enabled),
q.remote,
)
.await
}
#[post("/toggle_wspr_decode")]
pub async fn toggle_wspr_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
send_command(
&rig_tx,
RigCommand::SetWsprDecodeEnabled(!rig_state.decoders.wspr_decode_enabled),
q.remote,
)
.await
}
#[post("/toggle_lrpt_decode")]
pub async fn toggle_lrpt_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
send_command(
&rig_tx,
RigCommand::SetLrptDecodeEnabled(!rig_state.decoders.lrpt_decode_enabled),
q.remote,
)
.await
}
#[post("/toggle_wefax_decode")]
pub async fn toggle_wefax_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
send_command(
&rig_tx,
RigCommand::SetWefaxDecodeEnabled(!rig_state.decoders.wefax_decode_enabled),
q.remote,
)
.await
}
// ============================================================================
// Decoder clear endpoints
// ============================================================================
#[post("/clear_wefax_decode")]
pub async fn clear_wefax_decode(
query: web::Query<RemoteQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(
&rig_tx,
RigCommand::ResetWefaxDecoder,
query.into_inner().remote,
)
.await
}
#[post("/clear_lrpt_decode")]
pub async fn clear_lrpt_decode(
query: web::Query<RemoteQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(
&rig_tx,
RigCommand::ResetLrptDecoder,
query.into_inner().remote,
)
.await
}
#[post("/clear_ft8_decode")]
pub async fn clear_ft8_decode(
query: web::Query<RemoteQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_ft8_history(context.get_ref());
send_command(
&rig_tx,
RigCommand::ResetFt8Decoder,
query.into_inner().remote,
)
.await
}
#[post("/clear_ft4_decode")]
pub async fn clear_ft4_decode(
query: web::Query<RemoteQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_ft4_history(context.get_ref());
send_command(
&rig_tx,
RigCommand::ResetFt4Decoder,
query.into_inner().remote,
)
.await
}
#[post("/clear_ft2_decode")]
pub async fn clear_ft2_decode(
query: web::Query<RemoteQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_ft2_history(context.get_ref());
send_command(
&rig_tx,
RigCommand::ResetFt2Decoder,
query.into_inner().remote,
)
.await
}
#[post("/clear_wspr_decode")]
pub async fn clear_wspr_decode(
query: web::Query<RemoteQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_wspr_history(context.get_ref());
send_command(
&rig_tx,
RigCommand::ResetWsprDecoder,
query.into_inner().remote,
)
.await
}
#[post("/clear_aprs_decode")]
pub async fn clear_aprs_decode(
query: web::Query<RemoteQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_aprs_history(context.get_ref());
send_command(
&rig_tx,
RigCommand::ResetAprsDecoder,
query.into_inner().remote,
)
.await
}
#[post("/clear_hf_aprs_decode")]
pub async fn clear_hf_aprs_decode(
query: web::Query<RemoteQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_hf_aprs_history(context.get_ref());
send_command(
&rig_tx,
RigCommand::ResetHfAprsDecoder,
query.into_inner().remote,
)
.await
}
#[post("/clear_ais_decode")]
pub async fn clear_ais_decode(
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_ais_history(context.get_ref());
Ok(HttpResponse::Ok().finish())
}
#[post("/clear_vdes_decode")]
pub async fn clear_vdes_decode(
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_vdes_history(context.get_ref());
Ok(HttpResponse::Ok().finish())
}
#[post("/clear_cw_decode")]
pub async fn clear_cw_decode(
query: web::Query<RemoteQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_cw_history(context.get_ref());
send_command(
&rig_tx,
RigCommand::ResetCwDecoder,
query.into_inner().remote,
)
.await
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,245 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! HTTP API endpoints for audio recording.
use std::sync::Arc;
use actix_web::http::header;
use actix_web::{delete, get, post, web, Error, HttpResponse};
use bytes::Bytes;
use tokio::sync::{mpsc, watch};
use trx_core::{RigCommand, RigState};
use trx_frontend::FrontendRuntimeContext;
use super::send_command;
use crate::server::recorder::RecorderManager;
// ============================================================================
// Query types
// ============================================================================
#[derive(serde::Deserialize)]
pub struct RecorderStartQuery {
pub remote: Option<String>,
pub vchan_id: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct RecorderStopQuery {
pub remote: Option<String>,
pub vchan_id: Option<String>,
}
// ============================================================================
// Endpoints
// ============================================================================
/// Start recording audio for the active rig (or a specific vchan).
#[post("/api/recorder/start")]
pub async fn recorder_start(
query: web::Query<RecorderStartQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
recorder_mgr: web::Data<Arc<RecorderManager>>,
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<trx_core::RigRequest>>,
) -> Result<HttpResponse, Error> {
let rig_id = resolve_rig_id(&context, query.remote.as_deref());
let vchan_id = query.vchan_id.as_deref();
// Resolve the audio broadcast sender for this rig/vchan.
let (audio_tx, sample_rate, channels, frame_duration_ms) =
resolve_audio_source(&context, &rig_id, vchan_id)?;
let current_state = state.get_ref().borrow().clone();
let freq_hz = Some(current_state.status.freq.hz);
let mode = Some(trx_protocol::mode_to_string(&current_state.status.mode).into_owned());
let params = crate::server::recorder::AudioParams {
sample_rate,
channels,
frame_duration_ms,
};
match recorder_mgr.start(
&rig_id,
vchan_id,
audio_tx,
params,
freq_hz,
mode.as_deref(),
) {
Ok(info) => {
// Sync recorder_enabled state to the rig.
let _ = send_command(
&rig_tx,
RigCommand::SetRecorderEnabled(true),
query.remote.clone(),
)
.await;
Ok(HttpResponse::Ok().json(info))
}
Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": e }))),
}
}
/// Stop recording.
#[post("/api/recorder/stop")]
pub async fn recorder_stop(
query: web::Query<RecorderStopQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
recorder_mgr: web::Data<Arc<RecorderManager>>,
rig_tx: web::Data<mpsc::Sender<trx_core::RigRequest>>,
) -> Result<HttpResponse, Error> {
let rig_id = resolve_rig_id(&context, query.remote.as_deref());
let vchan_id = query.vchan_id.as_deref();
match recorder_mgr.stop(&rig_id, vchan_id).await {
Ok(result) => {
// Check if any recordings remain active for this rig.
let still_recording = recorder_mgr
.list_active()
.iter()
.any(|r| r.rig_id == rig_id);
if !still_recording {
let _ = send_command(
&rig_tx,
RigCommand::SetRecorderEnabled(false),
query.remote.clone(),
)
.await;
}
Ok(HttpResponse::Ok().json(result))
}
Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": e }))),
}
}
/// Get the status of active recordings.
#[get("/api/recorder/status")]
pub async fn recorder_status(
recorder_mgr: web::Data<Arc<RecorderManager>>,
) -> Result<HttpResponse, Error> {
let active = recorder_mgr.list_active();
Ok(HttpResponse::Ok().json(active))
}
/// List recorded files in the output directory.
#[get("/api/recorder/files")]
pub async fn recorder_files(
recorder_mgr: web::Data<Arc<RecorderManager>>,
) -> Result<HttpResponse, Error> {
let files = recorder_mgr.list_files();
Ok(HttpResponse::Ok().json(files))
}
/// Download a recorded file.
#[get("/api/recorder/download/{filename}")]
pub async fn recorder_download(
path: web::Path<String>,
recorder_mgr: web::Data<Arc<RecorderManager>>,
) -> Result<HttpResponse, Error> {
let filename = path.into_inner();
let file_path = recorder_mgr
.file_path(&filename)
.map_err(actix_web::error::ErrorNotFound)?;
let data = tokio::fs::read(&file_path)
.await
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("read error: {e}")))?;
Ok(HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "audio/ogg"))
.insert_header((
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
))
.body(Bytes::from(data)))
}
/// Delete a recorded file.
#[delete("/api/recorder/files/{filename}")]
pub async fn recorder_delete(
path: web::Path<String>,
recorder_mgr: web::Data<Arc<RecorderManager>>,
) -> Result<HttpResponse, Error> {
let filename = path.into_inner();
match recorder_mgr.delete_file(&filename) {
Ok(()) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": filename }))),
Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": e }))),
}
}
// ============================================================================
// Helpers
// ============================================================================
fn resolve_rig_id(context: &FrontendRuntimeContext, remote: Option<&str>) -> String {
if let Some(r) = remote {
return r.to_string();
}
context
.routing
.active_rig_id
.lock()
.ok()
.and_then(|v| v.clone())
.unwrap_or_else(|| "default".to_string())
}
fn resolve_audio_source(
context: &FrontendRuntimeContext,
rig_id: &str,
vchan_id: Option<&str>,
) -> Result<(tokio::sync::broadcast::Sender<bytes::Bytes>, u32, u8, u16), Error> {
if let Some(vchan_uuid_str) = vchan_id {
// Virtual channel audio.
let uuid: uuid::Uuid = vchan_uuid_str
.parse()
.map_err(|_| actix_web::error::ErrorBadRequest("invalid vchan_id UUID"))?;
let audio = context
.vchan
.audio
.read()
.unwrap_or_else(|e| e.into_inner());
let tx = audio
.get(&uuid)
.cloned()
.ok_or_else(|| actix_web::error::ErrorNotFound("vchan audio not found"))?;
// Virtual channels use the same stream info as the main rig.
let (sr, ch, fd) = stream_info_for_rig(context, rig_id);
Ok((tx, sr, ch, fd))
} else {
// Main rig audio — try per-rig first, then default.
let tx = context
.rig_audio
.rx
.read()
.ok()
.and_then(|map| map.get(rig_id).cloned())
.or_else(|| context.audio.rx.clone())
.ok_or_else(|| actix_web::error::ErrorNotFound("no audio source for rig"))?;
let (sr, ch, fd) = stream_info_for_rig(context, rig_id);
Ok((tx, sr, ch, fd))
}
}
fn stream_info_for_rig(context: &FrontendRuntimeContext, rig_id: &str) -> (u32, u8, u16) {
// Try per-rig stream info first.
if let Some(rx) = context.rig_audio_info_rx(rig_id) {
if let Some(info) = rx.borrow().as_ref() {
return (info.sample_rate, info.channels, info.frame_duration_ms);
}
}
// Fall back to the default audio info.
if let Some(ref info_rx) = context.audio.info {
if let Some(info) = info_rx.borrow().as_ref() {
return (info.sample_rate, info.channels, info.frame_duration_ms);
}
}
// Absolute fallback.
(48000, 2, 20)
}
@@ -0,0 +1,535 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Rig control endpoints: status, frequency, mode, PTT, SDR settings, etc.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use actix_web::{get, post, web, HttpResponse, Responder};
use actix_web::{http::header, Error};
use tokio::sync::{mpsc, watch};
use uuid::Uuid;
use trx_core::radio::freq::Freq;
use trx_core::rig::state::WfmDenoiseLevel;
use trx_core::{RigCommand, RigRequest, RigState};
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
use trx_protocol::parse_mode;
use crate::server::vchan::ClientChannelManager;
use super::{
active_rig_id_from_context, frontend_meta_from_context, send_command, wait_for_view,
RemoteQuery, SessionRigManager, SnapshotWithMeta, StatusQuery,
};
// ============================================================================
// Status
// ============================================================================
#[get("/status")]
pub async fn status_api(
query: web::Query<StatusQuery>,
state: web::Data<watch::Receiver<RigState>>,
clients: web::Data<Arc<AtomicUsize>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<impl Responder, Error> {
let rx = query
.remote
.as_deref()
.filter(|s| !s.is_empty())
.and_then(|rid| context.rig_state_rx(rid))
.unwrap_or_else(|| state.get_ref().clone());
let snapshot = wait_for_view(rx).await?;
let combined = SnapshotWithMeta {
snapshot: &snapshot,
meta: frontend_meta_from_context(
clients.load(Ordering::Relaxed),
context.get_ref().as_ref(),
None,
),
};
let json =
serde_json::to_string(&combined).map_err(actix_web::error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/json"))
.body(json))
}
// ============================================================================
// Power / VFO / Lock
// ============================================================================
#[post("/toggle_power")]
pub async fn toggle_power(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let desired_on = !matches!(state.get_ref().borrow().control.enabled, Some(true));
let cmd = if desired_on {
RigCommand::PowerOn
} else {
RigCommand::PowerOff
};
send_command(&rig_tx, cmd, query.into_inner().remote).await
}
#[post("/toggle_vfo")]
pub async fn toggle_vfo(
query: web::Query<RemoteQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::ToggleVfo, query.into_inner().remote).await
}
#[post("/lock")]
pub async fn lock_panel(
query: web::Query<RemoteQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::Lock, query.into_inner().remote).await
}
#[post("/unlock")]
pub async fn unlock_panel(
query: web::Query<RemoteQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::Unlock, query.into_inner().remote).await
}
// ============================================================================
// Frequency / Mode / PTT
// ============================================================================
#[derive(serde::Deserialize)]
pub struct FreqQuery {
pub hz: u64,
pub remote: Option<String>,
}
#[post("/set_freq")]
pub async fn set_freq(
query: web::Query<FreqQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetFreq(Freq { hz: q.hz }), q.remote).await
}
#[post("/set_center_freq")]
pub async fn set_center_freq(
query: web::Query<FreqQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(
&rig_tx,
RigCommand::SetCenterFreq(Freq { hz: q.hz }),
q.remote,
)
.await
}
#[derive(serde::Deserialize)]
pub struct ModeQuery {
pub mode: String,
pub remote: Option<String>,
}
#[post("/set_mode")]
pub async fn set_mode(
query: web::Query<ModeQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
let mode = parse_mode(&q.mode);
send_command(&rig_tx, RigCommand::SetMode(mode), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct PttQuery {
pub ptt: String,
pub remote: Option<String>,
}
#[post("/set_ptt")]
pub async fn set_ptt(
query: web::Query<PttQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
let ptt = match q.ptt.to_ascii_lowercase().as_str() {
"1" | "true" | "on" => Ok(true),
"0" | "false" | "off" => Ok(false),
other => Err(actix_web::error::ErrorBadRequest(format!(
"invalid ptt parameter: {other}"
))),
}?;
send_command(&rig_tx, RigCommand::SetPtt(ptt), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct TxLimitQuery {
pub limit: u8,
pub remote: Option<String>,
}
#[post("/set_tx_limit")]
pub async fn set_tx_limit(
query: web::Query<TxLimitQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetTxLimit(q.limit), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct BandwidthQuery {
pub hz: u32,
pub remote: Option<String>,
}
#[post("/set_bandwidth")]
pub async fn set_bandwidth(
query: web::Query<BandwidthQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetBandwidth(q.hz), q.remote).await
}
// ============================================================================
// SDR settings
// ============================================================================
#[derive(serde::Deserialize)]
pub struct SdrGainQuery {
pub db: f64,
pub remote: Option<String>,
}
#[post("/set_sdr_gain")]
pub async fn set_sdr_gain(
query: web::Query<SdrGainQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetSdrGain(q.db), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct SdrLnaGainQuery {
pub db: f64,
pub remote: Option<String>,
}
#[post("/set_sdr_lna_gain")]
pub async fn set_sdr_lna_gain(
query: web::Query<SdrLnaGainQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetSdrLnaGain(q.db), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct SdrAgcQuery {
pub enabled: bool,
pub remote: Option<String>,
}
#[post("/set_sdr_agc")]
pub async fn set_sdr_agc(
query: web::Query<SdrAgcQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetSdrAgc(q.enabled), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct SdrSquelchQuery {
pub enabled: bool,
pub threshold_db: f64,
pub remote: Option<String>,
}
#[post("/set_sdr_squelch")]
pub async fn set_sdr_squelch(
query: web::Query<SdrSquelchQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(
&rig_tx,
RigCommand::SetSdrSquelch {
enabled: q.enabled,
threshold_db: q.threshold_db,
},
q.remote,
)
.await
}
#[derive(serde::Deserialize)]
pub struct SdrNoiseBlankerQuery {
pub enabled: bool,
pub threshold: f64,
pub remote: Option<String>,
}
#[post("/set_sdr_noise_blanker")]
pub async fn set_sdr_noise_blanker(
query: web::Query<SdrNoiseBlankerQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(
&rig_tx,
RigCommand::SetSdrNoiseBlanker {
enabled: q.enabled,
threshold: q.threshold,
},
q.remote,
)
.await
}
// ============================================================================
// WFM / SAM settings
// ============================================================================
#[derive(serde::Deserialize)]
pub struct WfmDeemphasisQuery {
pub us: u32,
pub remote: Option<String>,
}
#[post("/set_wfm_deemphasis")]
pub async fn set_wfm_deemphasis(
query: web::Query<WfmDeemphasisQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetWfmDeemphasis(q.us), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct WfmStereoQuery {
pub enabled: bool,
pub remote: Option<String>,
}
#[post("/set_wfm_stereo")]
pub async fn set_wfm_stereo(
query: web::Query<WfmStereoQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetWfmStereo(q.enabled), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct WfmDenoiseQuery {
pub level: WfmDenoiseLevel,
pub remote: Option<String>,
}
#[post("/set_wfm_denoise")]
pub async fn set_wfm_denoise(
query: web::Query<WfmDenoiseQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetWfmDenoise(q.level), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct SamStereoWidthQuery {
pub width: f32,
pub remote: Option<String>,
}
#[post("/set_sam_stereo_width")]
pub async fn set_sam_stereo_width(
query: web::Query<SamStereoWidthQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetSamStereoWidth(q.width), q.remote).await
}
#[derive(serde::Deserialize)]
pub struct SamCarrierSyncQuery {
pub enabled: bool,
pub remote: Option<String>,
}
#[post("/set_sam_carrier_sync")]
pub async fn set_sam_carrier_sync(
query: web::Query<SamCarrierSyncQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetSamCarrierSync(q.enabled), q.remote).await
}
// ============================================================================
// Rig list / selection
// ============================================================================
#[derive(serde::Serialize)]
struct RigListItem {
remote: String,
display_name: Option<String>,
manufacturer: String,
model: String,
initialized: bool,
#[serde(skip_serializing_if = "Option::is_none")]
latitude: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
longitude: Option<f64>,
}
#[derive(serde::Serialize)]
struct RigListResponse {
active_remote: Option<String>,
rigs: Vec<RigListItem>,
}
fn build_rig_list_payload(context: &FrontendRuntimeContext) -> RigListResponse {
let active_remote = active_rig_id_from_context(context);
let rigs = context
.routing
.remote_rigs
.lock()
.ok()
.map(|entries| entries.iter().map(map_rig_entry).collect())
.unwrap_or_default();
RigListResponse {
active_remote,
rigs,
}
}
fn map_rig_entry(entry: &RemoteRigEntry) -> RigListItem {
RigListItem {
remote: entry.rig_id.clone(),
display_name: entry.display_name.clone(),
manufacturer: entry.state.info.manufacturer.clone(),
model: entry.state.info.model.clone(),
initialized: entry.state.initialized,
latitude: entry.state.server_latitude,
longitude: entry.state.server_longitude,
}
}
#[get("/rigs")]
pub async fn list_rigs(
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
Ok(HttpResponse::Ok().json(build_rig_list_payload(context.get_ref().as_ref())))
}
#[derive(serde::Deserialize)]
pub struct SelectRigQuery {
pub remote: String,
pub session_id: Option<String>,
}
#[post("/select_rig")]
pub async fn select_rig(
query: web::Query<SelectRigQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
session_rig_mgr: web::Data<Arc<SessionRigManager>>,
) -> Result<HttpResponse, Error> {
let remote = query.remote.trim();
if remote.is_empty() {
return Err(actix_web::error::ErrorBadRequest(
"remote must not be empty",
));
}
let known = context
.routing
.remote_rigs
.lock()
.ok()
.map(|entries| entries.iter().any(|entry| entry.rig_id == remote))
.unwrap_or(false);
if !known {
return Err(actix_web::error::ErrorBadRequest(format!(
"unknown remote: {remote}"
)));
}
// Only update per-session rig selection — never mutate the global
// active rig so that other tabs/sessions are unaffected.
if let Some(ref sid) = query.session_id {
if let Ok(uuid) = Uuid::parse_str(sid) {
session_rig_mgr.set_rig(uuid, remote.to_string());
}
}
// Broadcast the channel list for the newly selected rig so all SSE
// clients receive the correct virtual channels immediately.
let chans = vchan_mgr.channels(remote);
if let Ok(json) = serde_json::to_string(&chans) {
let _ = vchan_mgr.change_tx.send(format!("{remote}:{json}"));
}
Ok(HttpResponse::Ok().json(build_rig_list_payload(context.get_ref().as_ref())))
}
// ============================================================================
// Satellite passes
// ============================================================================
#[derive(serde::Serialize)]
struct SatPassesResponse {
passes: Vec<trx_core::geo::PassPrediction>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// Number of satellites evaluated for predictions.
satellite_count: usize,
/// Source of the TLE data used: "celestrak" or "unavailable".
tle_source: trx_core::geo::TleSource,
}
/// Return predicted passes for all known satellites over the next 24 h.
#[get("/sat_passes")]
pub async fn sat_passes(context: web::Data<Arc<FrontendRuntimeContext>>) -> impl Responder {
let cached = context
.routing
.sat_passes
.read()
.ok()
.and_then(|g| g.clone());
match cached {
Some(result) => {
let error = match result.tle_source {
trx_core::geo::TleSource::Unavailable => {
Some("TLE data not yet available — waiting for CelesTrak fetch".to_string())
}
trx_core::geo::TleSource::Celestrak => None,
};
web::Json(SatPassesResponse {
passes: result.passes,
error,
satellite_count: result.satellite_count,
tle_source: result.tle_source,
})
}
None => web::Json(SatPassesResponse {
passes: vec![],
error: Some("Satellite predictions not yet available from server".to_string()),
satellite_count: 0,
tle_source: trx_core::geo::TleSource::Unavailable,
}),
}
}
@@ -0,0 +1,450 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! SSE stream endpoints: /events (rig state) and /spectrum.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use actix_web::http::header;
use actix_web::Error;
use actix_web::{get, web, HttpResponse};
use bytes::Bytes;
use futures_util::stream::{select, StreamExt};
use tokio::sync::{broadcast, watch};
use tokio::time::{self, Duration};
use tokio_stream::wrappers::{IntervalStream, WatchStream};
use uuid::Uuid;
use trx_core::RigState;
use trx_frontend::FrontendRuntimeContext;
use trx_protocol::MeterUpdate;
use crate::server::vchan::ClientChannelManager;
use super::{
base64_encode, frontend_meta_from_context, wait_for_view, RemoteQuery, SessionRigManager,
SnapshotWithMeta,
};
// ============================================================================
// DropStream utility
// ============================================================================
/// A stream wrapper that calls a callback when dropped.
struct DropStream<I> {
inner: std::pin::Pin<Box<dyn futures_util::Stream<Item = I> + 'static>>,
on_drop: Option<Box<dyn FnOnce() + Send>>,
}
impl<I> DropStream<I> {
fn new<S, F>(inner: std::pin::Pin<Box<S>>, on_drop: F) -> Self
where
S: futures_util::Stream<Item = I> + 'static,
F: FnOnce() + Send + 'static,
{
Self {
inner,
on_drop: Some(Box::new(on_drop)),
}
}
}
impl<I> Drop for DropStream<I> {
fn drop(&mut self) {
if let Some(f) = self.on_drop.take() {
f();
}
}
}
impl<I> futures_util::Stream for DropStream<I> {
type Item = I;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.inner.as_mut().poll_next(cx)
}
}
// ============================================================================
// Spectrum encoding
// ============================================================================
/// Encode spectrum bins as a compact base64 string of i8 values (1 dB/step).
fn encode_spectrum_frame(frame: &trx_core::rig::state::SpectrumData) -> String {
let clamped: Vec<u8> = frame
.bins
.iter()
.map(|&v| v.round().clamp(-128.0, 127.0) as i8 as u8)
.collect();
let b64 = base64_encode(&clamped);
let mut out = String::with_capacity(40 + b64.len());
out.push_str(&frame.center_hz.to_string());
out.push(',');
out.push_str(&frame.sample_rate.to_string());
out.push(',');
out.push_str(&b64);
out
}
// ============================================================================
// Scheduler vchannel sync helper
// ============================================================================
fn sync_scheduler_vchannels(
vchan_mgr: &ClientChannelManager,
bookmark_store_map: &crate::server::bookmarks::BookmarkStoreMap,
scheduler_status: &crate::server::scheduler::SchedulerStatusMap,
scheduler_control: &crate::server::scheduler::SchedulerControlManager,
rig_id: &str,
) {
if !scheduler_control.scheduler_allowed() {
vchan_mgr.sync_scheduler_channels(rig_id, &[]);
return;
}
let desired = {
let map = scheduler_status.read().unwrap_or_else(|e| e.into_inner());
map.get(rig_id)
.filter(|status| status.active)
.map(|status| {
status
.last_bookmark_ids
.iter()
.filter_map(|bookmark_id| {
bookmark_store_map
.get_for_rig(rig_id, bookmark_id)
.map(|bookmark| {
(
bookmark_id.clone(),
bookmark.freq_hz,
bookmark.mode.clone(),
bookmark.bandwidth_hz.unwrap_or(0) as u32,
bookmark_decoder_kinds(&bookmark),
)
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default()
};
vchan_mgr.sync_scheduler_channels(rig_id, &desired);
}
fn bookmark_decoder_kinds(bookmark: &crate::server::bookmarks::Bookmark) -> Vec<String> {
trx_protocol::decoders::resolve_bookmark_decoders(&bookmark.decoders, &bookmark.mode, true)
}
// ============================================================================
// /events SSE endpoint
// ============================================================================
#[derive(serde::Deserialize)]
pub struct EventsQuery {
pub remote: Option<String>,
}
#[get("/events")]
#[allow(clippy::too_many_arguments)]
pub async fn events(
query: web::Query<EventsQuery>,
state: web::Data<watch::Receiver<RigState>>,
clients: web::Data<Arc<AtomicUsize>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
bookmark_store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
scheduler_status: web::Data<crate::server::scheduler::SchedulerStatusMap>,
scheduler_control: web::Data<crate::server::scheduler::SharedSchedulerControlManager>,
session_rig_mgr: web::Data<Arc<SessionRigManager>>,
) -> Result<HttpResponse, Error> {
let counter = clients.get_ref().clone();
let count = counter.fetch_add(1, Ordering::Relaxed) + 1;
// Assign a stable UUID to this SSE session for channel binding.
let session_id = Uuid::new_v4();
scheduler_control.register_session(session_id);
// Use the client-requested remote if provided, otherwise fall back to
// the global default.
let active_rig_id = query.remote.clone().filter(|s| !s.is_empty()).or_else(|| {
context
.routing
.active_rig_id
.lock()
.ok()
.and_then(|g| g.clone())
});
// Subscribe to the per-rig watch channel for this session's rig.
let rx = active_rig_id
.as_deref()
.and_then(|rid| context.rig_state_rx(rid))
.unwrap_or_else(|| state.get_ref().clone());
let initial = wait_for_view(rx.clone()).await?;
if let Some(ref rid) = active_rig_id {
session_rig_mgr.register(session_id, rid.clone());
vchan_mgr.init_rig(
rid,
initial.status.freq.hz,
&format!("{:?}", initial.status.mode),
);
sync_scheduler_vchannels(
vchan_mgr.get_ref().as_ref(),
bookmark_store_map.get_ref().as_ref(),
scheduler_status.get_ref(),
scheduler_control.get_ref().as_ref(),
rid,
);
}
// Build the prefix burst: rig state → session UUID → initial channels.
let initial_combined = SnapshotWithMeta {
snapshot: &initial,
meta: frontend_meta_from_context(
count,
context.get_ref().as_ref(),
active_rig_id.as_deref(),
),
};
let initial_json = serde_json::to_string(&initial_combined)
.map_err(actix_web::error::ErrorInternalServerError)?;
let mut prefix: Vec<Result<Bytes, Error>> = Vec::new();
prefix.push(Ok(Bytes::from(format!("data: {initial_json}\n\n"))));
prefix.push(Ok(Bytes::from(format!(
"event: session\ndata: {{\"session_id\":\"{session_id}\"}}\n\n"
))));
if let Some(ref rid) = active_rig_id {
let chans = vchan_mgr.channels(rid);
if let Ok(json) = serde_json::to_string(&chans) {
prefix.push(Ok(Bytes::from(format!(
"event: channels\ndata: {{\"remote\":\"{rid}\",\"channels\":{json}}}\n\n"
))));
}
}
let prefix_stream = futures_util::stream::iter(prefix);
// Live rig-state updates; side-effect: keep primary channel metadata in sync.
let counter_updates = counter.clone();
let context_updates = context.get_ref().clone();
let vchan_updates = vchan_mgr.get_ref().clone();
let bookmark_store_map_updates = bookmark_store_map.get_ref().clone();
let scheduler_status_updates = scheduler_status.get_ref().clone();
let scheduler_control_updates = scheduler_control.get_ref().clone();
let session_rig_mgr_updates = session_rig_mgr.get_ref().clone();
let updates = WatchStream::new(rx).filter_map(move |state| {
let counter = counter_updates.clone();
let context = context_updates.clone();
let vchan = vchan_updates.clone();
let bookmark_store_map = bookmark_store_map_updates.clone();
let scheduler_status = scheduler_status_updates.clone();
let scheduler_control = scheduler_control_updates.clone();
let session_rig_mgr = session_rig_mgr_updates.clone();
async move {
state.snapshot().and_then(|v| {
let rig_id_opt = session_rig_mgr.get_rig(session_id).or_else(|| {
context
.routing
.active_rig_id
.lock()
.ok()
.and_then(|g| g.clone())
});
if let Some(ref rig_id) = rig_id_opt {
vchan.update_primary(rig_id, v.status.freq.hz, &format!("{:?}", v.status.mode));
sync_scheduler_vchannels(
vchan.as_ref(),
bookmark_store_map.as_ref(),
&scheduler_status,
scheduler_control.as_ref(),
rig_id,
);
}
let combined = SnapshotWithMeta {
snapshot: &v,
meta: frontend_meta_from_context(
counter.load(Ordering::Relaxed),
context.as_ref(),
rig_id_opt.as_deref(),
),
};
serde_json::to_string(&combined)
.ok()
.map(|json| Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n"))))
})
}
});
// Channel-list change events from the virtual channel manager.
let vchan_change_rx = vchan_mgr.change_tx.subscribe();
let session_rig_for_chan = active_rig_id.clone();
let chan_updates = futures_util::stream::unfold(
(vchan_change_rx, session_rig_for_chan),
|(mut rx, srig)| async move {
loop {
match rx.recv().await {
Ok(msg) => {
if let Some(colon) = msg.find(':') {
let rig_id = &msg[..colon];
if let Some(ref expected) = srig {
if rig_id != expected.as_str() {
continue;
}
}
let channels_json = &msg[colon + 1..];
let payload =
format!("{{\"remote\":\"{rig_id}\",\"channels\":{channels_json}}}");
return Some((
Ok::<Bytes, Error>(Bytes::from(format!(
"event: channels\ndata: {payload}\n\n"
))),
(rx, srig),
));
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return None,
}
}
},
);
// Send a named "ping" event so the JS heartbeat can observe it.
let pings = IntervalStream::new(time::interval(Duration::from_secs(5)))
.map(|_| Ok::<Bytes, Error>(Bytes::from("event: ping\ndata: \n\n")));
let vchan_drop = vchan_mgr.get_ref().clone();
let counter_drop = counter.clone();
let scheduler_control_drop = scheduler_control.get_ref().clone();
let session_rig_mgr_drop = session_rig_mgr.get_ref().clone();
let live = select(select(pings, updates), chan_updates);
let stream = prefix_stream.chain(live);
let stream = DropStream::new(Box::pin(stream), move || {
counter_drop.fetch_sub(1, Ordering::Relaxed);
vchan_drop.release_session(session_id);
scheduler_control_drop.unregister_session(session_id);
session_rig_mgr_drop.unregister(session_id);
});
Ok(HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
.insert_header((header::CONTENT_ENCODING, "identity"))
.insert_header((header::CACHE_CONTROL, "no-cache"))
.insert_header((header::CONNECTION, "keep-alive"))
.streaming(stream))
}
// ============================================================================
// /meter SSE endpoint (fast signal-strength stream, ~30 Hz)
// ============================================================================
fn encode_meter_frame(update: &MeterUpdate) -> String {
// Compact JSON: one-line SSE frame, flushed immediately.
// Shape: {"sig":-72.3,"ts":12345}
format!(
"data: {{\"sig\":{:.2},\"ts\":{}}}\n\n",
update.sig_dbm, update.ts_ms
)
}
/// SSE stream for per-rig signal-strength updates.
///
/// Pushed from the server's per-rig meter broadcast; intentionally bypasses
/// the `/events` RigState path so high-rate meter samples are never gated by
/// full-state diffing. Each watch update produces exactly one SSE frame.
#[get("/meter")]
pub async fn meter(
query: web::Query<RemoteQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
let rig_id = query.remote.clone().filter(|s| !s.is_empty()).or_else(|| {
context
.routing
.active_rig_id
.lock()
.ok()
.and_then(|g| g.clone())
});
let rx = match rig_id.as_deref() {
Some(rid) => context.rig_meter_rx(rid),
None => return Ok(HttpResponse::NotFound().finish()),
};
let updates = WatchStream::new(rx).filter_map(|maybe| {
let chunk = maybe.as_ref().map(encode_meter_frame);
std::future::ready(chunk.map(|s| Ok::<Bytes, Error>(Bytes::from(s))))
});
// Infrequent keepalive comment; real meter frames carry the heartbeat.
let pings = IntervalStream::new(time::interval(Duration::from_secs(15)))
.map(|_| Ok::<Bytes, Error>(Bytes::from(": ping\n\n")));
let stream = select(pings, updates);
Ok(HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
.insert_header((header::CONTENT_ENCODING, "identity"))
.insert_header((header::CACHE_CONTROL, "no-cache"))
.insert_header((header::CONNECTION, "keep-alive"))
.streaming(stream))
}
// ============================================================================
// /spectrum SSE endpoint
// ============================================================================
/// SSE stream for spectrum data.
#[get("/spectrum")]
pub async fn spectrum(
query: web::Query<RemoteQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
let rx = if let Some(ref remote) = query.remote {
context.rig_spectrum_rx(remote)
} else {
context.spectrum.sender.subscribe()
};
let mut last_rds_json: Option<String> = None;
let mut last_vchan_rds_json: Option<String> = None;
let mut last_had_frame = false;
let updates = WatchStream::new(rx).filter_map(move |snapshot| {
let sse_chunk: Option<String> = if let Some(ref frame) = snapshot.frame {
last_had_frame = true;
let mut chunk = format!("event: b\ndata: {}\n\n", encode_spectrum_frame(frame));
if snapshot.rds_json != last_rds_json {
let data = snapshot.rds_json.as_deref().unwrap_or("null");
chunk.push_str(&format!("event: rds\ndata: {data}\n\n"));
last_rds_json = snapshot.rds_json;
}
if snapshot.vchan_rds_json != last_vchan_rds_json {
let data = snapshot.vchan_rds_json.as_deref().unwrap_or("null");
chunk.push_str(&format!("event: rds_vchan\ndata: {data}\n\n"));
last_vchan_rds_json = snapshot.vchan_rds_json;
}
Some(chunk)
} else if last_had_frame {
last_had_frame = false;
Some("data: null\n\n".to_string())
} else {
None
};
std::future::ready(sse_chunk.map(|s| Ok::<Bytes, Error>(Bytes::from(s))))
});
let pings = IntervalStream::new(time::interval(Duration::from_secs(15)))
.map(|_| Ok::<Bytes, Error>(Bytes::from(": ping\n\n")));
let stream = select(pings, updates);
Ok(HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
.insert_header((header::CONTENT_ENCODING, "identity"))
.insert_header((header::CACHE_CONTROL, "no-cache"))
.insert_header((header::CONNECTION, "keep-alive"))
.streaming(stream))
}
@@ -0,0 +1,270 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Virtual channel management endpoints.
use std::sync::Arc;
use actix_web::Error;
use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
use tokio::sync::mpsc;
use uuid::Uuid;
use trx_core::radio::freq::Freq;
use trx_core::{RigCommand, RigRequest};
use trx_protocol::parse_mode;
use crate::server::vchan::ClientChannelManager;
use super::send_command_to_rig;
// ============================================================================
// Channel CRUD
// ============================================================================
#[get("/channels/{remote}")]
pub async fn list_channels(
path: web::Path<String>,
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
) -> impl Responder {
let remote = path.into_inner();
HttpResponse::Ok().json(vchan_mgr.channels(&remote))
}
#[derive(serde::Deserialize)]
struct AllocateChannelBody {
session_id: Uuid,
freq_hz: u64,
mode: String,
}
#[post("/channels/{remote}")]
pub async fn allocate_channel(
path: web::Path<String>,
body: web::Json<AllocateChannelBody>,
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
) -> impl Responder {
let remote = path.into_inner();
match vchan_mgr.allocate(body.session_id, &remote, body.freq_hz, &body.mode) {
Ok(ch) => HttpResponse::Ok().json(ch),
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
}
}
#[delete("/channels/{remote}/{channel_id}")]
pub async fn delete_channel_route(
path: web::Path<(String, Uuid)>,
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
) -> impl Responder {
let (remote, channel_id) = path.into_inner();
match vchan_mgr.delete_channel(&remote, channel_id) {
Ok(()) => HttpResponse::Ok().finish(),
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
Err(crate::server::vchan::VChanClientError::Permanent) => {
HttpResponse::BadRequest().body("cannot remove the primary channel")
}
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
}
}
#[derive(serde::Deserialize)]
struct SubscribeBody {
session_id: Uuid,
}
#[post("/channels/{remote}/{channel_id}/subscribe")]
pub async fn subscribe_channel(
path: web::Path<(String, Uuid)>,
body: web::Json<SubscribeBody>,
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
bookmark_store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
scheduler_control: web::Data<crate::server::scheduler::SharedSchedulerControlManager>,
) -> impl Responder {
let body = body.into_inner();
let (remote, channel_id) = path.into_inner();
match vchan_mgr.subscribe_session(body.session_id, &remote, channel_id) {
Some(ch) => {
scheduler_control.set_released(body.session_id, false);
let Some(selected) = vchan_mgr.selected_channel(&remote, channel_id) else {
return HttpResponse::InternalServerError().body("subscribed channel missing");
};
if let Err(err) = apply_selected_channel(
rig_tx.get_ref(),
&remote,
&selected,
bookmark_store_map.get_ref().as_ref(),
)
.await
{
return HttpResponse::from_error(err);
}
HttpResponse::Ok().json(ch)
}
None => HttpResponse::NotFound().finish(),
}
}
// ============================================================================
// Channel property updates
// ============================================================================
#[derive(serde::Deserialize)]
struct SetChanFreqBody {
freq_hz: u64,
}
#[put("/channels/{remote}/{channel_id}/freq")]
pub async fn set_vchan_freq(
path: web::Path<(String, Uuid)>,
body: web::Json<SetChanFreqBody>,
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
) -> impl Responder {
let (remote, channel_id) = path.into_inner();
match vchan_mgr.set_channel_freq(&remote, channel_id, body.freq_hz) {
Ok(()) => HttpResponse::Ok().finish(),
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
}
}
#[derive(serde::Deserialize)]
struct SetChanBwBody {
bandwidth_hz: u32,
}
#[put("/channels/{remote}/{channel_id}/bw")]
pub async fn set_vchan_bw(
path: web::Path<(String, Uuid)>,
body: web::Json<SetChanBwBody>,
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
) -> impl Responder {
let (remote, channel_id) = path.into_inner();
match vchan_mgr.set_channel_bandwidth(&remote, channel_id, body.bandwidth_hz) {
Ok(()) => HttpResponse::Ok().finish(),
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
}
}
#[derive(serde::Deserialize)]
struct SetChanModeBody {
mode: String,
}
#[put("/channels/{remote}/{channel_id}/mode")]
pub async fn set_vchan_mode(
path: web::Path<(String, Uuid)>,
body: web::Json<SetChanModeBody>,
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
) -> impl Responder {
let (remote, channel_id) = path.into_inner();
match vchan_mgr.set_channel_mode(&remote, channel_id, &body.mode) {
Ok(()) => HttpResponse::Ok().finish(),
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
}
}
// ============================================================================
// Helpers
// ============================================================================
fn bookmark_decoder_state(
bookmark: &crate::server::bookmarks::Bookmark,
) -> (bool, bool, bool, bool, bool, bool, bool, bool) {
let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT");
let mut want_hf_aprs = false;
let mut want_ft8 = false;
let mut want_ft4 = false;
let mut want_ft2 = false;
let mut want_wspr = false;
let mut want_lrpt = false;
let mut want_wefax = false;
for decoder in bookmark
.decoders
.iter()
.map(|item| item.trim().to_ascii_lowercase())
{
match decoder.as_str() {
"aprs" => want_aprs = true,
"hf-aprs" => want_hf_aprs = true,
"ft8" => want_ft8 = true,
"ft4" => want_ft4 = true,
"ft2" => want_ft2 = true,
"wspr" => want_wspr = true,
"lrpt" => want_lrpt = true,
"wefax" => want_wefax = true,
_ => {}
}
}
(
want_aprs,
want_hf_aprs,
want_ft8,
want_ft4,
want_ft2,
want_wspr,
want_lrpt,
want_wefax,
)
}
async fn apply_selected_channel(
rig_tx: &mpsc::Sender<RigRequest>,
remote: &str,
channel: &crate::server::vchan::SelectedChannel,
bookmark_store_map: &crate::server::bookmarks::BookmarkStoreMap,
) -> Result<(), Error> {
send_command_to_rig(
rig_tx,
remote,
RigCommand::SetMode(parse_mode(&channel.mode)),
)
.await?;
if channel.bandwidth_hz > 0 {
send_command_to_rig(
rig_tx,
remote,
RigCommand::SetBandwidth(channel.bandwidth_hz),
)
.await?;
}
send_command_to_rig(
rig_tx,
remote,
RigCommand::SetFreq(Freq {
hz: channel.freq_hz,
}),
)
.await?;
let Some(bookmark_id) = channel.scheduler_bookmark_id.as_deref() else {
return Ok(());
};
let Some(bookmark) = bookmark_store_map.get_for_rig(remote, bookmark_id) else {
return Ok(());
};
let (want_aprs, want_hf_aprs, want_ft8, want_ft4, want_ft2, want_wspr, want_lrpt, want_wefax) =
bookmark_decoder_state(&bookmark);
let desired = [
RigCommand::SetAprsDecodeEnabled(want_aprs),
RigCommand::SetHfAprsDecodeEnabled(want_hf_aprs),
RigCommand::SetFt8DecodeEnabled(want_ft8),
RigCommand::SetFt4DecodeEnabled(want_ft4),
RigCommand::SetFt2DecodeEnabled(want_ft2),
RigCommand::SetWsprDecodeEnabled(want_wspr),
RigCommand::SetLrptDecodeEnabled(want_lrpt),
RigCommand::SetWefaxDecodeEnabled(want_wefax),
];
for cmd in desired {
send_command_to_rig(rig_tx, remote, cmd).await?;
}
Ok(())
}
@@ -0,0 +1,856 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Audio WebSocket endpoint for the HTTP frontend.
//!
//! Exposes `/audio` which upgrades to a WebSocket:
//! - First text message: JSON `AudioStreamInfo`
//! - Subsequent binary messages: raw Opus packets (RX)
//! - Browser sends binary messages: raw Opus packets (TX)
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use actix_web::{get, web, Error, HttpRequest, HttpResponse};
use actix_ws::Message;
use base64::Engine as _;
use bytes::Bytes;
use serde::Deserialize;
use tokio::sync::broadcast;
use tracing::warn;
use uuid::Uuid;
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
WsprMessage,
};
use trx_frontend::FrontendRuntimeContext;
fn current_timestamp_ms() -> i64 {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
i64::try_from(millis).unwrap_or(i64::MAX)
}
fn decode_history_retention(context: &FrontendRuntimeContext) -> Duration {
let default_minutes = context.http_ui.decode_history_retention_min.max(1);
let minutes = context
.routing
.active_rig_id
.lock()
.ok()
.and_then(|v| v.clone())
.and_then(|rig_id| {
context
.http_ui
.decode_history_retention_min_by_rig
.get(&rig_id)
.copied()
})
.filter(|minutes| *minutes > 0)
.unwrap_or(default_minutes);
Duration::from_secs(minutes.saturating_mul(60))
}
fn decode_history_cutoff(context: &FrontendRuntimeContext) -> Instant {
Instant::now() - decode_history_retention(context)
}
fn prune_aprs_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, Option<String>, AprsPacket)>,
) {
let cutoff = decode_history_cutoff(context);
while let Some((ts, _, _)) = history.front() {
if *ts >= cutoff {
break;
}
history.pop_front();
}
}
fn prune_hf_aprs_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, Option<String>, AprsPacket)>,
) {
let cutoff = decode_history_cutoff(context);
while let Some((ts, _, _)) = history.front() {
if *ts >= cutoff {
break;
}
history.pop_front();
}
}
fn prune_ais_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, Option<String>, AisMessage)>,
) {
let cutoff = decode_history_cutoff(context);
while let Some((ts, _, _)) = history.front() {
if *ts >= cutoff {
break;
}
history.pop_front();
}
}
fn prune_vdes_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, Option<String>, VdesMessage)>,
) {
let cutoff = decode_history_cutoff(context);
while let Some((ts, _, _)) = history.front() {
if *ts >= cutoff {
break;
}
history.pop_front();
}
}
fn active_rig_id(context: &FrontendRuntimeContext) -> Option<String> {
context
.routing
.active_rig_id
.lock()
.ok()
.and_then(|g| g.clone())
}
fn record_ais(context: &FrontendRuntimeContext, mut msg: AisMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.decode_history
.ais
.lock()
.expect("ais history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
prune_ais_history(context, &mut history);
}
fn record_vdes(context: &FrontendRuntimeContext, mut msg: VdesMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.decode_history
.vdes
.lock()
.expect("vdes history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
prune_vdes_history(context, &mut history);
}
fn prune_cw_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, Option<String>, CwEvent)>,
) {
let cutoff = decode_history_cutoff(context);
while let Some((ts, _, _)) = history.front() {
if *ts >= cutoff {
break;
}
history.pop_front();
}
}
fn prune_ft8_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, Option<String>, Ft8Message)>,
) {
let cutoff = decode_history_cutoff(context);
while let Some((ts, _, _)) = history.front() {
if *ts >= cutoff {
break;
}
history.pop_front();
}
}
fn prune_ft4_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, Option<String>, Ft8Message)>,
) {
let cutoff = decode_history_cutoff(context);
while let Some((ts, _, _)) = history.front() {
if *ts >= cutoff {
break;
}
history.pop_front();
}
}
fn prune_ft2_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, Option<String>, Ft8Message)>,
) {
let cutoff = decode_history_cutoff(context);
while let Some((ts, _, _)) = history.front() {
if *ts >= cutoff {
break;
}
history.pop_front();
}
}
fn prune_wspr_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, Option<String>, WsprMessage)>,
) {
let cutoff = decode_history_cutoff(context);
while let Some((ts, _, _)) = history.front() {
if *ts >= cutoff {
break;
}
history.pop_front();
}
}
fn record_aprs(context: &FrontendRuntimeContext, mut pkt: AprsPacket) {
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let rig_id = pkt.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.decode_history
.aprs
.lock()
.expect("aprs history mutex poisoned");
history.push_back((Instant::now(), rig_id, pkt));
prune_aprs_history(context, &mut history);
}
fn record_hf_aprs(context: &FrontendRuntimeContext, mut pkt: AprsPacket) {
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let rig_id = pkt.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.decode_history
.hf_aprs
.lock()
.expect("hf_aprs history mutex poisoned");
history.push_back((Instant::now(), rig_id, pkt));
prune_hf_aprs_history(context, &mut history);
}
fn record_cw(context: &FrontendRuntimeContext, event: CwEvent) {
let rig_id = event.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.decode_history
.cw
.lock()
.expect("cw history mutex poisoned");
history.push_back((Instant::now(), rig_id, event));
prune_cw_history(context, &mut history);
}
fn record_ft8(context: &FrontendRuntimeContext, msg: Ft8Message) {
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.decode_history
.ft8
.lock()
.expect("ft8 history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
prune_ft8_history(context, &mut history);
}
fn record_ft4(context: &FrontendRuntimeContext, msg: Ft8Message) {
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.decode_history
.ft4
.lock()
.expect("ft4 history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
prune_ft4_history(context, &mut history);
}
fn record_ft2(context: &FrontendRuntimeContext, msg: Ft8Message) {
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.decode_history
.ft2
.lock()
.expect("ft2 history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
prune_ft2_history(context, &mut history);
}
fn record_wspr(context: &FrontendRuntimeContext, msg: WsprMessage) {
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.decode_history
.wspr
.lock()
.expect("wspr history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
prune_wspr_history(context, &mut history);
}
fn record_wefax(context: &FrontendRuntimeContext, mut msg: WefaxMessage) {
// If the server sent PNG data, save it to the local cache so the
// `/images/` endpoint can serve it.
if let Some(ref data) = msg.png_data {
if let Some(ref path) = msg.path {
if let Some(filename) = std::path::Path::new(path).file_name() {
let dir = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("wefax");
if std::fs::create_dir_all(&dir).is_ok() {
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
let local_path = dir.join(filename);
if let Err(e) = std::fs::write(&local_path, &bytes) {
tracing::warn!("WEFAX: failed to save local image: {}", e);
}
}
}
}
}
}
// Strip bulk data before storing in memory.
msg.png_data = None;
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.decode_history
.wefax
.lock()
.expect("wefax history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
// Wefax images are large; keep a small history.
while history.len() > 100 {
history.pop_front();
}
}
/// Returns `true` if the entry's rig_id matches the optional filter.
/// `None` filter means "all rigs".
fn matches_rig_filter(entry_rig: Option<&str>, filter: Option<&str>) -> bool {
match filter {
None => true,
Some(f) => entry_rig == Some(f),
}
}
pub fn snapshot_aprs_history(
context: &FrontendRuntimeContext,
rig_filter: Option<&str>,
) -> Vec<AprsPacket> {
let mut history = context
.decode_history
.aprs
.lock()
.expect("aprs history mutex poisoned");
prune_aprs_history(context, &mut history);
history
.iter()
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
.map(|(_, _, pkt)| pkt.clone())
.collect()
}
pub fn snapshot_hf_aprs_history(
context: &FrontendRuntimeContext,
rig_filter: Option<&str>,
) -> Vec<AprsPacket> {
let mut history = context
.decode_history
.hf_aprs
.lock()
.expect("hf_aprs history mutex poisoned");
prune_hf_aprs_history(context, &mut history);
history
.iter()
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
.map(|(_, _, pkt)| pkt.clone())
.collect()
}
/// Return the latest message per MMSI seen within the retention window.
///
/// AIS vessels transmit every 230 s; returning every individual message would
/// produce a response too large to be useful. One entry per vessel matches
/// what the map shows (current position/state) and keeps the response compact.
/// The returned vec is sorted ascending by `ts_ms` so the client can replay
/// in chronological order.
pub fn snapshot_ais_history(
context: &FrontendRuntimeContext,
rig_filter: Option<&str>,
) -> Vec<AisMessage> {
let mut history = context
.decode_history
.ais
.lock()
.expect("ais history mutex poisoned");
prune_ais_history(context, &mut history);
// Iterate oldest-first; later entries overwrite earlier ones so the
// HashMap always holds the newest message per MMSI.
let mut latest: HashMap<u32, AisMessage> = HashMap::new();
for (_, rid, msg) in history.iter() {
if matches_rig_filter(rid.as_deref(), rig_filter) {
latest.insert(msg.mmsi, msg.clone());
}
}
let mut out: Vec<AisMessage> = latest.into_values().collect();
out.sort_by_key(|m| m.ts_ms.unwrap_or(0));
out
}
pub fn snapshot_vdes_history(
context: &FrontendRuntimeContext,
rig_filter: Option<&str>,
) -> Vec<VdesMessage> {
let mut history = context
.decode_history
.vdes
.lock()
.expect("vdes history mutex poisoned");
prune_vdes_history(context, &mut history);
history
.iter()
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
.map(|(_, _, msg)| msg.clone())
.collect()
}
pub fn snapshot_cw_history(
context: &FrontendRuntimeContext,
rig_filter: Option<&str>,
) -> Vec<CwEvent> {
let mut history = context
.decode_history
.cw
.lock()
.expect("cw history mutex poisoned");
prune_cw_history(context, &mut history);
history
.iter()
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
.map(|(_, _, evt)| evt.clone())
.collect()
}
pub fn snapshot_ft8_history(
context: &FrontendRuntimeContext,
rig_filter: Option<&str>,
) -> Vec<Ft8Message> {
let mut history = context
.decode_history
.ft8
.lock()
.expect("ft8 history mutex poisoned");
prune_ft8_history(context, &mut history);
history
.iter()
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
.map(|(_, _, msg)| msg.clone())
.collect()
}
pub fn snapshot_ft4_history(
context: &FrontendRuntimeContext,
rig_filter: Option<&str>,
) -> Vec<Ft8Message> {
let mut history = context
.decode_history
.ft4
.lock()
.expect("ft4 history mutex poisoned");
prune_ft4_history(context, &mut history);
history
.iter()
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
.map(|(_, _, msg)| msg.clone())
.collect()
}
pub fn snapshot_ft2_history(
context: &FrontendRuntimeContext,
rig_filter: Option<&str>,
) -> Vec<Ft8Message> {
let mut history = context
.decode_history
.ft2
.lock()
.expect("ft2 history mutex poisoned");
prune_ft2_history(context, &mut history);
history
.iter()
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
.map(|(_, _, msg)| msg.clone())
.collect()
}
pub fn snapshot_wspr_history(
context: &FrontendRuntimeContext,
rig_filter: Option<&str>,
) -> Vec<WsprMessage> {
let mut history = context
.decode_history
.wspr
.lock()
.expect("wspr history mutex poisoned");
prune_wspr_history(context, &mut history);
history
.iter()
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
.map(|(_, _, msg)| msg.clone())
.collect()
}
pub fn snapshot_wefax_history(
context: &FrontendRuntimeContext,
rig_filter: Option<&str>,
) -> Vec<WefaxMessage> {
let history = context
.decode_history
.wefax
.lock()
.expect("wefax history mutex poisoned");
history
.iter()
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
.map(|(_, _, msg)| msg.clone())
.collect()
}
pub fn clear_wefax_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
.wefax
.lock()
.expect("wefax history mutex poisoned");
history.clear();
}
pub fn clear_aprs_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
.aprs
.lock()
.expect("aprs history mutex poisoned");
history.clear();
}
pub fn clear_hf_aprs_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
.hf_aprs
.lock()
.expect("hf_aprs history mutex poisoned");
history.clear();
}
pub fn clear_ais_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
.ais
.lock()
.expect("ais history mutex poisoned");
history.clear();
}
pub fn clear_vdes_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
.vdes
.lock()
.expect("vdes history mutex poisoned");
history.clear();
}
pub fn clear_cw_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
.cw
.lock()
.expect("cw history mutex poisoned");
history.clear();
}
pub fn clear_ft8_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
.ft8
.lock()
.expect("ft8 history mutex poisoned");
history.clear();
}
pub fn clear_ft4_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
.ft4
.lock()
.expect("ft4 history mutex poisoned");
history.clear();
}
pub fn clear_ft2_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
.ft2
.lock()
.expect("ft2 history mutex poisoned");
history.clear();
}
pub fn clear_wspr_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
.wspr
.lock()
.expect("wspr history mutex poisoned");
history.clear();
}
pub fn subscribe_decode(
context: &FrontendRuntimeContext,
) -> Option<broadcast::Receiver<DecodedMessage>> {
context.audio.decode_rx.as_ref().map(|tx| tx.subscribe())
}
pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
if context
.decode_collector_started
.swap(true, Ordering::AcqRel)
{
return;
}
let Some(tx) = context.audio.decode_rx.as_ref().cloned() else {
return;
};
tokio::spawn(async move {
let mut rx = tx.subscribe();
loop {
match rx.recv().await {
Ok(msg) => match msg {
DecodedMessage::Ais(msg) => record_ais(&context, msg),
DecodedMessage::Vdes(msg) => record_vdes(&context, msg),
DecodedMessage::Aprs(pkt) => record_aprs(&context, pkt),
DecodedMessage::HfAprs(pkt) => record_hf_aprs(&context, pkt),
DecodedMessage::Cw(evt) => record_cw(&context, evt),
DecodedMessage::Ft8(msg) => record_ft8(&context, msg),
DecodedMessage::Ft4(msg) => record_ft4(&context, msg),
DecodedMessage::Ft2(msg) => record_ft2(&context, msg),
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
DecodedMessage::Wefax(msg) => record_wefax(&context, msg),
DecodedMessage::WefaxProgress(_) => {}
DecodedMessage::LrptImage(_) => {}
DecodedMessage::LrptProgress(_) => {}
},
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
}
#[derive(Deserialize)]
pub struct AudioQuery {
pub channel_id: Option<Uuid>,
pub remote: Option<String>,
}
#[get("/audio")]
pub async fn audio_ws(
req: HttpRequest,
body: web::Payload,
query: web::Query<AudioQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
let Some(tx_sender) = context.audio.tx.as_ref().cloned() else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
};
// Plain GET probe (no WebSocket upgrade) - return 204 to signal audio is available.
if !req.headers().contains_key("upgrade") {
return Ok(HttpResponse::NoContent().finish());
}
// If a channel_id is specified, subscribe to the per-channel broadcaster.
// The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives
// from the server, which may lag the HTTP allocation by up to ~100 ms.
// Poll for up to 2 s so a tight JS timer doesn't race and get a 404.
let (rx_sub, mut info_rx): (
broadcast::Receiver<Bytes>,
tokio::sync::watch::Receiver<Option<trx_core::audio::AudioStreamInfo>>,
) = if let Some(ch_id) = query.channel_id {
let info_rx = if let Some(ref remote) = query.remote {
context.rig_audio_info_rx(remote)
} else {
context.audio.info.as_ref().cloned()
};
let Some(info_rx) = info_rx else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
};
let deadline = Instant::now() + Duration::from_secs(2);
let rx_sub = loop {
match context.vchan.audio.read() {
Ok(map) => {
if let Some(tx) = map.get(&ch_id) {
break tx.subscribe();
}
}
Err(_) => return Ok(HttpResponse::InternalServerError().finish()),
}
if Instant::now() >= deadline {
return Ok(HttpResponse::NotFound().body("channel not found"));
}
tokio::time::sleep(Duration::from_millis(50)).await;
};
(rx_sub, info_rx)
} else if let Some(ref remote) = query.remote {
// Per-rig audio: subscribe to the specific rig's broadcast.
// Do NOT fall back to global — that would silently deliver the wrong
// rig's audio. Wait briefly for the per-rig channel to appear (it is
// lazily created by the audio relay sync task every 500ms).
let deadline = Instant::now() + Duration::from_secs(3);
let (rx_sub, info_rx) = loop {
if let (Some(rx), Some(info_rx)) = (
context.rig_audio_subscribe(remote),
context.rig_audio_info_rx(remote),
) {
break (rx, info_rx);
}
if Instant::now() >= deadline {
return Ok(
HttpResponse::NotFound().body(format!("audio not available for rig {remote}"))
);
}
tokio::time::sleep(Duration::from_millis(100)).await;
};
(rx_sub, info_rx)
} else {
let Some(info_rx) = context.audio.info.as_ref().cloned() else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
};
let Some(rx) = context.audio.rx.as_ref() else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
};
(rx.subscribe(), info_rx)
};
let mut rx_sub = rx_sub;
let (response, mut session, mut msg_stream) = actix_ws::handle(&req, body)?;
let audio_clients = context.audio.clients.clone();
audio_clients.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
actix_web::rt::spawn(async move {
let mut current_info = loop {
if let Some(info) = info_rx.borrow().clone() {
break info;
}
if info_rx.changed().await.is_err() {
let _ = session.close(None).await;
return;
}
};
let info_json = match serde_json::to_string(&current_info) {
Ok(j) => j,
Err(_) => {
let _ = session.close(None).await;
return;
}
};
if session.text(info_json).await.is_err() {
return;
}
loop {
tokio::select! {
changed = info_rx.changed() => {
match changed {
Ok(()) => {
let Some(next_info) = info_rx.borrow().clone() else {
continue;
};
let changed = next_info.sample_rate != current_info.sample_rate
|| next_info.channels != current_info.channels
|| next_info.frame_duration_ms != current_info.frame_duration_ms
|| next_info.bitrate_bps != current_info.bitrate_bps;
if changed {
current_info = next_info;
let info_json = match serde_json::to_string(&current_info) {
Ok(j) => j,
Err(_) => break,
};
if session.text(info_json).await.is_err() {
break;
}
}
}
Err(_) => break,
}
}
packet = rx_sub.recv() => {
match packet {
Ok(packet) => {
if session.binary(packet).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("Audio WS: dropped {} RX frames", n);
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
msg = msg_stream.recv() => {
match msg {
Some(Ok(Message::Binary(data))) => {
let _ = tx_sender.send(Bytes::from(data.to_vec())).await;
}
Some(Ok(Message::Close(_))) => break,
Some(Ok(_)) => {}
Some(Err(_)) | None => break,
}
}
}
}
let _ = session.close(None).await;
audio_clients.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
});
Ok(response)
}
#[cfg(test)]
mod tests {
use super::AudioQuery;
use uuid::Uuid;
#[test]
fn audio_query_accepts_remote() {
let query: AudioQuery =
serde_json::from_str(r#"{"remote":"lidzbark-vhf"}"#).expect("query parse");
assert_eq!(query.remote.as_deref(), Some("lidzbark-vhf"));
}
#[test]
fn audio_query_accepts_channel_id_with_remote() {
let channel_id = Uuid::new_v4();
let query: AudioQuery = serde_json::from_str(&format!(
r#"{{"channel_id":"{channel_id}","remote":"lidzbark-vhf"}}"#
))
.expect("query parse");
assert_eq!(query.channel_id, Some(channel_id));
assert_eq!(query.remote.as_deref(), Some("lidzbark-vhf"));
}
}
@@ -0,0 +1,831 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! HTTP authentication module for trx-frontend-http.
//!
//! Provides optional session-based authentication with two roles:
//! - `Rx`: read-only access to status/events/audio
//! - `Control`: full access including TX/PTT control
use actix_web::{
cookie::Cookie,
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
get, post, web, Error, HttpRequest, HttpResponse, Responder,
};
use futures_util::future::LocalBoxFuture;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant, SystemTime};
use tracing::warn;
/// Unique session identifier (hex-encoded 128-bit random)
pub type SessionId = String;
/// Authentication role
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthRole {
/// Read-only access (rx passphrase)
Rx,
/// Full control access (control passphrase)
Control,
}
impl AuthRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::Rx => "rx",
Self::Control => "control",
}
}
}
/// Session record stored in the session store
#[derive(Debug, Clone)]
pub struct SessionRecord {
pub role: AuthRole,
pub issued_at: SystemTime,
pub expires_at: SystemTime,
pub last_seen: SystemTime,
}
impl SessionRecord {
pub fn is_expired(&self) -> bool {
SystemTime::now() > self.expires_at
}
pub fn update_last_seen(&mut self) {
self.last_seen = SystemTime::now();
}
}
/// Thread-safe in-memory session store
#[derive(Clone)]
pub struct SessionStore {
sessions: Arc<RwLock<HashMap<SessionId, SessionRecord>>>,
}
impl SessionStore {
pub fn new() -> Self {
Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Create a new session with the given role and TTL
pub fn create(&self, role: AuthRole, ttl: Duration) -> SessionId {
let now = SystemTime::now();
let expires_at = now + ttl;
let session_id = Self::generate_session_id();
let record = SessionRecord {
role,
issued_at: now,
expires_at,
last_seen: now,
};
let mut store = self.sessions.write().unwrap_or_else(|e| {
warn!("Session store lock poisoned (create), recovering");
e.into_inner()
});
store.insert(session_id.clone(), record);
session_id
}
/// Get session by ID (returns None if expired or not found)
pub fn get(&self, session_id: &SessionId) -> Option<SessionRecord> {
let mut store = self.sessions.write().unwrap_or_else(|e| {
warn!("Session store lock poisoned (get), recovering");
e.into_inner()
});
if let Some(record) = store.get_mut(session_id) {
if !record.is_expired() {
record.update_last_seen();
return Some(record.clone());
} else {
store.remove(session_id);
}
}
None
}
/// Invalidate a session
pub fn remove(&self, session_id: &SessionId) {
let mut store = self.sessions.write().unwrap_or_else(|e| {
warn!("Session store lock poisoned (remove), recovering");
e.into_inner()
});
store.remove(session_id);
}
/// Remove all expired sessions
pub fn cleanup_expired(&self) {
let mut store = self.sessions.write().unwrap_or_else(|e| {
warn!("Session store lock poisoned (cleanup), recovering");
e.into_inner()
});
let now = SystemTime::now();
store.retain(|_, record| record.expires_at > now);
}
/// Generate a new random session ID (128-bit, hex-encoded)
fn generate_session_id() -> SessionId {
let random_bytes = rand::random::<[u8; 16]>();
hex::encode(random_bytes)
}
}
impl Default for SessionStore {
fn default() -> Self {
Self::new()
}
}
/// Cookie SameSite attribute
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SameSite {
Strict,
#[default]
Lax,
None,
}
impl SameSite {
pub fn as_str(&self) -> &'static str {
match self {
Self::Strict => "Strict",
Self::Lax => "Lax",
Self::None => "None",
}
}
}
/// Runtime authentication configuration
#[derive(Debug, Clone)]
pub struct AuthConfig {
pub enabled: bool,
pub rx_passphrase: Option<String>,
pub control_passphrase: Option<String>,
pub tx_access_control_enabled: bool,
pub session_ttl: Duration,
pub cookie_secure: bool,
pub cookie_same_site: SameSite,
}
impl AuthConfig {
/// Create a new auth config with all fields
pub fn new(
enabled: bool,
rx_passphrase: Option<String>,
control_passphrase: Option<String>,
tx_access_control_enabled: bool,
session_ttl: Duration,
cookie_secure: bool,
cookie_same_site: SameSite,
) -> Self {
Self {
enabled,
rx_passphrase,
control_passphrase,
tx_access_control_enabled,
session_ttl,
cookie_secure,
cookie_same_site,
}
}
/// Check passphrase and return the corresponding role
pub fn check_passphrase(&self, passphrase: &str) -> Option<AuthRole> {
// Use constant-time comparison to reduce timing attacks
if let Some(ctrl_pass) = &self.control_passphrase {
if constant_time_eq(passphrase, ctrl_pass) {
return Some(AuthRole::Control);
}
}
if let Some(rx_pass) = &self.rx_passphrase {
if constant_time_eq(passphrase, rx_pass) {
return Some(AuthRole::Rx);
}
}
None
}
}
/// Simple per-IP rate limiter for login attempts.
///
/// Tracks failed attempts per IP and enforces a cooldown window after
/// exceeding the maximum number of attempts.
pub struct LoginRateLimiter {
/// Maps IP → (attempt_count, window_start).
attempts: Mutex<HashMap<String, (u32, Instant)>>,
/// Maximum allowed attempts within the window.
max_attempts: u32,
/// Duration of the rate-limit window.
window: Duration,
}
impl LoginRateLimiter {
pub fn new(max_attempts: u32, window: Duration) -> Self {
Self {
attempts: Mutex::new(HashMap::new()),
max_attempts,
window,
}
}
/// Check whether an IP is rate-limited. Returns `true` if the request
/// should be allowed, `false` if rate-limited.
pub fn check(&self, ip: &str) -> bool {
let mut map = self.attempts.lock().unwrap_or_else(|e| {
warn!("Rate limiter lock poisoned (check), recovering");
e.into_inner()
});
let now = Instant::now();
if let Some((count, window_start)) = map.get_mut(ip) {
if now.duration_since(*window_start) > self.window {
// Window expired, reset.
*count = 1;
*window_start = now;
true
} else if *count >= self.max_attempts {
false
} else {
*count += 1;
true
}
} else {
map.insert(ip.to_string(), (1, now));
true
}
}
/// Record a successful login — clears the rate-limit counter for the IP.
pub fn reset(&self, ip: &str) {
let mut map = self.attempts.lock().unwrap_or_else(|e| {
warn!("Rate limiter lock poisoned (reset), recovering");
e.into_inner()
});
map.remove(ip);
}
}
impl Default for LoginRateLimiter {
fn default() -> Self {
// 10 attempts per 60-second window.
Self::new(10, Duration::from_secs(60))
}
}
/// Application data for authentication
pub struct AuthState {
pub config: AuthConfig,
pub store: SessionStore,
pub rate_limiter: LoginRateLimiter,
}
impl AuthState {
pub fn new(config: AuthConfig) -> Self {
Self {
config,
store: SessionStore::new(),
rate_limiter: LoginRateLimiter::default(),
}
}
}
/// Constant-time string comparison to mitigate timing attacks
fn constant_time_eq(a: &str, b: &str) -> bool {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
if a_bytes.len() != b_bytes.len() {
return false;
}
let mut result = 0u8;
for (x, y) in a_bytes.iter().zip(b_bytes.iter()) {
result |= x ^ y;
}
result == 0
}
/// Login request body
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub passphrase: String,
}
/// Session status response
#[derive(Debug, Serialize)]
pub struct SessionStatus {
pub authenticated: bool,
pub role: Option<String>,
}
/// Login response
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub authenticated: bool,
pub role: String,
}
/// Extract session from cookie
fn extract_session_id(req: &HttpRequest) -> Option<SessionId> {
req.cookie("trx_http_sid")
.map(|cookie| cookie.value().to_string())
}
/// Get session from request, return role if valid
pub fn get_session_role(req: &HttpRequest, auth_state: &AuthState) -> Option<AuthRole> {
let session_id = extract_session_id(req)?;
let record = auth_state.store.get(&session_id)?;
Some(record.role)
}
// ============================================================================
// Endpoints
// ============================================================================
/// POST /auth/login
#[post("/auth/login")]
pub async fn login(
req: HttpRequest,
body: web::Json<LoginRequest>,
auth_state: web::Data<AuthState>,
) -> Result<impl Responder, Error> {
if !auth_state.config.enabled {
return Ok(HttpResponse::NotFound().finish());
}
// Per-IP rate limiting to mitigate brute-force attacks.
let peer_ip = req
.peer_addr()
.map(|a| a.ip().to_string())
.unwrap_or_default();
if !auth_state.rate_limiter.check(&peer_ip) {
return Ok(HttpResponse::TooManyRequests().json(serde_json::json!({
"error": "Too many login attempts, please try again later"
})));
}
// Check passphrase
let role = match auth_state.config.check_passphrase(&body.passphrase) {
Some(r) => r,
None => {
return Ok(HttpResponse::Unauthorized().json(serde_json::json!({
"error": "Invalid credentials"
})));
}
};
// Successful login — clear rate limit counter.
auth_state.rate_limiter.reset(&peer_ip);
// Create session
let session_id = auth_state.store.create(role, auth_state.config.session_ttl);
let mut cookie = Cookie::new("trx_http_sid", session_id);
cookie.set_path("/");
cookie.set_http_only(true);
cookie.set_secure(auth_state.config.cookie_secure);
// Set SameSite attribute
match auth_state.config.cookie_same_site {
SameSite::Strict => cookie.set_same_site(actix_web::cookie::SameSite::Strict),
SameSite::Lax => cookie.set_same_site(actix_web::cookie::SameSite::Lax),
SameSite::None => cookie.set_same_site(actix_web::cookie::SameSite::None),
};
// Convert Duration to cookie time::Duration
let ttl_secs = auth_state.config.session_ttl.as_secs() as i64;
cookie.set_max_age(actix_web::cookie::time::Duration::seconds(ttl_secs));
Ok(HttpResponse::Ok().cookie(cookie).json(LoginResponse {
authenticated: true,
role: role.as_str().to_string(),
}))
}
/// POST /auth/logout
#[post("/auth/logout")]
pub async fn logout(
req: HttpRequest,
auth_state: web::Data<AuthState>,
) -> Result<impl Responder, Error> {
if !auth_state.config.enabled {
return Ok(HttpResponse::NotFound().finish());
}
// Invalidate session
if let Some(session_id) = extract_session_id(&req) {
auth_state.store.remove(&session_id);
}
// Clear cookie by setting max_age to 0
let mut cookie = Cookie::new("trx_http_sid", "");
cookie.set_path("/");
cookie.set_http_only(true);
cookie.set_max_age(actix_web::cookie::time::Duration::seconds(0));
Ok(HttpResponse::Ok().cookie(cookie).json(serde_json::json!({
"logged_out": true
})))
}
/// GET /auth/session
#[get("/auth/session")]
pub async fn session_status(
req: HttpRequest,
auth_state: web::Data<AuthState>,
) -> Result<impl Responder, Error> {
// If auth is disabled, grant full control access without requiring login
if !auth_state.config.enabled {
return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: true,
role: Some("control".to_string()),
}));
}
let session_id = extract_session_id(&req);
if let Some(session_record) = session_id.and_then(|sid| auth_state.store.get(&sid)) {
// User has valid session
return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: true,
role: Some(session_record.role.as_str().to_string()),
}));
}
// No session - check if rx access is unrestricted
if auth_state.config.rx_passphrase.is_none() {
// No rx passphrase required - grant rx role to unauthenticated users
return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: false,
role: Some("rx".to_string()),
}));
}
// Auth required but no valid session
Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: false,
role: None,
}))
}
// ============================================================================
// Middleware
// ============================================================================
/// Route classification for access control
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RouteAccess {
/// Publicly accessible (no auth required)
Public,
/// Read-only (rx or control role required)
Read,
/// Control only (control role required)
Control,
}
impl RouteAccess {
/// Classify a request path
fn from_path(path: &str) -> Self {
// Public routes
if path == "/"
|| path == "/index.html"
|| path == "/map"
|| path == "/digital-modes"
|| path == "/settings"
|| path == "/about"
|| path.starts_with("/auth/")
{
return Self::Public;
}
// Static assets
if path.starts_with("/style.css")
|| path.starts_with("/app.js")
|| path.ends_with(".js")
|| path.ends_with(".css")
|| path.ends_with(".png")
|| path.ends_with(".jpg")
|| path.ends_with(".gif")
|| path.ends_with(".svg")
|| path.ends_with(".favicon")
|| path.ends_with(".ico")
{
return Self::Public;
}
// Read-only routes
if path == "/status"
|| path == "/rigs"
|| path == "/events"
|| path == "/decode"
|| path == "/decode/history"
|| path == "/spectrum"
|| path == "/meter"
|| path == "/audio"
|| path == "/bookmarks"
|| path.starts_with("/status?")
|| path.starts_with("/rigs?")
|| path.starts_with("/events?")
|| path.starts_with("/decode?")
|| path.starts_with("/decode/history?")
|| path.starts_with("/spectrum?")
|| path.starts_with("/meter?")
|| path.starts_with("/audio?")
|| path.starts_with("/bookmarks?")
|| path.starts_with("/bookmarks/")
|| path.starts_with("/scheduler/")
|| path.starts_with("/scheduler-control")
|| path.starts_with("/channels/")
{
return Self::Read;
}
// All other routes require control
Self::Control
}
fn allows(&self, role: Option<AuthRole>) -> bool {
match self {
Self::Public => true,
Self::Read => role.is_some(),
Self::Control => matches!(role, Some(AuthRole::Control)),
}
}
}
/// Authentication middleware
pub struct AuthMiddleware;
impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = AuthMiddlewareService<S>;
type Future = std::future::Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
std::future::ready(Ok(AuthMiddlewareService { service }))
}
}
pub struct AuthMiddlewareService<S> {
service: S,
}
impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let path = req.path().to_string();
let access = RouteAccess::from_path(&path);
// If route is public, allow unconditionally
if access == RouteAccess::Public {
let fut = self.service.call(req);
return Box::pin(async move {
let res = fut.await?;
Ok(res)
});
}
// For protected routes, check auth
let auth_state = req.app_data::<web::Data<AuthState>>().cloned();
if let Some(auth_state) = auth_state {
if !auth_state.config.enabled {
// Auth disabled - allow all
let fut = self.service.call(req);
return Box::pin(async move {
let res = fut.await?;
Ok(res)
});
}
// Auth enabled - check role
let role = get_session_role(req.request(), &auth_state);
// If rx_passphrase is not set, allow unauthenticated read access
let allow_unrestricted_read = auth_state.config.rx_passphrase.is_none();
let is_read_route = access == RouteAccess::Read;
if is_read_route && allow_unrestricted_read {
// No rx authentication required - allow read access without role
let fut = self.service.call(req);
return Box::pin(async move {
let res = fut.await?;
Ok(res)
});
}
if !access.allows(role) {
// Access denied
return Box::pin(async move {
if role.is_some() {
// Has session but insufficient permissions - 403 Forbidden
Err(actix_web::error::ErrorForbidden(
"Insufficient permissions".to_string(),
))
} else if allow_unrestricted_read {
// No session but rx access is unrestricted - 403 Forbidden
// (user has implicit rx role from unrestricted access)
Err(actix_web::error::ErrorForbidden(
"Insufficient permissions".to_string(),
))
} else {
// No session and no unrestricted access - 401 Unauthorized
Err(actix_web::error::ErrorUnauthorized(
"Authentication required".to_string(),
))
}
});
}
}
let fut = self.service.call(req);
Box::pin(async move {
let res = fut.await?;
Ok(res)
})
}
}
/// Check if a path is a TX/PTT endpoint (used for TX access control).
pub fn is_tx_endpoint(path: &str) -> bool {
path.contains("ptt")
|| path.contains("set_ptt")
|| path.contains("toggle_ptt")
|| path.contains("set_tx")
|| path.contains("toggle_tx")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_route_access_public_paths() {
assert_eq!(RouteAccess::from_path("/"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/map"), RouteAccess::Public);
assert_eq!(
RouteAccess::from_path("/digital-modes"),
RouteAccess::Public
);
assert_eq!(RouteAccess::from_path("/settings"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/about"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/auth/login"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/style.css"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/app.js"), RouteAccess::Public);
}
#[test]
fn test_route_access_read_paths() {
assert_eq!(RouteAccess::from_path("/status"), RouteAccess::Read);
assert_eq!(RouteAccess::from_path("/rigs"), RouteAccess::Read);
assert_eq!(RouteAccess::from_path("/events"), RouteAccess::Read);
assert_eq!(RouteAccess::from_path("/decode"), RouteAccess::Read);
assert_eq!(RouteAccess::from_path("/spectrum"), RouteAccess::Read);
assert_eq!(RouteAccess::from_path("/meter"), RouteAccess::Read);
assert_eq!(RouteAccess::from_path("/audio"), RouteAccess::Read);
}
#[test]
fn test_route_access_control_paths() {
assert_eq!(RouteAccess::from_path("/set_freq"), RouteAccess::Control);
assert_eq!(
RouteAccess::from_path("/set_center_freq"),
RouteAccess::Control
);
assert_eq!(RouteAccess::from_path("/set_mode"), RouteAccess::Control);
}
#[test]
fn test_route_access_allows() {
assert!(RouteAccess::Public.allows(None));
assert!(RouteAccess::Public.allows(Some(AuthRole::Rx)));
assert!(RouteAccess::Public.allows(Some(AuthRole::Control)));
assert!(!RouteAccess::Read.allows(None));
assert!(RouteAccess::Read.allows(Some(AuthRole::Rx)));
assert!(RouteAccess::Read.allows(Some(AuthRole::Control)));
assert!(!RouteAccess::Control.allows(None));
assert!(!RouteAccess::Control.allows(Some(AuthRole::Rx)));
assert!(RouteAccess::Control.allows(Some(AuthRole::Control)));
}
#[test]
fn test_session_store_create_and_get() {
let store = SessionStore::new();
let ttl = Duration::from_secs(3600);
let session_id = store.create(AuthRole::Rx, ttl);
let record = store.get(&session_id);
assert!(record.is_some());
let record = record.unwrap();
assert_eq!(record.role, AuthRole::Rx);
assert!(!record.is_expired());
}
#[test]
fn test_session_store_remove() {
let store = SessionStore::new();
let ttl = Duration::from_secs(3600);
let session_id = store.create(AuthRole::Rx, ttl);
store.remove(&session_id);
assert!(store.get(&session_id).is_none());
}
#[test]
fn test_constant_time_eq() {
assert!(constant_time_eq("test", "test"));
assert!(!constant_time_eq("test", "fail"));
assert!(!constant_time_eq("test", "test2"));
assert!(!constant_time_eq("", "test"));
}
#[test]
fn test_auth_config_check_passphrase_control() {
let config = AuthConfig {
enabled: true,
rx_passphrase: None,
control_passphrase: Some("ctrl-pass".to_string()),
tx_access_control_enabled: true,
session_ttl: Duration::from_secs(3600),
cookie_secure: false,
cookie_same_site: SameSite::Lax,
};
assert_eq!(
config.check_passphrase("ctrl-pass"),
Some(AuthRole::Control)
);
assert_eq!(config.check_passphrase("wrong"), None);
}
#[test]
fn test_auth_config_check_passphrase_rx() {
let config = AuthConfig {
enabled: true,
rx_passphrase: Some("rx-pass".to_string()),
control_passphrase: None,
tx_access_control_enabled: true,
session_ttl: Duration::from_secs(3600),
cookie_secure: false,
cookie_same_site: SameSite::Lax,
};
assert_eq!(config.check_passphrase("rx-pass"), Some(AuthRole::Rx));
assert_eq!(config.check_passphrase("wrong"), None);
}
#[test]
fn test_auth_config_check_passphrase_both() {
let config = AuthConfig {
enabled: true,
rx_passphrase: Some("rx-pass".to_string()),
control_passphrase: Some("ctrl-pass".to_string()),
tx_access_control_enabled: true,
session_ttl: Duration::from_secs(3600),
cookie_secure: false,
cookie_same_site: SameSite::Lax,
};
// Control is checked first
assert_eq!(
config.check_passphrase("ctrl-pass"),
Some(AuthRole::Control)
);
assert_eq!(config.check_passphrase("rx-pass"), Some(AuthRole::Rx));
assert_eq!(config.check_passphrase("wrong"), None);
}
#[test]
fn test_is_tx_endpoint() {
assert!(is_tx_endpoint("/set_ptt"));
assert!(is_tx_endpoint("/toggle_ptt"));
assert!(is_tx_endpoint("/set_tx"));
assert!(!is_tx_endpoint("/status"));
}
}
@@ -0,0 +1,895 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use actix_web::{delete, get, put, web, HttpResponse, Responder};
use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use tokio::time;
use tracing::warn;
use trx_frontend::{FrontendRuntimeContext, SharedSpectrum, VChanAudioCmd};
use uuid::Uuid;
use crate::server::bookmarks::{Bookmark, BookmarkStoreMap};
use crate::server::scheduler::{SchedulerStatusMap, SharedSchedulerControlManager};
use crate::server::vchan::{ClientChannel, ClientChannelManager};
use trx_protocol::decoders::resolve_bookmark_decoders;
const CHANNEL_KIND_NAME: &str = "VirtualBackgroundDecodeChannel";
const VISIBLE_CHANNEL_KIND_NAME: &str = "VirtualChannel";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BackgroundDecodeConfig {
pub rig_id: String,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub bookmark_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct BackgroundDecodeBookmarkStatus {
pub bookmark_id: String,
pub bookmark_name: Option<String>,
pub freq_hz: Option<u64>,
pub mode: Option<String>,
#[serde(default)]
pub decoder_kinds: Vec<String>,
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_kind: Option<String>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct BackgroundDecodeStatus {
pub rig_id: String,
pub enabled: bool,
pub active_rig: bool,
pub center_hz: Option<u64>,
pub sample_rate: Option<u32>,
#[serde(default)]
pub entries: Vec<BackgroundDecodeBookmarkStatus>,
}
#[derive(Debug)]
struct VirtualBackgroundDecodeChannel {
uuid: Uuid,
rig_id: String,
bookmark_id: String,
freq_hz: u64,
mode: String,
bandwidth_hz: u32,
decoder_kinds: Vec<String>,
}
#[derive(Default)]
struct BackgroundRuntimeState {
current_rig_id: Option<String>,
active_channels: HashMap<String, VirtualBackgroundDecodeChannel>,
}
pub struct BackgroundDecodeStore {
db: Arc<RwLock<PickleDb>>,
}
impl BackgroundDecodeStore {
pub fn open(path: &Path) -> Self {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let db = if path.exists() {
PickleDb::load(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
.unwrap_or_else(|_| {
PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
})
} else {
PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
};
Self {
db: Arc::new(RwLock::new(db)),
}
}
pub fn default_path() -> PathBuf {
dirs::config_dir()
.map(|p| p.join("trx-rs").join("background_decode.db"))
.unwrap_or_else(|| PathBuf::from("background_decode.db"))
}
pub async fn get(&self, rig_id: &str) -> Option<BackgroundDecodeConfig> {
let db = self.db.read().await;
db.get::<BackgroundDecodeConfig>(&format!("bgd:{rig_id}"))
}
pub async fn upsert(&self, config: &BackgroundDecodeConfig) -> bool {
let mut db = self.db.write().await;
db.set(&format!("bgd:{}", config.rig_id), config).is_ok()
}
pub async fn remove(&self, rig_id: &str) -> bool {
let mut db = self.db.write().await;
db.rem(&format!("bgd:{rig_id}")).unwrap_or(false)
}
}
pub struct BackgroundDecodeManager {
store: Arc<BackgroundDecodeStore>,
bookmarks: Arc<BookmarkStoreMap>,
context: Arc<FrontendRuntimeContext>,
scheduler_status: SchedulerStatusMap,
scheduler_control: SharedSchedulerControlManager,
vchan_mgr: Arc<ClientChannelManager>,
status: Arc<RwLock<HashMap<String, BackgroundDecodeStatus>>>,
notify_tx: broadcast::Sender<()>,
}
impl BackgroundDecodeManager {
pub fn new(
store: Arc<BackgroundDecodeStore>,
bookmarks: Arc<BookmarkStoreMap>,
context: Arc<FrontendRuntimeContext>,
scheduler_status: SchedulerStatusMap,
scheduler_control: SharedSchedulerControlManager,
vchan_mgr: Arc<ClientChannelManager>,
) -> Arc<Self> {
let (notify_tx, _) = broadcast::channel(16);
Arc::new(Self {
store,
bookmarks,
context,
scheduler_status,
scheduler_control,
vchan_mgr,
status: Arc::new(RwLock::new(HashMap::new())),
notify_tx,
})
}
pub fn spawn(self: &Arc<Self>) {
let manager = self.clone();
tokio::spawn(async move {
manager.run().await;
});
}
pub async fn get_config(&self, rig_id: &str) -> BackgroundDecodeConfig {
self.store
.get(rig_id)
.await
.unwrap_or_else(|| BackgroundDecodeConfig {
rig_id: rig_id.to_string(),
enabled: false,
bookmark_ids: Vec::new(),
})
}
pub async fn put_config(
&self,
mut config: BackgroundDecodeConfig,
) -> Option<BackgroundDecodeConfig> {
config.bookmark_ids = dedup_ids(&config.bookmark_ids);
if self.store.upsert(&config).await {
self.trigger();
Some(config)
} else {
None
}
}
pub async fn reset_config(&self, rig_id: &str) -> bool {
let removed = self.store.remove(rig_id).await;
self.trigger();
removed
}
pub async fn status(&self, rig_id: &str) -> BackgroundDecodeStatus {
{
let status = self.status.read().await;
if let Some(entry) = status.get(rig_id) {
return entry.clone();
}
}
let cfg = self.get_config(rig_id).await;
let bookmarks: HashMap<String, Bookmark> = self
.bookmarks
.list_for_rig(rig_id)
.into_iter()
.map(|bookmark| (bookmark.id.clone(), bookmark))
.collect();
BackgroundDecodeStatus {
rig_id: rig_id.to_string(),
enabled: cfg.enabled,
active_rig: self.active_rig_id().as_deref() == Some(rig_id),
center_hz: None,
sample_rate: None,
entries: cfg
.bookmark_ids
.into_iter()
.map(|bookmark_id| {
let bookmark = bookmarks.get(&bookmark_id);
BackgroundDecodeBookmarkStatus {
bookmark_id,
bookmark_name: bookmark.map(|item| item.name.clone()),
freq_hz: bookmark.map(|item| item.freq_hz),
mode: bookmark.map(|item| item.mode.clone()),
decoder_kinds: bookmark.map(bookmark_decoder_kinds).unwrap_or_default(),
state: "inactive".to_string(),
channel_kind: None,
}
})
.collect(),
}
}
pub fn trigger(&self) {
let _ = self.notify_tx.send(());
}
fn active_rig_id(&self) -> Option<String> {
self.context
.routing
.active_rig_id
.lock()
.ok()
.and_then(|guard| guard.clone())
}
fn send_audio_cmd_to_rig(&self, rig_id: &str, cmd: VChanAudioCmd) {
// Route through per-rig sender when available.
if let Ok(map) = self.context.vchan.rig_audio_cmd.read() {
if let Some(tx) = map.get(rig_id) {
let _ = tx.try_send(cmd);
return;
}
}
// Fall back to global sender.
if let Ok(guard) = self.context.vchan.audio_cmd.lock() {
if let Some(tx) = guard.as_ref() {
let _ = tx.try_send(cmd);
}
}
}
fn remove_channel(&self, channel: &VirtualBackgroundDecodeChannel) {
self.send_audio_cmd_to_rig(&channel.rig_id, VChanAudioCmd::Remove(channel.uuid));
}
fn clear_runtime_channels(&self, runtime: &mut BackgroundRuntimeState) {
let channels: Vec<VirtualBackgroundDecodeChannel> =
runtime.active_channels.drain().map(|(_, ch)| ch).collect();
for channel in channels {
self.remove_channel(&channel);
}
runtime.current_rig_id = None;
}
fn desired_channel(
&self,
rig_id: &str,
bookmark: &Bookmark,
decoder_kinds: Vec<String>,
) -> VirtualBackgroundDecodeChannel {
VirtualBackgroundDecodeChannel {
uuid: Uuid::new_v4(),
rig_id: rig_id.to_string(),
bookmark_id: bookmark.id.clone(),
freq_hz: bookmark.freq_hz,
mode: bookmark.mode.clone(),
bandwidth_hz: bookmark.bandwidth_hz.unwrap_or(0).min(u32::MAX as u64) as u32,
decoder_kinds,
}
}
fn channel_matches(
channel: &VirtualBackgroundDecodeChannel,
desired: &VirtualBackgroundDecodeChannel,
) -> bool {
channel.rig_id == desired.rig_id
&& channel.bookmark_id == desired.bookmark_id
&& channel.freq_hz == desired.freq_hz
&& channel.mode == desired.mode
&& channel.bandwidth_hz == desired.bandwidth_hz
&& channel.decoder_kinds == desired.decoder_kinds
}
fn virtual_channels_cover_bookmark(&self, rig_id: &str, bookmark: &Bookmark) -> bool {
self.vchan_mgr
.channels(rig_id)
.into_iter()
.any(|channel| channel_matches_bookmark(&channel, bookmark))
}
async fn reconcile(&self, runtime: &mut BackgroundRuntimeState, spectrum: &SharedSpectrum) {
let active_rig_id = self.active_rig_id();
if runtime.current_rig_id != active_rig_id {
if let Some(prev_rig_id) = runtime.current_rig_id.clone() {
let mut guard = self.status.write().await;
if let Some(prev_status) = guard.get_mut(&prev_rig_id) {
prev_status.active_rig = false;
}
}
self.clear_runtime_channels(runtime);
}
let Some(rig_id) = active_rig_id else {
return;
};
runtime.current_rig_id = Some(rig_id.clone());
let config = self.get_config(&rig_id).await;
let selected = dedup_ids(&config.bookmark_ids);
let users_connected = self.context.sse_clients.load(Ordering::Relaxed) > 0;
let scheduler_has_control = self.scheduler_control.scheduler_allowed() && users_connected;
let scheduled_bookmark_ids = if scheduler_has_control || !users_connected {
self.scheduler_bookmark_ids(&rig_id)
} else {
Vec::new()
};
let selected_bookmarks: HashMap<String, Bookmark> = self
.bookmarks
.list_for_rig(&rig_id)
.into_iter()
.filter(|bookmark| selected.iter().any(|id| id == &bookmark.id))
.map(|bookmark| (bookmark.id.clone(), bookmark))
.collect();
let frame = spectrum.frame.as_ref().map(Arc::as_ref);
let center_hz = frame.map(|frame| frame.center_hz);
let sample_rate = frame.map(|frame| frame.sample_rate);
let half_span_hz = frame.map(|frame| i64::from(frame.sample_rate) / 2);
let spectrum_span = match (center_hz, half_span_hz) {
(Some(c), Some(h)) => Some((c as i64, h)),
_ => None,
};
let scheduled_set: HashSet<String> = scheduled_bookmark_ids.into_iter().collect();
let mut statuses = Vec::new();
let mut desired_channels = HashMap::new();
for bookmark_id in selected {
let Some(bookmark) = selected_bookmarks.get(&bookmark_id) else {
statuses.push(BackgroundDecodeBookmarkStatus {
bookmark_id,
state: "missing_bookmark".to_string(),
..BackgroundDecodeBookmarkStatus::default()
});
continue;
};
let decoder_kinds = bookmark_decoder_kinds(bookmark);
let mut status = BackgroundDecodeBookmarkStatus {
bookmark_id: bookmark.id.clone(),
bookmark_name: Some(bookmark.name.clone()),
freq_hz: Some(bookmark.freq_hz),
mode: Some(bookmark.mode.clone()),
decoder_kinds: decoder_kinds.clone(),
state: "disabled".to_string(),
channel_kind: None,
};
let vchan_covers = self.virtual_channels_cover_bookmark(&rig_id, bookmark);
let action = evaluate_bookmark(
decoder_kinds.is_empty(),
config.enabled,
users_connected,
scheduler_has_control,
&scheduled_set,
&bookmark.id,
vchan_covers,
spectrum_span,
bookmark.freq_hz,
);
match action {
ChannelAction::Active => {
status.state = "active".to_string();
status.channel_kind = Some(CHANNEL_KIND_NAME.to_string());
let desired = self.desired_channel(&rig_id, bookmark, decoder_kinds);
desired_channels.insert(bookmark.id.clone(), desired);
}
ChannelAction::Skip { reason } => {
status.state = reason.to_string();
if reason == "handled_by_virtual_channel" {
status.channel_kind = Some(VISIBLE_CHANNEL_KIND_NAME.to_string());
}
}
}
statuses.push(status);
}
let mut to_remove = Vec::new();
for (bookmark_id, channel) in &runtime.active_channels {
if let Some(desired) = desired_channels.get(bookmark_id) {
if !Self::channel_matches(channel, desired) {
to_remove.push(bookmark_id.clone());
}
} else {
to_remove.push(bookmark_id.clone());
}
}
for bookmark_id in to_remove {
if let Some(channel) = runtime.active_channels.remove(&bookmark_id) {
self.remove_channel(&channel);
}
}
for (bookmark_id, desired) in desired_channels {
if runtime.active_channels.contains_key(&bookmark_id) {
continue;
}
self.send_audio_cmd_to_rig(
&desired.rig_id,
VChanAudioCmd::SubscribeBackground {
uuid: desired.uuid,
freq_hz: desired.freq_hz,
mode: desired.mode.clone(),
bandwidth_hz: desired.bandwidth_hz,
decoder_kinds: desired.decoder_kinds.clone(),
},
);
runtime.active_channels.insert(bookmark_id, desired);
}
let mut guard = self.status.write().await;
guard.insert(
rig_id.clone(),
BackgroundDecodeStatus {
rig_id,
enabled: config.enabled,
active_rig: true,
center_hz,
sample_rate,
entries: statuses,
},
);
}
fn scheduler_bookmark_ids(&self, rig_id: &str) -> Vec<String> {
let Ok(guard) = self.scheduler_status.read() else {
return Vec::new();
};
let Some(status) = guard.get(rig_id) else {
return Vec::new();
};
if !status.active {
return Vec::new();
}
let mut out = Vec::new();
if let Some(id) = status.last_bookmark_id.clone() {
out.push(id);
}
for id in &status.last_bookmark_ids {
if !out.iter().any(|existing| existing == id) {
out.push(id.clone());
}
}
out
}
async fn run(self: Arc<Self>) {
let mut runtime = BackgroundRuntimeState::default();
let mut notify_rx = self.notify_tx.subscribe();
let mut spectrum_rx: Option<tokio::sync::watch::Receiver<SharedSpectrum>> = None;
let mut interval = time::interval(Duration::from_secs(2));
loop {
let users_connected = self.context.sse_clients.load(Ordering::Relaxed) > 0;
if users_connected && spectrum_rx.is_none() {
spectrum_rx = Some(self.context.spectrum.sender.subscribe());
} else if !users_connected {
spectrum_rx = None;
}
let spectrum = spectrum_rx
.as_ref()
.map(|rx| rx.borrow().clone())
.unwrap_or_default();
self.reconcile(&mut runtime, &spectrum).await;
tokio::select! {
changed = async {
match spectrum_rx.as_mut() {
Some(rx) => rx.changed().await.map_err(|_| ()),
None => std::future::pending::<Result<(), ()>>().await,
}
} => {
if changed.is_err() {
warn!("background decode: spectrum watch closed");
self.clear_runtime_channels(&mut runtime);
break;
}
}
recv = notify_rx.recv() => {
match recv {
Ok(()) => {}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => break,
}
}
_ = interval.tick() => {}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ChannelAction {
Active,
Skip { reason: &'static str },
}
/// Pure decision function that determines whether a bookmark should produce an
/// active background-decode channel or be skipped (with a reason).
#[allow(clippy::too_many_arguments)]
fn evaluate_bookmark(
decoder_kinds_empty: bool,
enabled: bool,
users_connected: bool,
scheduler_has_control: bool,
scheduled_bookmark_ids: &HashSet<String>,
bookmark_id: &str,
vchan_covers_bookmark: bool,
spectrum_span: Option<(i64, i64)>,
freq_hz: u64,
) -> ChannelAction {
if decoder_kinds_empty {
return ChannelAction::Skip {
reason: "no_supported_decoders",
};
}
if !enabled {
return ChannelAction::Skip { reason: "disabled" };
}
if !users_connected {
return ChannelAction::Skip {
reason: "waiting_for_user",
};
}
if scheduler_has_control {
return ChannelAction::Skip {
reason: "scheduler_has_control",
};
}
if scheduled_bookmark_ids.contains(bookmark_id) {
return ChannelAction::Skip {
reason: "handled_by_scheduler",
};
}
if vchan_covers_bookmark {
return ChannelAction::Skip {
reason: "handled_by_virtual_channel",
};
}
let Some((center_hz, half_span_hz)) = spectrum_span else {
return ChannelAction::Skip {
reason: "waiting_for_spectrum",
};
};
let offset_hz = freq_hz as i64 - center_hz;
if offset_hz.abs() > half_span_hz {
return ChannelAction::Skip {
reason: "out_of_span",
};
}
ChannelAction::Active
}
fn dedup_ids(ids: &[String]) -> Vec<String> {
let mut out = Vec::new();
for id in ids {
if !out.iter().any(|existing| existing == id) {
out.push(id.clone());
}
}
out
}
fn bookmark_decoder_kinds(bookmark: &Bookmark) -> Vec<String> {
resolve_bookmark_decoders(&bookmark.decoders, &bookmark.mode, true)
}
fn channel_matches_bookmark(channel: &ClientChannel, bookmark: &Bookmark) -> bool {
channel.freq_hz == bookmark.freq_hz
&& normalized_mode(&channel.mode) == normalized_mode(&bookmark.mode)
}
fn normalized_mode(mode: &str) -> String {
mode.trim().to_ascii_lowercase()
}
#[get("/background-decode/{rig_id}")]
pub async fn get_background_decode(
path: web::Path<String>,
manager: web::Data<Arc<BackgroundDecodeManager>>,
) -> impl Responder {
HttpResponse::Ok().json(manager.get_config(&path.into_inner()).await)
}
#[put("/background-decode/{rig_id}")]
pub async fn put_background_decode(
path: web::Path<String>,
body: web::Json<BackgroundDecodeConfig>,
manager: web::Data<Arc<BackgroundDecodeManager>>,
) -> impl Responder {
let rig_id = path.into_inner();
let mut config = body.into_inner();
config.rig_id = rig_id;
match manager.put_config(config).await {
Some(saved) => HttpResponse::Ok().json(saved),
None => HttpResponse::InternalServerError().body("failed to save background decode config"),
}
}
#[delete("/background-decode/{rig_id}")]
pub async fn delete_background_decode(
path: web::Path<String>,
manager: web::Data<Arc<BackgroundDecodeManager>>,
) -> impl Responder {
let rig_id = path.into_inner();
manager.reset_config(&rig_id).await;
HttpResponse::Ok().json(BackgroundDecodeConfig {
rig_id,
enabled: false,
bookmark_ids: Vec::new(),
})
}
#[get("/background-decode/{rig_id}/status")]
pub async fn get_background_decode_status(
path: web::Path<String>,
manager: web::Data<Arc<BackgroundDecodeManager>>,
) -> impl Responder {
HttpResponse::Ok().json(manager.status(&path.into_inner()).await)
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_scheduled() -> HashSet<String> {
HashSet::new()
}
#[test]
fn active_when_all_conditions_met() {
let action = evaluate_bookmark(
false, // decoder_kinds_empty
true, // enabled
true, // users_connected
false, // scheduler_has_control
&empty_scheduled(),
"bm1",
false, // vchan_covers_bookmark
Some((14_074_000, 96_000)), // spectrum_span (center, half)
14_074_000, // freq_hz
);
assert_eq!(action, ChannelAction::Active);
}
#[test]
fn skip_no_supported_decoders() {
let action = evaluate_bookmark(
true,
true,
true,
false,
&empty_scheduled(),
"bm1",
false,
Some((14_074_000, 96_000)),
14_074_000,
);
assert_eq!(
action,
ChannelAction::Skip {
reason: "no_supported_decoders"
}
);
}
#[test]
fn skip_disabled() {
let action = evaluate_bookmark(
false,
false,
true,
false,
&empty_scheduled(),
"bm1",
false,
Some((14_074_000, 96_000)),
14_074_000,
);
assert_eq!(action, ChannelAction::Skip { reason: "disabled" });
}
#[test]
fn skip_waiting_for_user() {
let action = evaluate_bookmark(
false,
true,
false,
false,
&empty_scheduled(),
"bm1",
false,
Some((14_074_000, 96_000)),
14_074_000,
);
assert_eq!(
action,
ChannelAction::Skip {
reason: "waiting_for_user"
}
);
}
#[test]
fn skip_scheduler_has_control() {
let action = evaluate_bookmark(
false,
true,
true,
true,
&empty_scheduled(),
"bm1",
false,
Some((14_074_000, 96_000)),
14_074_000,
);
assert_eq!(
action,
ChannelAction::Skip {
reason: "scheduler_has_control"
}
);
}
#[test]
fn skip_handled_by_scheduler() {
let mut scheduled = HashSet::new();
scheduled.insert("bm1".to_string());
let action = evaluate_bookmark(
false,
true,
true,
false,
&scheduled,
"bm1",
false,
Some((14_074_000, 96_000)),
14_074_000,
);
assert_eq!(
action,
ChannelAction::Skip {
reason: "handled_by_scheduler"
}
);
}
#[test]
fn skip_handled_by_virtual_channel() {
let action = evaluate_bookmark(
false,
true,
true,
false,
&empty_scheduled(),
"bm1",
true,
Some((14_074_000, 96_000)),
14_074_000,
);
assert_eq!(
action,
ChannelAction::Skip {
reason: "handled_by_virtual_channel"
}
);
}
#[test]
fn skip_waiting_for_spectrum() {
let action = evaluate_bookmark(
false,
true,
true,
false,
&empty_scheduled(),
"bm1",
false,
None,
14_074_000,
);
assert_eq!(
action,
ChannelAction::Skip {
reason: "waiting_for_spectrum"
}
);
}
#[test]
fn skip_out_of_span() {
let action = evaluate_bookmark(
false,
true,
true,
false,
&empty_scheduled(),
"bm1",
false,
Some((14_074_000, 96_000)), // center 14.074 MHz, half span 96 kHz
7_074_000, // way outside the span
);
assert_eq!(
action,
ChannelAction::Skip {
reason: "out_of_span"
}
);
}
#[test]
fn active_at_edge_of_span() {
let action = evaluate_bookmark(
false,
true,
true,
false,
&empty_scheduled(),
"bm1",
false,
Some((14_074_000, 96_000)),
14_074_000 + 96_000, // exactly at the edge
);
assert_eq!(action, ChannelAction::Active);
}
#[test]
fn priority_no_decoders_over_disabled() {
// Even if disabled, "no_supported_decoders" should take precedence
let action = evaluate_bookmark(
true,
false,
true,
false,
&empty_scheduled(),
"bm1",
false,
Some((14_074_000, 96_000)),
14_074_000,
);
assert_eq!(
action,
ChannelAction::Skip {
reason: "no_supported_decoders"
}
);
}
}
@@ -0,0 +1,185 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bookmark {
pub id: String,
pub name: String,
pub freq_hz: u64,
pub mode: String,
pub bandwidth_hz: Option<u64>,
#[serde(default)]
pub locator: Option<String>,
pub comment: String,
pub category: String,
pub decoders: Vec<String>,
}
pub struct BookmarkStore {
db: Arc<RwLock<PickleDb>>,
}
impl BookmarkStore {
/// Open (or create) the bookmark store at `path`.
pub fn open(path: &Path) -> Self {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let db = if path.exists() {
PickleDb::load(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
.unwrap_or_else(|_| {
PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
})
} else {
PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
};
Self {
db: Arc::new(RwLock::new(db)),
}
}
/// General (shared) bookmarks path: `~/.config/trx-rs/bookmarks.db`.
pub fn general_path() -> PathBuf {
dirs::config_dir()
.map(|p| p.join("trx-rs").join("bookmarks.db"))
.unwrap_or_else(|| PathBuf::from("bookmarks.db"))
}
/// Per-rig bookmarks path: `~/.config/trx-rs/bookmark.{remote}.db`.
pub fn rig_path(remote: &str) -> PathBuf {
dirs::config_dir()
.map(|p| p.join("trx-rs").join(format!("bookmark.{remote}.db")))
.unwrap_or_else(|| PathBuf::from(format!("bookmark.{remote}.db")))
}
pub fn list(&self) -> Vec<Bookmark> {
let db = self.db.read().unwrap_or_else(|e| e.into_inner());
db.iter()
.filter_map(|kv| {
if kv.get_key().starts_with("bm:") {
kv.get_value::<Bookmark>()
} else {
None
}
})
.collect()
}
pub fn get(&self, id: &str) -> Option<Bookmark> {
let db = self.db.read().unwrap_or_else(|e| e.into_inner());
db.get::<Bookmark>(&format!("bm:{id}"))
}
/// Insert a new bookmark. Returns false if the DB write fails.
pub fn insert(&self, bm: &Bookmark) -> bool {
let mut db = self.db.write().unwrap_or_else(|e| e.into_inner());
db.set(&format!("bm:{}", bm.id), bm).is_ok()
}
/// Update an existing bookmark by id. Returns false if not found.
pub fn upsert(&self, id: &str, bm: &Bookmark) -> bool {
let mut db = self.db.write().unwrap_or_else(|e| e.into_inner());
let key = format!("bm:{id}");
if db.exists(&key) {
db.set(&key, bm).is_ok()
} else {
false
}
}
/// Remove a bookmark by id. Returns false if not found.
pub fn remove(&self, id: &str) -> bool {
let mut db = self.db.write().unwrap_or_else(|e| e.into_inner());
db.rem(&format!("bm:{id}")).unwrap_or(false)
}
/// Returns true if any bookmark (other than `exclude_id`) has `freq_hz`.
pub fn freq_taken(&self, freq_hz: u64, exclude_id: Option<&str>) -> bool {
self.list()
.into_iter()
.any(|bm| bm.freq_hz == freq_hz && exclude_id.is_none_or(|ex| bm.id != ex))
}
}
/// Two-tier bookmark storage: a shared **general** store (`bookmarks.db`)
/// and lazily-opened per-rig stores (`bookmark.{remote}.db`).
pub struct BookmarkStoreMap {
general: Arc<BookmarkStore>,
rig_stores: Mutex<HashMap<String, Arc<BookmarkStore>>>,
}
impl Default for BookmarkStoreMap {
fn default() -> Self {
Self::new()
}
}
impl BookmarkStoreMap {
pub fn new() -> Self {
let general_path = BookmarkStore::general_path();
Self {
general: Arc::new(BookmarkStore::open(&general_path)),
rig_stores: Mutex::new(HashMap::new()),
}
}
/// The shared general bookmark store.
pub fn general(&self) -> &Arc<BookmarkStore> {
&self.general
}
/// Return the per-rig store for `remote`, opening it on first access.
pub fn store_for(&self, remote: &str) -> Arc<BookmarkStore> {
let mut stores = self.rig_stores.lock().unwrap_or_else(|e| e.into_inner());
stores
.entry(remote.to_owned())
.or_insert_with(|| {
let path = BookmarkStore::rig_path(remote);
Arc::new(BookmarkStore::open(&path))
})
.clone()
}
/// Look up a bookmark by id, checking the rig-specific store first,
/// then falling back to the general store.
pub fn get_for_rig(&self, remote: &str, id: &str) -> Option<Bookmark> {
self.store_for(remote)
.get(id)
.or_else(|| self.general.get(id))
}
/// List all bookmarks visible to `remote`: rig-specific bookmarks merged
/// with general bookmarks (rig-specific wins on duplicate IDs).
pub fn list_for_rig(&self, remote: &str) -> Vec<Bookmark> {
let mut map: HashMap<String, Bookmark> = self
.general
.list()
.into_iter()
.map(|bm| (bm.id.clone(), bm))
.collect();
for bm in self.store_for(remote).list() {
map.insert(bm.id.clone(), bm);
}
map.into_values().collect()
}
}
@@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
pub mod server;
pub fn register_frontend_on(context: &mut trx_frontend::FrontendRegistrationContext) {
use trx_frontend::FrontendSpawner;
context.register_frontend("http", server::HttpFrontend::spawn_frontend);
}
@@ -0,0 +1,589 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Audio recorder — writes incoming Opus packets to OGG/Opus files.
//!
//! The recorder subscribes to the same `broadcast::Sender<Bytes>` channels
//! that feed the WebSocket audio endpoint, capturing pre-encoded Opus packets
//! without any re-encoding.
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, watch};
use tracing::{error, info, warn};
// ============================================================================
// Configuration
// ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RecorderConfig {
/// Whether the recorder feature is available.
pub enabled: bool,
/// Directory for recorded files. Default: `$XDG_CACHE_HOME/trx-rs/recordings/`.
pub output_dir: Option<String>,
/// Maximum duration of a single recording in seconds. None = unlimited.
pub max_duration_secs: Option<u64>,
}
impl Default for RecorderConfig {
fn default() -> Self {
Self {
enabled: true,
output_dir: None,
max_duration_secs: None,
}
}
}
impl RecorderConfig {
pub fn resolve_output_dir(&self) -> PathBuf {
if let Some(ref dir) = self.output_dir {
PathBuf::from(dir)
} else {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from(".cache"))
.join("trx-rs")
.join("recordings")
}
}
}
// ============================================================================
// Recording metadata
// ============================================================================
#[derive(Debug, Clone, Serialize)]
pub struct RecordingInfo {
pub key: String,
pub rig_id: String,
pub vchan_id: Option<String>,
pub path: String,
pub started_at: i64,
pub sample_rate: u32,
pub channels: u8,
}
#[derive(Debug, Clone, Serialize)]
pub struct RecordingResult {
pub key: String,
pub path: String,
pub duration_secs: f64,
pub bytes_written: u64,
}
/// Audio stream parameters for a recording.
#[derive(Debug, Clone, Copy)]
pub struct AudioParams {
pub sample_rate: u32,
pub channels: u8,
pub frame_duration_ms: u16,
}
// ============================================================================
// OGG/Opus writer
// ============================================================================
/// Minimal OGG/Opus file writer.
///
/// Writes the mandatory OpusHead and OpusTags pages, then wraps each incoming
/// Opus packet in its own OGG page. This produces a valid, seekable OGG Opus
/// stream without pulling in an external OGG crate.
struct OggOpusWriter {
file: std::fs::File,
serial: u32,
page_seq: u32,
granule_pos: u64,
samples_per_frame: u64,
bytes_written: u64,
}
impl OggOpusWriter {
fn create(
path: &Path,
sample_rate: u32,
channels: u8,
frame_duration_ms: u16,
) -> std::io::Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::File::create(path)?;
let serial = {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
(ts & 0xFFFF_FFFF) as u32
};
let samples_per_frame = (sample_rate as u64) * (frame_duration_ms as u64) / 1000;
let mut writer = Self {
file,
serial,
page_seq: 0,
granule_pos: 0,
samples_per_frame,
bytes_written: 0,
};
writer.write_opus_head(sample_rate, channels)?;
writer.write_opus_tags()?;
Ok(writer)
}
/// Write the OpusHead identification header (OGG page, BOS).
fn write_opus_head(&mut self, sample_rate: u32, channels: u8) -> std::io::Result<()> {
let mut head = Vec::with_capacity(19);
head.extend_from_slice(b"OpusHead");
head.push(1); // version
head.push(channels);
head.extend_from_slice(&0u16.to_le_bytes()); // pre-skip
head.extend_from_slice(&sample_rate.to_le_bytes()); // input sample rate
head.extend_from_slice(&0u16.to_le_bytes()); // output gain
head.push(0); // channel mapping family
// BOS flag = 0x02
self.write_ogg_page(0x02, 0, &head)
}
/// Write the OpusTags comment header.
fn write_opus_tags(&mut self) -> std::io::Result<()> {
let vendor = b"trx-rs";
let mut tags = Vec::with_capacity(24);
tags.extend_from_slice(b"OpusTags");
tags.extend_from_slice(&(vendor.len() as u32).to_le_bytes());
tags.extend_from_slice(vendor);
tags.extend_from_slice(&0u32.to_le_bytes()); // no user comments
self.write_ogg_page(0x00, 0, &tags)
}
/// Write a single Opus audio packet as an OGG page.
fn write_audio_packet(&mut self, opus_data: &[u8]) -> std::io::Result<()> {
self.granule_pos += self.samples_per_frame;
self.write_ogg_page(0x00, self.granule_pos, opus_data)
}
/// Finalize the stream by writing an EOS page.
fn finalize(mut self) -> std::io::Result<u64> {
// Write an empty EOS page.
self.write_ogg_page(0x04, self.granule_pos, &[])?;
self.file.flush()?;
Ok(self.bytes_written)
}
/// Write a single OGG page.
fn write_ogg_page(
&mut self,
header_type: u8,
granule_position: u64,
data: &[u8],
) -> std::io::Result<()> {
// OGG page header
let mut header = Vec::with_capacity(27 + 255);
header.extend_from_slice(b"OggS"); // capture pattern
header.push(0); // stream structure version
header.push(header_type); // header type flag
header.extend_from_slice(&granule_position.to_le_bytes()); // granule position
header.extend_from_slice(&self.serial.to_le_bytes()); // stream serial number
header.extend_from_slice(&self.page_seq.to_le_bytes()); // page sequence number
header.extend_from_slice(&0u32.to_le_bytes()); // CRC (placeholder)
self.page_seq += 1;
// Segment table: split data into 255-byte segments.
let num_segments = if data.is_empty() {
1
} else {
data.len().div_ceil(255)
};
// A single packet needs lacing values: full 255-byte segments + final remainder.
let mut segments = Vec::with_capacity(num_segments);
let mut remaining = data.len();
while remaining >= 255 {
segments.push(255u8);
remaining -= 255;
}
segments.push(remaining as u8);
header.push(segments.len() as u8); // number of page segments
header.extend_from_slice(&segments);
// Compute CRC-32 over header + data
let crc = ogg_crc32(&header, data);
header[22..26].copy_from_slice(&crc.to_le_bytes());
self.file.write_all(&header)?;
self.file.write_all(data)?;
self.bytes_written += header.len() as u64 + data.len() as u64;
Ok(())
}
}
/// OGG CRC-32 (polynomial 0x04C11DB7, direct algorithm).
fn ogg_crc32(header: &[u8], data: &[u8]) -> u32 {
static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new();
let table = TABLE.get_or_init(|| {
let mut t = [0u32; 256];
for i in 0..256u32 {
let mut r = i << 24;
for _ in 0..8 {
r = if r & 0x80000000 != 0 {
(r << 1) ^ 0x04C11DB7
} else {
r << 1
};
}
t[i as usize] = r;
}
t
});
let mut crc = 0u32;
for &b in header.iter().chain(data.iter()) {
crc = (crc << 8) ^ table[((crc >> 24) ^ (b as u32)) as usize];
}
crc
}
// ============================================================================
// RecorderHandle
// ============================================================================
struct RecorderHandle {
stop_tx: watch::Sender<bool>,
handle: tokio::task::JoinHandle<Option<RecordingResult>>,
info: RecordingInfo,
}
// ============================================================================
// RecorderManager
// ============================================================================
pub struct RecorderManager {
recordings: Mutex<HashMap<String, RecorderHandle>>,
config: RecorderConfig,
}
impl RecorderManager {
pub fn new(config: RecorderConfig) -> Self {
Self {
recordings: Mutex::new(HashMap::new()),
config,
}
}
/// Build a recording key from rig_id and optional vchan_id.
fn make_key(rig_id: &str, vchan_id: Option<&str>) -> String {
match vchan_id {
Some(v) => format!("{rig_id}:{v}"),
None => rig_id.to_string(),
}
}
/// Start recording the given audio stream.
pub fn start(
&self,
rig_id: &str,
vchan_id: Option<&str>,
audio_rx: broadcast::Sender<Bytes>,
params: AudioParams,
freq_hz: Option<u64>,
mode: Option<&str>,
) -> Result<RecordingInfo, String> {
if !self.config.enabled {
return Err("recorder is disabled".into());
}
let key = Self::make_key(rig_id, vchan_id);
let mut recordings = self.recordings.lock().unwrap_or_else(|e| e.into_inner());
if recordings.contains_key(&key) {
return Err(format!("already recording: {key}"));
}
let output_dir = self.config.resolve_output_dir();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let ts = chrono_timestamp(now.as_secs());
let filename = {
let freq_part = freq_hz.map(|f| format!("_{f}")).unwrap_or_default();
let mode_part = mode.map(|m| format!("_{m}")).unwrap_or_default();
let vchan_part = vchan_id.map(|v| format!("_vchan-{v}")).unwrap_or_default();
format!("{rig_id}{freq_part}{mode_part}{vchan_part}_{ts}.ogg")
};
let path = output_dir.join(&filename);
let (stop_tx, stop_rx) = watch::channel(false);
let rx = audio_rx.subscribe();
let path_clone = path.clone();
let max_duration = self.config.max_duration_secs;
let key_clone = key.clone();
let handle = tokio::task::spawn_blocking(move || {
run_recorder(&key_clone, &path_clone, rx, stop_rx, params, max_duration)
});
let started_at = now.as_secs() as i64;
let info = RecordingInfo {
key: key.clone(),
rig_id: rig_id.to_string(),
vchan_id: vchan_id.map(str::to_string),
path: path.to_string_lossy().into_owned(),
started_at,
sample_rate: params.sample_rate,
channels: params.channels,
};
recordings.insert(
key,
RecorderHandle {
stop_tx,
handle,
info: info.clone(),
},
);
Ok(info)
}
/// Stop a recording and return the result.
pub async fn stop(
&self,
rig_id: &str,
vchan_id: Option<&str>,
) -> Result<RecordingResult, String> {
let key = Self::make_key(rig_id, vchan_id);
let handle = {
let mut recordings = self.recordings.lock().unwrap_or_else(|e| e.into_inner());
recordings.remove(&key)
};
match handle {
Some(h) => {
let _ = h.stop_tx.send(true);
match h.handle.await {
Ok(Some(result)) => Ok(result),
Ok(None) => Err("recording failed".into()),
Err(e) => Err(format!("recorder task panicked: {e}")),
}
}
None => Err(format!("no active recording: {key}")),
}
}
/// List active recordings.
pub fn list_active(&self) -> Vec<RecordingInfo> {
let recordings = self.recordings.lock().unwrap_or_else(|e| e.into_inner());
recordings.values().map(|h| h.info.clone()).collect()
}
/// List recorded files in the output directory.
pub fn list_files(&self) -> Vec<RecordedFile> {
let dir = self.config.resolve_output_dir();
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "ogg") {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
files.push(RecordedFile { name, size });
}
}
}
files.sort_by(|a, b| b.name.cmp(&a.name)); // newest first
files
}
/// Resolve and validate a filename, returning the full path.
///
/// Rejects path traversal attempts and files outside the output directory.
fn validate_filename(&self, filename: &str) -> Result<PathBuf, String> {
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err("invalid filename".into());
}
if !filename.ends_with(".ogg") {
return Err("only .ogg files are accessible".into());
}
let dir = self.config.resolve_output_dir();
let path = dir.join(filename);
if !path.exists() {
return Err(format!("file not found: {filename}"));
}
Ok(path)
}
/// Get the full path to a recorded file for download.
pub fn file_path(&self, filename: &str) -> Result<PathBuf, String> {
self.validate_filename(filename)
}
/// Delete a recorded file.
pub fn delete_file(&self, filename: &str) -> Result<(), String> {
let path = self.validate_filename(filename)?;
std::fs::remove_file(&path).map_err(|e| format!("failed to delete: {e}"))
}
/// Check if a recording is active for the given key.
pub fn is_recording(&self, rig_id: &str, vchan_id: Option<&str>) -> bool {
let key = Self::make_key(rig_id, vchan_id);
let recordings = self.recordings.lock().unwrap_or_else(|e| e.into_inner());
recordings.contains_key(&key)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RecordedFile {
pub name: String,
pub size: u64,
}
// ============================================================================
// Recording task (runs in spawn_blocking)
// ============================================================================
fn run_recorder(
key: &str,
path: &Path,
mut rx: broadcast::Receiver<Bytes>,
mut stop_rx: watch::Receiver<bool>,
params: AudioParams,
max_duration_secs: Option<u64>,
) -> Option<RecordingResult> {
let mut writer = match OggOpusWriter::create(
path,
params.sample_rate,
params.channels,
params.frame_duration_ms,
) {
Ok(w) => w,
Err(e) => {
error!("Recorder [{key}]: failed to create file {path:?}: {e}");
return None;
}
};
info!("Recorder [{key}]: started → {}", path.display());
let start = std::time::Instant::now();
let max_dur = max_duration_secs.map(std::time::Duration::from_secs);
let mut packets: u64 = 0;
// Use a small runtime to bridge async broadcast → blocking writer.
let rt = tokio::runtime::Handle::current();
loop {
// Check stop signal.
if *stop_rx.borrow() {
break;
}
// Check max duration.
if let Some(max) = max_dur {
if start.elapsed() >= max {
info!("Recorder [{key}]: max duration reached");
break;
}
}
// Receive next Opus packet (blocking in spawn_blocking context).
let packet = rt.block_on(async {
tokio::select! {
result = rx.recv() => Some(result),
_ = stop_rx.changed() => None,
}
});
match packet {
Some(Ok(data)) => {
if let Err(e) = writer.write_audio_packet(&data) {
error!("Recorder [{key}]: write error: {e}");
break;
}
packets += 1;
}
Some(Err(broadcast::error::RecvError::Lagged(n))) => {
warn!("Recorder [{key}]: dropped {n} packets (lag)");
// Continue recording despite lag.
}
Some(Err(broadcast::error::RecvError::Closed)) => {
info!("Recorder [{key}]: audio channel closed");
break;
}
None => {
// Stop signal received.
break;
}
}
}
let duration_secs = start.elapsed().as_secs_f64();
let bytes_written = match writer.finalize() {
Ok(n) => n,
Err(e) => {
error!("Recorder [{key}]: finalize error: {e}");
0
}
};
info!(
"Recorder [{key}]: stopped — {packets} packets, {duration_secs:.1}s, {} bytes",
bytes_written
);
Some(RecordingResult {
key: key.to_string(),
path: path.to_string_lossy().into_owned(),
duration_secs,
bytes_written,
})
}
// ============================================================================
// Helpers
// ============================================================================
/// Format a Unix timestamp as `YYYY-MM-DD_HH-MM-SS`.
fn chrono_timestamp(epoch_secs: u64) -> String {
let secs = epoch_secs;
let days = secs / 86400;
let time = secs % 86400;
let hours = time / 3600;
let minutes = (time % 3600) / 60;
let seconds = time % 60;
// Simple Gregorian calendar calculation from epoch days.
let (y, m, d) = epoch_days_to_ymd(days as i64);
format!("{y:04}-{m:02}-{d:02}_{hours:02}-{minutes:02}-{seconds:02}")
}
fn epoch_days_to_ymd(days: i64) -> (i32, u32, u32) {
// Algorithm from http://howardhinnant.github.io/date_algorithms.html
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + 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;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as i32, m, d)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,295 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
#[path = "api/mod.rs"]
pub mod api;
#[path = "audio.rs"]
pub mod audio;
#[path = "auth.rs"]
pub mod auth;
#[path = "background_decode.rs"]
pub mod background_decode;
#[path = "bookmarks.rs"]
pub mod bookmarks;
#[path = "recorder.rs"]
pub mod recorder;
#[path = "scheduler.rs"]
pub mod scheduler;
#[path = "status.rs"]
pub mod status;
#[path = "vchan.rs"]
pub mod vchan;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use actix_web::dev::Server;
use actix_web::{
middleware::{Compress, DefaultHeaders, Logger},
web, App, HttpServer,
};
use tokio::signal;
use tokio::sync::{broadcast, mpsc, watch};
use tokio::task::JoinHandle;
use tracing::{error, info, warn};
use trx_core::RigRequest;
use trx_core::RigState;
use trx_frontend::{FrontendRuntimeContext, FrontendSpawner};
use auth::{AuthConfig, AuthState, SameSite};
use background_decode::{BackgroundDecodeManager, BackgroundDecodeStore};
use recorder::{RecorderConfig, RecorderManager};
use scheduler::{
SchedulerControlManager, SchedulerStatusMap, SchedulerStoreMap, SharedActivityLogMap,
};
use vchan::ClientChannelManager;
/// HTTP frontend implementation.
pub struct HttpFrontend;
impl FrontendSpawner for HttpFrontend {
fn spawn_frontend(
state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>,
callsign: Option<String>,
listen_addr: SocketAddr,
context: Arc<FrontendRuntimeContext>,
) -> JoinHandle<()> {
tokio::spawn(async move {
if let Err(e) = serve(listen_addr, state_rx, rig_tx, callsign, context).await {
error!("HTTP status server error: {:?}", e);
}
})
}
}
async fn serve(
addr: SocketAddr,
state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>,
callsign: Option<String>,
context: Arc<FrontendRuntimeContext>,
) -> Result<(), actix_web::Error> {
audio::start_decode_history_collector(context.clone());
// Collect rig IDs for per-rig store initialisation / migration.
let rig_ids: Vec<String> = context
.routing
.remote_rigs
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|r| r.rig_id.clone())
.collect();
let rig_id_refs: Vec<&str> = rig_ids.iter().map(String::as_str).collect();
let scheduler_store = Arc::new(SchedulerStoreMap::new(&rig_id_refs));
let bookmark_store_map = Arc::new(bookmarks::BookmarkStoreMap::new());
let scheduler_status: SchedulerStatusMap = Arc::new(RwLock::new(HashMap::new()));
let scheduler_control = Arc::new(SchedulerControlManager::default());
let activity_log_map: SharedActivityLogMap = Arc::new(RwLock::new(HashMap::new()));
let recorder_config = RecorderConfig::default();
let recorder_mgr = Arc::new(RecorderManager::new(recorder_config));
scheduler::spawn_scheduler_task(
context.clone(),
rig_tx.clone(),
scheduler_store.clone(),
bookmark_store_map.clone(),
scheduler_status.clone(),
scheduler_control.clone(),
Some(recorder_mgr.clone()),
activity_log_map.clone(),
);
let background_decode_path = BackgroundDecodeStore::default_path();
let background_decode_store = Arc::new(BackgroundDecodeStore::open(&background_decode_path));
let vchan_mgr = Arc::new(ClientChannelManager::new(
4,
context.vchan.rig_audio_cmd.clone(),
));
let session_rig_mgr = Arc::new(api::SessionRigManager::default());
let background_decode_mgr = BackgroundDecodeManager::new(
background_decode_store,
bookmark_store_map.clone(),
context.clone(),
scheduler_status.clone(),
scheduler_control.clone(),
vchan_mgr.clone(),
);
background_decode_mgr.spawn();
// Wire the audio-command sender so allocate/delete/freq/mode operations on
// virtual channels are forwarded to the audio-client task.
if let Ok(guard) = context.vchan.audio_cmd.lock() {
if let Some(tx) = guard.as_ref() {
vchan_mgr.set_audio_cmd(tx.clone());
}
}
// Spawn a task that removes channels destroyed server-side (OOB) from the
// client-side registry so the SSE channel list stays in sync.
if let Some(ref destroyed_tx) = context.vchan.destroyed {
let mut destroyed_rx = destroyed_tx.subscribe();
let mgr_for_destroyed = vchan_mgr.clone();
tokio::spawn(async move {
loop {
match destroyed_rx.recv().await {
Ok(uuid) => {
mgr_for_destroyed.remove_by_uuid(uuid);
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
}
let server = build_server(
addr,
state_rx,
rig_tx,
callsign,
context,
bookmark_store_map,
scheduler_store,
scheduler_status,
scheduler_control,
activity_log_map,
vchan_mgr,
session_rig_mgr,
background_decode_mgr,
recorder_mgr,
)?;
let handle = server.handle();
tokio::spawn(async move {
let _ = signal::ctrl_c().await;
handle.stop(false).await;
});
info!("http frontend listening on {}", addr);
info!("http frontend ready (status/control)");
server.await?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn build_server(
addr: SocketAddr,
state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>,
_callsign: Option<String>,
context: Arc<FrontendRuntimeContext>,
bookmark_store_map: Arc<bookmarks::BookmarkStoreMap>,
scheduler_store: Arc<SchedulerStoreMap>,
scheduler_status: SchedulerStatusMap,
scheduler_control: Arc<SchedulerControlManager>,
activity_log_map: SharedActivityLogMap,
vchan_mgr: Arc<ClientChannelManager>,
session_rig_mgr: Arc<api::SessionRigManager>,
background_decode_mgr: Arc<BackgroundDecodeManager>,
recorder_mgr: Arc<RecorderManager>,
) -> Result<Server, actix_web::Error> {
let state_data = web::Data::new(state_rx);
let rig_tx = web::Data::new(rig_tx);
// Share the same AtomicUsize that lives in FrontendRuntimeContext so the
// scheduler task can observe the connected-client count.
let clients = web::Data::new(context.sse_clients.clone());
let bookmark_store = web::Data::new(bookmark_store_map);
let scheduler_store = web::Data::new(scheduler_store);
let scheduler_status = web::Data::new(scheduler_status);
let scheduler_control = web::Data::new(scheduler_control);
let activity_log_map = web::Data::new(activity_log_map);
let vchan_mgr = web::Data::new(vchan_mgr);
let session_rig_mgr = web::Data::new(session_rig_mgr);
let background_decode_mgr = web::Data::new(background_decode_mgr);
let recorder_mgr = web::Data::new(recorder_mgr);
// Extract auth config values before moving context
let same_site = match context.http_auth.cookie_same_site.as_str() {
"Strict" => SameSite::Strict,
"None" => SameSite::None,
_ => SameSite::Lax, // default
};
let auth_config = AuthConfig::new(
context.http_auth.enabled,
context.http_auth.rx_passphrase.clone(),
context.http_auth.control_passphrase.clone(),
context.http_auth.tx_access_control_enabled,
Duration::from_secs(context.http_auth.session_ttl_secs),
context.http_auth.cookie_secure,
same_site,
);
// Warn operators if auth is enabled but cookie_secure is false,
// which means session cookies will be sent over plain HTTP.
if auth_config.enabled && !auth_config.cookie_secure {
warn!(
"HTTP auth is enabled but cookie_secure is false — \
session cookies will be sent over unencrypted connections. \
Set cookie_secure = true when behind a TLS-terminating proxy."
);
}
let context_data = web::Data::new(context);
let auth_state = web::Data::new(AuthState::new(auth_config.clone()));
// Spawn session cleanup task if auth is enabled
if auth_config.enabled {
let store_cleanup = auth_state.store.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(300)); // 5 minutes
loop {
interval.tick().await;
store_cleanup.cleanup_expired();
}
});
}
let server = HttpServer::new(move || {
App::new()
.app_data(state_data.clone())
.app_data(rig_tx.clone())
.app_data(clients.clone())
.app_data(context_data.clone())
.app_data(auth_state.clone())
.app_data(bookmark_store.clone())
.app_data(scheduler_store.clone())
.app_data(scheduler_status.clone())
.app_data(scheduler_control.clone())
.app_data(activity_log_map.clone())
.app_data(vchan_mgr.clone())
.app_data(session_rig_mgr.clone())
.app_data(background_decode_mgr.clone())
.app_data(recorder_mgr.clone())
.wrap(Compress::default())
.wrap(
DefaultHeaders::new()
.add(("Referrer-Policy", "same-origin"))
.add(("Cross-Origin-Resource-Policy", "same-origin"))
.add(("Cross-Origin-Opener-Policy", "same-origin"))
.add(("X-Content-Type-Options", "nosniff")),
)
// Use "real IP" so reverse-proxy setups can pass client address
// via Forwarded / X-Forwarded-For / X-Real-IP headers.
.wrap(Logger::new(
r#"%{r}a "%r" %s %b "%{Referer}i" "%{User-Agent}i" %T"#,
))
.wrap(auth::AuthMiddleware)
.configure(api::configure)
})
.shutdown_timeout(1)
.disable_signals()
.bind(addr)?
.run();
Ok(server)
}
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.configure(api::configure);
}
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use std::sync::OnceLock;
const PKG_NAME: &str = env!("CARGO_PKG_NAME");
const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
const CLIENT_BUILD_DATE: &str = env!("TRX_CLIENT_BUILD_DATE");
const INDEX_HTML: &str = include_str!("../assets/web/index.html");
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
pub const THEMES_CSS: &str = include_str!("../assets/web/themes.css");
pub const APP_JS: &str = include_str!("../assets/web/app.js");
pub const MAP_CORE_JS: &str = include_str!("../assets/web/map-core.js");
pub const SCREENSHOT_JS: &str = include_str!("../assets/web/screenshot.js");
pub const DECODE_HISTORY_WORKER_JS: &str = include_str!("../assets/web/decode-history-worker.js");
pub const WEBGL_RENDERER_JS: &str = include_str!("../assets/web/webgl-renderer.js");
pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str =
include_str!("../assets/web/leaflet-ais-tracksymbol.js");
pub const AIS_JS: &str = include_str!("../assets/web/plugins/ais.js");
pub const VDES_JS: &str = include_str!("../assets/web/plugins/vdes.js");
pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js");
pub const HF_APRS_JS: &str = include_str!("../assets/web/plugins/hf-aprs.js");
pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
pub const FT4_JS: &str = include_str!("../assets/web/plugins/ft4.js");
pub const FT2_JS: &str = include_str!("../assets/web/plugins/ft2.js");
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
pub const SAT_JS: &str = include_str!("../assets/web/plugins/sat.js");
pub const WEFAX_JS: &str = include_str!("../assets/web/plugins/wefax.js");
pub const BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js");
pub const SCHEDULER_JS: &str = include_str!("../assets/web/plugins/scheduler.js");
pub const SAT_SCHEDULER_JS: &str = include_str!("../assets/web/plugins/sat-scheduler.js");
pub const BACKGROUND_DECODE_JS: &str = include_str!("../assets/web/plugins/background-decode.js");
pub const VCHAN_JS: &str = include_str!("../assets/web/plugins/vchan.js");
pub const BANDPLAN_JSON: &str = include_str!("../assets/web/bandplan.json");
// Vendored DSEG14 Classic font
pub const DSEG14_CLASSIC_WOFF2: &[u8] =
include_bytes!("../assets/web/vendor/dseg14-classic-latin-400-normal.woff2");
// Vendored Leaflet 1.9.4
pub const LEAFLET_JS: &str = include_str!("../assets/web/vendor/leaflet.js");
pub const LEAFLET_CSS: &str = include_str!("../assets/web/vendor/leaflet.css");
pub const LEAFLET_MARKER_ICON: &[u8] = include_bytes!("../assets/web/vendor/marker-icon.png");
pub const LEAFLET_MARKER_ICON_2X: &[u8] = include_bytes!("../assets/web/vendor/marker-icon-2x.png");
pub const LEAFLET_MARKER_SHADOW: &[u8] = include_bytes!("../assets/web/vendor/marker-shadow.png");
pub const LEAFLET_LAYERS: &[u8] = include_bytes!("../assets/web/vendor/layers.png");
pub const LEAFLET_LAYERS_2X: &[u8] = include_bytes!("../assets/web/vendor/layers-2x.png");
/// Build version tag used for cache-busting asset URLs and ETag headers.
/// Computed once from `PKG_VERSION` + `CLIENT_BUILD_DATE`.
pub fn build_version_tag() -> &'static str {
static TAG: OnceLock<String> = OnceLock::new();
TAG.get_or_init(|| format!("{PKG_VERSION}-{CLIENT_BUILD_DATE}"))
}
/// Pre-computed index HTML with version/date placeholders resolved.
/// Computed once on first access, avoiding three `.replace()` calls per
/// request on the ~50 KB HTML template.
pub fn index_html() -> &'static str {
static HTML: OnceLock<String> = OnceLock::new();
HTML.get_or_init(|| {
INDEX_HTML
.replace("{pkg}", PKG_NAME)
.replace("{ver}", PKG_VERSION)
.replace("{client_build_date}", CLIENT_BUILD_DATE)
})
}
@@ -0,0 +1,829 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Client-side virtual channel registry.
//!
//! Each rig has a list of virtual channels tracked entirely within the HTTP
//! frontend process. Channel 0 is permanent and mirrors the rig's current
//! dial frequency. Additional channels are allocated by a tab (identified by
//! its SSE session UUID) and freed when that session disconnects or the tab
//! explicitly deletes them.
//!
//! Actual DSP on the server is unaffected by this registry in Phase 1; the
//! registry is the source of truth for metadata (freq/mode per channel) and
//! drives `SetFreq`/`SetMode` commands to the server when a tab selects or
//! tunes a channel.
//!
//! # Lock ordering
//!
//! [`ClientChannelManager`] owns several synchronisation primitives. To
//! prevent deadlocks, all code in this module acquires them in the following
//! fixed order:
//!
//! 1. `rigs` (RwLock) — primary channel data
//! 2. `sessions` (RwLock) — session-to-channel mapping
//! 3. `audio_cmd` / `rig_vchan_audio_cmd` (Mutex / RwLock) — fire-and-forget command senders
//!
//! **Rule**: always `drop(rigs)` before acquiring `sessions` or `audio_cmd`.
//! The command senders are independent of the first two and may be acquired
//! at any point provided no higher-priority lock is held.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, mpsc};
use uuid::Uuid;
use trx_frontend::VChanAudioCmd;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// HTTP-visible snapshot of one channel.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientChannel {
pub id: Uuid,
/// Position in the ordered list (0 = primary).
pub index: usize,
pub freq_hz: u64,
pub mode: String,
/// Audio filter bandwidth in Hz (0 = mode default).
pub bandwidth_hz: u32,
/// True for channel 0 — cannot be deleted.
pub permanent: bool,
/// Number of SSE sessions currently subscribed to this channel.
pub subscribers: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectedChannel {
pub id: Uuid,
pub freq_hz: u64,
pub mode: String,
pub bandwidth_hz: u32,
pub scheduler_bookmark_id: Option<String>,
}
#[derive(Debug, Clone)]
pub enum VChanClientError {
/// Channel cap would be exceeded.
CapReached { max: usize },
/// Channel UUID not found.
NotFound,
/// Tried to delete the permanent primary channel.
Permanent,
}
impl std::fmt::Display for VChanClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VChanClientError::CapReached { max } => {
write!(f, "channel cap reached (max {})", max)
}
VChanClientError::NotFound => write!(f, "channel not found"),
VChanClientError::Permanent => write!(f, "cannot remove the primary channel"),
}
}
}
// ---------------------------------------------------------------------------
// Internal record
// ---------------------------------------------------------------------------
struct InternalChannel {
id: Uuid,
freq_hz: u64,
mode: String,
/// Audio filter bandwidth in Hz (0 = mode default).
bandwidth_hz: u32,
decoder_kinds: Vec<String>,
permanent: bool,
scheduler_bookmark_id: Option<String>,
/// Session UUIDs currently subscribed to this channel.
session_ids: Vec<Uuid>,
}
// ---------------------------------------------------------------------------
// ClientChannelManager
// ---------------------------------------------------------------------------
/// Per-rig channel registry shared across all actix handlers.
pub struct ClientChannelManager {
/// rig_id → ordered channel list.
rigs: RwLock<HashMap<String, Vec<InternalChannel>>>,
/// session_id → (rig_id, channel_id).
sessions: RwLock<HashMap<Uuid, (String, Uuid)>>,
/// Broadcast used to push updated channel lists to SSE streams.
/// Payload: JSON string (serialised `Vec<ClientChannel>`), prefixed by
/// `"<rig_id>:"` so subscribers can filter by rig.
pub change_tx: broadcast::Sender<String>,
pub max_channels: usize,
/// Global fallback sender to the audio-client task for virtual-channel audio commands.
pub audio_cmd: std::sync::Mutex<Option<mpsc::Sender<VChanAudioCmd>>>,
/// Per-rig vchan command senders. Commands are routed to the per-rig sender
/// when available, falling back to the global `audio_cmd`.
pub rig_vchan_audio_cmd: Arc<RwLock<HashMap<String, mpsc::Sender<VChanAudioCmd>>>>,
}
impl ClientChannelManager {
pub fn new(
max_channels: usize,
rig_vchan_audio_cmd: Arc<RwLock<HashMap<String, mpsc::Sender<VChanAudioCmd>>>>,
) -> Self {
let (change_tx, _) = broadcast::channel(64);
Self {
rigs: RwLock::new(HashMap::new()),
sessions: RwLock::new(HashMap::new()),
change_tx,
max_channels: max_channels.max(1),
audio_cmd: std::sync::Mutex::new(None),
rig_vchan_audio_cmd,
}
}
/// Wire the global audio-command sender as fallback.
pub fn set_audio_cmd(&self, tx: mpsc::Sender<VChanAudioCmd>) {
*self.audio_cmd.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
}
/// Fire-and-forget: send a `VChanAudioCmd`, routing to the per-rig sender
/// when available or falling back to the global sender.
fn send_audio_cmd_for_rig(&self, rig_id: &str, cmd: VChanAudioCmd) {
// Try per-rig sender first.
if let Ok(map) = self.rig_vchan_audio_cmd.read() {
if let Some(tx) = map.get(rig_id) {
let _ = tx.try_send(cmd);
return;
}
}
// Fall back to global sender.
if let Some(tx) = self
.audio_cmd
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
{
let _ = tx.try_send(cmd);
}
}
// -- helpers --------------------------------------------------------
fn broadcast_change(&self, rig_id: &str, channels: &[InternalChannel]) {
let list: Vec<ClientChannel> = channels
.iter()
.enumerate()
.map(|(idx, c)| ClientChannel {
id: c.id,
index: idx,
freq_hz: c.freq_hz,
mode: c.mode.clone(),
bandwidth_hz: c.bandwidth_hz,
permanent: c.permanent || c.scheduler_bookmark_id.is_some(),
subscribers: c.session_ids.len(),
})
.collect();
if let Ok(json) = serde_json::to_string(&list) {
let _ = self.change_tx.send(format!("{}:{}", rig_id, json));
}
}
// -- public API -------------------------------------------------------
/// Ensure channel 0 exists for `rig_id`. Call this when the SSE stream
/// first delivers rig state so the primary channel reflects the current freq.
pub fn init_rig(&self, rig_id: &str, freq_hz: u64, mode: &str) {
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
let channels = rigs.entry(rig_id.to_string()).or_default();
if channels.is_empty() {
channels.push(InternalChannel {
id: Uuid::new_v4(),
freq_hz,
mode: mode.to_string(),
bandwidth_hz: 0,
decoder_kinds: Vec::new(),
permanent: true,
scheduler_bookmark_id: None,
session_ids: Vec::new(),
});
}
}
/// Update channel 0's freq/mode when the server pushes a new rig state.
pub fn update_primary(&self, rig_id: &str, freq_hz: u64, mode: &str) {
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
if let Some(channels) = rigs.get_mut(rig_id) {
if let Some(ch) = channels.first_mut() {
if ch.freq_hz != freq_hz || ch.mode != mode {
ch.freq_hz = freq_hz;
ch.mode = mode.to_string();
self.broadcast_change(rig_id, channels);
}
}
}
}
/// List all channels for a rig (returns empty vec if rig unknown).
pub fn channels(&self, rig_id: &str) -> Vec<ClientChannel> {
let rigs = self.rigs.read().unwrap_or_else(|e| e.into_inner());
rigs.get(rig_id)
.map(|chs| {
chs.iter()
.enumerate()
.map(|(idx, c)| ClientChannel {
id: c.id,
index: idx,
freq_hz: c.freq_hz,
mode: c.mode.clone(),
bandwidth_hz: c.bandwidth_hz,
permanent: c.permanent || c.scheduler_bookmark_id.is_some(),
subscribers: c.session_ids.len(),
})
.collect()
})
.unwrap_or_default()
}
/// Allocate a new virtual channel for `session_id`.
/// If `session_id` already owns a channel on this rig, it is released first.
/// Returns the new `ClientChannel` snapshot.
pub fn allocate(
&self,
session_id: Uuid,
rig_id: &str,
freq_hz: u64,
mode: &str,
) -> Result<ClientChannel, VChanClientError> {
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
let channels = rigs.entry(rig_id.to_string()).or_default();
if channels.len() >= self.max_channels {
return Err(VChanClientError::CapReached {
max: self.max_channels,
});
}
let id = Uuid::new_v4();
let idx = channels.len();
channels.push(InternalChannel {
id,
freq_hz,
mode: mode.to_string(),
bandwidth_hz: 0,
decoder_kinds: Vec::new(),
permanent: false,
scheduler_bookmark_id: None,
session_ids: vec![session_id],
});
let snapshot = ClientChannel {
id,
index: idx,
freq_hz,
mode: mode.to_string(),
bandwidth_hz: 0,
permanent: false,
subscribers: 1,
};
self.broadcast_change(rig_id, channels);
// Update session → channel mapping.
drop(rigs);
self.sessions
.write()
.unwrap_or_else(|e| e.into_inner())
.insert(session_id, (rig_id.to_string(), id));
// Request server-side DSP channel + audio subscription.
self.send_audio_cmd_for_rig(
rig_id,
VChanAudioCmd::Subscribe {
uuid: id,
freq_hz,
mode: mode.to_string(),
bandwidth_hz: 0,
decoder_kinds: Vec::new(),
},
);
Ok(snapshot)
}
/// Subscribe an SSE session to a channel (by channel UUID).
/// Idempotent. Returns `None` if channel not found.
pub fn subscribe_session(
&self,
session_id: Uuid,
rig_id: &str,
channel_id: Uuid,
) -> Option<ClientChannel> {
// Release previous subscription on this rig.
self.release_session_on_rig(session_id, rig_id);
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
let channels = rigs.get_mut(rig_id)?;
let (idx, ch) = channels
.iter_mut()
.enumerate()
.find(|(_, c)| c.id == channel_id)?;
if !ch.session_ids.contains(&session_id) {
ch.session_ids.push(session_id);
}
let snapshot = ClientChannel {
id: ch.id,
index: idx,
freq_hz: ch.freq_hz,
mode: ch.mode.clone(),
bandwidth_hz: ch.bandwidth_hz,
permanent: ch.permanent || ch.scheduler_bookmark_id.is_some(),
subscribers: ch.session_ids.len(),
};
self.broadcast_change(rig_id, channels);
drop(rigs);
self.sessions
.write()
.unwrap_or_else(|e| e.into_inner())
.insert(session_id, (rig_id.to_string(), channel_id));
Some(snapshot)
}
/// Release all channel subscriptions for `session_id` across all rigs.
/// Auto-removes non-permanent channels that reach 0 subscribers.
pub fn release_session(&self, session_id: Uuid) {
let mapping = {
let mut sessions = self.sessions.write().unwrap_or_else(|e| e.into_inner());
sessions.remove(&session_id)
};
if let Some((rig_id, _)) = mapping {
self.release_session_on_rig(session_id, &rig_id);
}
}
fn release_session_on_rig(&self, session_id: Uuid, rig_id: &str) {
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
let Some(channels) = rigs.get_mut(rig_id) else {
return;
};
let mut changed = false;
let mut removed_channel_ids = Vec::new();
for ch in channels.iter_mut() {
if let Some(pos) = ch.session_ids.iter().position(|&s| s == session_id) {
ch.session_ids.remove(pos);
changed = true;
}
}
let mut idx = 0;
while idx < channels.len() {
if !channels[idx].permanent
&& channels[idx].scheduler_bookmark_id.is_none()
&& channels[idx].session_ids.is_empty()
{
removed_channel_ids.push(channels[idx].id);
channels.remove(idx);
changed = true;
} else {
idx += 1;
}
}
if changed {
self.broadcast_change(rig_id, channels);
}
drop(rigs);
for channel_id in removed_channel_ids {
self.send_audio_cmd_for_rig(rig_id, VChanAudioCmd::Remove(channel_id));
}
}
/// Explicitly delete a channel by UUID (any session may do this).
pub fn delete_channel(&self, rig_id: &str, channel_id: Uuid) -> Result<(), VChanClientError> {
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
let channels = rigs.get_mut(rig_id).ok_or(VChanClientError::NotFound)?;
let pos = channels
.iter()
.position(|c| c.id == channel_id)
.ok_or(VChanClientError::NotFound)?;
if channels[pos].permanent || channels[pos].scheduler_bookmark_id.is_some() {
return Err(VChanClientError::Permanent);
}
// Collect evicted sessions to clean up the session map.
let evicted: Vec<Uuid> = channels[pos].session_ids.clone();
channels.remove(pos);
self.broadcast_change(rig_id, channels);
drop(rigs);
let mut sessions = self.sessions.write().unwrap_or_else(|e| e.into_inner());
for sid in evicted {
sessions.remove(&sid);
}
// Remove server-side DSP channel and stop audio encoding.
self.send_audio_cmd_for_rig(rig_id, VChanAudioCmd::Remove(channel_id));
Ok(())
}
/// Remove a channel by UUID across all rigs (called when the server destroys
/// it due to out-of-band center-frequency change). Does NOT send a
/// `VChanAudioCmd::Remove` since the server-side channel is already gone.
pub fn remove_by_uuid(&self, channel_id: Uuid) {
let evicted_sessions: Vec<Uuid>;
let rig_id_opt: Option<String>;
{
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
let mut found = false;
let mut evicted = Vec::new();
let mut found_rig = None;
for (rig_id, channels) in rigs.iter_mut() {
if let Some(pos) = channels.iter().position(|c| c.id == channel_id) {
evicted = channels[pos].session_ids.clone();
channels.remove(pos);
self.broadcast_change(rig_id, channels);
found_rig = Some(rig_id.clone());
found = true;
break;
}
}
evicted_sessions = evicted;
rig_id_opt = found_rig;
let _ = found; // suppress warning
}
// Clean up session → channel mapping for sessions that were subscribed.
if rig_id_opt.is_some() {
let mut sessions = self.sessions.write().unwrap_or_else(|e| e.into_inner());
for sid in evicted_sessions {
if matches!(sessions.get(&sid), Some((_, ch)) if *ch == channel_id) {
sessions.remove(&sid);
}
}
}
}
/// Update freq/mode metadata for a channel.
pub fn set_channel_freq(
&self,
rig_id: &str,
channel_id: Uuid,
freq_hz: u64,
) -> Result<(), VChanClientError> {
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
let channels = rigs.get_mut(rig_id).ok_or(VChanClientError::NotFound)?;
let ch = channels
.iter_mut()
.find(|c| c.id == channel_id)
.ok_or(VChanClientError::NotFound)?;
ch.freq_hz = freq_hz;
self.broadcast_change(rig_id, channels);
drop(rigs);
self.send_audio_cmd_for_rig(
rig_id,
VChanAudioCmd::SetFreq {
uuid: channel_id,
freq_hz,
},
);
Ok(())
}
pub fn set_channel_mode(
&self,
rig_id: &str,
channel_id: Uuid,
mode: &str,
) -> Result<(), VChanClientError> {
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
let channels = rigs.get_mut(rig_id).ok_or(VChanClientError::NotFound)?;
let ch = channels
.iter_mut()
.find(|c| c.id == channel_id)
.ok_or(VChanClientError::NotFound)?;
ch.mode = mode.to_string();
self.broadcast_change(rig_id, channels);
drop(rigs);
self.send_audio_cmd_for_rig(
rig_id,
VChanAudioCmd::SetMode {
uuid: channel_id,
mode: mode.to_string(),
},
);
Ok(())
}
pub fn set_channel_bandwidth(
&self,
rig_id: &str,
channel_id: Uuid,
bandwidth_hz: u32,
) -> Result<(), VChanClientError> {
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
let channels = rigs.get_mut(rig_id).ok_or(VChanClientError::NotFound)?;
let ch = channels
.iter_mut()
.find(|c| c.id == channel_id)
.ok_or(VChanClientError::NotFound)?;
ch.bandwidth_hz = bandwidth_hz;
self.broadcast_change(rig_id, channels);
drop(rigs);
self.send_audio_cmd_for_rig(
rig_id,
VChanAudioCmd::SetBandwidth {
uuid: channel_id,
bandwidth_hz,
},
);
Ok(())
}
/// Return the channel a session is currently subscribed to.
pub fn session_channel(&self, session_id: Uuid) -> Option<(String, Uuid)> {
self.sessions
.read()
.unwrap_or_else(|e| e.into_inner())
.get(&session_id)
.cloned()
}
/// Return the selected channel's tune metadata.
pub fn selected_channel(&self, rig_id: &str, channel_id: Uuid) -> Option<SelectedChannel> {
let rigs = self.rigs.read().unwrap_or_else(|e| e.into_inner());
let channels = rigs.get(rig_id)?;
let channel = channels.iter().find(|channel| channel.id == channel_id)?;
Some(SelectedChannel {
id: channel.id,
freq_hz: channel.freq_hz,
mode: channel.mode.clone(),
bandwidth_hz: channel.bandwidth_hz,
scheduler_bookmark_id: channel.scheduler_bookmark_id.clone(),
})
}
/// Reconcile visible scheduler-managed channels for a rig.
///
/// These channels are user-visible virtual channels sourced from the
/// scheduler's currently active extra bookmarks. They are kept separate
/// from user-allocated channels so connect-time sync can materialise them
/// without duplicating arbitrary user state.
pub fn sync_scheduler_channels(
&self,
rig_id: &str,
desired: &[(String, u64, String, u32, Vec<String>)],
) {
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
let Some(channels) = rigs.get_mut(rig_id) else {
return;
};
let mut changed = false;
let desired_map: HashMap<String, (u64, String, u32, Vec<String>)> = desired
.iter()
.map(
|(bookmark_id, freq_hz, mode, bandwidth_hz, decoder_kinds)| {
(
bookmark_id.clone(),
(*freq_hz, mode.clone(), *bandwidth_hz, decoder_kinds.clone()),
)
},
)
.collect();
let desired_ids: std::collections::HashSet<&str> =
desired_map.keys().map(String::as_str).collect();
let mut idx = 0;
while idx < channels.len() {
let remove = if let Some(bookmark_id) = channels[idx].scheduler_bookmark_id.as_deref() {
!desired_ids.contains(bookmark_id) && channels[idx].session_ids.is_empty()
} else {
false
};
if remove {
let channel_id = channels[idx].id;
channels.remove(idx);
self.send_audio_cmd_for_rig(rig_id, VChanAudioCmd::Remove(channel_id));
changed = true;
continue;
}
idx += 1;
}
for channel in channels.iter_mut() {
let Some(bookmark_id) = channel.scheduler_bookmark_id.as_deref() else {
continue;
};
let Some((freq_hz, mode, bandwidth_hz, decoder_kinds)) = desired_map.get(bookmark_id)
else {
continue;
};
if channel.freq_hz != *freq_hz {
channel.freq_hz = *freq_hz;
self.send_audio_cmd_for_rig(
rig_id,
VChanAudioCmd::SetFreq {
uuid: channel.id,
freq_hz: *freq_hz,
},
);
changed = true;
}
if channel.mode != *mode {
channel.mode = mode.clone();
self.send_audio_cmd_for_rig(
rig_id,
VChanAudioCmd::SetMode {
uuid: channel.id,
mode: mode.clone(),
},
);
changed = true;
}
if channel.bandwidth_hz != *bandwidth_hz {
channel.bandwidth_hz = *bandwidth_hz;
self.send_audio_cmd_for_rig(
rig_id,
VChanAudioCmd::SetBandwidth {
uuid: channel.id,
bandwidth_hz: *bandwidth_hz,
},
);
changed = true;
}
if channel.decoder_kinds != *decoder_kinds {
channel.decoder_kinds = decoder_kinds.clone();
self.send_audio_cmd_for_rig(
rig_id,
VChanAudioCmd::Subscribe {
uuid: channel.id,
freq_hz: channel.freq_hz,
mode: channel.mode.clone(),
bandwidth_hz: channel.bandwidth_hz,
decoder_kinds: channel.decoder_kinds.clone(),
},
);
changed = true;
}
}
for (bookmark_id, freq_hz, mode, bandwidth_hz, decoder_kinds) in desired {
let exists = channels.iter().any(|channel| {
channel.scheduler_bookmark_id.as_deref() == Some(bookmark_id.as_str())
});
if exists {
continue;
}
if channels.len() >= self.max_channels {
break;
}
let channel_id = Uuid::new_v4();
channels.push(InternalChannel {
id: channel_id,
freq_hz: *freq_hz,
mode: mode.clone(),
bandwidth_hz: *bandwidth_hz,
decoder_kinds: decoder_kinds.clone(),
permanent: false,
scheduler_bookmark_id: Some(bookmark_id.clone()),
session_ids: Vec::new(),
});
self.send_audio_cmd_for_rig(
rig_id,
VChanAudioCmd::Subscribe {
uuid: channel_id,
freq_hz: *freq_hz,
mode: mode.clone(),
bandwidth_hz: *bandwidth_hz,
decoder_kinds: decoder_kinds.clone(),
},
);
changed = true;
}
if changed {
self.broadcast_change(rig_id, channels);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_session_removes_last_non_permanent_channel() {
let mgr = ClientChannelManager::new(4, Arc::new(RwLock::new(HashMap::new())));
let rig_id = "rig-a";
let session_id = Uuid::new_v4();
mgr.init_rig(rig_id, 14_074_000, "USB");
let channel = mgr
.allocate(session_id, rig_id, 14_075_000, "DIG")
.expect("allocate vchan");
assert_eq!(mgr.channels(rig_id).len(), 2);
mgr.release_session(session_id);
let channels = mgr.channels(rig_id);
assert_eq!(channels.len(), 1);
assert!(channels.iter().all(|ch| ch.id != channel.id));
assert!(mgr.session_channel(session_id).is_none());
}
#[test]
fn sync_scheduler_channels_materializes_visible_scheduler_channels() {
let mgr = ClientChannelManager::new(4, Arc::new(RwLock::new(HashMap::new())));
let rig_id = "rig-a";
mgr.init_rig(rig_id, 14_074_000, "USB");
mgr.sync_scheduler_channels(
rig_id,
&[(
"bm-ft8".to_string(),
14_074_000,
"DIG".to_string(),
3_000,
vec!["ft8".to_string()],
)],
);
let channels = mgr.channels(rig_id);
assert_eq!(channels.len(), 2);
assert_eq!(channels[1].freq_hz, 14_074_000);
assert_eq!(channels[1].mode, "DIG");
assert_eq!(channels[1].bandwidth_hz, 3_000);
assert_eq!(channels[1].subscribers, 0);
assert!(channels[1].permanent);
}
#[test]
fn release_session_keeps_scheduler_managed_channels() {
let mgr = ClientChannelManager::new(4, Arc::new(RwLock::new(HashMap::new())));
let rig_id = "rig-a";
let session_id = Uuid::new_v4();
mgr.init_rig(rig_id, 14_074_000, "USB");
let _channel = mgr
.allocate(session_id, rig_id, 14_075_000, "DIG")
.expect("allocate vchan");
mgr.sync_scheduler_channels(
rig_id,
&[(
"bm-ft8".to_string(),
14_074_000,
"DIG".to_string(),
3_000,
vec!["ft8".to_string()],
)],
);
mgr.release_session(session_id);
let channels = mgr.channels(rig_id);
assert_eq!(channels.len(), 2);
assert_eq!(channels[1].mode, "DIG");
assert_eq!(channels[1].subscribers, 0);
}
#[test]
fn subscribed_scheduler_channel_survives_scheduler_clear_until_released() {
let mgr = ClientChannelManager::new(4, Arc::new(RwLock::new(HashMap::new())));
let rig_id = "rig-a";
let session_id = Uuid::new_v4();
mgr.init_rig(rig_id, 14_074_000, "USB");
mgr.sync_scheduler_channels(
rig_id,
&[(
"bm-aprs".to_string(),
144_800_000,
"PKT".to_string(),
12_500,
vec!["aprs".to_string()],
)],
);
let channel_id = mgr.channels(rig_id)[1].id;
mgr.subscribe_session(session_id, rig_id, channel_id)
.expect("subscribe scheduler channel");
mgr.sync_scheduler_channels(rig_id, &[]);
let channels = mgr.channels(rig_id);
assert_eq!(channels.len(), 2);
assert_eq!(channels[1].id, channel_id);
assert_eq!(channels[1].subscribers, 1);
mgr.release_session(session_id);
mgr.sync_scheduler_channels(rig_id, &[]);
let channels = mgr.channels(rig_id);
assert_eq!(channels.len(), 1);
}
}