Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00191c8d7a | ||
|
|
56c363a517 | ||
|
|
5ad91b4ab6 | ||
|
|
1843522b45 | ||
|
|
41ddecf5a7 | ||
|
|
b06c37affa | ||
|
|
b4912f5879 | ||
|
|
27f2558193 | ||
|
|
3a9bf1b7ce | ||
|
|
dd5760c436 | ||
|
|
eee3630f04 | ||
|
|
b31790ff48 | ||
|
|
2f4973ed70 | ||
|
|
26b00608b2 | ||
|
|
c2455bb08c | ||
|
|
22ff1349f3 | ||
|
|
8b72573521 | ||
|
|
f64032dcbe | ||
|
|
8fd1761688 | ||
|
|
7022f20b76 | ||
|
|
0d4c657b97 | ||
|
|
23dbcac5b6 | ||
|
|
695434942f | ||
|
|
a1a8d1d1d3 | ||
|
|
ddb33b6ff3 | ||
|
|
f87289b129 | ||
|
|
812359c744 | ||
|
|
d929ad3bde | ||
|
|
8aeca613aa | ||
|
|
ee59d8efdd | ||
|
|
ddd151c1ee | ||
|
|
5136704826 | ||
|
|
e300cc343d | ||
|
|
1fb196a64e | ||
|
|
c8cc235e88 | ||
|
|
ab3fe4bbb7 | ||
|
|
40accb9697 | ||
|
|
cbca810a30 | ||
|
|
26a167cb82 | ||
|
|
3d49839ed5 | ||
|
|
26be675242 | ||
|
|
1a42d58075 | ||
|
|
844de809a4 | ||
|
|
e910e644d5 | ||
|
|
50e07e2715 | ||
|
|
82121491c5 | ||
|
|
0338c8b8d2 | ||
|
|
c7994347e9 | ||
|
|
9fef0ebc7b | ||
|
|
508cf2ba87 | ||
|
|
6a83e2e90a | ||
|
|
fc2f55bab9 | ||
|
|
4fb65971e4 | ||
|
|
021d31d780 | ||
|
|
074c67c7b9 | ||
|
|
d93f784aad | ||
|
|
ec59908e0d | ||
|
|
7251ec276d | ||
|
|
0ab0f80986 | ||
|
|
2cbcc23fec | ||
|
|
968fb3ea2d | ||
|
|
5ba3ecf59b | ||
|
|
03bdc10b02 | ||
|
|
d96624be4f | ||
|
|
a5dccd5489 | ||
|
|
42e5dc8604 | ||
|
|
e6f593b959 | ||
|
|
9ac6d7f82c | ||
|
|
a58553ba66 | ||
|
|
7442437757 | ||
|
|
bbc53d56b0 | ||
|
|
888f793eb8 | ||
|
|
860760ecfc | ||
|
|
b4ea35baf8 | ||
|
|
508ed8a8e7 | ||
|
|
d9fc623ef4 | ||
|
|
53736ef750 | ||
|
|
6566eae2c7 | ||
|
|
f3eeddff7c | ||
|
|
1540ca5b4e | ||
|
|
e449704fd2 | ||
|
|
af74430551 | ||
|
|
d07dbdc645 | ||
|
|
d07a627508 | ||
|
|
9fbf90ee91 | ||
|
|
d393b3cefc | ||
|
|
35a492ec95 | ||
|
|
b20da5c541 | ||
|
|
a0bdaa2c4b | ||
|
|
6676b66993 | ||
|
|
412651d612 | ||
|
|
d347b493d9 | ||
|
|
39e59dca96 | ||
|
|
5654520901 | ||
|
|
ff75fcc692 | ||
|
|
4728b578ae | ||
|
|
7db7fad9b0 | ||
|
|
830f7299fe | ||
|
|
061738a63b | ||
|
|
e8bd97655f | ||
|
|
7d0b36450d | ||
|
|
b12c83e8b5 | ||
|
|
ff4e2a5c5d | ||
|
|
2ef9f80fc1 | ||
|
|
6fe549e34e | ||
|
|
406a8f86b1 | ||
|
|
4b17b4ac1d | ||
|
|
977f7b709e | ||
|
|
a2838b06a2 | ||
|
|
c6cd661676 | ||
|
|
bf08c7ebc0 | ||
|
|
c7470acc1c | ||
|
|
bcde03de38 | ||
|
|
63d46562c7 | ||
|
|
e575b3b365 | ||
|
|
2121817c04 | ||
|
|
d0bd38d7df | ||
|
|
bf9617e24d | ||
|
|
3c235fce5e |
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "trx-rs SDK",
|
||||||
|
"image": "git.haxx.space/sjg/trx-rs/sdk:latest",
|
||||||
|
"workspaceFolder": "/work",
|
||||||
|
"workspaceMount": "source=${localWorkspaceFolder},target=/work,type=bind",
|
||||||
|
"mounts": [
|
||||||
|
"source=trx-rs-sccache,target=/sccache,type=volume"
|
||||||
|
],
|
||||||
|
"containerEnv": {
|
||||||
|
"RUSTC_WRAPPER": "sccache",
|
||||||
|
"CARGO_INCREMENTAL": "0",
|
||||||
|
"SCCACHE_DIR": "/sccache"
|
||||||
|
},
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"rust-lang.rust-analyzer",
|
||||||
|
"tamasfe.even-better-toml"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
# CI for the Docker-executor runner (VM). The lint, test and frontend jobs run
|
||||||
|
# inside the shared trx-rs SDK image (container/Containerfile), which bakes in
|
||||||
|
# the pinned Rust toolchain, Node.js, Chromium and all build dependencies. The
|
||||||
|
# reuse job uses the upstream Docker action, which the Docker executor launches
|
||||||
|
# as a sibling container.
|
||||||
|
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
# sccache: shared compilation cache persisted on the runner host (see the
|
||||||
|
# -v mount in runner-config.example.yaml). CARGO_INCREMENTAL=0 because
|
||||||
|
# sccache cannot cache incremental artifacts.
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
CARGO_INCREMENTAL: "0"
|
||||||
|
SCCACHE_DIR: /sccache
|
||||||
|
SCCACHE_CACHE_SIZE: "20G"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: rustfmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
- name: clippy
|
||||||
|
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
- name: sccache stats
|
||||||
|
if: always()
|
||||||
|
run: sccache --show-stats
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --workspace --all-targets --locked
|
||||||
|
- name: Test
|
||||||
|
run: cargo test --workspace --locked
|
||||||
|
- name: sccache stats
|
||||||
|
if: always()
|
||||||
|
run: sccache --show-stats
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/trx-client/trx-frontend/trx-frontend-http/frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Cache npm downloads
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: npm-${{ runner.os }}-${{ hashFiles('src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
npm-${{ runner.os }}-
|
||||||
|
- name: Install locked frontend dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Type-check
|
||||||
|
run: npm run typecheck
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
- name: Test
|
||||||
|
run: npm test
|
||||||
|
# Chromium comes from the SDK image at the path the smoke test defaults
|
||||||
|
# to, so there is nothing to install here.
|
||||||
|
- name: Browser smoke test
|
||||||
|
run: npm run test:browser
|
||||||
|
- name: Verify generated assets
|
||||||
|
run: npm run verify-generated
|
||||||
|
|
||||||
|
reuse:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: fsfe/reuse-action@v5
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
name: Sync docs to Wiki
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'docs/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
wiki:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Checkout wiki
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: ${{ github.repository }}.wiki
|
|
||||||
path: wiki
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Sync docs to wiki
|
|
||||||
run: |
|
|
||||||
rsync -av --delete --exclude='.git' docs/ wiki/
|
|
||||||
cd wiki
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add -A
|
|
||||||
if git diff --cached --quiet; then
|
|
||||||
echo "No wiki changes to commit."
|
|
||||||
else
|
|
||||||
git commit -m "Sync docs from ${GITHUB_SHA::8}"
|
|
||||||
git push
|
|
||||||
fi
|
|
||||||
@@ -21,6 +21,9 @@ Cargo.lock
|
|||||||
coverage/
|
coverage/
|
||||||
benchmarks/
|
benchmarks/
|
||||||
|
|
||||||
|
# Frontend dependencies
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
+21
-1
@@ -18,7 +18,7 @@ When contributing to the project, please follow these guidelines:
|
|||||||
- Use a maximum of 80 characters per line.
|
- Use a maximum of 80 characters per line.
|
||||||
- Use a blank line between the commit message and the body.
|
- Use a blank line between the commit message and the body.
|
||||||
- Sign your commits with `git commit -s`.
|
- Sign your commits with `git commit -s`.
|
||||||
- Explicitly mark LLM usage in commit messages with 'Co-authored-by:'.
|
- Disclose AI/LLM assistance with an `Assisted-By:` trailer (see below).
|
||||||
|
|
||||||
Use the format below for commit titles:
|
Use the format below for commit titles:
|
||||||
[<type>](<crate>): <description>
|
[<type>](<crate>): <description>
|
||||||
@@ -39,3 +39,23 @@ Allowed types:
|
|||||||
- chore: build or maintenance changes
|
- chore: build or maintenance changes
|
||||||
|
|
||||||
Write isolated commits for each crate.
|
Write isolated commits for each crate.
|
||||||
|
|
||||||
|
## Attribution trailers
|
||||||
|
|
||||||
|
This project follows the Linux kernel convention for crediting work.
|
||||||
|
|
||||||
|
The `Co-Authored-By:` and `Co-Developed-By:` trailers name **people** who
|
||||||
|
authored the change. They are reserved for humans, and every person named
|
||||||
|
this way must also add their own `Signed-off-by:` line. Never use these
|
||||||
|
trailers for tools, assistants, or bots.
|
||||||
|
|
||||||
|
When a commit was produced with help from an AI assistant or LLM,
|
||||||
|
disclose it with an `Assisted-By:` trailer naming the tool (and model,
|
||||||
|
where relevant). The human committer remains the author of record and
|
||||||
|
takes responsibility for the change through `Signed-off-by:`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
Assisted-By: Claude Code (claude-opus-4)
|
||||||
|
Co-Authored-By: Jane Developer <jane@example.com>
|
||||||
|
Signed-off-by: Your Name <you@example.com>
|
||||||
|
|||||||
Generated
+35
@@ -2645,6 +2645,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "termcolor"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -3137,6 +3146,7 @@ dependencies = [
|
|||||||
"sgp4",
|
"sgp4",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"ts-rs",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3195,6 +3205,7 @@ dependencies = [
|
|||||||
"trx-core",
|
"trx-core",
|
||||||
"trx-frontend",
|
"trx-frontend",
|
||||||
"trx-protocol",
|
"trx-protocol",
|
||||||
|
"ts-rs",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3238,6 +3249,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
|
"ts-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3333,6 +3345,29 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ts-rs"
|
||||||
|
version = "12.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"ts-rs-macros",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ts-rs-macros"
|
||||||
|
version = "12.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"termcolor",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.0"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) <year> <copyright holders>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
SIL OPEN FONT LICENSE
|
||||||
|
|
||||||
|
Version 1.1 - 26 February 2007
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
|
||||||
|
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
|
||||||
|
This license becomes null and void if any of the above conditions are not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -64,7 +64,7 @@ brew install soapysdr
|
|||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
See [Build Requirements](https://github.com/sgrams/trx-rs/wiki/User-Manual#build-requirements)
|
See [Build Requirements](https://git.haxx.space/sjg/trx-rs/wiki/User-Manual#build-requirements)
|
||||||
in the wiki for details on each library.
|
in the wiki for details on each library.
|
||||||
|
|
||||||
> **Note:** `cmake` is required even when a system Opus library is installed.
|
> **Note:** `cmake` is required even when a system Opus library is installed.
|
||||||
@@ -133,15 +133,15 @@ a unified set of frontends.
|
|||||||
|
|
||||||
| Resource | Description |
|
| Resource | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| [User Manual](https://github.com/sgrams/trx-rs/wiki/User-Manual) | Configuration, features, and usage |
|
| [User Manual](https://git.haxx.space/sjg/trx-rs/wiki/User-Manual) | Configuration, features, and usage |
|
||||||
| [Architecture](https://github.com/sgrams/trx-rs/wiki/Architecture) | System design, crate layout, data flow, and internals |
|
| [Architecture](https://git.haxx.space/sjg/trx-rs/wiki/Architecture) | System design, crate layout, data flow, and internals |
|
||||||
| [Optimization Guidelines](https://github.com/sgrams/trx-rs/wiki/Optimization-Guidelines) | Performance guidelines for the real-time DSP pipeline |
|
| [Optimization Guidelines](https://git.haxx.space/sjg/trx-rs/wiki/Optimization-Guidelines) | Performance guidelines for the real-time DSP pipeline |
|
||||||
| [Planned Features](https://github.com/sgrams/trx-rs/wiki/Planned-Features) | Roadmap and design notes |
|
| [Planned Features](https://git.haxx.space/sjg/trx-rs/wiki/Planned-Features) | Roadmap and design notes |
|
||||||
| [Contributing](CONTRIBUTING.md) | Commit conventions, workflow, and code style |
|
| [Contributing](CONTRIBUTING.md) | Commit conventions, workflow, and code style |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
|
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
|
||||||
bundled third-party license files. Bundled third-party components (Leaflet and
|
bundled third-party license files. Bundled third-party components retain their
|
||||||
the Leaflet AIS tracksymbol plugin under `assets/web/vendor/`) retain their
|
original licenses: Leaflet is BSD-2-Clause, DSEG is OFL-1.1, and opus-decoder
|
||||||
original BSD-2-Clause license.
|
is MIT.
|
||||||
|
|||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
version = 1
|
||||||
|
|
||||||
|
# Project-owned files without an in-file SPDX header (docs, config,
|
||||||
|
# repo metadata, logos, and bespoke web assets).
|
||||||
|
[[annotations]]
|
||||||
|
path = [
|
||||||
|
".gitattributes",
|
||||||
|
".gitignore",
|
||||||
|
"CLAUDE.md",
|
||||||
|
"CONTRIBUTING.md",
|
||||||
|
"README.md",
|
||||||
|
"trx-rs.toml.example",
|
||||||
|
"docs/**",
|
||||||
|
"aidocs/**",
|
||||||
|
"container/**",
|
||||||
|
".devcontainer/**",
|
||||||
|
"src/decoders/trx-ftx/README.md",
|
||||||
|
"src/decoders/trx-wxsat/README.md",
|
||||||
|
"assets/trx-logo.png",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-favicon.png",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-logo.png",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/bandplan.json",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/**",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.json",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.worker.json",
|
||||||
|
]
|
||||||
|
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
|
||||||
|
SPDX-License-Identifier = "GPL-2.0-or-later"
|
||||||
|
|
||||||
|
# Vendored Leaflet 1.9.4 (https://leafletjs.com), distributed under BSD-2-Clause.
|
||||||
|
[[annotations]]
|
||||||
|
path = [
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/leaflet.js",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/leaflet.css",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/layers.png",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/layers-2x.png",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-icon.png",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-icon-2x.png",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-shadow.png",
|
||||||
|
]
|
||||||
|
SPDX-FileCopyrightText = "2010-2023 Vladimir Agafonkin, 2010-2011 CloudMade"
|
||||||
|
SPDX-License-Identifier = "BSD-2-Clause"
|
||||||
|
|
||||||
|
# Vendored DSEG14 font (https://github.com/keshikan/DSEG), SIL OFL 1.1.
|
||||||
|
[[annotations]]
|
||||||
|
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/dseg14-classic-latin-400-normal.woff2"]
|
||||||
|
SPDX-FileCopyrightText = "2020 The DSEG Authors (https://github.com/keshikan/DSEG)"
|
||||||
|
SPDX-License-Identifier = "OFL-1.1"
|
||||||
|
|
||||||
|
# Vendored opus-decoder 0.7.11 browser build
|
||||||
|
# (https://github.com/eshaz/wasm-audio-decoders), MIT.
|
||||||
|
# SHA-256: fd73ee0a9c8a5e233c0b88234df14f46003ad89bb4ba27435bc8714db2a6dc62
|
||||||
|
[[annotations]]
|
||||||
|
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/opus-decoder-0.7.11.min.js"]
|
||||||
|
SPDX-FileCopyrightText = "2021-2025 Ethan Halsall"
|
||||||
|
SPDX-License-Identifier = "MIT"
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Work In Progress — Project Improvement Areas
|
||||||
|
|
||||||
|
Living tracker for engineering-infrastructure and hardening work identified
|
||||||
|
during a July 2026 repo scan. The architecture-level backlog
|
||||||
|
(`docs/Improvement-Areas.md`, P0–P3) is closed; these items focus on the
|
||||||
|
tooling, robustness, and product concerns *around* the code.
|
||||||
|
|
||||||
|
Status legend: **Done** · **In progress** · **Not started**
|
||||||
|
|
||||||
|
| # | Tier | Area | Status |
|
||||||
|
|----|------|------|--------|
|
||||||
|
| 1 | 1 — Infrastructure | Gitea Actions CI (fmt, clippy, test, REUSE) | In progress |
|
||||||
|
| 2 | 1 — Infrastructure | Supply-chain & lint governance (cargo-deny/audit, MSRV, `[workspace.lints]`) | Not started |
|
||||||
|
| 3 | 1 — Infrastructure | Release & deployment (container image, systemd units, binary releases) | Not started |
|
||||||
|
| 4 | 2 — Robustness | Panic-resilience audit (harden ~495 unwrap/expect/panic sites) | Not started |
|
||||||
|
| 5 | 2 — Robustness | `unsafe` SIMD safety scaffolding (`# Safety` docs, scalar↔SIMD equivalence tests) | Not started |
|
||||||
|
| 6 | 2 — Robustness | DSP performance benchmarks (criterion, guard optimization gains) | Not started |
|
||||||
|
| 7 | 2 — Robustness | Test-coverage measurement & gap-filling (llvm-cov; CAT backends, server tasks) | Not started |
|
||||||
|
| 8 | 3 — Product | Frontend modularization & tooling (split `app.js` into ES modules, ESLint, JS tests) | Not started |
|
||||||
|
| 9 | 3 — Product | Runtime observability (`/health`, Prometheus metrics) | Not started |
|
||||||
|
| 10 | 3 — Product | Documentation freshness & consolidation (reconcile `docs/` with code) | Not started |
|
||||||
|
|
||||||
|
## Tier 1 — Infrastructure gaps
|
||||||
|
|
||||||
|
### 1. Gitea Actions CI
|
||||||
|
No `.gitea/` / `.github/` / Woodpecker workflows exist, despite 768 tests.
|
||||||
|
Add a workflow running `cargo fmt --check`, `cargo clippy -D warnings`,
|
||||||
|
`cargo build`/`cargo test`, and REUSE lint on push + pull_request.
|
||||||
|
System deps: `pkg-config cmake libopus-dev libasound2-dev libsoapysdr-dev`
|
||||||
|
(the `soapysdr` backend is a default feature).
|
||||||
|
|
||||||
|
Notes for the runner: assumes an `act_runner` registered with an
|
||||||
|
`ubuntu-latest` label and GitHub-action proxying enabled (used by
|
||||||
|
`actions/checkout`, `actions/cache`, `fsfe/reuse-action`). `clippy` runs
|
||||||
|
with `-D warnings`; core crates are already clean, but if the first full
|
||||||
|
`--all-features` run surfaces warnings in a less-travelled crate, fix them
|
||||||
|
(preferred) or temporarily soften that step.
|
||||||
|
|
||||||
|
### 2. Supply-chain & lint governance
|
||||||
|
No `deny.toml`, `cargo audit`, `rustfmt.toml`/`clippy.toml`, declared MSRV,
|
||||||
|
or `[workspace.lints]`. Add dependency auditing to CI, pin an MSRV
|
||||||
|
(`rust-version`), and centralize lint policy in the workspace manifest.
|
||||||
|
|
||||||
|
### 3. Release & deployment
|
||||||
|
No `Dockerfile`, systemd units, packaging, or release automation (only
|
||||||
|
`script/dummy-server.sh`). Add a container image, example systemd units for
|
||||||
|
`trx-server`/`trx-client`, and a tag-triggered static-binary release job.
|
||||||
|
|
||||||
|
## Tier 2 — Robustness & correctness
|
||||||
|
|
||||||
|
### 4. Panic-resilience audit
|
||||||
|
495 `unwrap()`/`expect()`/`panic!` sites, 11 in the hottest server files
|
||||||
|
(`audio.rs`, `rig_task.rs`). A panic there can drop a rig task or the
|
||||||
|
process. Convert hot-path panics to error propagation / graceful
|
||||||
|
degradation; reserve `expect` for documented invariants.
|
||||||
|
|
||||||
|
### 5. `unsafe` SIMD safety scaffolding
|
||||||
|
13 `unsafe` blocks (AVX2 DSP). Add `# Safety` docs stating invariants,
|
||||||
|
confirm runtime feature detection is tested, and add property tests
|
||||||
|
asserting SIMD output matches the scalar fallback across random inputs.
|
||||||
|
|
||||||
|
### 6. DSP performance benchmarks
|
||||||
|
`docs/Optimization-Guidelines.md` documents NCO/polyphase/AVX2 gains, but no
|
||||||
|
`criterion` benches guard them. Add benches for the demod/resample/FFT hot
|
||||||
|
paths so regressions surface as numbers.
|
||||||
|
|
||||||
|
### 7. Test-coverage measurement & gap-filling
|
||||||
|
67 of 146 Rust files have no test module. Protocol is well covered; backends
|
||||||
|
(CAT BCD/ASCII encoding), `listener.rs`, and `config.rs` look thin. Wire up
|
||||||
|
`cargo-llvm-cov` and target the CAT backends and server tasks first.
|
||||||
|
|
||||||
|
## Tier 3 — Product & maintainability
|
||||||
|
|
||||||
|
### 8. Frontend modularization & tooling
|
||||||
|
`app.js` is 8,760 lines and `map-core.js` 3,515, with no modules, linter, or
|
||||||
|
tests. Keeping vanilla HTML+JS (no framework), split into native ES modules
|
||||||
|
by concern, add ESLint + Prettier, and add `node:test` unit tests for pure
|
||||||
|
logic (frequency formatting, unit math, decode parsing).
|
||||||
|
|
||||||
|
### 9. Runtime observability
|
||||||
|
`tracing` is set up, but there is no `/health` endpoint or metrics
|
||||||
|
instrumentation. Add health/readiness endpoints and Prometheus-format
|
||||||
|
metrics (decode rates, reconnects, audio underruns, per-rig state).
|
||||||
|
|
||||||
|
### 10. Documentation freshness & consolidation
|
||||||
|
`docs/` (13 files) is mostly dated 2026-03-29 while code moved into July, and
|
||||||
|
mixes planning artifacts with reference docs. Reconcile against current code,
|
||||||
|
separate "plans" from "reference," and fold still-true content into the
|
||||||
|
canonical docs.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
# trx-rs SDK / build image.
|
||||||
|
#
|
||||||
|
# Single source of truth for the build environment. Used two ways:
|
||||||
|
# * CI — as the job container for the lint/test jobs (Docker executor).
|
||||||
|
# * Dev — run locally or via .devcontainer for a reproducible toolchain.
|
||||||
|
#
|
||||||
|
# Pinning the Rust version here (and in rust-toolchain.toml) means CI and every
|
||||||
|
# developer share the exact same rustc/clippy, so "works locally, fails in CI"
|
||||||
|
# cannot happen.
|
||||||
|
FROM docker.io/library/debian:bookworm-slim
|
||||||
|
|
||||||
|
# Keep in sync with rust-toolchain.toml.
|
||||||
|
ARG RUST_VERSION=1.97.1
|
||||||
|
ARG NODE_MAJOR=20
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
RUSTUP_HOME=/usr/local/rustup \
|
||||||
|
CARGO_HOME=/usr/local/cargo \
|
||||||
|
PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
|
||||||
|
# Build dependencies (mirror README's manual instructions).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl git \
|
||||||
|
build-essential pkg-config cmake clang libclang-dev \
|
||||||
|
libopus-dev libasound2-dev libsoapysdr-dev chromium \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Node.js — JS-based actions (actions/checkout, actions/cache) run *inside*
|
||||||
|
# the job container under the Docker executor, so node must be present.
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||||
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Pinned Rust toolchain, installed world-readable so any UID the runner or a
|
||||||
|
# devcontainer uses can invoke cargo.
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
|
| sh -s -- -y --no-modify-path \
|
||||||
|
--default-toolchain "${RUST_VERSION}" --profile minimal \
|
||||||
|
--component rustfmt --component clippy \
|
||||||
|
&& chmod -R a+rwX "$RUSTUP_HOME" "$CARGO_HOME"
|
||||||
|
|
||||||
|
# sccache — shared compilation cache. Enabled at build time via
|
||||||
|
# RUSTC_WRAPPER (see the CI workflow and .devcontainer), not repo-wide, so
|
||||||
|
# non-SDK builds are unaffected. musl build is static and runs anywhere.
|
||||||
|
#
|
||||||
|
# The release asset is per-architecture, so resolve it from `uname -m` rather
|
||||||
|
# than hardcoding one triple: everything else in this image is arch-agnostic,
|
||||||
|
# and a pinned x86_64 URL is what forces an amd64 build (and Rosetta or qemu)
|
||||||
|
# on an arm64 host. `uname -m` reflects the build platform under plain
|
||||||
|
# docker/podman build as well as buildx, unlike the BuildKit-only TARGETARCH.
|
||||||
|
ARG SCCACHE_VERSION=0.8.2
|
||||||
|
RUN set -eux; \
|
||||||
|
case "$(uname -m)" in \
|
||||||
|
x86_64) sccache_arch=x86_64 ;; \
|
||||||
|
aarch64|arm64) sccache_arch=aarch64 ;; \
|
||||||
|
*) echo "unsupported architecture for sccache: $(uname -m)" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
sccache_dist="sccache-v${SCCACHE_VERSION}-${sccache_arch}-unknown-linux-musl"; \
|
||||||
|
curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/${sccache_dist}.tar.gz" \
|
||||||
|
| tar -xz -C /tmp; \
|
||||||
|
install -m755 "/tmp/${sccache_dist}/sccache" /usr/local/bin/sccache; \
|
||||||
|
rm -rf /tmp/sccache-*
|
||||||
|
|
||||||
|
WORKDIR /work
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# trx-rs SDK image
|
||||||
|
|
||||||
|
A single container image that is the canonical build environment for trx-rs,
|
||||||
|
used **both** by CI and by developers. It bakes in the pinned Rust toolchain
|
||||||
|
(matching `rust-toolchain.toml`) and every build dependency, so the compiler
|
||||||
|
and `clippy` are identical everywhere — no "works on my machine".
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `Containerfile` | The SDK image (Debian + build deps + pinned Rust + Node + git). |
|
||||||
|
| `runner-config.example.yaml` | Example act_runner config for the CI VM (Docker executor). |
|
||||||
|
|
||||||
|
## Build and publish
|
||||||
|
|
||||||
|
Nothing in the image is architecture-specific: the base image, the Debian build
|
||||||
|
dependencies, Node.js, `rustup` and the `sccache` release all resolve per
|
||||||
|
architecture, so the same `Containerfile` builds natively on x86_64 and arm64.
|
||||||
|
|
||||||
|
Single architecture — the tag then only works on the architecture you built it
|
||||||
|
on:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from the repo root
|
||||||
|
podman build -t git.haxx.space/sjg/trx-rs/sdk:latest container
|
||||||
|
podman login git.haxx.space
|
||||||
|
podman push git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Both architectures without emulation.** The CI runner is x86_64 and Apple
|
||||||
|
Silicon developer machines are arm64, so `:latest` has to be a manifest list —
|
||||||
|
a single-architecture tag makes the other side fall back to Rosetta or qemu.
|
||||||
|
Build each half natively on a host of that architecture, then join them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# on an x86_64 host
|
||||||
|
podman build --platform linux/amd64 -t git.haxx.space/sjg/trx-rs/sdk:latest-amd64 container
|
||||||
|
podman push git.haxx.space/sjg/trx-rs/sdk:latest-amd64
|
||||||
|
|
||||||
|
# on an arm64 host
|
||||||
|
podman build --platform linux/arm64 -t git.haxx.space/sjg/trx-rs/sdk:latest-arm64 container
|
||||||
|
podman push git.haxx.space/sjg/trx-rs/sdk:latest-arm64
|
||||||
|
|
||||||
|
# from either, once both are pushed
|
||||||
|
podman manifest create git.haxx.space/sjg/trx-rs/sdk:latest \
|
||||||
|
git.haxx.space/sjg/trx-rs/sdk:latest-amd64 \
|
||||||
|
git.haxx.space/sjg/trx-rs/sdk:latest-arm64
|
||||||
|
podman manifest push --all git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Building both from one machine is a single command
|
||||||
|
(`podman build --platform linux/amd64,linux/arm64 --manifest ...`), but the
|
||||||
|
foreign half runs under emulation and is slow — the two-host flow above is
|
||||||
|
what keeps every build native.
|
||||||
|
|
||||||
|
Tag with the Rust version too (e.g. `:1.97.1`) if you want reproducible pins.
|
||||||
|
Make the package **public** (Gitea → Packages → the image → Settings) so the CI
|
||||||
|
runner and developers can pull it without credentials. If you keep it private,
|
||||||
|
add `credentials:` under the workflow's `container:` and log the runner into the
|
||||||
|
registry.
|
||||||
|
|
||||||
|
Pushing a rebuilt image is not enough on its own: `:latest` is a moving tag, and
|
||||||
|
act_runner reuses whatever it cached the first time unless `force_pull: true` is
|
||||||
|
set (see `runner-config.example.yaml`). Without it the job log says
|
||||||
|
`Image exists? true` and the run behaves as though the image were never
|
||||||
|
rebuilt — a tool added to the `Containerfile` reads as missing from the image.
|
||||||
|
Either set `force_pull`, or refresh the VM's copy by hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
|
docker run --rm git.haxx.space/sjg/trx-rs/sdk:latest sccache --version
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS note
|
||||||
|
|
||||||
|
Apple's `container` CLI builds through a BuildKit helper VM that is configured
|
||||||
|
with Rosetta whether or not the target is x86_64, so `container build` fails
|
||||||
|
with *"Rosetta is not installed"* on a clean machine. That is a property of the
|
||||||
|
builder, not of this image — `container run` works natively without it. Either
|
||||||
|
install Rosetta once (`softwareupdate --install-rosetta`, after which an arm64
|
||||||
|
build still produces a native arm64 image), or build with Podman, whose arm64
|
||||||
|
BuildKit needs no emulation.
|
||||||
|
|
||||||
|
## Developer use
|
||||||
|
|
||||||
|
Reproducible one-off build, no local toolchain needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman run --rm -it -v "$PWD":/work -w /work \
|
||||||
|
git.haxx.space/sjg/trx-rs/sdk:latest \
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Or open the repo in the image via VS Code / JetBrains "Reopen in Container"
|
||||||
|
(`.devcontainer/devcontainer.json` points at the same image).
|
||||||
|
|
||||||
|
Building outside the container? `rust-toolchain.toml` pins the same rustc, so
|
||||||
|
`rustup` installs the matching toolchain automatically.
|
||||||
|
|
||||||
|
## CI use
|
||||||
|
|
||||||
|
`.gitea/workflows/ci.yml` runs the `lint`, `test` and `frontend` jobs *inside*
|
||||||
|
this image via the `container:` key, so they skip all setup and go straight to
|
||||||
|
`cargo` and `npm`. The frontend job needs three things from the image beyond
|
||||||
|
Rust: Node.js for the toolchain, Chromium at `/usr/bin/chromium` for the
|
||||||
|
browser smoke test, and `cargo` — `npm run verify-generated` regenerates the
|
||||||
|
Rust wire contracts before checking for drift.
|
||||||
|
|
||||||
|
The `reuse` job stays on the upstream `fsfe/reuse-action` (a Docker action the
|
||||||
|
Docker executor launches as a sibling container) — nothing REUSE-related is
|
||||||
|
baked into the SDK, and it lints the whole repository, so no job runs its own
|
||||||
|
licence check.
|
||||||
|
|
||||||
|
## Compilation cache (sccache)
|
||||||
|
|
||||||
|
The SDK image ships [`sccache`](https://github.com/mozilla/sccache). It is
|
||||||
|
enabled via `RUSTC_WRAPPER=sccache` in CI and the devcontainer (not repo-wide,
|
||||||
|
so plain `cargo` builds outside the SDK are unaffected).
|
||||||
|
|
||||||
|
- **CI** persists the cache on the runner host — create the dir once:
|
||||||
|
`mkdir -p /var/cache/sccache`. It is bind-mounted into each job container at
|
||||||
|
`/sccache` (see `runner-config.example.yaml`), so cache survives across runs
|
||||||
|
and is shared between the lint/test jobs and both projects.
|
||||||
|
- **Devcontainer** uses a named volume (`trx-rs-sccache`).
|
||||||
|
- Check effectiveness with `sccache --show-stats` (the CI jobs print it).
|
||||||
|
|
||||||
|
`CARGO_INCREMENTAL=0` is set wherever sccache is on, since sccache cannot cache
|
||||||
|
incremental artifacts.
|
||||||
|
|
||||||
|
## CI runner (Alpine / OpenRC)
|
||||||
|
|
||||||
|
The runner uses the **Docker executor** (not the host executor): per-job
|
||||||
|
container isolation and standard `ubuntu-latest` semantics. `act_runner` runs
|
||||||
|
as an OpenRC service. Files provided:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `act_runner.openrc` | OpenRC init script (`supervise-daemon`, depends on docker). |
|
||||||
|
| `act_runner.confd.example` | Per-instance `conf.d` settings for multi-runner hosts. |
|
||||||
|
|
||||||
|
**Cap the thread budget.** In a VM, pin its vCPUs to specific host threads
|
||||||
|
(libvirt/KVM):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<vcpu placement='static'>2</vcpu>
|
||||||
|
<cputune>
|
||||||
|
<vcpupin vcpu='0' cpuset='4'/>
|
||||||
|
<vcpupin vcpu='1' cpuset='5'/>
|
||||||
|
</cputune>
|
||||||
|
```
|
||||||
|
|
||||||
|
On bare metal, the `container.options: "--cpus=2"` and `capacity: 1` in
|
||||||
|
`runner-config.example.yaml` already bound each runner.
|
||||||
|
|
||||||
|
**Set it up:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Docker + a dedicated user with socket access
|
||||||
|
apk add docker docker-cli
|
||||||
|
rc-update add docker default && rc-service docker start
|
||||||
|
adduser -S -D -H -h /var/lib/act_runner act
|
||||||
|
addgroup act docker
|
||||||
|
|
||||||
|
# 2. act_runner binary (static Go build, works on musl)
|
||||||
|
# Upstream publishes per-architecture builds; pick the host's.
|
||||||
|
case "$(uname -m)" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac
|
||||||
|
curl -fsSL -o /usr/local/bin/act_runner \
|
||||||
|
"https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-${arch}"
|
||||||
|
chmod +x /usr/local/bin/act_runner
|
||||||
|
|
||||||
|
# 3. Config + register one runner per project (scope keeps their jobs apart)
|
||||||
|
install -Dm644 container/runner-config.example.yaml /etc/act_runner/trx-rs.yaml
|
||||||
|
install -d -o act /var/lib/act_runner/trx-rs
|
||||||
|
su act -s /bin/sh -c 'cd /var/lib/act_runner/trx-rs && \
|
||||||
|
act_runner register --no-interactive \
|
||||||
|
--instance https://git.haxx.space --token <TOKEN> \
|
||||||
|
--name trx-rs-ci \
|
||||||
|
--labels "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"'
|
||||||
|
|
||||||
|
# 4. OpenRC service (repeat the symlink+conf.d for the second project)
|
||||||
|
install -m755 container/act_runner.openrc /etc/init.d/act_runner
|
||||||
|
ln -s act_runner /etc/init.d/act_runner.trx-rs
|
||||||
|
install -m644 container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
|
||||||
|
rc-update add act_runner.trx-rs default
|
||||||
|
rc-service act_runner.trx-rs start
|
||||||
|
```
|
||||||
|
|
||||||
|
Check it with `rc-service act_runner.trx-rs status` and
|
||||||
|
`tail -f /var/log/act_runner.trx-rs.log`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Per-instance settings for an act_runner OpenRC service.
|
||||||
|
# Copy to /etc/conf.d/<service-name>, e.g. /etc/conf.d/act_runner.trx-rs
|
||||||
|
# (the name must match the /etc/init.d/ symlink).
|
||||||
|
|
||||||
|
# User that runs the daemon. Must be a member of the `docker` group.
|
||||||
|
runner_user="act"
|
||||||
|
|
||||||
|
# Per-instance state dir (holds the .runner registration) and config file,
|
||||||
|
# so two runners on one host stay independent.
|
||||||
|
runner_dir="/var/lib/act_runner/trx-rs"
|
||||||
|
runner_config="/etc/act_runner/trx-rs.yaml"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/sbin/openrc-run
|
||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# OpenRC service for a Gitea act_runner (Docker executor) on Alpine.
|
||||||
|
#
|
||||||
|
# Install as /etc/init.d/act_runner (chmod +x). Single instance uses
|
||||||
|
# /etc/act_runner/config.yaml. For one runner per project, symlink this script
|
||||||
|
# and add a matching conf.d file:
|
||||||
|
#
|
||||||
|
# ln -s act_runner /etc/init.d/act_runner.trx-rs
|
||||||
|
# cp container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
|
||||||
|
# $EDITOR /etc/conf.d/act_runner.trx-rs # set runner_dir / runner_config
|
||||||
|
# rc-update add act_runner.trx-rs default
|
||||||
|
# rc-service act_runner.trx-rs start
|
||||||
|
|
||||||
|
description="Gitea Actions runner"
|
||||||
|
|
||||||
|
: "${runner_user:=act}"
|
||||||
|
: "${runner_dir:=/var/lib/act_runner}"
|
||||||
|
: "${runner_config:=/etc/act_runner/config.yaml}"
|
||||||
|
|
||||||
|
command="/usr/local/bin/act_runner"
|
||||||
|
command_args="daemon --config ${runner_config}"
|
||||||
|
# No group given, so supplementary groups (incl. docker) are initialised.
|
||||||
|
command_user="${runner_user}"
|
||||||
|
directory="${runner_dir}"
|
||||||
|
|
||||||
|
supervisor="supervise-daemon"
|
||||||
|
respawn_delay=5
|
||||||
|
respawn_max=0
|
||||||
|
pidfile="/run/${RC_SVCNAME}.pid"
|
||||||
|
output_log="/var/log/${RC_SVCNAME}.log"
|
||||||
|
error_log="/var/log/${RC_SVCNAME}.log"
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need docker
|
||||||
|
use net dns
|
||||||
|
}
|
||||||
|
|
||||||
|
start_pre() {
|
||||||
|
checkpath -d -m 0750 -o "${runner_user}" "${runner_dir}"
|
||||||
|
checkpath -f -m 0640 -o "${runner_user}" "${output_log}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Example act_runner config for the Docker-executor runner that lives in the
|
||||||
|
# CI VM. This is NOT the SDK image — it configures the runner that launches
|
||||||
|
# per-job containers (including the trx-rs SDK image referenced by the
|
||||||
|
# workflow's `container:` key). Copy to the VM and pass with
|
||||||
|
# `act_runner daemon --config`.
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
runner:
|
||||||
|
file: .runner
|
||||||
|
# One concurrent job. With one runner per project on a 2-vCPU VM this keeps
|
||||||
|
# total CI usage at ~2 threads.
|
||||||
|
capacity: 1
|
||||||
|
timeout: 3h
|
||||||
|
# Docker executor: no ":host" suffix. Maps runs-on labels to base images
|
||||||
|
# (the workflow overrides these per job via `container:`).
|
||||||
|
labels:
|
||||||
|
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
|
||||||
|
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
container:
|
||||||
|
# Cap every job container's CPU so CI stays within the 2-thread budget even
|
||||||
|
# if capacity is raised later. The -v mount persists the sccache cache on the
|
||||||
|
# host (create it first: `mkdir -p /var/cache/sccache`), matching SCCACHE_DIR
|
||||||
|
# in the workflow.
|
||||||
|
options: "--cpus=2 -v /var/cache/sccache:/sccache"
|
||||||
|
# act_runner rejects every bind mount unless it is listed here — the default
|
||||||
|
# is an empty allowlist, so the -v above is dropped with only a
|
||||||
|
# "[...] is not a valid volume, will be ignored" line in the job log, and
|
||||||
|
# SCCACHE_DIR then points at a directory that does not outlive the job.
|
||||||
|
valid_volumes:
|
||||||
|
- /var/cache/sccache
|
||||||
|
# Reuse the host VM's Docker network for the built-in cache/artifact server.
|
||||||
|
network: "host"
|
||||||
|
# The workflow pulls the SDK image by the moving `:latest` tag. Without this
|
||||||
|
# the runner logs "Image exists? true" and reuses whatever it cached the
|
||||||
|
# first time, so pushing a rebuilt image has no effect until someone pulls
|
||||||
|
# on the VM by hand — which looks like the image is missing a tool it in
|
||||||
|
# fact has. The extra registry round-trip per job is nothing next to a build.
|
||||||
|
force_pull: true
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Frontend architecture
|
||||||
|
|
||||||
|
The HTTP frontend is a strict TypeScript application built with esbuild. Rust
|
||||||
|
embeds deterministic JavaScript output from `assets/web/generated`; Cargo does
|
||||||
|
not run Node.js or contact the network.
|
||||||
|
|
||||||
|
## Runtime graph
|
||||||
|
|
||||||
|
`frontend/src/bootstrap.ts` is the only first-party script referenced by the
|
||||||
|
HTML document. Its imports establish startup order for WebGL support, shared UI
|
||||||
|
services, decoder dispatch, the local Leaflet AIS adapter, and the application
|
||||||
|
coordinator. Leaflet and the Opus decoder remain isolated vendored scripts.
|
||||||
|
|
||||||
|
Feature entries under `frontend/src/plugins` are ESM bundles. The typed plugin
|
||||||
|
loader imports them by feature group and keeps expensive map, scheduling, and
|
||||||
|
decoder behavior lazy. Shared code is emitted as content-hashed chunks. The
|
||||||
|
decode-history worker is an independent entry compiled against Web Worker
|
||||||
|
globals.
|
||||||
|
|
||||||
|
Dependencies point from application and feature code toward `core` and `api`.
|
||||||
|
`api/generated.ts` contains Rust wire formats; `api/client.ts` and focused
|
||||||
|
parsers validate untrusted HTTP, SSE, WebSocket, and worker data before it is
|
||||||
|
used as typed application state.
|
||||||
|
|
||||||
|
## Browser host contract
|
||||||
|
|
||||||
|
Separate lazy bundles cannot share module instances with the stable application
|
||||||
|
entry, so they use three intentional host namespaces:
|
||||||
|
|
||||||
|
| Global | Purpose | Mutation policy |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `window.trx` | Application state, core services, and feature registrations | The root is frozen; lazy features may register only their documented `modules.*` service. |
|
||||||
|
| `window.trxPluginRuntime` | Typed decoder registration and message dispatch | Runtime object is installed once; plugins register lifecycle handlers through its API. |
|
||||||
|
| `window.trxUi` | Notifications, confirmations, tab accessibility, and control presentation | Installed once by `ui-core.ts`; consumers call methods but do not replace them. |
|
||||||
|
|
||||||
|
`frontend/src/plugins/host.ts` declares the typed view of `window.trx.state`
|
||||||
|
and `window.trx.core` that feature bundles consume. Feature entries import it
|
||||||
|
instead of re-deriving the contract, so a service that moves out of the
|
||||||
|
application entry is added in one place. Application state that a feature needs
|
||||||
|
belongs in `trx.state`, and shared behavior belongs in `trx.core`; a feature
|
||||||
|
that has to intercept application behavior registers a `modules.*` method the
|
||||||
|
application calls, as the virtual-channel entry does for tuning, mode,
|
||||||
|
bandwidth, and frequency display.
|
||||||
|
|
||||||
|
The WebGL adapter exposes `createTrxWebGlRenderer`, `trxParseCssColor`,
|
||||||
|
`trxHslToRgba`, and `trxClearCssColorCache` for the application bundle. Leaflet
|
||||||
|
adds `L.TrxAisTrackSymbol` and `L.trxAisTrackSymbol` to the vendored Leaflet
|
||||||
|
namespace.
|
||||||
|
|
||||||
|
The following transitional properties are explicitly part of the lazy-feature
|
||||||
|
host contract and are declared in `app.ts`: `lastSpectrumData`, `lastFreqHz`,
|
||||||
|
`currentBandwidthHz`, `ft8BaseHz`, `getDecodeHistoryRetentionMs`,
|
||||||
|
`applyDecodeHistoryRetention`, `getDecodeRigMeta`, `renderRdsOverlays`,
|
||||||
|
`buildAisVesselUrl`, `trxScheduleUiFrameJob`,
|
||||||
|
`takeSchedulerControlForDecoderDisable`, `navigateToTab`, `_syncRecorderState`,
|
||||||
|
and `refreshRdsUi`. Optional callbacks owned by lazy features are
|
||||||
|
`refreshCwTonePicker`, `updateFt8RfDisplay`, `clearSatPredictionDom`,
|
||||||
|
`syncWefaxToggle`, `updateAisBar`, `updateVdesBar`, `updateAprsBar`,
|
||||||
|
`updateFt8Bar`, `updateSatLiveState`, `applyCwAutoUi`, and
|
||||||
|
`applyCwAutoUiFromServer`.
|
||||||
|
|
||||||
|
This list is closed: new standalone mutable `window` properties are not an
|
||||||
|
accepted integration mechanism. Extend an existing typed service or introduce
|
||||||
|
an imported interface instead. Removing transitional properties as feature
|
||||||
|
boundaries become directly importable remains preferable.
|
||||||
|
|
||||||
|
## Build and verification
|
||||||
|
|
||||||
|
The generated directory is removed before every build, preventing orphaned
|
||||||
|
compatibility bundles. Stable feature entry names are allowlisted by the Rust
|
||||||
|
asset manifest; shared chunks use content hashes. The generic Rust handler
|
||||||
|
rejects unknown names and unsupported MIME types and serves embedded assets
|
||||||
|
with compression, ETags, and immutable caching.
|
||||||
|
|
||||||
|
CI installs from `package-lock.json`, caches only npm downloads, type-checks the
|
||||||
|
window and worker environments separately, lints, runs unit and DOM tests,
|
||||||
|
starts the application in Chromium, regenerates Rust contracts and bundles,
|
||||||
|
checks for drift, and runs REUSE validation after generation.
|
||||||
|
|
||||||
|
See `frontend/src/README.md` for local commands and
|
||||||
|
`docs/ts-migration-plan.md` for the migration decisions and completion gates.
|
||||||
@@ -7,7 +7,11 @@ trx-rs web frontend (`trx-frontend-http`). The frontend is a single-page
|
|||||||
application served as embedded static assets (gzip-compressed with ETag
|
application served as embedded static assets (gzip-compressed with ETag
|
||||||
caching) from the Actix-Web server.
|
caching) from the Actix-Web server.
|
||||||
|
|
||||||
## Current asset inventory
|
## Historical asset inventory
|
||||||
|
|
||||||
|
This inventory records the frontend before the TypeScript migration. The
|
||||||
|
first-party JavaScript copies listed here have since been replaced by strict
|
||||||
|
TypeScript source and deterministic output under `assets/web/generated`.
|
||||||
|
|
||||||
| File | Lines | Size |
|
| File | Lines | Size |
|
||||||
|------|------:|-----:|
|
|------|------:|-----:|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# TypeScript migration baseline
|
||||||
|
|
||||||
|
This baseline records the browser architecture at the start of the migration.
|
||||||
|
It is intentionally historical; generated output sizes are tracked separately
|
||||||
|
by the deterministic frontend build.
|
||||||
|
|
||||||
|
## Startup order
|
||||||
|
|
||||||
|
The initial document loads these scripts in order:
|
||||||
|
|
||||||
|
1. `/vendor/opus-decoder-0.7.11.min.js`
|
||||||
|
2. `/vendor/leaflet.js`
|
||||||
|
3. `/leaflet-ais-tracksymbol.js`
|
||||||
|
4. `/webgl-renderer.js`
|
||||||
|
5. `/ui-core.js`
|
||||||
|
6. `/app.js`
|
||||||
|
|
||||||
|
Decoder and feature scripts are then loaded lazily by the inline loader in
|
||||||
|
`index.html`. The frontend smoke test locks the core ordering and rejects
|
||||||
|
remote script or stylesheet URLs.
|
||||||
|
|
||||||
|
## Source measurements
|
||||||
|
|
||||||
|
At baseline, first-party JavaScript comprised 23 files and approximately
|
||||||
|
776 KiB. The largest sources were:
|
||||||
|
|
||||||
|
| Source | Bytes |
|
||||||
|
|---|---:|
|
||||||
|
| `app.js` | 337,111 |
|
||||||
|
| `map-core.js` | 131,899 |
|
||||||
|
| `plugins/scheduler.js` | 60,883 |
|
||||||
|
| `plugins/bookmarks.js` | 30,543 |
|
||||||
|
| `plugins/sat.js` | 22,541 |
|
||||||
|
| `plugins/vchan.js` | 20,195 |
|
||||||
|
| `webgl-renderer.js` | 18,993 |
|
||||||
|
|
||||||
|
The migrated source snapshot contained 123 distinct direct `window.*`
|
||||||
|
assignments. These are compatibility callbacks, shared state, and plugin entry
|
||||||
|
points. New TypeScript code must not add to that set; the typed plugin registry
|
||||||
|
and explicit services replace it over the course of the migration.
|
||||||
|
|
||||||
|
All production scripts, stylesheets, fonts, icons, and map assets were already
|
||||||
|
served from local embedded routes. The automated startup test ensures no
|
||||||
|
remote runtime script or stylesheet dependency is introduced.
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# TypeScript Migration Plan
|
||||||
|
|
||||||
|
> **Scope**: `src/trx-client/trx-frontend/trx-frontend-http/`
|
||||||
|
>
|
||||||
|
> **Status**: Complete (2026-08-01)
|
||||||
|
|
||||||
|
## Implementation result
|
||||||
|
|
||||||
|
The migration was completed on `feat/typescript-frontend-migration`. The
|
||||||
|
baseline and phased sections below are retained as the decision record; their
|
||||||
|
descriptions of JavaScript files and classic loading refer to the pre-migration
|
||||||
|
state.
|
||||||
|
|
||||||
|
Completion evidence:
|
||||||
|
|
||||||
|
- all first-party browser sources are strict `.ts` files, checked by separate
|
||||||
|
DOM and Web Worker TypeScript projects with no JavaScript compatibility mode
|
||||||
|
or suppression directives;
|
||||||
|
- `bootstrap.ts` is the single first-party HTML entry and esbuild represents
|
||||||
|
startup order, lazy feature imports, shared hashed chunks, and the worker in
|
||||||
|
its module graph;
|
||||||
|
- obsolete source and generated compatibility JavaScript was removed;
|
||||||
|
- Rust generates rig, status, capability, decoder, and flattened frontend
|
||||||
|
metadata contracts into `api/generated.ts`; runtime guards validate HTTP,
|
||||||
|
SSE, WebSocket, and worker boundaries;
|
||||||
|
- the generic embedded-asset handler serves a build-generated allowlist with
|
||||||
|
constrained MIME types, compression, ETags, immutable caching, and no file
|
||||||
|
system lookup;
|
||||||
|
- intentional browser host namespaces and transitional lazy-feature properties
|
||||||
|
are documented in `docs/frontend-architecture.md`;
|
||||||
|
- CI uses locked npm dependencies, caches npm downloads rather than
|
||||||
|
`node_modules`, runs strict type checking and linting, unit/DOM/worker tests,
|
||||||
|
Chromium startup coverage, generated-output drift checks, and REUSE after
|
||||||
|
generation;
|
||||||
|
- Cargo continues to consume committed generated assets without invoking Node
|
||||||
|
or requiring network access.
|
||||||
|
|
||||||
|
The final local gate ran `npm ci`, type checking, linting, 30 frontend tests,
|
||||||
|
the Chromium smoke flow (startup, auth gate, audio controls, rig switching, map
|
||||||
|
initialization, and navigation), generated-contract and bundle verification,
|
||||||
|
workspace formatting, Clippy with warnings denied, all-target builds, workspace
|
||||||
|
tests, and REUSE 3.3 validation.
|
||||||
|
|
||||||
|
## 1. Decision
|
||||||
|
|
||||||
|
Migrating the web frontend to TypeScript is worthwhile, provided it is used to
|
||||||
|
remove implicit contracts and global coupling. Renaming JavaScript files to
|
||||||
|
`.ts` without changing their boundaries would add tooling without delivering
|
||||||
|
the main safety benefits.
|
||||||
|
|
||||||
|
The migration must be incremental. Every intermediate commit and pull request
|
||||||
|
must leave the frontend buildable and usable.
|
||||||
|
|
||||||
|
## 2. Baseline State
|
||||||
|
|
||||||
|
The frontend currently contains roughly 21,700 lines of first-party
|
||||||
|
JavaScript. Its largest components include:
|
||||||
|
|
||||||
|
- `app.js`: approximately 8,900 lines;
|
||||||
|
- `map-core.js`: approximately 3,500 lines;
|
||||||
|
- `plugins/scheduler.js`: approximately 1,500 lines;
|
||||||
|
- `plugins/bookmarks.js`: approximately 800 lines;
|
||||||
|
- `plugins/vchan.js`: approximately 560 lines.
|
||||||
|
|
||||||
|
Important characteristics of the current architecture are:
|
||||||
|
|
||||||
|
- scripts are embedded individually into the Rust binary with `include_str!`;
|
||||||
|
- the Rust HTTP server exposes an explicit route for each asset;
|
||||||
|
- plugins are loaded dynamically as classic scripts;
|
||||||
|
- script order is significant;
|
||||||
|
- modules communicate through `window.*`, `window.trx`, callbacks, and shared
|
||||||
|
mutable state;
|
||||||
|
- the main HTML file contains the plugin loader;
|
||||||
|
- a Web Worker is loaded through a fixed URL;
|
||||||
|
- Cargo builds do not require Node.js;
|
||||||
|
- CI images contain Node.js, but CI currently runs only Rust and REUSE checks;
|
||||||
|
- Leaflet, Opus decoder, fonts, images, and other vendored assets are local.
|
||||||
|
|
||||||
|
These constraints make a big-bang rewrite unnecessarily risky.
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
|
||||||
|
The migration should:
|
||||||
|
|
||||||
|
1. Create checked contracts between Rust responses and browser code.
|
||||||
|
2. Replace global callbacks with explicit module interfaces.
|
||||||
|
3. Split `app.js` by responsibility.
|
||||||
|
4. Make rig, capability, decoder, scheduler, audio, and spectrum state explicit.
|
||||||
|
5. Preserve lazy loading for expensive features.
|
||||||
|
6. Add frontend type checking, linting, and automated tests to CI.
|
||||||
|
7. Keep ordinary Cargo builds independent of Node.js and network access.
|
||||||
|
8. Preserve existing browser behavior throughout the migration.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
|
||||||
|
The migration will not initially:
|
||||||
|
|
||||||
|
- introduce a UI framework;
|
||||||
|
- redesign the user interface;
|
||||||
|
- convert vendored JavaScript to TypeScript;
|
||||||
|
- change REST, SSE, WebSocket, or worker protocols unless required to make an
|
||||||
|
existing contract unambiguous;
|
||||||
|
- make `build.rs` install packages or download frontend dependencies;
|
||||||
|
- convert all of `app.js` in one pull request.
|
||||||
|
|
||||||
|
## 5. Target Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
trx-frontend-http/
|
||||||
|
├── frontend/
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── package-lock.json
|
||||||
|
│ ├── tsconfig.json
|
||||||
|
│ ├── tsconfig.worker.json
|
||||||
|
│ ├── build.mjs
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── bootstrap.ts
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ │ ├── client.ts
|
||||||
|
│ │ │ └── generated.ts
|
||||||
|
│ │ ├── core/
|
||||||
|
│ │ │ ├── dom.ts
|
||||||
|
│ │ │ ├── events.ts
|
||||||
|
│ │ │ ├── settings.ts
|
||||||
|
│ │ │ └── state.ts
|
||||||
|
│ │ ├── features/
|
||||||
|
│ │ │ ├── audio/
|
||||||
|
│ │ │ ├── bookmarks/
|
||||||
|
│ │ │ ├── map/
|
||||||
|
│ │ │ ├── navigation/
|
||||||
|
│ │ │ ├── radio/
|
||||||
|
│ │ │ ├── recorder/
|
||||||
|
│ │ │ ├── scheduler/
|
||||||
|
│ │ │ └── spectrum/
|
||||||
|
│ │ ├── decoders/
|
||||||
|
│ │ ├── workers/
|
||||||
|
│ │ └── legacy/
|
||||||
|
│ │ └── global-bridge.ts
|
||||||
|
│ └── tests/
|
||||||
|
└── assets/web/
|
||||||
|
├── generated/
|
||||||
|
├── vendor/
|
||||||
|
├── index.html
|
||||||
|
├── style.css
|
||||||
|
└── themes.css
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact feature directories may evolve, but dependencies should point from
|
||||||
|
features toward `core` and `api`, never from `core` back into features.
|
||||||
|
|
||||||
|
## 6. Tooling
|
||||||
|
|
||||||
|
Use TypeScript for type checking and esbuild for bundling. A framework-specific
|
||||||
|
development server is not needed.
|
||||||
|
|
||||||
|
Recommended compiler baseline:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"useUnknownInCatchVariables": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Worker code should use a separate configuration with `WebWorker` rather than
|
||||||
|
`DOM` globals.
|
||||||
|
|
||||||
|
The package scripts should provide at least:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
npm run verify-generated
|
||||||
|
```
|
||||||
|
|
||||||
|
Pin dependencies in `package-lock.json`. New package and generated files must
|
||||||
|
carry or inherit valid REUSE licensing information.
|
||||||
|
|
||||||
|
## 7. Cargo and Asset Integration
|
||||||
|
|
||||||
|
Cargo must remain usable on machines without Node.js. Do not invoke `npm`
|
||||||
|
automatically from `build.rs`.
|
||||||
|
|
||||||
|
The initial integration should work as follows:
|
||||||
|
|
||||||
|
1. TypeScript and JavaScript sources live under `frontend/src`.
|
||||||
|
2. esbuild writes browser-ready output under `assets/web/generated`.
|
||||||
|
3. Generated browser output is committed to the repository.
|
||||||
|
4. Rust embeds generated output, not TypeScript source.
|
||||||
|
5. CI rebuilds the frontend and fails if committed output is stale.
|
||||||
|
|
||||||
|
During early phases, retain the existing public URLs, including:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/app.js
|
||||||
|
/ui-core.js
|
||||||
|
/map-core.js
|
||||||
|
/scheduler.js
|
||||||
|
/decode-history-worker.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Keeping these URLs stable avoids coupling the first migration phases to changes
|
||||||
|
in HTML loading, authentication rules, caching, or Rust routing.
|
||||||
|
|
||||||
|
After modules and code splitting are established, replace the explicit list of
|
||||||
|
Rust constants and handlers with an embedded generated-asset directory. The
|
||||||
|
server must still enforce an allowlist, correct MIME types, cache headers, and
|
||||||
|
path traversal protection.
|
||||||
|
|
||||||
|
## 8. Rust-to-TypeScript Contracts
|
||||||
|
|
||||||
|
Generate TypeScript definitions from Rust wire types instead of maintaining
|
||||||
|
parallel handwritten representations.
|
||||||
|
|
||||||
|
Initial candidates include:
|
||||||
|
|
||||||
|
- `RigState`, `RigInfo`, and `RigCapabilities`;
|
||||||
|
- the `/rigs` response;
|
||||||
|
- decoder registry entries;
|
||||||
|
- scheduler configuration and status;
|
||||||
|
- filter and spectrum state;
|
||||||
|
- recorder responses;
|
||||||
|
- SSE update payloads;
|
||||||
|
- decoded-message variants;
|
||||||
|
- WebSocket control and status messages.
|
||||||
|
|
||||||
|
A generator such as `ts-rs` can produce
|
||||||
|
`frontend/src/api/generated.ts`. Generated definitions should describe only
|
||||||
|
wire formats. UI state and view models should remain handwritten.
|
||||||
|
|
||||||
|
CI must regenerate these definitions and fail when the working tree changes.
|
||||||
|
Serde renames, tagged enums, optional fields, flattened values, and numeric
|
||||||
|
ranges must be checked explicitly during the initial generator integration.
|
||||||
|
|
||||||
|
TypeScript types do not validate data at runtime. Critical compatibility
|
||||||
|
boundaries should retain focused runtime checks for missing or malformed data,
|
||||||
|
particularly when clients and servers may run different versions.
|
||||||
|
|
||||||
|
## 9. Target Module Contracts
|
||||||
|
|
||||||
|
Plugins should eventually implement an explicit interface rather than assigning
|
||||||
|
callbacks to `window`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TrxPlugin {
|
||||||
|
readonly id: string;
|
||||||
|
initialize(context: PluginContext): void | Promise<void>;
|
||||||
|
dispose?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginContext {
|
||||||
|
api: TrxApi;
|
||||||
|
events: TrxEventBus;
|
||||||
|
navigation: NavigationService;
|
||||||
|
notifications: NotificationService;
|
||||||
|
state: ReadonlyRadioState;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
During the transition, `legacy/global-bridge.ts` may expose the minimum globals
|
||||||
|
required by unmigrated scripts. The bridge must shrink as migration progresses;
|
||||||
|
new feature code must not add new global callbacks.
|
||||||
|
|
||||||
|
State should be divided by responsibility rather than replaced by one large
|
||||||
|
typed global:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface RadioState {
|
||||||
|
activeRigId: string | null;
|
||||||
|
capabilities: RigCapabilities | null;
|
||||||
|
connection: ConnectionState;
|
||||||
|
frequencyHz: number | null;
|
||||||
|
mode: RigMode | null;
|
||||||
|
bandwidthHz: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioState {
|
||||||
|
rx: AudioStreamState;
|
||||||
|
tx: AudioStreamState;
|
||||||
|
volume: VolumeState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecoderState {
|
||||||
|
registry: DecoderDescriptor[];
|
||||||
|
status: ReadonlyMap<DecoderId, DecoderStatus>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Commands should receive a rig ID explicitly. They must not infer their target
|
||||||
|
from mutable global selection state after an asynchronous operation begins.
|
||||||
|
|
||||||
|
## 10. Migration Phases
|
||||||
|
|
||||||
|
### Phase 0: Baseline and Measurements
|
||||||
|
|
||||||
|
- Record current asset names, sizes, and load order.
|
||||||
|
- Add a browser startup smoke test.
|
||||||
|
- Record existing global symbols and plugin callbacks.
|
||||||
|
- Confirm that generated bundles do not introduce remote runtime dependencies.
|
||||||
|
|
||||||
|
**Exit criterion:** current behavior and asset loading have an automated
|
||||||
|
baseline.
|
||||||
|
|
||||||
|
### Phase 1: Tooling Scaffold
|
||||||
|
|
||||||
|
- Add the frontend package, lockfile, TypeScript configuration, and esbuild.
|
||||||
|
- Allow existing JavaScript as build input without type checking it globally.
|
||||||
|
- Produce fixed-name outputs matching current URLs.
|
||||||
|
- Add frontend commands and CI checks.
|
||||||
|
- Document local frontend development commands.
|
||||||
|
|
||||||
|
**Exit criterion:** existing JavaScript passes through the frontend build with
|
||||||
|
no runtime changes, and CI detects stale generated output.
|
||||||
|
|
||||||
|
### Phase 2: Generated API Types
|
||||||
|
|
||||||
|
- Add Rust-to-TypeScript type generation.
|
||||||
|
- Generate the first status, rig, capability, and decoder contracts.
|
||||||
|
- Introduce a typed fetch/post client and typed SSE decoding boundary.
|
||||||
|
- Keep narrow runtime guards at compatibility boundaries.
|
||||||
|
|
||||||
|
**Exit criterion:** new API consumers cannot use untyped response objects.
|
||||||
|
|
||||||
|
### Phase 3: Core Browser Services
|
||||||
|
|
||||||
|
Convert or create:
|
||||||
|
|
||||||
|
- DOM lookup helpers;
|
||||||
|
- notifications and confirmations;
|
||||||
|
- settings and per-rig preferences;
|
||||||
|
- navigation;
|
||||||
|
- the event bus;
|
||||||
|
- application state stores;
|
||||||
|
- plugin registry and loader.
|
||||||
|
|
||||||
|
Convert `ui-core.js` as the first strict TypeScript entry.
|
||||||
|
|
||||||
|
**Exit criterion:** shared UI behavior is TypeScript, tested, and does not add
|
||||||
|
new globals.
|
||||||
|
|
||||||
|
### Phase 4: Independent Leaf Modules
|
||||||
|
|
||||||
|
Convert lower-coupling code first:
|
||||||
|
|
||||||
|
1. WebGL renderer;
|
||||||
|
2. decode-history worker;
|
||||||
|
3. screenshot support;
|
||||||
|
4. FT2 and FT4;
|
||||||
|
5. WSPR;
|
||||||
|
6. CW and other decoder views.
|
||||||
|
|
||||||
|
Workers must be separate build entries.
|
||||||
|
|
||||||
|
**Exit criterion:** each converted module has typed inputs, outputs, and tests.
|
||||||
|
|
||||||
|
### Phase 5: Plugin Loading
|
||||||
|
|
||||||
|
- Move the inline loader out of `index.html`.
|
||||||
|
- Replace classic-script injection with typed dynamic imports.
|
||||||
|
- Register plugins through `TrxPlugin`.
|
||||||
|
- Preserve lazy loading by tab and feature.
|
||||||
|
- Remove the corresponding global callbacks after each plugin migrates.
|
||||||
|
|
||||||
|
**Exit criterion:** plugin dependencies and loading order are represented by the
|
||||||
|
module graph rather than implicit script order.
|
||||||
|
|
||||||
|
### Phase 6: Split the Main Application
|
||||||
|
|
||||||
|
Extract `app.js` by responsibility:
|
||||||
|
|
||||||
|
1. authentication and API transport;
|
||||||
|
2. rig enumeration and switching;
|
||||||
|
3. radio commands and capabilities;
|
||||||
|
4. audio streaming;
|
||||||
|
5. spectrum state and rendering;
|
||||||
|
6. decode history;
|
||||||
|
7. recorder;
|
||||||
|
8. keyboard shortcuts;
|
||||||
|
9. application bootstrap.
|
||||||
|
|
||||||
|
Do not split by arbitrary line ranges. Each extraction must establish an
|
||||||
|
explicit interface and remove the corresponding globals.
|
||||||
|
|
||||||
|
**Exit criterion:** the bootstrap file composes services and features but does
|
||||||
|
not contain their implementations.
|
||||||
|
|
||||||
|
### Phase 7: High-Coupling Features
|
||||||
|
|
||||||
|
Convert the remaining large features after the core contracts are stable:
|
||||||
|
|
||||||
|
- scheduler and satellite scheduler;
|
||||||
|
- bookmarks;
|
||||||
|
- virtual channels;
|
||||||
|
- map and map-backed statistics;
|
||||||
|
- remaining decoder plugins.
|
||||||
|
|
||||||
|
**Exit criterion:** no first-party classic scripts or untyped plugin callbacks
|
||||||
|
remain.
|
||||||
|
|
||||||
|
### Phase 8: Asset-Server Consolidation
|
||||||
|
|
||||||
|
- Enable code splitting and hashed chunks.
|
||||||
|
- Embed the generated asset directory in Rust.
|
||||||
|
- Serve generated assets through a generic, allowlisted handler.
|
||||||
|
- Add appropriate immutable caching for hashed output.
|
||||||
|
- Retain stable handling for HTML and version metadata.
|
||||||
|
- Remove obsolete fixed-asset constants and handlers.
|
||||||
|
|
||||||
|
**Exit criterion:** Rust no longer needs a source edit for every generated
|
||||||
|
frontend chunk.
|
||||||
|
|
||||||
|
### Phase 9: Strictness and Cleanup
|
||||||
|
|
||||||
|
- Remove `allowJs`.
|
||||||
|
- Remove the legacy global bridge.
|
||||||
|
- Enable all selected strict compiler and lint rules.
|
||||||
|
- Remove obsolete generated compatibility bundles.
|
||||||
|
- Update architecture and contributor documentation.
|
||||||
|
|
||||||
|
**Exit criterion:** all first-party frontend source is strict TypeScript and the
|
||||||
|
browser runtime exposes only intentionally documented globals.
|
||||||
|
|
||||||
|
## 11. Testing Strategy
|
||||||
|
|
||||||
|
Frontend CI should run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
git diff --exit-code -- assets/web/generated frontend/src/api/generated.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Testing should cover four layers:
|
||||||
|
|
||||||
|
1. **Pure unit tests** for bandwidth calculations, formatting, state changes,
|
||||||
|
capability decisions, and message routing.
|
||||||
|
2. **DOM component tests** for dialogs, tabs, workspace selection, rig
|
||||||
|
switching, and collapsible control sections.
|
||||||
|
3. **Worker tests** for decode-history pruning, batching, and message formats.
|
||||||
|
4. **Browser smoke tests** for startup, authentication gating, plugin loading,
|
||||||
|
navigation, rig switching, audio controls, and map initialization.
|
||||||
|
|
||||||
|
The existing dependency-free `ui-core` test can remain during the scaffold
|
||||||
|
phase. It should be moved to the standard TypeScript test runner once that
|
||||||
|
runner is established.
|
||||||
|
|
||||||
|
## 12. CI Changes
|
||||||
|
|
||||||
|
Add a dedicated frontend job rather than hiding frontend work inside the Cargo
|
||||||
|
jobs. The job should:
|
||||||
|
|
||||||
|
- use the Node.js version provided by the runner image;
|
||||||
|
- run with `npm ci`, never a mutable install;
|
||||||
|
- cache the npm download cache, not `node_modules`;
|
||||||
|
- type-check, lint, test, and build;
|
||||||
|
- verify generated artifacts and Rust-generated types are current;
|
||||||
|
- run REUSE validation after generated files are produced.
|
||||||
|
|
||||||
|
Rust lint and test jobs should continue to consume committed generated assets.
|
||||||
|
|
||||||
|
## 13. Pull Request Strategy
|
||||||
|
|
||||||
|
Use small, independently reversible pull requests. A recommended sequence is:
|
||||||
|
|
||||||
|
1. tooling and unchanged JavaScript build;
|
||||||
|
2. generated Rust wire types and typed API client;
|
||||||
|
3. `ui-core` conversion;
|
||||||
|
4. worker and WebGL conversion;
|
||||||
|
5. decoder plugin conversions in small groups;
|
||||||
|
6. plugin registry and dynamic imports;
|
||||||
|
7. one `app.js` responsibility per PR;
|
||||||
|
8. scheduler, bookmarks, and map conversions;
|
||||||
|
9. generic embedded asset serving;
|
||||||
|
10. removal of JavaScript compatibility mode.
|
||||||
|
|
||||||
|
Every PR should include:
|
||||||
|
|
||||||
|
- runtime behavior preserved or intentionally documented;
|
||||||
|
- frontend type checking and tests;
|
||||||
|
- strict Rust formatting and Clippy;
|
||||||
|
- generated-artifact drift verification;
|
||||||
|
- no newly introduced undocumented `window` globals;
|
||||||
|
- no remote runtime asset dependency.
|
||||||
|
|
||||||
|
## 14. Risks and Mitigations
|
||||||
|
|
||||||
|
### Superficial Conversion
|
||||||
|
|
||||||
|
**Risk:** globals receive declarations but remain coupled and mutable.
|
||||||
|
|
||||||
|
**Mitigation:** require every converted module to expose explicit inputs and
|
||||||
|
outputs and remove at least the globals it replaces.
|
||||||
|
|
||||||
|
### Cargo Becomes Dependent on Node
|
||||||
|
|
||||||
|
**Risk:** builds fail on systems without Node or network access.
|
||||||
|
|
||||||
|
**Mitigation:** commit deterministic generated output and keep npm outside
|
||||||
|
`build.rs`.
|
||||||
|
|
||||||
|
### Script-Order Regressions
|
||||||
|
|
||||||
|
**Risk:** switching to modules changes execution timing and scope.
|
||||||
|
|
||||||
|
**Mitigation:** retain current public entries initially, then replace script
|
||||||
|
loading only after the plugin registry is in place.
|
||||||
|
|
||||||
|
### Unreviewable `app.js` Rewrite
|
||||||
|
|
||||||
|
**Risk:** a large conversion is difficult to review, test, or bisect.
|
||||||
|
|
||||||
|
**Mitigation:** extract one responsibility at a time and keep bootstrap working
|
||||||
|
after every extraction.
|
||||||
|
|
||||||
|
### Rust and Browser Contracts Drift
|
||||||
|
|
||||||
|
**Risk:** TypeScript compiles against stale wire definitions.
|
||||||
|
|
||||||
|
**Mitigation:** generate types from Rust and enforce a clean working tree after
|
||||||
|
generation in CI.
|
||||||
|
|
||||||
|
### Generated Asset Noise
|
||||||
|
|
||||||
|
**Risk:** committed bundles make reviews noisy.
|
||||||
|
|
||||||
|
**Mitigation:** separate source and generated commits when useful, include
|
||||||
|
source maps, and require deterministic output.
|
||||||
|
|
||||||
|
### Bundle or Startup Regression
|
||||||
|
|
||||||
|
**Risk:** bundling loads too much code eagerly.
|
||||||
|
|
||||||
|
**Mitigation:** record the current baseline, preserve feature-level lazy loads,
|
||||||
|
and track entry/chunk sizes in CI.
|
||||||
|
|
||||||
|
## 15. Initial Proof of Concept
|
||||||
|
|
||||||
|
The first implementation PR should be deliberately limited to:
|
||||||
|
|
||||||
|
1. adding the frontend package and locked toolchain;
|
||||||
|
2. building existing JavaScript to fixed-name generated output;
|
||||||
|
3. adding frontend CI and drift checks;
|
||||||
|
4. generating initial rig/status TypeScript definitions;
|
||||||
|
5. introducing the typed API client;
|
||||||
|
6. converting `ui-core.js` to strict TypeScript;
|
||||||
|
7. preserving every existing asset URL and observable behavior.
|
||||||
|
|
||||||
|
This proof of concept is the decision gate for the remainder of the migration.
|
||||||
|
If it materially improves contract safety without making Cargo development
|
||||||
|
unwieldy, continue with the phased plan. If it does not, the repository can
|
||||||
|
retain the tooling and the converted core without committing to a full rewrite.
|
||||||
|
|
||||||
|
## 16. Completion Criteria
|
||||||
|
|
||||||
|
The migration is complete when:
|
||||||
|
|
||||||
|
- all first-party browser source is strict TypeScript;
|
||||||
|
- vendor files remain isolated and declared through narrow type adapters;
|
||||||
|
- Rust wire types generate their browser contracts;
|
||||||
|
- no feature depends on accidental script ordering;
|
||||||
|
- no undocumented mutable `window` callbacks remain;
|
||||||
|
- `app.js` has been replaced by a small typed bootstrap and feature modules;
|
||||||
|
- frontend type checking, linting, unit tests, component tests, browser smoke
|
||||||
|
tests, and generated-output checks run in CI;
|
||||||
|
- Cargo builds remain possible without Node.js or network access;
|
||||||
|
- production assets remain local, embedded, cacheable, and reproducible.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Pins the Rust toolchain for reproducible builds. Keep in sync with the
|
||||||
|
# SDK image (container/Containerfile, ARG RUST_VERSION). rustup honours this
|
||||||
|
# automatically for local builds outside the SDK container.
|
||||||
|
|
||||||
|
[toolchain]
|
||||||
|
channel = "1.97.1"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
@@ -243,7 +243,7 @@ fn parse_frame(frame: RawFrame, channel: &str) -> Option<AisMessage> {
|
|||||||
|
|
||||||
let message_type = get_uint(&bits, 0, 6)? as u8;
|
let message_type = get_uint(&bits, 0, 6)? as u8;
|
||||||
let repeat = get_uint(&bits, 6, 2)? as u8;
|
let repeat = get_uint(&bits, 6, 2)? as u8;
|
||||||
let mmsi = get_uint(&bits, 8, 30)? as u32;
|
let mmsi = get_uint(&bits, 8, 30)?;
|
||||||
|
|
||||||
let mut msg = AisMessage {
|
let mut msg = AisMessage {
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
|
|||||||
@@ -638,7 +638,7 @@ mod tests {
|
|||||||
for (i, &ch) in b"N0CALL".iter().enumerate() {
|
for (i, &ch) in b"N0CALL".iter().enumerate() {
|
||||||
addr[i] = ch << 1;
|
addr[i] = ch << 1;
|
||||||
}
|
}
|
||||||
addr[6] = (0 << 1) | 1; // SSID=0, last=true
|
addr[6] = 1; // SSID=0, last=true
|
||||||
|
|
||||||
let decoded = decode_ax25_address(&addr, 0);
|
let decoded = decode_ax25_address(&addr, 0);
|
||||||
assert_eq!(decoded.call, "N0CALL");
|
assert_eq!(decoded.call, "N0CALL");
|
||||||
@@ -652,7 +652,7 @@ mod tests {
|
|||||||
for (i, &ch) in b"SP2SJG".iter().enumerate() {
|
for (i, &ch) in b"SP2SJG".iter().enumerate() {
|
||||||
addr[i] = ch << 1;
|
addr[i] = ch << 1;
|
||||||
}
|
}
|
||||||
addr[6] = (5 << 1) | 0; // SSID=5, last=false
|
addr[6] = 5 << 1; // SSID=5, last=false
|
||||||
|
|
||||||
let decoded = decode_ax25_address(&addr, 0);
|
let decoded = decode_ax25_address(&addr, 0);
|
||||||
assert_eq!(decoded.call, "SP2SJG");
|
assert_eq!(decoded.call, "SP2SJG");
|
||||||
@@ -667,7 +667,7 @@ mod tests {
|
|||||||
for (i, &ch) in b"W1AW ".iter().enumerate() {
|
for (i, &ch) in b"W1AW ".iter().enumerate() {
|
||||||
addr[i] = ch << 1;
|
addr[i] = ch << 1;
|
||||||
}
|
}
|
||||||
addr[6] = (0 << 1) | 1;
|
addr[6] = 1;
|
||||||
|
|
||||||
let decoded = decode_ax25_address(&addr, 0);
|
let decoded = decode_ax25_address(&addr, 0);
|
||||||
assert_eq!(decoded.call, "W1AW");
|
assert_eq!(decoded.call, "W1AW");
|
||||||
@@ -691,8 +691,8 @@ mod tests {
|
|||||||
for &ch in src_bytes.as_bytes().iter().take(6) {
|
for &ch in src_bytes.as_bytes().iter().take(6) {
|
||||||
frame.push(ch << 1);
|
frame.push(ch << 1);
|
||||||
}
|
}
|
||||||
frame.push((0 << 1) | 1); // SSID=0, last=true
|
frame.push(1); // SSID=0, last=true
|
||||||
// Control + PID
|
// Control + PID
|
||||||
frame.push(0x03); // UI frame
|
frame.push(0x03); // UI frame
|
||||||
frame.push(0xF0); // No layer-3 protocol
|
frame.push(0xF0); // No layer-3 protocol
|
||||||
// Info field
|
// Info field
|
||||||
|
|||||||
@@ -160,18 +160,14 @@ impl CallsignHashTable {
|
|||||||
let mut idx = start_idx;
|
let mut idx = start_idx;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match &self.entries[idx] {
|
let entry = self.entries[idx].as_ref()?;
|
||||||
Some(entry) => {
|
let stored = (entry.hash & HASH22_MASK) >> shift;
|
||||||
let stored = (entry.hash & HASH22_MASK) >> shift;
|
if stored == target {
|
||||||
if stored == target {
|
return Some(entry.callsign.clone());
|
||||||
return Some(entry.callsign.clone());
|
}
|
||||||
}
|
idx = (idx + 1) % CALLSIGN_HASHTABLE_SIZE;
|
||||||
idx = (idx + 1) % CALLSIGN_HASHTABLE_SIZE;
|
if idx == start_idx {
|
||||||
if idx == start_idx {
|
return None;
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => return None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ pub(crate) fn ldpc_check(codeword: &[u8; FTX_LDPC_N]) -> i32 {
|
|||||||
for m in 0..FTX_LDPC_M {
|
for m in 0..FTX_LDPC_M {
|
||||||
let mut x: u8 = 0;
|
let mut x: u8 = 0;
|
||||||
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||||
for i in 0..num_rows {
|
for &nm in FTX_LDPC_NM[m].iter().take(num_rows) {
|
||||||
x ^= codeword[FTX_LDPC_NM[m][i] as usize - 1];
|
x ^= codeword[nm as usize - 1];
|
||||||
}
|
}
|
||||||
if x != 0 {
|
if x != 0 {
|
||||||
errors += 1;
|
errors += 1;
|
||||||
@@ -81,11 +81,11 @@ pub fn ldpc_decode(
|
|||||||
for j in 0..FTX_LDPC_M {
|
for j in 0..FTX_LDPC_M {
|
||||||
let num_rows = FTX_LDPC_NUM_ROWS[j] as usize;
|
let num_rows = FTX_LDPC_NUM_ROWS[j] as usize;
|
||||||
let m_row = j * FTX_LDPC_N;
|
let m_row = j * FTX_LDPC_N;
|
||||||
for ii1 in 0..num_rows {
|
for &nm1 in FTX_LDPC_NM[j].iter().take(num_rows) {
|
||||||
let i1 = FTX_LDPC_NM[j][ii1] as usize - 1;
|
let i1 = nm1 as usize - 1;
|
||||||
let mut a = 1.0f32;
|
let mut a = 1.0f32;
|
||||||
for ii2 in 0..num_rows {
|
for &nm2 in FTX_LDPC_NM[j].iter().take(num_rows) {
|
||||||
let i2 = FTX_LDPC_NM[j][ii2] as usize - 1;
|
let i2 = nm2 as usize - 1;
|
||||||
if i2 != i1 {
|
if i2 != i1 {
|
||||||
a *= fast_tanh(-m_matrix[m_row + i2] / 2.0f32);
|
a *= fast_tanh(-m_matrix[m_row + i2] / 2.0f32);
|
||||||
}
|
}
|
||||||
@@ -97,8 +97,8 @@ pub fn ldpc_decode(
|
|||||||
// Hard decisions
|
// Hard decisions
|
||||||
for i in 0..FTX_LDPC_N {
|
for i in 0..FTX_LDPC_N {
|
||||||
let mut l = codeword[i];
|
let mut l = codeword[i];
|
||||||
for j in 0..3 {
|
for &mn in FTX_LDPC_MN[i].iter().take(3) {
|
||||||
l += e_matrix[(FTX_LDPC_MN[i][j] as usize - 1) * FTX_LDPC_N + i];
|
l += e_matrix[(mn as usize - 1) * FTX_LDPC_N + i];
|
||||||
}
|
}
|
||||||
plain[i] = if l > 0.0 { 1 } else { 0 };
|
plain[i] = if l > 0.0 { 1 } else { 0 };
|
||||||
}
|
}
|
||||||
@@ -113,12 +113,12 @@ pub fn ldpc_decode(
|
|||||||
|
|
||||||
// Update m[][] from e[][]
|
// Update m[][] from e[][]
|
||||||
for i in 0..FTX_LDPC_N {
|
for i in 0..FTX_LDPC_N {
|
||||||
for ji1 in 0..3 {
|
for (ji1, &mn1) in FTX_LDPC_MN[i].iter().enumerate().take(3) {
|
||||||
let j1 = FTX_LDPC_MN[i][ji1] as usize - 1;
|
let j1 = mn1 as usize - 1;
|
||||||
let mut l = codeword[i];
|
let mut l = codeword[i];
|
||||||
for ji2 in 0..3 {
|
for (ji2, &mn2) in FTX_LDPC_MN[i].iter().enumerate().take(3) {
|
||||||
if ji1 != ji2 {
|
if ji1 != ji2 {
|
||||||
let j2 = FTX_LDPC_MN[i][ji2] as usize - 1;
|
let j2 = mn2 as usize - 1;
|
||||||
l += e_matrix[j2 * FTX_LDPC_N + i];
|
l += e_matrix[j2 * FTX_LDPC_N + i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,10 +64,8 @@ pub fn charn(mut c: i32, table: CharTable) -> char {
|
|||||||
return EXTRAS[c as usize];
|
return EXTRAS[c as usize];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CharTable::AlphanumSpaceSlash => {
|
CharTable::AlphanumSpaceSlash if c == 0 => {
|
||||||
if c == 0 {
|
return '/';
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -116,10 +114,8 @@ pub fn nchar(c: char, table: CharTable) -> Option<i32> {
|
|||||||
'?' => return Some(n + 4),
|
'?' => return Some(n + 4),
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
CharTable::AlphanumSpaceSlash => {
|
CharTable::AlphanumSpaceSlash if c == '/' => {
|
||||||
if c == '/' {
|
return Some(n);
|
||||||
return Some(n);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -632,32 +632,17 @@ impl Candidate {
|
|||||||
}
|
}
|
||||||
let segment = usize::from((block_b & 0x0003) as u8);
|
let segment = usize::from((block_b & 0x0003) as u8);
|
||||||
let di = ((block_b >> 2) & 0x1) != 0;
|
let di = ((block_b >> 2) & 0x1) != 0;
|
||||||
match segment {
|
let di_flag = Some(di);
|
||||||
0 => {
|
let slot = match segment {
|
||||||
if self.state.dynamic_pty != Some(di) {
|
0 => &mut self.state.dynamic_pty,
|
||||||
self.state.dynamic_pty = Some(di);
|
1 => &mut self.state.compressed,
|
||||||
changed = true;
|
2 => &mut self.state.artificial_head,
|
||||||
}
|
3 => &mut self.state.stereo,
|
||||||
}
|
_ => unreachable!("segment is masked to two bits"),
|
||||||
1 => {
|
};
|
||||||
if self.state.compressed != Some(di) {
|
if *slot != di_flag {
|
||||||
self.state.compressed = Some(di);
|
*slot = di_flag;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
|
||||||
}
|
|
||||||
2 => {
|
|
||||||
if self.state.artificial_head != Some(di) {
|
|
||||||
self.state.artificial_head = Some(di);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3 => {
|
|
||||||
if self.state.stereo != Some(di) {
|
|
||||||
self.state.stereo = Some(di);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
let [b0, b1] = block_d.to_be_bytes();
|
let [b0, b1] = block_d.to_be_bytes();
|
||||||
self.ps_bytes[segment * 2] = sanitize_text_byte(b0);
|
self.ps_bytes[segment * 2] = sanitize_text_byte(b0);
|
||||||
@@ -1458,9 +1443,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BPSK modulate onto the 57 kHz subcarrier.
|
// BPSK modulate onto the 57 kHz subcarrier.
|
||||||
for t in 0..n {
|
for (t, sample) in shaped.iter_mut().enumerate().take(n) {
|
||||||
let phase = TAU * RDS_SUBCARRIER_HZ * t as f32 / sample_rate;
|
let phase = TAU * RDS_SUBCARRIER_HZ * t as f32 / sample_rate;
|
||||||
shaped[t] *= phase.cos();
|
*sample *= phase.cos();
|
||||||
}
|
}
|
||||||
shaped
|
shaped
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,9 +134,7 @@ mod tests {
|
|||||||
.flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
|
.flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
|
||||||
.collect();
|
.collect();
|
||||||
// Append wrong CRC
|
// Append wrong CRC
|
||||||
for _ in 0..16 {
|
bits.resize(bits.len() + 16, 0);
|
||||||
bits.push(0);
|
|
||||||
}
|
|
||||||
assert!(!check_crc16(&bits));
|
assert!(!check_crc16(&bits));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -346,8 +346,8 @@ mod tests {
|
|||||||
write_bits(&mut bits, 12, 32, 123456); // source_id
|
write_bits(&mut bits, 12, 32, 123456); // source_id
|
||||||
write_bits(&mut bits, 44, 11, 20); // data_count = 20
|
write_bits(&mut bits, 44, 11, 20); // data_count = 20
|
||||||
// Fill some payload
|
// Fill some payload
|
||||||
for i in 55..75 {
|
for (i, bit) in bits.iter_mut().enumerate().take(75).skip(55) {
|
||||||
bits[i] = (i % 2) as u8;
|
*bit = (i % 2) as u8;
|
||||||
}
|
}
|
||||||
append_crc(&mut bits);
|
append_crc(&mut bits);
|
||||||
|
|
||||||
|
|||||||
@@ -274,7 +274,8 @@ mod tests {
|
|||||||
|
|
||||||
// Pseudo-random noise vs gradient — correlation should be low.
|
// Pseudo-random noise vs gradient — correlation should be low.
|
||||||
let noise: Vec<u8> = (0..256)
|
let noise: Vec<u8> = (0..256)
|
||||||
.map(|i| ((i * 1103515245 + 12345) as u32 >> 8 & 0xff) as u8)
|
.map(|i| (i as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345))
|
||||||
|
.map(|value| ((value >> 8) & 0xff) as u8)
|
||||||
.collect();
|
.collect();
|
||||||
let r = asm.correlation_with_last(&noise).expect("r");
|
let r = asm.correlation_with_last(&noise).expect("r");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -374,8 +375,8 @@ mod tests {
|
|||||||
let (y, m, d, h, mi, _) = unix_to_utc(1775055000);
|
let (y, m, d, h, mi, _) = unix_to_utc(1775055000);
|
||||||
assert_eq!(y, 2026);
|
assert_eq!(y, 2026);
|
||||||
// Just verify reasonable values without asserting exact date.
|
// Just verify reasonable values without asserting exact date.
|
||||||
assert!(m >= 1 && m <= 12);
|
assert!((1..=12).contains(&m));
|
||||||
assert!(d >= 1 && d <= 31);
|
assert!((1..=31).contains(&d));
|
||||||
assert!(h < 24);
|
assert!(h < 24);
|
||||||
assert!(mi < 60);
|
assert!(mi < 60);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,10 +161,8 @@ mod tests {
|
|||||||
|
|
||||||
for line_idx in 0..20 {
|
for line_idx in 0..20 {
|
||||||
let mut line = vec![1.0f32; spl];
|
let mut line = vec![1.0f32; spl];
|
||||||
for j in pulse_start..pulse_start + pw {
|
for slot in line.iter_mut().skip(pulse_start).take(pw) {
|
||||||
if j < spl {
|
*slot = 0.0;
|
||||||
line[j] = 0.0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let result = det.process(&line);
|
let result = det.process(&line);
|
||||||
if let Some(offset) = result {
|
if let Some(offset) = result {
|
||||||
|
|||||||
@@ -483,7 +483,7 @@ mod tests {
|
|||||||
let c4 = idx27(b'T');
|
let c4 = idx27(b'T');
|
||||||
let c5 = idx27(b' ');
|
let c5 = idx27(b' ');
|
||||||
let n1 = ((c0 * 36 + c1) * 10 + c2) * 27u32.pow(3) + c3 * 27u32.pow(2) + c4 * 27 + c5;
|
let n1 = ((c0 * 36 + c1) * 10 + c2) * 27u32.pow(3) + c3 * 27u32.pow(2) + c4 * 27 + c5;
|
||||||
let m1 = (179 - 10 * 5 - 2) * 180 + 10 * 13 + 0; // FN20
|
let m1 = (179 - 10 * 5 - 2) * 180 + 10 * 13; // FN20 (final term is 0)
|
||||||
let power_code = 37u32;
|
let power_code = 37u32;
|
||||||
|
|
||||||
let mut input_bits = [0u8; NBITS];
|
let mut input_bits = [0u8; NBITS];
|
||||||
@@ -530,8 +530,8 @@ mod tests {
|
|||||||
fn interleave_deinterleave_roundtrip() {
|
fn interleave_deinterleave_roundtrip() {
|
||||||
// Create a sequence of distinguishable values
|
// Create a sequence of distinguishable values
|
||||||
let mut original = [0u8; NSYMS];
|
let mut original = [0u8; NSYMS];
|
||||||
for i in 0..NSYMS {
|
for (i, slot) in original.iter_mut().enumerate() {
|
||||||
original[i] = (i % 256) as u8;
|
*slot = (i % 256) as u8;
|
||||||
}
|
}
|
||||||
|
|
||||||
let interleaved = interleave(&original);
|
let interleaved = interleave(&original);
|
||||||
|
|||||||
@@ -356,59 +356,6 @@ async fn run_single_rig_audio_client(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{resolve_audio_addr, AudioConnectConfig};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_audio_addr_prefers_fixed_url() {
|
|
||||||
let mut rig_connect = HashMap::new();
|
|
||||||
rig_connect.insert(
|
|
||||||
"home-hf".to_string(),
|
|
||||||
AudioConnectConfig::fixed("audio.example.com:4700".to_string()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let addr = resolve_audio_addr(
|
|
||||||
"home-hf",
|
|
||||||
Some(4531),
|
|
||||||
&rig_connect,
|
|
||||||
&AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
|
||||||
);
|
|
||||||
assert_eq!(addr, "audio.example.com:4700");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_audio_addr_uses_advertised_port_with_remote_host() {
|
|
||||||
let mut rig_connect = HashMap::new();
|
|
||||||
rig_connect.insert(
|
|
||||||
"home-hf".to_string(),
|
|
||||||
AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
|
||||||
);
|
|
||||||
|
|
||||||
let addr = resolve_audio_addr(
|
|
||||||
"home-hf",
|
|
||||||
Some(4600),
|
|
||||||
&rig_connect,
|
|
||||||
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
|
||||||
);
|
|
||||||
assert_eq!(addr, "control.example.com:4600");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_audio_addr_falls_back_to_default_port() {
|
|
||||||
let rig_connect = HashMap::new();
|
|
||||||
|
|
||||||
let addr = resolve_audio_addr(
|
|
||||||
"home-hf",
|
|
||||||
None,
|
|
||||||
&rig_connect,
|
|
||||||
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
|
||||||
);
|
|
||||||
assert_eq!(addr, "fallback.example.com:4531");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a single TCP connection for one rig. Similar to `handle_audio_connection`
|
/// Handle a single TCP connection for one rig. Similar to `handle_audio_connection`
|
||||||
/// but publishes to per-rig channels directly and mirrors to global when selected.
|
/// but publishes to per-rig channels directly and mirrors to global when selected.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -767,3 +714,56 @@ async fn handle_single_rig_connection(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{resolve_audio_addr, AudioConnectConfig};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_audio_addr_prefers_fixed_url() {
|
||||||
|
let mut rig_connect = HashMap::new();
|
||||||
|
rig_connect.insert(
|
||||||
|
"home-hf".to_string(),
|
||||||
|
AudioConnectConfig::fixed("audio.example.com:4700".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let addr = resolve_audio_addr(
|
||||||
|
"home-hf",
|
||||||
|
Some(4531),
|
||||||
|
&rig_connect,
|
||||||
|
&AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
||||||
|
);
|
||||||
|
assert_eq!(addr, "audio.example.com:4700");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_audio_addr_uses_advertised_port_with_remote_host() {
|
||||||
|
let mut rig_connect = HashMap::new();
|
||||||
|
rig_connect.insert(
|
||||||
|
"home-hf".to_string(),
|
||||||
|
AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
||||||
|
);
|
||||||
|
|
||||||
|
let addr = resolve_audio_addr(
|
||||||
|
"home-hf",
|
||||||
|
Some(4600),
|
||||||
|
&rig_connect,
|
||||||
|
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
||||||
|
);
|
||||||
|
assert_eq!(addr, "control.example.com:4600");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_audio_addr_falls_back_to_default_port() {
|
||||||
|
let rig_connect = HashMap::new();
|
||||||
|
|
||||||
|
let addr = resolve_audio_addr(
|
||||||
|
"home-hf",
|
||||||
|
None,
|
||||||
|
&rig_connect,
|
||||||
|
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
||||||
|
);
|
||||||
|
assert_eq!(addr, "fallback.example.com:4531");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1110,36 +1110,40 @@ url = "remote.example.com:4530"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_rejects_duplicate_remote_names() {
|
fn test_validate_rejects_duplicate_remote_names() {
|
||||||
let mut config = ClientConfig::default();
|
let config = ClientConfig {
|
||||||
config.remotes = vec![
|
remotes: vec![
|
||||||
RemoteEntry {
|
RemoteEntry {
|
||||||
name: "dup".to_string(),
|
name: "dup".to_string(),
|
||||||
url: "a:4530".to_string(),
|
url: "a:4530".to_string(),
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
},
|
},
|
||||||
RemoteEntry {
|
RemoteEntry {
|
||||||
name: "dup".to_string(),
|
name: "dup".to_string(),
|
||||||
url: "b:4530".to_string(),
|
url: "b:4530".to_string(),
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
assert!(config.validate().unwrap_err().contains("duplicate name"));
|
assert!(config.validate().unwrap_err().contains("duplicate name"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_rejects_empty_remote_name() {
|
fn test_validate_rejects_empty_remote_name() {
|
||||||
let mut config = ClientConfig::default();
|
let config = ClientConfig {
|
||||||
config.remotes = vec![RemoteEntry {
|
remotes: vec![RemoteEntry {
|
||||||
name: "".to_string(),
|
name: "".to_string(),
|
||||||
url: "a:4530".to_string(),
|
url: "a:4530".to_string(),
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
}];
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
assert!(config
|
assert!(config
|
||||||
.validate()
|
.validate()
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
@@ -1148,14 +1152,16 @@ url = "remote.example.com:4530"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_rejects_empty_remote_url() {
|
fn test_validate_rejects_empty_remote_url() {
|
||||||
let mut config = ClientConfig::default();
|
let config = ClientConfig {
|
||||||
config.remotes = vec![RemoteEntry {
|
remotes: vec![RemoteEntry {
|
||||||
name: "hf".to_string(),
|
name: "hf".to_string(),
|
||||||
url: " ".to_string(),
|
url: " ".to_string(),
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
}];
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
assert!(config
|
assert!(config
|
||||||
.validate()
|
.validate()
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
@@ -1164,14 +1170,16 @@ url = "remote.example.com:4530"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_rejects_zero_remote_poll_interval() {
|
fn test_validate_rejects_zero_remote_poll_interval() {
|
||||||
let mut config = ClientConfig::default();
|
let config = ClientConfig {
|
||||||
config.remotes = vec![RemoteEntry {
|
remotes: vec![RemoteEntry {
|
||||||
name: "hf".to_string(),
|
name: "hf".to_string(),
|
||||||
url: "a:4530".to_string(),
|
url: "a:4530".to_string(),
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 0,
|
poll_interval_ms: 0,
|
||||||
}];
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
assert!(config
|
assert!(config
|
||||||
.validate()
|
.validate()
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
|
|||||||
@@ -1678,7 +1678,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn global_target_for_snapshot_skips_other_server_selection() {
|
fn global_target_for_snapshot_skips_other_server_selection() {
|
||||||
let snapshot = sample_snapshot();
|
let snapshot = sample_snapshot();
|
||||||
let rigs = vec![RigEntry {
|
let rigs = [RigEntry {
|
||||||
rig_id: "hf".to_string(),
|
rig_id: "hf".to_string(),
|
||||||
display_name: Some("Gdansk HF".to_string()),
|
display_name: Some("Gdansk HF".to_string()),
|
||||||
state: snapshot,
|
state: snapshot,
|
||||||
|
|||||||
@@ -29,3 +29,4 @@ hex = "0.4"
|
|||||||
pickledb = "0.5"
|
pickledb = "0.5"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
ts-rs = "12.0.1"
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import {
|
||||||
|
hostCore,
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/ais.ts
|
||||||
|
var aisWindow = window;
|
||||||
|
var escapeAisHtml = (input) => hostCore.escapeMapHtml(input);
|
||||||
|
var aisStatus = document.getElementById("ais-status");
|
||||||
|
var aisMessagesEl = document.getElementById("ais-messages");
|
||||||
|
var aisFilterInput = document.getElementById("ais-filter");
|
||||||
|
var aisBarOverlay = document.getElementById("ais-bar-overlay");
|
||||||
|
var aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
||||||
|
var aisVesselCountEl = document.getElementById("ais-vessel-count");
|
||||||
|
var aisLatestSeenEl = document.getElementById("ais-latest-seen");
|
||||||
|
var AIS_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
|
var AIS_DEFAULT_A_HZ = 161975e3;
|
||||||
|
var AIS_CHANNEL_SPACING_HZ = 5e4;
|
||||||
|
var aisFilterText = "";
|
||||||
|
var aisMessageHistory = [];
|
||||||
|
function currentAisHistoryRetentionMs() {
|
||||||
|
return typeof aisWindow.getDecodeHistoryRetentionMs === "function" ? aisWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneAisMessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
||||||
|
aisMessageHistory = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleAisUi(key, job) {
|
||||||
|
if (typeof aisWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
aisWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function scheduleAisHistoryRender() {
|
||||||
|
scheduleAisUi("ais-history", () => {
|
||||||
|
renderAisHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function scheduleAisBarUpdate() {
|
||||||
|
scheduleAisUi("ais-bar", () => {
|
||||||
|
updateAisBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function formatAisMhz(freqHz) {
|
||||||
|
return `${(freqHz / 1e6).toFixed(3)} MHz`;
|
||||||
|
}
|
||||||
|
function currentAisChannelPlan() {
|
||||||
|
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||||
|
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
||||||
|
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
||||||
|
return {
|
||||||
|
aHz: safeAHz,
|
||||||
|
bHz: safeAHz + AIS_CHANNEL_SPACING_HZ
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function aisChannelInfo(channel) {
|
||||||
|
const plan = currentAisChannelPlan();
|
||||||
|
const ch = (channel ?? "").trim().toUpperCase();
|
||||||
|
if (ch === "B") {
|
||||||
|
return {
|
||||||
|
label: "AIS-B",
|
||||||
|
badgeClass: "ais-badge ais-badge-channel-b",
|
||||||
|
freqText: formatAisMhz(plan.bHz)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: "AIS-A",
|
||||||
|
badgeClass: "ais-badge ais-badge-channel-a",
|
||||||
|
freqText: formatAisMhz(plan.aHz)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function aisDisplayName(msg) {
|
||||||
|
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
||||||
|
}
|
||||||
|
function aisDisplayNameHtml(msg) {
|
||||||
|
const label = escapeAisHtml(aisDisplayName(msg));
|
||||||
|
const url = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
||||||
|
if (!url) return label;
|
||||||
|
return `<a class="title-link" href="${escapeAisHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
||||||
|
}
|
||||||
|
function aisTypeLabel(type) {
|
||||||
|
switch (Number(type)) {
|
||||||
|
case 1:
|
||||||
|
case 2:
|
||||||
|
case 3:
|
||||||
|
return "Class A Position";
|
||||||
|
case 4:
|
||||||
|
return "Base Station";
|
||||||
|
case 5:
|
||||||
|
return "Static/Voyage";
|
||||||
|
case 18:
|
||||||
|
return "Class B Position";
|
||||||
|
case 19:
|
||||||
|
return "Class B Extended";
|
||||||
|
case 21:
|
||||||
|
return "Aid to Nav";
|
||||||
|
case 24:
|
||||||
|
return "Class B Static";
|
||||||
|
default:
|
||||||
|
return `Type ${type ?? "--"}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function aisAgeText(tsMs) {
|
||||||
|
if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1e3);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
function aisMotionText(msg) {
|
||||||
|
const parts = [
|
||||||
|
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
||||||
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}° COG` : null,
|
||||||
|
msg.heading_deg != null ? `${msg.heading_deg.toFixed(0)}° HDG` : null
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
function aisRouteText(msg) {
|
||||||
|
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
||||||
|
}
|
||||||
|
function aisDistanceText(msg) {
|
||||||
|
if (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, msg.lat, msg.lon);
|
||||||
|
if (!Number.isFinite(distKm)) return "";
|
||||||
|
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||||
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
|
}
|
||||||
|
function aisLatestByVessel(messages) {
|
||||||
|
const byMmsi = /* @__PURE__ */ new Map();
|
||||||
|
for (const msg of messages) {
|
||||||
|
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
|
||||||
|
if (!byMmsi.has(key)) byMmsi.set(key, msg);
|
||||||
|
}
|
||||||
|
return Array.from(byMmsi.values());
|
||||||
|
}
|
||||||
|
function 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 || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
const name = aisDisplayName(msg);
|
||||||
|
const nameHtml = aisDisplayNameHtml(msg);
|
||||||
|
const channel = aisChannelInfo(msg.channel);
|
||||||
|
const motion = aisMotionText(msg);
|
||||||
|
const route = aisRouteText(msg);
|
||||||
|
const distance = aisDistanceText(msg);
|
||||||
|
const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" 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}">${escapeAisHtml(channel.label)}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span></div><div class="ais-row-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` + (route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") + `<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span></div><div class="ais-row-detail">` + (motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) + (distance ? `<span>${escapeAisHtml(distance)}</span>` : "") + (pos ? `<span>${pos}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span></div>`;
|
||||||
|
applyAisFilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function applyAisFilterToRow(row) {
|
||||||
|
if (!aisFilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = row.dataset.filterText || "";
|
||||||
|
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
function updateAisBar() {
|
||||||
|
if (!aisBarOverlay) return;
|
||||||
|
updateAisSummary();
|
||||||
|
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||||
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
|
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
|
if (!isAis || messages.length === 0) {
|
||||||
|
aisBarOverlay.style.display = "none";
|
||||||
|
aisBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">AIS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAisBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAisBar();}" aria-label="Clear AIS overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`;
|
||||||
|
for (const msg of messages) {
|
||||||
|
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||||
|
const pin = msg.lat != null && msg.lon != null ? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>` : "";
|
||||||
|
const name = `<span class="ais-call">${aisDisplayNameHtml(msg)}</span>`;
|
||||||
|
const channel = aisChannelInfo(msg.channel);
|
||||||
|
const distance = aisDistanceText(msg);
|
||||||
|
const details = [
|
||||||
|
`MMSI ${escapeAisHtml(String(msg.mmsi))}`,
|
||||||
|
escapeAisHtml(channel.label),
|
||||||
|
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
||||||
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}°` : null,
|
||||||
|
distance ? escapeAisHtml(distance) : null,
|
||||||
|
escapeAisHtml(aisAgeText(msg._tsMs))
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div></div>`;
|
||||||
|
}
|
||||||
|
aisBarOverlay.innerHTML = html;
|
||||||
|
aisBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
aisWindow.updateAisBar = updateAisBar;
|
||||||
|
aisWindow.clearAisBar = function() {
|
||||||
|
resetAisHistoryView();
|
||||||
|
};
|
||||||
|
function resetAisHistoryView() {
|
||||||
|
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
||||||
|
aisMessageHistory = [];
|
||||||
|
updateAisBar();
|
||||||
|
renderAisHistory();
|
||||||
|
aisWindow.clearMapMarkersByType?.("ais");
|
||||||
|
}
|
||||||
|
function renderAisHistory() {
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
if (!aisMessagesEl) {
|
||||||
|
updateAisSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const message of aisMessageHistory) {
|
||||||
|
fragment.appendChild(renderAisRow(message));
|
||||||
|
}
|
||||||
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
|
updateAisSummary();
|
||||||
|
}
|
||||||
|
function addAisMessage(msg) {
|
||||||
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
msg._tsMs = tsMs;
|
||||||
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
aisMessageHistory.unshift(msg);
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
scheduleAisBarUpdate();
|
||||||
|
scheduleAisHistoryRender();
|
||||||
|
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
|
||||||
|
aisWindow.aisMapAddVessel(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function normalizeServerAisMessage(msg) {
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
rig_id: msg.rig_id || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function onServerAisBatch(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
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 && aisWindow.aisMapAddVessel) {
|
||||||
|
aisWindow.aisMapAddVessel(next);
|
||||||
|
}
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
aisMessageHistory = normalized.concat(aisMessageHistory);
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
scheduleAisBarUpdate();
|
||||||
|
scheduleAisHistoryRender();
|
||||||
|
}
|
||||||
|
function pruneAisHistoryView() {
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
updateAisBar();
|
||||||
|
renderAisHistory();
|
||||||
|
}
|
||||||
|
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_ais_decode");
|
||||||
|
resetAisHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("AIS history clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
if (aisFilterInput) {
|
||||||
|
aisFilterInput.addEventListener("input", () => {
|
||||||
|
aisFilterText = aisFilterInput.value.trim().toUpperCase();
|
||||||
|
renderAisHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onServerAis(msg) {
|
||||||
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
|
addAisMessage(normalizeServerAisMessage(msg));
|
||||||
|
}
|
||||||
|
updateAisSummary();
|
||||||
|
window.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "ais",
|
||||||
|
onMessage: onServerAis,
|
||||||
|
onBatch: onServerAisBatch,
|
||||||
|
restore: onServerAisBatch,
|
||||||
|
reset: resetAisHistoryView,
|
||||||
|
prune: pruneAisHistoryView
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,318 @@
|
|||||||
|
import {
|
||||||
|
aprsAgeText,
|
||||||
|
aprsCategoryLabel,
|
||||||
|
aprsHexBytes,
|
||||||
|
aprsPacketCategory,
|
||||||
|
collapseAprsDuplicates,
|
||||||
|
normalizeAprsPacket,
|
||||||
|
renderAprsInfo,
|
||||||
|
renderLocalAprsSymbol
|
||||||
|
} from "./chunk-M2I6DH4X.js";
|
||||||
|
import {
|
||||||
|
hostCore,
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/aprs.ts
|
||||||
|
var aprsWindow = window;
|
||||||
|
var escapeAprsHtml = (input) => hostCore.escapeMapHtml(input);
|
||||||
|
var showAprsHint = (message, durationMs) => {
|
||||||
|
hostCore.showHint(message, durationMs);
|
||||||
|
};
|
||||||
|
var aprsStatus = document.getElementById("aprs-status");
|
||||||
|
var aprsPacketsEl = document.getElementById("aprs-packets");
|
||||||
|
var aprsFilterInput = document.getElementById("aprs-filter");
|
||||||
|
var aprsBarOverlay = document.getElementById("aprs-bar-overlay");
|
||||||
|
var aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
|
||||||
|
var aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
|
||||||
|
var aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
|
||||||
|
var aprsTotalCountEl = document.getElementById("aprs-total-count");
|
||||||
|
var aprsVisibleCountEl = document.getElementById("aprs-visible-count");
|
||||||
|
var aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
|
||||||
|
var APRS_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
|
var aprsFilterText = "";
|
||||||
|
var aprsPacketHistory = [];
|
||||||
|
var aprsBarDismissedAtMs = 0;
|
||||||
|
var aprsOnlyPos = false;
|
||||||
|
var aprsHideCrc = false;
|
||||||
|
var aprsCollapseDup = false;
|
||||||
|
var aprsTypeFilter = "all";
|
||||||
|
function currentAprsHistoryRetentionMs() {
|
||||||
|
return typeof aprsWindow.getDecodeHistoryRetentionMs === "function" ? aprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneAprsPacketHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
|
||||||
|
aprsPacketHistory = aprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleAprsUi(key, job) {
|
||||||
|
if (typeof aprsWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
aprsWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function scheduleAprsHistoryRender() {
|
||||||
|
scheduleAprsUi("aprs-history", () => {
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function scheduleAprsBarUpdate() {
|
||||||
|
scheduleAprsUi("aprs-bar", () => {
|
||||||
|
updateAprsBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function aprsDistanceText(pkt) {
|
||||||
|
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||||
|
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
|
||||||
|
if (!Number.isFinite(distKm)) return "";
|
||||||
|
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||||
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
|
}
|
||||||
|
function aprsFilterMatch(pkt) {
|
||||||
|
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||||
|
if (aprsHideCrc && !pkt.crcOk) return false;
|
||||||
|
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
|
||||||
|
if (!aprsFilterText) return true;
|
||||||
|
const haystack = [
|
||||||
|
pkt.srcCall,
|
||||||
|
pkt.destCall,
|
||||||
|
pkt.path,
|
||||||
|
pkt.info,
|
||||||
|
pkt.type,
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||||
|
aprsPacketCategory(pkt)
|
||||||
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
|
return haystack.includes(aprsFilterText);
|
||||||
|
}
|
||||||
|
function aprsVisiblePackets() {
|
||||||
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
||||||
|
return packets.filter(aprsFilterMatch);
|
||||||
|
}
|
||||||
|
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 || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const age = aprsAgeText(pkt._tsMs);
|
||||||
|
const category = aprsPacketCategory(pkt);
|
||||||
|
const categoryLabel = aprsCategoryLabel(category);
|
||||||
|
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||||
|
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
|
||||||
|
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||||
|
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
|
||||||
|
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
|
||||||
|
const distance = aprsDistanceText(pkt);
|
||||||
|
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
||||||
|
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + symbolHtml + `<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span><span>>${escapeAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
||||||
|
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||||
|
el.addEventListener("click", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const raw = el.dataset.aprsMap ?? "";
|
||||||
|
const [lat, lon] = raw.split(",").map(Number);
|
||||||
|
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
|
aprsWindow.navigateToAprsMap(lat, lon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||||
|
if (copyBtn) {
|
||||||
|
copyBtn.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
const raw = copyBtn.dataset.aprsCopy ?? "";
|
||||||
|
try {
|
||||||
|
const clipboard = Reflect.get(navigator, "clipboard");
|
||||||
|
if (clipboard) {
|
||||||
|
await clipboard.writeText(raw);
|
||||||
|
showAprsHint("Coordinates copied", 1200);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showAprsHint("Copy failed", 1500);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderAprsHistory() {
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
if (!aprsPacketsEl) {
|
||||||
|
updateAprsSummary();
|
||||||
|
updateAprsChipState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visible = aprsVisiblePackets();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const [index, packet] of visible.entries()) {
|
||||||
|
fragment.appendChild(renderAprsRow(packet, index === 0));
|
||||||
|
}
|
||||||
|
aprsPacketsEl.replaceChildren(fragment);
|
||||||
|
updateAprsSummary();
|
||||||
|
updateAprsChipState();
|
||||||
|
}
|
||||||
|
function updateAprsBar() {
|
||||||
|
if (!aprsBarOverlay) return;
|
||||||
|
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||||
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
|
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
||||||
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
|
aprsBarOverlay.style.display = "none";
|
||||||
|
aprsBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">×</button></span></div>`;
|
||||||
|
for (const pkt of frames) {
|
||||||
|
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
|
||||||
|
const call = `<span class="aprs-bar-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>`;
|
||||||
|
const dest = escapeAprsHtml(pkt.destCall || "");
|
||||||
|
const info = escapeAprsHtml(pkt.info || "");
|
||||||
|
const pin = pkt.lat != null && pkt.lon != null ? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>` : "";
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div></div>`;
|
||||||
|
}
|
||||||
|
aprsBarOverlay.innerHTML = html;
|
||||||
|
aprsBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
aprsWindow.updateAprsBar = updateAprsBar;
|
||||||
|
aprsWindow.clearAprsBar = function() {
|
||||||
|
resetAprsHistoryView();
|
||||||
|
};
|
||||||
|
aprsWindow.closeAprsBar = function() {
|
||||||
|
aprsBarDismissedAtMs = Date.now();
|
||||||
|
if (aprsBarOverlay) {
|
||||||
|
aprsBarOverlay.style.display = "none";
|
||||||
|
aprsBarOverlay.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function resetAprsHistoryView() {
|
||||||
|
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
|
||||||
|
aprsPacketHistory = [];
|
||||||
|
updateAprsBar();
|
||||||
|
renderAprsHistory();
|
||||||
|
aprsWindow.clearMapMarkersByType?.("aprs");
|
||||||
|
}
|
||||||
|
function pruneAprsHistoryView() {
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
updateAprsBar();
|
||||||
|
renderAprsHistory();
|
||||||
|
}
|
||||||
|
function addAprsPacket(pkt) {
|
||||||
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
|
pkt._tsMs = tsMs;
|
||||||
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
aprsPacketHistory.unshift(pkt);
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
|
||||||
|
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
||||||
|
}
|
||||||
|
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||||
|
scheduleAprsHistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerAprsPacket(pkt) {
|
||||||
|
return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null);
|
||||||
|
}
|
||||||
|
function onServerAprsBatch(packets) {
|
||||||
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
|
if (aprsStatus) 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 && aprsWindow.aprsMapAddStation) {
|
||||||
|
aprsWindow.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();
|
||||||
|
}
|
||||||
|
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_aprs_decode");
|
||||||
|
resetAprsHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("APRS history clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
if (aprsOnlyPosBtn) {
|
||||||
|
aprsOnlyPosBtn.addEventListener("click", () => {
|
||||||
|
aprsOnlyPos = !aprsOnlyPos;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (aprsHideCrcBtn) {
|
||||||
|
aprsHideCrcBtn.addEventListener("click", () => {
|
||||||
|
aprsHideCrc = !aprsHideCrc;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (aprsCollapseDupBtn) {
|
||||||
|
aprsCollapseDupBtn.addEventListener("click", () => {
|
||||||
|
aprsCollapseDup = !aprsCollapseDup;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||||
|
const btn = document.getElementById(`aprs-type-${type}`);
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
aprsTypeFilter = type;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (aprsFilterInput) {
|
||||||
|
aprsFilterInput.addEventListener("input", () => {
|
||||||
|
aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onServerAprs(pkt) {
|
||||||
|
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||||
|
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||||
|
}
|
||||||
|
renderAprsHistory();
|
||||||
|
window.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "aprs",
|
||||||
|
onMessage: onServerAprs,
|
||||||
|
onBatch: onServerAprsBatch,
|
||||||
|
restore: onServerAprsBatch,
|
||||||
|
reset: resetAprsHistoryView,
|
||||||
|
prune: pruneAprsHistoryView
|
||||||
|
});
|
||||||
+370
@@ -0,0 +1,370 @@
|
|||||||
|
import {
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/background-decode.ts
|
||||||
|
var bgdWindow = window;
|
||||||
|
(function() {
|
||||||
|
"use strict";
|
||||||
|
function bgdSupportedIds() {
|
||||||
|
return (bgdWindow.decoderRegistry || []).filter(function(d) {
|
||||||
|
return d.background_decode;
|
||||||
|
}).map(function(d) {
|
||||||
|
return d.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let backgroundDecodeRole = null;
|
||||||
|
let currentRigId = null;
|
||||||
|
let currentConfig = null;
|
||||||
|
let bookmarkList = [];
|
||||||
|
let statusInterval = null;
|
||||||
|
let bgdDirty = false;
|
||||||
|
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 ${String(r.status)}`);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apiPutConfig(rigId, config) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
}).then(function(r) {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apiResetConfig(rigId) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||||
|
method: "DELETE"
|
||||||
|
}).then(function(r) {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apiGetStatus(rigId) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function(r) {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apiGetBookmarks() {
|
||||||
|
return fetch("/bookmarks").then(function(r) {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadBackgroundDecode() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
Promise.all([apiGetConfig(rigId), apiGetBookmarks()]).then(function([config, bookmarks]) {
|
||||||
|
currentConfig = config;
|
||||||
|
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
|
||||||
|
renderBackgroundDecode();
|
||||||
|
clearBgdDirty();
|
||||||
|
pollBackgroundDecodeStatus();
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.error("background decode load failed", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function supportedBookmarks() {
|
||||||
|
return bookmarkList.filter(function(bookmark) {
|
||||||
|
return bookmarkDecoderKinds(bookmark).length > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bookmarkDecoderKinds(bookmark) {
|
||||||
|
const ids = bgdSupportedIds();
|
||||||
|
const decoders = bookmark.decoders ?? [];
|
||||||
|
const explicit = decoders.map(function(item) {
|
||||||
|
return item.trim().toLowerCase();
|
||||||
|
}).filter(function(item, index, arr) {
|
||||||
|
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
|
||||||
|
});
|
||||||
|
if (explicit.length > 0) return explicit;
|
||||||
|
const mode = bookmark.mode.trim().toUpperCase();
|
||||||
|
return (bgdWindow.decoderRegistry || []).filter(function(d) {
|
||||||
|
return d.activation === "mode_bound" && d.background_decode && d.active_modes.indexOf(mode) >= 0;
|
||||||
|
}).map(function(d) {
|
||||||
|
return d.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderBackgroundDecode() {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||||
|
renderBookmarkChecklist();
|
||||||
|
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||||
|
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) {
|
||||||
|
const text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
|
||||||
|
return text.indexOf(filter) >= 0;
|
||||||
|
}) : all;
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = '<div class="bgd-checklist-empty">' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "</div>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filtered.forEach(function(bookmark) {
|
||||||
|
const row = document.createElement("label");
|
||||||
|
row.className = "bgd-checklist-row";
|
||||||
|
const decoders = bookmarkDecoderKinds(bookmark);
|
||||||
|
const 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.currentTarget.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 ?? false,
|
||||||
|
bookmark_ids: currentConfig?.bookmark_ids.slice() ?? []
|
||||||
|
};
|
||||||
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
apiPutConfig(rigId, payload).then(function(saved) {
|
||||||
|
currentConfig = saved;
|
||||||
|
renderBackgroundDecode();
|
||||||
|
clearBgdDirty();
|
||||||
|
pollBackgroundDecodeStatus();
|
||||||
|
showToast("Background decode saved.", false);
|
||||||
|
}).catch(function(err) {
|
||||||
|
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||||
|
}).finally(function() {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function resetBackgroundDecode() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
if (!await bgdWindow.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return;
|
||||||
|
apiResetConfig(rigId).then(function(saved) {
|
||||||
|
currentConfig = saved;
|
||||||
|
renderBackgroundDecode();
|
||||||
|
clearBgdDirty();
|
||||||
|
pollBackgroundDecodeStatus();
|
||||||
|
showToast("Background decode reset.", false);
|
||||||
|
}).catch(function(err) {
|
||||||
|
showToast(`Reset failed: ${errorMessage(err)}`, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function startStatusPolling() {
|
||||||
|
if (statusInterval) clearInterval(statusInterval);
|
||||||
|
statusInterval = setInterval(pollBackgroundDecodeStatus, 15e3);
|
||||||
|
}
|
||||||
|
function pollBackgroundDecodeStatus() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
apiGetStatus(rigId).then(renderStatus).catch(function() {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderStatus(status) {
|
||||||
|
const card = document.getElementById("background-decode-status-card");
|
||||||
|
if (!card) return;
|
||||||
|
const entries = status.entries ?? [];
|
||||||
|
if (!entries.length) {
|
||||||
|
card.textContent = "No background decode bookmarks configured.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const summary = [];
|
||||||
|
if (status.active_rig) {
|
||||||
|
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
||||||
|
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
||||||
|
} else {
|
||||||
|
summary.push("This rig is not currently selected for audio.");
|
||||||
|
}
|
||||||
|
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
||||||
|
html += '<div class="bgd-status-list">';
|
||||||
|
entries.forEach(function(entry) {
|
||||||
|
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
||||||
|
const parts = [];
|
||||||
|
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
||||||
|
if (entry.mode) parts.push(entry.mode);
|
||||||
|
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
||||||
|
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
||||||
|
}
|
||||||
|
html += '<div class="bgd-status-row"><div><div class="bgd-status-name">' + escHtml(name) + '</div><div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div></div><div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '"><svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' + escHtml(prettyState(entry.state)) + "</div></div>";
|
||||||
|
});
|
||||||
|
html += "</div>";
|
||||||
|
card.innerHTML = html;
|
||||||
|
}
|
||||||
|
function prettyState(state) {
|
||||||
|
switch (state) {
|
||||||
|
case "active":
|
||||||
|
return "✓ Active";
|
||||||
|
case "out_of_span":
|
||||||
|
return "△ Out of span";
|
||||||
|
case "waiting_for_spectrum":
|
||||||
|
return "△ Waiting";
|
||||||
|
case "waiting_for_user":
|
||||||
|
return "△ No user";
|
||||||
|
case "missing_bookmark":
|
||||||
|
return "✗ Missing";
|
||||||
|
case "no_supported_decoders":
|
||||||
|
return "✗ Unsupported";
|
||||||
|
case "disabled":
|
||||||
|
return "△ Disabled";
|
||||||
|
case "handled_by_scheduler":
|
||||||
|
return "△ Scheduler";
|
||||||
|
case "scheduler_has_control":
|
||||||
|
return "△ Scheduler";
|
||||||
|
case "handled_by_virtual_channel":
|
||||||
|
return "△ VChan";
|
||||||
|
default:
|
||||||
|
return "△ 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 `${String(hz)} Hz`;
|
||||||
|
}
|
||||||
|
function escHtml(value) {
|
||||||
|
const text = typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : "";
|
||||||
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function errorMessage(error) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
function markBgdDirty() {
|
||||||
|
if (bgdDirty) return;
|
||||||
|
bgdDirty = true;
|
||||||
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (btn) btn.classList.add("sch-dirty");
|
||||||
|
}
|
||||||
|
function clearBgdDirty() {
|
||||||
|
bgdDirty = false;
|
||||||
|
const 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";
|
||||||
|
}, 3e3);
|
||||||
|
}
|
||||||
|
function selectAllBookmarks() {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
const ids = supportedBookmarks().map(function(bm) {
|
||||||
|
return bm.id;
|
||||||
|
});
|
||||||
|
currentConfig.bookmark_ids = ids;
|
||||||
|
renderBookmarkChecklist(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", () => {
|
||||||
|
void resetBackgroundDecode();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bgdWindow.trx ??= {};
|
||||||
|
bgdWindow.trx.modules ??= {};
|
||||||
|
bgdWindow.trx.modules.backgroundDecode = {
|
||||||
|
initialize: initBackgroundDecode,
|
||||||
|
wireEvents: wireBackgroundDecodeEvents,
|
||||||
|
setRig: setBackgroundDecodeRig
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
import {
|
||||||
|
hostCore,
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/bookmarks.ts
|
||||||
|
var bridge = window;
|
||||||
|
function bmEl(id) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (!element) throw new Error(`Missing bookmark element #${id}`);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
function bmOptionalEl(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
function errorMessage(error) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
var bmScope = "general";
|
||||||
|
function bmScopeParam(prefix, scope) {
|
||||||
|
const sep = prefix ? "&" : "?";
|
||||||
|
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
|
||||||
|
}
|
||||||
|
var bmList = [];
|
||||||
|
var bmOverlayList = [];
|
||||||
|
var bmOverlayRevision = 0;
|
||||||
|
var bmFilteredList = [];
|
||||||
|
var bmEditScope = null;
|
||||||
|
var bmCurrentPage = 1;
|
||||||
|
var BM_PAGE_SIZE = 25;
|
||||||
|
var bmSelected = /* @__PURE__ */ new Set();
|
||||||
|
function bmFmtFreq(hz) {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
|
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + " GHz";
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + " MHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + " kHz";
|
||||||
|
return `${hz} Hz`;
|
||||||
|
}
|
||||||
|
function bmEsc(str) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.appendChild(document.createTextNode(String(str)));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
function bmCanControl() {
|
||||||
|
return !hostState.authEnabled || hostState.authRole === "control";
|
||||||
|
}
|
||||||
|
function bmSyncAccess() {
|
||||||
|
const canCtrl = bmCanControl();
|
||||||
|
const addBtn = bmEl("bm-add-btn");
|
||||||
|
const selectAllBtn = bmEl("bm-select-all-btn");
|
||||||
|
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
|
||||||
|
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
||||||
|
}
|
||||||
|
function bmListScope() {
|
||||||
|
return hostState.lastActiveRigId || "general";
|
||||||
|
}
|
||||||
|
async function bmFetchOverlay() {
|
||||||
|
const overlayScope = bmListScope();
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
bmOverlayList = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch overlay bookmarks:", e);
|
||||||
|
bmOverlayList = [];
|
||||||
|
}
|
||||||
|
bmOverlayRevision++;
|
||||||
|
if (typeof bridge.syncBookmarkMapLocators === "function") {
|
||||||
|
bridge.syncBookmarkMapLocators(bmOverlayList);
|
||||||
|
}
|
||||||
|
hostCore.scheduleSpectrumDraw();
|
||||||
|
}
|
||||||
|
async function bmFetch(categoryFilter) {
|
||||||
|
let url = "/bookmarks";
|
||||||
|
let hasQuery = false;
|
||||||
|
if (categoryFilter && categoryFilter !== "") {
|
||||||
|
url += "?category=" + encodeURIComponent(categoryFilter);
|
||||||
|
hasQuery = true;
|
||||||
|
}
|
||||||
|
url += bmScopeParam(hasQuery);
|
||||||
|
const overlayPromise = bmFetchOverlay();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
bmList = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch bookmarks:", e);
|
||||||
|
bmList = [];
|
||||||
|
}
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
bmSyncAccess();
|
||||||
|
bmApplyFilters();
|
||||||
|
void bmRefreshCategoryFilter(categoryFilter);
|
||||||
|
await overlayPromise;
|
||||||
|
}
|
||||||
|
function bmApplyFilters() {
|
||||||
|
const text = (bmEl("bm-text-filter")?.value || "").trim().toLowerCase();
|
||||||
|
const modeFilter = (bmEl("bm-mode-filter")?.value || "").trim().toUpperCase();
|
||||||
|
let filtered = modeFilter ? bmList.filter((bm) => (bm.mode || "").toUpperCase() === modeFilter) : bmList;
|
||||||
|
filtered = text ? filtered.filter(
|
||||||
|
(bm) => (bm.name || "").toLowerCase().includes(text) || (bm.locator || "").toLowerCase().includes(text) || (bm.category || "").toLowerCase().includes(text) || (bm.comment || "").toLowerCase().includes(text)
|
||||||
|
) : filtered;
|
||||||
|
bmFilteredList = filtered;
|
||||||
|
bmCurrentPage = 1;
|
||||||
|
bmRender(filtered);
|
||||||
|
}
|
||||||
|
async function bmRefreshCategoryFilter(keepValue) {
|
||||||
|
const sel = bmEl("bm-category-filter");
|
||||||
|
const modeSel = bmEl("bm-mode-filter");
|
||||||
|
if (!sel && !modeSel) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks" + bmScopeParam(false));
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const all = await resp.json();
|
||||||
|
if (sel) {
|
||||||
|
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
|
||||||
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
|
cats.forEach((cat) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = cat;
|
||||||
|
opt.textContent = cat;
|
||||||
|
sel.add(opt);
|
||||||
|
});
|
||||||
|
if (keepValue && cats.includes(keepValue)) sel.value = keepValue;
|
||||||
|
}
|
||||||
|
if (modeSel) {
|
||||||
|
const keepMode = modeSel.value;
|
||||||
|
const modes = [...new Set(all.map((b) => (b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
|
||||||
|
while (modeSel.options.length > 1) modeSel.remove(1);
|
||||||
|
modes.forEach((mode) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = mode;
|
||||||
|
opt.textContent = mode;
|
||||||
|
modeSel.add(opt);
|
||||||
|
});
|
||||||
|
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmRender(list) {
|
||||||
|
const tbody = bmEl("bm-tbody");
|
||||||
|
const emptyEl = bmEl("bm-empty");
|
||||||
|
const paginatorEl = bmEl("bm-paginator");
|
||||||
|
const pageSummaryEl = bmEl("bm-page-summary");
|
||||||
|
const pageIndicatorEl = bmEl("bm-page-indicator");
|
||||||
|
const prevBtn = bmEl("bm-page-prev");
|
||||||
|
const nextBtn = bmEl("bm-page-next");
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
if (list.length === 0) {
|
||||||
|
if (emptyEl) emptyEl.style.display = "";
|
||||||
|
if (paginatorEl) paginatorEl.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (emptyEl) emptyEl.style.display = "none";
|
||||||
|
const canControl = bmCanControl();
|
||||||
|
const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE));
|
||||||
|
const page = Math.min(Math.max(bmCurrentPage, 1), totalPages);
|
||||||
|
bmCurrentPage = page;
|
||||||
|
const startIndex = (page - 1) * BM_PAGE_SIZE;
|
||||||
|
const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length);
|
||||||
|
const pageItems = list.slice(startIndex, endIndex);
|
||||||
|
const showScope = bmScope !== "general";
|
||||||
|
pageItems.forEach((bm) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.dataset.bmId = bm.id;
|
||||||
|
const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
|
||||||
|
const locatorCell = bm.locator || "--";
|
||||||
|
const catCell = bm.category || "Uncategorised";
|
||||||
|
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
|
||||||
|
const commentCell = bm.comment || "";
|
||||||
|
const checked = bmSelected.has(bm.id) ? " checked" : "";
|
||||||
|
const scopeBadge = showScope && bm.scope === "general" ? ' <span class="bm-scope-badge">G</span>' : "";
|
||||||
|
tr.innerHTML = `<td class="bm-col-sel"><input type="checkbox" class="bm-row-sel" data-bm-id="${bmEsc(bm.id)}"${checked} aria-label="Select ${bmEsc(bm.name)}" /></td><td class="bm-col-name">${bmEsc(bm.name)}${scopeBadge}</td><td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td><td class="bm-col-mode">${bmEsc(bm.mode)}</td><td class="bm-col-bw">${bwCell}</td><td class="bm-col-loc">${bmEsc(locatorCell)}</td><td class="bm-col-cat">${bmEsc(catCell)}</td><td class="bm-col-dec">${bmEsc(decoderCell)}</td><td class="bm-col-cmt">${bmEsc(commentCell)}</td><td class="bm-col-act"><button class="bm-tune-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Tune</button>` + (canControl ? `<button class="bm-edit-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Edit</button><button class="bm-del-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Delete</button>` : "") + `</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
|
||||||
|
if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
|
||||||
|
if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`;
|
||||||
|
if (prevBtn) prevBtn.disabled = page <= 1;
|
||||||
|
if (nextBtn) nextBtn.disabled = page >= totalPages;
|
||||||
|
}
|
||||||
|
function bmChangePage(delta) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
|
||||||
|
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
|
||||||
|
if (nextPage === bmCurrentPage) return;
|
||||||
|
bmCurrentPage = nextPage;
|
||||||
|
bmRender(bmFilteredList);
|
||||||
|
}
|
||||||
|
function bmReadDecoders() {
|
||||||
|
return hostState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
|
||||||
|
}
|
||||||
|
function bmWriteDecoders(decoders) {
|
||||||
|
const set = new Set(decoders || []);
|
||||||
|
hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||||
|
const el = bmOptionalEl("bm-dec-" + d.id);
|
||||||
|
if (el) el.checked = set.has(d.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bmBuildDecoderCheckboxes() {
|
||||||
|
const container = bmEl("bm-decoder-checkboxes");
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = "";
|
||||||
|
hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "bm-decoder-check";
|
||||||
|
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
|
||||||
|
container.appendChild(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bmOpenForm(bm) {
|
||||||
|
const wrap = bmEl("bm-form-wrap");
|
||||||
|
if (!wrap) return;
|
||||||
|
bmEditScope = bm ? bm.scope || bmScope : null;
|
||||||
|
bmBuildDecoderCheckboxes();
|
||||||
|
bmEl("bm-id").value = bm ? bm.id : "";
|
||||||
|
bmEl("bm-name").value = bm ? bm.name : "";
|
||||||
|
bmEl("bm-freq").value = bm ? String(bm.freq_hz) : "";
|
||||||
|
bmEl("bm-mode").value = bm ? bm.mode : "";
|
||||||
|
bmEl("bm-bw").value = bm?.bandwidth_hz ? String(bm.bandwidth_hz) : "";
|
||||||
|
bmEl("bm-locator").value = bm ? bm.locator || "" : "";
|
||||||
|
bmEl("bm-category-input").value = bm ? bm.category || "" : "";
|
||||||
|
bmEl("bm-comment").value = bm ? bm.comment || "" : "";
|
||||||
|
bmWriteDecoders(bm?.decoders ?? []);
|
||||||
|
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
||||||
|
wrap.style.display = "flex";
|
||||||
|
bmEl("bm-name").focus();
|
||||||
|
}
|
||||||
|
function bmCloseForm() {
|
||||||
|
const wrap = bmEl("bm-form-wrap");
|
||||||
|
if (wrap) wrap.style.display = "none";
|
||||||
|
}
|
||||||
|
function bmPrefillFromStatus() {
|
||||||
|
const freqHz = hostState.lastFreqHz;
|
||||||
|
if (freqHz != null && Number.isFinite(freqHz)) {
|
||||||
|
bmEl("bm-freq").value = String(Math.round(freqHz));
|
||||||
|
}
|
||||||
|
if (hostState.lastModeName) {
|
||||||
|
bmEl("bm-mode").value = hostState.lastModeName;
|
||||||
|
}
|
||||||
|
if (hostState.currentBandwidthHz > 0) {
|
||||||
|
bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
|
||||||
|
}
|
||||||
|
const activeDecoders = hostState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true").map((d) => d.id);
|
||||||
|
bmWriteDecoders(activeDecoders);
|
||||||
|
}
|
||||||
|
async function bmSave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = bmEl("bm-id").value;
|
||||||
|
const name = bmEl("bm-name").value.trim();
|
||||||
|
const freqStr = bmEl("bm-freq").value;
|
||||||
|
const freq_hz = parseInt(freqStr, 10);
|
||||||
|
const mode = bmEl("bm-mode").value.trim();
|
||||||
|
const bwStr = bmEl("bm-bw").value;
|
||||||
|
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
|
||||||
|
const locator = bmEl("bm-locator").value.trim().toUpperCase();
|
||||||
|
const category = bmEl("bm-category-input").value.trim();
|
||||||
|
const comment = bmEl("bm-comment").value.trim();
|
||||||
|
const decoders = bmReadDecoders();
|
||||||
|
const formError = bmEl("bm-form-error");
|
||||||
|
if (formError) formError.textContent = "";
|
||||||
|
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
||||||
|
if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
|
||||||
|
const invalid = !name ? bmEl("bm-name") : !Number.isFinite(freq_hz) ? bmEl("bm-freq") : bmEl("bm-mode");
|
||||||
|
invalid?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
freq_hz,
|
||||||
|
mode,
|
||||||
|
bandwidth_hz,
|
||||||
|
locator: locator || null,
|
||||||
|
category,
|
||||||
|
comment,
|
||||||
|
decoders
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
let resp;
|
||||||
|
if (id) {
|
||||||
|
resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resp = await fetch("/bookmarks" + bmScopeParam(false), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
if (resp.status === 409) {
|
||||||
|
throw new Error("A bookmark for that frequency already exists.");
|
||||||
|
}
|
||||||
|
throw new Error(text || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
bmCloseForm();
|
||||||
|
await bmFetch(bmEl("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save bookmark:", err);
|
||||||
|
if (formError) formError.textContent = "Failed to save bookmark: " + errorMessage(err);
|
||||||
|
bridge.trxUi.notify?.("Bookmark could not be saved", { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function bmDelete(id) {
|
||||||
|
if (!await bridge.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
|
||||||
|
const bm = bmList.find((b) => b.id === id);
|
||||||
|
const scope = bm ? bm.scope : void 0;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
await bmFetch(bmEl("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete bookmark:", err);
|
||||||
|
bridge.trxUi.notify?.("Failed to delete bookmark: " + errorMessage(err), { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmApply(bm) {
|
||||||
|
try {
|
||||||
|
const modeEl = document.getElementById("mode");
|
||||||
|
if (modeEl) {
|
||||||
|
modeEl.value = (bm.mode || "").toUpperCase();
|
||||||
|
}
|
||||||
|
if (bm.bandwidth_hz) {
|
||||||
|
hostState.currentBandwidthHz = bm.bandwidth_hz;
|
||||||
|
hostCore.syncBandwidthInput(bm.bandwidth_hz);
|
||||||
|
}
|
||||||
|
hostCore.armOptimisticFrequency(bm.freq_hz);
|
||||||
|
hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
|
||||||
|
if (hostState.lastSpectrumData) {
|
||||||
|
hostCore.scheduleSpectrumDraw();
|
||||||
|
}
|
||||||
|
const tunePromise = (async () => {
|
||||||
|
await bridge.trx.modules.vchan?.takeSchedulerControl();
|
||||||
|
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
|
||||||
|
if (!onVirtual) {
|
||||||
|
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||||
|
}
|
||||||
|
if (bm.bandwidth_hz) {
|
||||||
|
const bwHandledByVchan = await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
||||||
|
if (!bwHandledByVchan) {
|
||||||
|
await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hostCore.setRigFrequency(bm.freq_hz);
|
||||||
|
})();
|
||||||
|
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||||
|
const modeUp = (bm.mode || "").toUpperCase();
|
||||||
|
const allToggleDecoders = hostState.decoderRegistry.filter(
|
||||||
|
(d) => d.activation === "toggle"
|
||||||
|
);
|
||||||
|
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||||
|
let statusUrl = "/status";
|
||||||
|
const rigId = hostState.lastActiveRigId;
|
||||||
|
if (rigId) {
|
||||||
|
statusUrl += "?remote=" + encodeURIComponent(rigId);
|
||||||
|
}
|
||||||
|
const statusResp = await fetch(statusUrl);
|
||||||
|
if (!statusResp.ok) return;
|
||||||
|
const st = await statusResp.json();
|
||||||
|
const toggles = [];
|
||||||
|
for (const d of allToggleDecoders) {
|
||||||
|
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||||||
|
const currentlyOn = !!st[statusKey];
|
||||||
|
const compatible = Array.isArray(d.active_modes) && d.active_modes.includes(modeUp);
|
||||||
|
let wanted;
|
||||||
|
if (!compatible) {
|
||||||
|
wanted = false;
|
||||||
|
} else if (hasDecoders) {
|
||||||
|
wanted = bm.decoders?.includes(d.id) ?? false;
|
||||||
|
} else {
|
||||||
|
wanted = currentlyOn;
|
||||||
|
}
|
||||||
|
if (wanted !== currentlyOn) {
|
||||||
|
toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toggles.length) await Promise.all(toggles);
|
||||||
|
})() : Promise.resolve();
|
||||||
|
void Promise.all([tunePromise, decoderPromise]).catch((error) => {
|
||||||
|
console.error("Bookmark apply background error:", error);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to apply bookmark:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bridge.trx.modules.bookmarks = {
|
||||||
|
get overlayList() {
|
||||||
|
return bmOverlayList;
|
||||||
|
},
|
||||||
|
get overlayRevision() {
|
||||||
|
return bmOverlayRevision;
|
||||||
|
},
|
||||||
|
refreshOverlay: bmFetchOverlay,
|
||||||
|
invalidateColors() {
|
||||||
|
bmOverlayRevision += 1;
|
||||||
|
},
|
||||||
|
apply: bmApply,
|
||||||
|
formatFrequency: bmFmtFreq,
|
||||||
|
fetch: bmFetch,
|
||||||
|
populateScopePicker: bmPopulateScopePicker
|
||||||
|
};
|
||||||
|
function bmUpdateSelectionUi() {
|
||||||
|
const count = bmSelected.size;
|
||||||
|
const canCtrl = bmCanControl();
|
||||||
|
const visible = count > 0 && canCtrl;
|
||||||
|
const btn = bmEl("bm-del-selected-btn");
|
||||||
|
const countEl = bmEl("bm-del-selected-count");
|
||||||
|
if (btn) btn.style.display = visible ? "" : "none";
|
||||||
|
if (countEl) countEl.textContent = String(count);
|
||||||
|
const moveWrap = bmEl("bm-move-selected-wrap");
|
||||||
|
const moveCountEl = bmEl("bm-move-selected-count");
|
||||||
|
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
|
||||||
|
if (moveCountEl) moveCountEl.textContent = String(count);
|
||||||
|
if (visible) bmPopulateMoveTarget();
|
||||||
|
const selectAllBtn = bmEl("bm-select-all-btn");
|
||||||
|
if (selectAllBtn && bmCanControl()) {
|
||||||
|
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||||
|
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmPopulateMoveTarget() {
|
||||||
|
const sel = bmEl("bm-move-target");
|
||||||
|
if (!sel) return;
|
||||||
|
const rigIds = hostState.lastRigIds;
|
||||||
|
const displayNames = hostState.lastRigDisplayNames;
|
||||||
|
const prev = sel.value;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
if (bmScope !== "general") {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "general";
|
||||||
|
opt.textContent = "General";
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
rigIds.forEach((id) => {
|
||||||
|
if (id === bmScope) return;
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = displayNames[id] || id;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) {
|
||||||
|
sel.value = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function bmMoveSelected() {
|
||||||
|
const ids = Array.from(bmSelected);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const target = bmEl("bm-move-target")?.value;
|
||||||
|
if (!target) return;
|
||||||
|
const targetLabel = bmEl("bm-move-target")?.selectedOptions[0]?.textContent || target;
|
||||||
|
if (!await bridge.trxUi.confirm({
|
||||||
|
title: "Move selected bookmarks?",
|
||||||
|
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
|
||||||
|
confirmLabel: "Move",
|
||||||
|
danger: false
|
||||||
|
})) return;
|
||||||
|
try {
|
||||||
|
const byScope = {};
|
||||||
|
for (const id of ids) {
|
||||||
|
const bm = bmList.find((b) => b.id === id);
|
||||||
|
const scope = bm?.scope || bmScope;
|
||||||
|
if (scope === target) continue;
|
||||||
|
(byScope[scope] ||= []).push(id);
|
||||||
|
}
|
||||||
|
await Promise.all(Object.entries(byScope).map(
|
||||||
|
([scope, scopeIds]) => fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: scopeIds, to: target })
|
||||||
|
}).then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
})
|
||||||
|
));
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
await bmFetch(bmEl("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to move bookmarks:", err);
|
||||||
|
bridge.trxUi.notify?.("Failed to move bookmarks: " + errorMessage(err), { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmSyncSelectAllCheckbox() {
|
||||||
|
const selectAll = bmEl("bm-select-all");
|
||||||
|
if (!selectAll) return;
|
||||||
|
const checkboxes = document.querySelectorAll(".bm-row-sel");
|
||||||
|
if (checkboxes.length === 0) {
|
||||||
|
selectAll.checked = false;
|
||||||
|
selectAll.indeterminate = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length;
|
||||||
|
selectAll.checked = checkedCount === checkboxes.length;
|
||||||
|
selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
|
||||||
|
}
|
||||||
|
async function bmDeleteSelected() {
|
||||||
|
const ids = Array.from(bmSelected);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
if (!await bridge.trxUi.confirm({
|
||||||
|
title: "Delete selected bookmarks?",
|
||||||
|
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
|
||||||
|
confirmLabel: "Delete"
|
||||||
|
})) return;
|
||||||
|
try {
|
||||||
|
const byScope = {};
|
||||||
|
for (const id of ids) {
|
||||||
|
const bm = bmList.find((b) => b.id === id);
|
||||||
|
const scope = bm?.scope || bmScope;
|
||||||
|
(byScope[scope] ||= []).push(id);
|
||||||
|
}
|
||||||
|
await Promise.all(Object.entries(byScope).map(
|
||||||
|
([scope, scopeIds]) => fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: scopeIds })
|
||||||
|
}).then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
})
|
||||||
|
));
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
await bmFetch(bmEl("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete bookmarks:", err);
|
||||||
|
bridge.trxUi.notify?.("Failed to delete bookmarks: " + errorMessage(err), { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmPopulateScopePicker() {
|
||||||
|
const picker = bmEl("bm-scope-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
const rigIds = hostState.lastRigIds;
|
||||||
|
const displayNames = hostState.lastRigDisplayNames;
|
||||||
|
const prev = picker.value;
|
||||||
|
while (picker.options.length > 1) picker.remove(1);
|
||||||
|
rigIds.forEach((id) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = displayNames[id] || id;
|
||||||
|
picker.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (prev && (prev === "general" || rigIds.includes(prev))) {
|
||||||
|
picker.value = prev;
|
||||||
|
} else {
|
||||||
|
picker.value = "general";
|
||||||
|
}
|
||||||
|
bmScope = picker.value;
|
||||||
|
}
|
||||||
|
(function initBookmarks() {
|
||||||
|
bmSyncAccess();
|
||||||
|
bmBuildDecoderCheckboxes();
|
||||||
|
hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||||
|
bmPopulateScopePicker();
|
||||||
|
const scopePicker = bmEl("bm-scope-picker");
|
||||||
|
if (scopePicker) {
|
||||||
|
scopePicker.addEventListener("change", (e) => {
|
||||||
|
bmScope = e.currentTarget.value;
|
||||||
|
void bmFetch(bmEl("bm-category-filter").value || "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelector(".tab-bar")?.addEventListener("click", (e) => {
|
||||||
|
const btn = e.target instanceof Element ? e.target.closest('.tab[data-tab="bookmarks"]') : null;
|
||||||
|
if (!btn) return;
|
||||||
|
void bmFetch(bmEl("bm-category-filter").value);
|
||||||
|
});
|
||||||
|
bmEl("bm-add-btn").addEventListener("click", () => {
|
||||||
|
bmOpenForm(null);
|
||||||
|
bmPrefillFromStatus();
|
||||||
|
});
|
||||||
|
bmEl("bm-category-filter").addEventListener("change", (e) => {
|
||||||
|
void bmFetch(e.currentTarget.value);
|
||||||
|
});
|
||||||
|
bmEl("bm-mode-filter").addEventListener("change", () => {
|
||||||
|
bmApplyFilters();
|
||||||
|
});
|
||||||
|
bmEl("bm-text-filter").addEventListener("input", () => {
|
||||||
|
bmApplyFilters();
|
||||||
|
});
|
||||||
|
bmEl("bm-page-prev").addEventListener("click", () => {
|
||||||
|
bmChangePage(-1);
|
||||||
|
});
|
||||||
|
bmEl("bm-page-next").addEventListener("click", () => {
|
||||||
|
bmChangePage(1);
|
||||||
|
});
|
||||||
|
bmEl("bm-form").addEventListener("submit", (event) => {
|
||||||
|
void bmSave(event);
|
||||||
|
});
|
||||||
|
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
|
||||||
|
const formWrap = bmEl("bm-form-wrap");
|
||||||
|
if (formWrap) {
|
||||||
|
formWrap.addEventListener("click", (e) => {
|
||||||
|
if (e.target === formWrap) bmCloseForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && bmEl("bm-form-wrap")?.style.display === "flex") {
|
||||||
|
bmCloseForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bmEl("bm-select-all").addEventListener("change", (e) => {
|
||||||
|
const checked = e.currentTarget.checked;
|
||||||
|
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||||
|
cb.checked = checked;
|
||||||
|
const id = cb.dataset.bmId;
|
||||||
|
if (!id) return;
|
||||||
|
if (checked) bmSelected.add(id);
|
||||||
|
else bmSelected.delete(id);
|
||||||
|
});
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
});
|
||||||
|
bmEl("bm-select-all-btn").addEventListener("click", () => {
|
||||||
|
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||||
|
if (allSelected) {
|
||||||
|
bmSelected.clear();
|
||||||
|
} else {
|
||||||
|
bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
|
||||||
|
}
|
||||||
|
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||||
|
cb.checked = !!cb.dataset.bmId && bmSelected.has(cb.dataset.bmId);
|
||||||
|
});
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
});
|
||||||
|
bmEl("bm-del-selected-btn").addEventListener("click", () => {
|
||||||
|
void bmDeleteSelected();
|
||||||
|
});
|
||||||
|
bmEl("bm-move-selected-btn").addEventListener("click", () => {
|
||||||
|
void bmMoveSelected();
|
||||||
|
});
|
||||||
|
bmEl("bm-tbody").addEventListener("click", (e) => {
|
||||||
|
void (async () => {
|
||||||
|
if (!(e.target instanceof Element)) return;
|
||||||
|
const checkbox = e.target.closest(".bm-row-sel");
|
||||||
|
if (checkbox) {
|
||||||
|
const id = checkbox.dataset.bmId;
|
||||||
|
if (!id) return;
|
||||||
|
if (checkbox.checked) bmSelected.add(id);
|
||||||
|
else bmSelected.delete(id);
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tuneBtn = e.target.closest(".bm-tune-btn");
|
||||||
|
const editBtn = e.target.closest(".bm-edit-btn");
|
||||||
|
const delBtn = e.target.closest(".bm-del-btn");
|
||||||
|
if (tuneBtn) {
|
||||||
|
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
|
||||||
|
if (bm) bmApply(bm);
|
||||||
|
} else if (editBtn) {
|
||||||
|
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
|
||||||
|
if (bm) bmOpenForm(bm);
|
||||||
|
} else if (delBtn) {
|
||||||
|
const id = delBtn.dataset.bmId;
|
||||||
|
if (id) await bmDelete(id);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
void bmFetch("");
|
||||||
|
})();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// src/plugins/host.ts
|
||||||
|
var host = window;
|
||||||
|
var hostState = host.trx.state;
|
||||||
|
var hostCore = host.trx.core;
|
||||||
|
|
||||||
|
export {
|
||||||
|
hostState,
|
||||||
|
hostCore
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// src/plugins/aprs-shared.ts
|
||||||
|
function aprsPacketCategory(packet) {
|
||||||
|
const type = (packet.type ?? "").toLowerCase();
|
||||||
|
const info = (packet.info ?? "").toLowerCase();
|
||||||
|
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
|
||||||
|
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||||
|
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||||
|
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
function aprsCategoryLabel(category) {
|
||||||
|
switch (category) {
|
||||||
|
case "position":
|
||||||
|
return "Position";
|
||||||
|
case "message":
|
||||||
|
return "Message";
|
||||||
|
case "weather":
|
||||||
|
return "Weather";
|
||||||
|
case "telemetry":
|
||||||
|
return "Telemetry";
|
||||||
|
default:
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function aprsAgeText(timestampMs) {
|
||||||
|
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
|
||||||
|
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${String(seconds)}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${String(minutes)}m ago`;
|
||||||
|
return `${String(Math.round(minutes / 60))}h ago`;
|
||||||
|
}
|
||||||
|
function aprsPacketSignature(packet) {
|
||||||
|
return [
|
||||||
|
packet.srcCall ?? "",
|
||||||
|
packet.destCall ?? "",
|
||||||
|
packet.path ?? "",
|
||||||
|
packet.info ?? "",
|
||||||
|
packet.type ?? "",
|
||||||
|
packet.lat?.toFixed(4) ?? "",
|
||||||
|
packet.lon?.toFixed(4) ?? ""
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
function collapseAprsDuplicates(packets) {
|
||||||
|
const seen = /* @__PURE__ */ new Set();
|
||||||
|
return packets.filter((packet) => {
|
||||||
|
const signature = aprsPacketSignature(packet);
|
||||||
|
if (seen.has(signature)) return false;
|
||||||
|
seen.add(signature);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function aprsHexBytes(bytes) {
|
||||||
|
if (!bytes?.length) return "--";
|
||||||
|
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||||
|
}
|
||||||
|
function renderAprsInfo(packet) {
|
||||||
|
if (packet.info_bytes?.length) {
|
||||||
|
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
|
||||||
|
}
|
||||||
|
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
|
||||||
|
}
|
||||||
|
function renderAprsByte(byte) {
|
||||||
|
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||||
|
}
|
||||||
|
function renderAprsCharacter(character) {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||||
|
}
|
||||||
|
function escapeAprsCharacter(character) {
|
||||||
|
if (character === "<") return "<";
|
||||||
|
if (character === ">") return ">";
|
||||||
|
if (character === "&") return "&";
|
||||||
|
if (character === '"') return """;
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
function renderLocalAprsSymbol(packet, escapeHtml) {
|
||||||
|
if (!packet.symbolTable || !packet.symbolCode) return "";
|
||||||
|
const symbol = escapeHtml(packet.symbolCode);
|
||||||
|
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
|
||||||
|
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
||||||
|
}
|
||||||
|
function normalizeAprsPacket(packet, receiver) {
|
||||||
|
return {
|
||||||
|
rig_id: packet.rig_id || null,
|
||||||
|
receiver,
|
||||||
|
srcCall: packet.src_call ?? "",
|
||||||
|
destCall: packet.dest_call ?? "",
|
||||||
|
path: packet.path ?? "",
|
||||||
|
info: packet.info ?? "",
|
||||||
|
info_bytes: packet.info_bytes ?? [],
|
||||||
|
type: packet.packet_type ?? "",
|
||||||
|
crcOk: packet.crc_ok ?? false,
|
||||||
|
ts_ms: packet.ts_ms ?? null,
|
||||||
|
lat: packet.lat ?? null,
|
||||||
|
lon: packet.lon ?? null,
|
||||||
|
symbolTable: packet.symbol_table ?? null,
|
||||||
|
symbolCode: packet.symbol_code ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
aprsPacketCategory,
|
||||||
|
aprsCategoryLabel,
|
||||||
|
aprsAgeText,
|
||||||
|
collapseAprsDuplicates,
|
||||||
|
aprsHexBytes,
|
||||||
|
renderAprsInfo,
|
||||||
|
renderLocalAprsSymbol,
|
||||||
|
normalizeAprsPacket
|
||||||
|
};
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import {
|
||||||
|
hostCore
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/ftx-family.ts
|
||||||
|
var bridge = window;
|
||||||
|
function formatBarTime(timestampMs) {
|
||||||
|
if (!timestampMs) return "--:--:--";
|
||||||
|
return new Date(timestampMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
function finiteNumber(value) {
|
||||||
|
const number = typeof value === "number" ? value : Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
function isAlphaNumeric(value) {
|
||||||
|
return value !== void 0 && /[A-Za-z0-9]/.test(value);
|
||||||
|
}
|
||||||
|
function isGrid(value) {
|
||||||
|
const normalized = value.trim().toUpperCase();
|
||||||
|
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && normalized !== "RR73" && normalized !== "73" && normalized !== "RR";
|
||||||
|
}
|
||||||
|
function escapeFtxHtml(input) {
|
||||||
|
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
|
function extractFtxGrids(message) {
|
||||||
|
return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))];
|
||||||
|
}
|
||||||
|
function tokenize(message) {
|
||||||
|
return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean);
|
||||||
|
}
|
||||||
|
function isCallsign(token) {
|
||||||
|
return token !== void 0 && token.length >= 3 && token.length <= 12 && !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token) && /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
|
||||||
|
}
|
||||||
|
function extractFtxLocatorDetails(message) {
|
||||||
|
const tokens = tokenize(message);
|
||||||
|
const grids = extractFtxGrids(message);
|
||||||
|
const gridIndex = tokens.findIndex(isGrid);
|
||||||
|
const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign);
|
||||||
|
const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? "");
|
||||||
|
const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null;
|
||||||
|
const target = directed ? callsigns[0] ?? null : null;
|
||||||
|
return grids.map((grid) => ({ grid, station: source, source, target }));
|
||||||
|
}
|
||||||
|
function extractFtxCallsign(message) {
|
||||||
|
return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null;
|
||||||
|
}
|
||||||
|
function renderFtxMessage(message) {
|
||||||
|
let html = "";
|
||||||
|
let index = 0;
|
||||||
|
while (index < message.length) {
|
||||||
|
if (!isAlphaNumeric(message[index])) {
|
||||||
|
html += escapeFtxHtml(message[index] ?? "");
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let end = index + 1;
|
||||||
|
while (end < message.length && isAlphaNumeric(message[end])) end += 1;
|
||||||
|
const token = message.slice(index, end);
|
||||||
|
const grid = token.toUpperCase();
|
||||||
|
html += isGrid(grid) ? `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>` : escapeFtxHtml(token);
|
||||||
|
index = end;
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
function installFtxCompatibilityHelpers() {
|
||||||
|
bridge.renderFt8Message = renderFtxMessage;
|
||||||
|
bridge.ft8EscapeHtml = escapeFtxHtml;
|
||||||
|
bridge.ft8ExtractLocatorDetails = extractFtxLocatorDetails;
|
||||||
|
bridge.ft8ExtractAllGrids = extractFtxGrids;
|
||||||
|
bridge.ft8ExtractLikelyCallsign = extractFtxCallsign;
|
||||||
|
}
|
||||||
|
function initializeFt8FamilyBar() {
|
||||||
|
const labels = { ft8: "FT8", ft4: "FT4", ft2: "FT2" };
|
||||||
|
const builders = {};
|
||||||
|
const dismissed = { ft8: 0, ft4: 0, ft2: 0 };
|
||||||
|
const overlay = document.getElementById("ft8-bar-overlay");
|
||||||
|
let active = "ft8";
|
||||||
|
const update = () => {
|
||||||
|
if (!overlay) return;
|
||||||
|
const mode = (document.getElementById("mode")?.value ?? "").toUpperCase();
|
||||||
|
const result = builders[active]?.();
|
||||||
|
if (mode !== "DIG" && mode !== "USB" || !result || result.count === 0 || result.newestTsMs <= dismissed[active]) {
|
||||||
|
overlay.style.display = "none";
|
||||||
|
overlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = labels[active];
|
||||||
|
overlay.innerHTML = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">×</button></span></div>${result.html}`;
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
};
|
||||||
|
bridge.registerFt8FamilyBarRenderer = (decoder, builder) => {
|
||||||
|
builders[decoder] = builder;
|
||||||
|
};
|
||||||
|
bridge.setFt8FamilyBarDecoder = (decoder) => {
|
||||||
|
active = decoder;
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
bridge.updateFt8Bar = update;
|
||||||
|
bridge.clearFt8Bar = () => {
|
||||||
|
bridge.trxPluginRuntime.reset(active);
|
||||||
|
};
|
||||||
|
bridge.closeFt8Bar = () => {
|
||||||
|
dismissed[active] = Date.now();
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function initializeFtxDecoder(config) {
|
||||||
|
const { id, label, periodMs, periodDigits = 1 } = config;
|
||||||
|
const status = document.getElementById(`${id}-status`);
|
||||||
|
const period = document.getElementById(`${id}-period`);
|
||||||
|
const messagesElement = document.getElementById(`${id}-messages`);
|
||||||
|
const filterInput = document.getElementById(`${id}-filter`);
|
||||||
|
let filterText = "";
|
||||||
|
let history = [];
|
||||||
|
const renderMessage = (message) => {
|
||||||
|
return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message);
|
||||||
|
};
|
||||||
|
const retentionMs = () => bridge.getDecodeHistoryRetentionMs?.() ?? 864e5;
|
||||||
|
const prune = () => {
|
||||||
|
const cutoff = Date.now() - retentionMs();
|
||||||
|
history = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= cutoff);
|
||||||
|
};
|
||||||
|
const schedule = (job) => {
|
||||||
|
if (bridge.trxScheduleUiFrameJob) bridge.trxScheduleUiFrameJob(`${id}-history`, job);
|
||||||
|
else job();
|
||||||
|
};
|
||||||
|
const displayFrequency = (value) => {
|
||||||
|
const raw = finiteNumber(value);
|
||||||
|
if (raw === null) return null;
|
||||||
|
const base = finiteNumber(bridge.ft8BaseHz);
|
||||||
|
return base !== null && base > 0 && raw >= 0 && raw < 1e5 ? base + raw : raw;
|
||||||
|
};
|
||||||
|
const renderRow = (message) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft8-row";
|
||||||
|
const raw = message.message ?? "";
|
||||||
|
row.dataset.message = raw.toUpperCase();
|
||||||
|
row.dataset.decoder = id;
|
||||||
|
const storedFrequency = finiteNumber(message.freq_hz);
|
||||||
|
row.dataset.storedFreqHz = storedFrequency === null ? "" : String(storedFrequency);
|
||||||
|
const snr = finiteNumber(message.snr_db);
|
||||||
|
const delta = finiteNumber(message.dt_s);
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||||
|
const time = timestamp === null ? "--:--:--" : new Date(timestamp).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
const render = () => {
|
||||||
|
prune();
|
||||||
|
if (!messagesElement) return;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
let count = 0;
|
||||||
|
for (const message of history) {
|
||||||
|
if (count >= 200) break;
|
||||||
|
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||||
|
fragment.appendChild(renderRow(message));
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
messagesElement.replaceChildren(fragment);
|
||||||
|
};
|
||||||
|
const normalize = (message) => {
|
||||||
|
const raw = message.message ?? "";
|
||||||
|
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
|
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
||||||
|
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
if (grids.length > 0) {
|
||||||
|
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||||
|
...message,
|
||||||
|
freq_hz: frequency ?? message.freq_hz,
|
||||||
|
locator_details: locatorDetails
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||||
|
ts_ms: message.ts_ms,
|
||||||
|
snr_db: message.snr_db,
|
||||||
|
dt_s: message.dt_s,
|
||||||
|
freq_hz: frequency ?? message.freq_hz,
|
||||||
|
message: message.message,
|
||||||
|
_tsMs: finiteNumber(message.ts_ms) ?? Date.now()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const receiveBatch = (messages) => {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
if (status) status.textContent = "Receiving";
|
||||||
|
history = messages.map(normalize).reverse().concat(history);
|
||||||
|
prune();
|
||||||
|
bridge.setFt8FamilyBarDecoder?.(id);
|
||||||
|
bridge.updateFt8Bar?.();
|
||||||
|
schedule(render);
|
||||||
|
};
|
||||||
|
const reset = () => {
|
||||||
|
history = [];
|
||||||
|
bridge.updateFt8Bar?.();
|
||||||
|
render();
|
||||||
|
bridge.clearMapMarkersByType?.(id);
|
||||||
|
};
|
||||||
|
const barFrames = () => {
|
||||||
|
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
|
||||||
|
let html = "";
|
||||||
|
for (const message of recent) {
|
||||||
|
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||||
|
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${formatBarTime(timestamp)}</span>`;
|
||||||
|
const snr = finiteNumber(message.snr_db);
|
||||||
|
const delta = finiteNumber(message.dt_s);
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
const detail = [snr === null ? "-- dB" : `${snr.toFixed(1)} dB`, delta === null ? null : `dt ${delta.toFixed(2)}`, frequency === null ? null : `${frequency.toFixed(0)} Hz`].filter((part) => part !== null).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||||
|
}
|
||||||
|
return { count: recent.length, newestTsMs: recent.reduce((latest, message) => Math.max(latest, finiteNumber(message._tsMs ?? message.ts_ms) ?? 0), 0), html };
|
||||||
|
};
|
||||||
|
bridge.trxPluginRuntime.registerDecoder({
|
||||||
|
id,
|
||||||
|
onMessage: (message) => {
|
||||||
|
receiveBatch([message]);
|
||||||
|
},
|
||||||
|
onBatch: receiveBatch,
|
||||||
|
restore: receiveBatch,
|
||||||
|
prune: () => {
|
||||||
|
prune();
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
reset
|
||||||
|
});
|
||||||
|
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||||
|
const updatePeriod = () => {
|
||||||
|
if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1e3).toFixed(periodDigits)}s`;
|
||||||
|
};
|
||||||
|
updatePeriod();
|
||||||
|
window.setInterval(updatePeriod, 250);
|
||||||
|
filterInput?.addEventListener("input", () => {
|
||||||
|
filterText = filterInput.value.trim().toUpperCase();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
messagesElement?.addEventListener("click", (event) => {
|
||||||
|
if (!(event.target instanceof Element)) return;
|
||||||
|
const grid = event.target.closest(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid;
|
||||||
|
if (grid) {
|
||||||
|
bridge.navigateToMapLocator?.(grid.toUpperCase(), id);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const toggle = document.getElementById(`${id}-decode-toggle-btn`);
|
||||||
|
toggle?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
|
||||||
|
await hostCore.postPath(`/toggle_${id}_decode`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${label} toggle failed`, error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
document.getElementById(`settings-clear-${id}-history`)?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath(`/clear_${id}_decode`);
|
||||||
|
reset();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${label} history clear failed`, error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
installFtxCompatibilityHelpers,
|
||||||
|
initializeFt8FamilyBar,
|
||||||
|
initializeFtxDecoder
|
||||||
|
};
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import {
|
||||||
|
hostCore
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/cw.ts
|
||||||
|
var cwWindow = window;
|
||||||
|
var cwStatusEl = document.getElementById("cw-status");
|
||||||
|
var cwOutputEl = document.getElementById("cw-output");
|
||||||
|
var cwAutoInput = document.getElementById("cw-auto");
|
||||||
|
var cwWpmInput = document.getElementById("cw-wpm");
|
||||||
|
var cwToneInput = document.getElementById("cw-tone");
|
||||||
|
var cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
||||||
|
var cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
||||||
|
var cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null;
|
||||||
|
var cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
||||||
|
var cwToneRangeEl = document.getElementById("cw-tone-range");
|
||||||
|
var cwBarOverlay = document.getElementById("cw-bar-overlay");
|
||||||
|
var CW_MAX_LINES = 200;
|
||||||
|
var CW_TONE_MIN_HZ = 100;
|
||||||
|
var CW_TONE_MAX_HZ = 1e4;
|
||||||
|
var CW_WPM_MIN = 5;
|
||||||
|
var CW_WPM_MAX = 40;
|
||||||
|
var CW_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
|
var CW_BAR_LINE_GAP_MS = 5e3;
|
||||||
|
var cwLastAppendTime = 0;
|
||||||
|
var cwTonePickerRaf = null;
|
||||||
|
var cwBarHistory = [];
|
||||||
|
var cwBarCurrentLine = null;
|
||||||
|
var cwBarDismissedAtMs = 0;
|
||||||
|
var cwAutoLocalOverride = null;
|
||||||
|
function escapeCwHtml(input) {
|
||||||
|
return hostCore.escapeMapHtml(input);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cwWindow.applyCwAutoUi = applyCwAutoUi;
|
||||||
|
cwWindow.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);
|
||||||
|
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||||
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||||
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
|
cwBarOverlay.style.display = "none";
|
||||||
|
cwBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">CW</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearCwBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearCwBar();}" aria-label="Clear CW overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">×</button></span></div>`;
|
||||||
|
for (const line of liveLines.slice(0, 8)) {
|
||||||
|
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
||||||
|
const meta = [
|
||||||
|
line.wpm ? `${String(line.wpm)} WPM` : null,
|
||||||
|
line.tone_hz ? `${String(line.tone_hz)} Hz` : null
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` + (meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") + `</div></div>`;
|
||||||
|
}
|
||||||
|
cwBarOverlay.innerHTML = html;
|
||||||
|
cwBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
cwWindow.updateCwBar = updateCwBar;
|
||||||
|
cwWindow.clearCwBar = function() {
|
||||||
|
resetCwHistoryView();
|
||||||
|
};
|
||||||
|
cwWindow.closeCwBar = function() {
|
||||||
|
cwBarDismissedAtMs = Date.now();
|
||||||
|
if (cwBarOverlay) {
|
||||||
|
cwBarOverlay.style.display = "none";
|
||||||
|
cwBarOverlay.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function clampCwWpm(wpm) {
|
||||||
|
const numeric = Number(wpm);
|
||||||
|
if (!Number.isFinite(numeric)) return 15;
|
||||||
|
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
||||||
|
}
|
||||||
|
function clampCwTone(tone) {
|
||||||
|
const numeric = Number(tone);
|
||||||
|
if (!Number.isFinite(numeric)) return 700;
|
||||||
|
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
||||||
|
}
|
||||||
|
function currentCwToneRange() {
|
||||||
|
const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
|
||||||
|
const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
|
||||||
|
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mode = (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;
|
||||||
|
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 (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) {
|
||||||
|
if (cwToneRangeEl) {
|
||||||
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
|
if (mode !== "CW" && mode !== "CWR") {
|
||||||
|
cwToneRangeEl.textContent = "CW/CWR mode required";
|
||||||
|
} else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) {
|
||||||
|
cwToneRangeEl.textContent = "Waiting for spectrum";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cwToneRangeEl) {
|
||||||
|
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
||||||
|
cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`;
|
||||||
|
}
|
||||||
|
const bins = cwWindow.lastSpectrumData.bins;
|
||||||
|
const sampleRate = cwWindow.lastSpectrumData.sample_rate;
|
||||||
|
const centerHz = cwWindow.lastSpectrumData.center_hz;
|
||||||
|
const maxIdx = Math.max(1, bins.length - 1);
|
||||||
|
const fullLoHz = centerHz - sampleRate / 2;
|
||||||
|
const tones = new Array(width).fill(-140);
|
||||||
|
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] ?? -140;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140;
|
||||||
|
}
|
||||||
|
const sorted = smoothed.slice().sort((a, b) => a - b);
|
||||||
|
const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
|
||||||
|
const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
|
||||||
|
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 cwWindow.trxParseCssColor === "function" ? cwWindow.trxParseCssColor : null;
|
||||||
|
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
||||||
|
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
||||||
|
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
||||||
|
const hGridCount = 4;
|
||||||
|
const gridSegments = [];
|
||||||
|
for (let i = 1; i <= hGridCount; i += 1) {
|
||||||
|
const y = Math.round(i / (hGridCount + 1) * (height - 1));
|
||||||
|
gridSegments.push(0, y, width, y);
|
||||||
|
}
|
||||||
|
cwToneGl.drawSegments(gridSegments, axisColor, 1);
|
||||||
|
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1e3 ? 100 : 200;
|
||||||
|
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
||||||
|
const tickSegments = [];
|
||||||
|
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
||||||
|
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] ?? -140));
|
||||||
|
}
|
||||||
|
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
||||||
|
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
||||||
|
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
||||||
|
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
||||||
|
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
||||||
|
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))] ?? -140);
|
||||||
|
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
||||||
|
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
||||||
|
if (cwAutoInput?.checked) {
|
||||||
|
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function setCwTone(tone, { syncInput = true } = {}) {
|
||||||
|
const range = currentCwToneRange();
|
||||||
|
const clamped = toneClampForRange(tone, range);
|
||||||
|
if (cwToneInput && syncInput) {
|
||||||
|
cwToneInput.value = String(clamped);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("CW tone set failed", e);
|
||||||
|
}
|
||||||
|
drawCwTonePicker();
|
||||||
|
}
|
||||||
|
if (cwAutoInput) {
|
||||||
|
cwAutoInput.addEventListener("change", () => {
|
||||||
|
void (async () => {
|
||||||
|
const enabled = cwAutoInput.checked;
|
||||||
|
cwAutoLocalOverride = enabled;
|
||||||
|
applyCwAutoUi(enabled);
|
||||||
|
try {
|
||||||
|
await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||||
|
drawCwTonePicker();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("CW auto toggle failed", error);
|
||||||
|
} finally {
|
||||||
|
cwAutoLocalOverride = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cwWpmInput) {
|
||||||
|
cwWpmInput.addEventListener("change", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (cwAutoInput?.checked) return;
|
||||||
|
const wpm = clampCwWpm(cwWpmInput.value);
|
||||||
|
cwWpmInput.value = String(wpm);
|
||||||
|
try {
|
||||||
|
await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("CW WPM set failed", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cwToneInput) {
|
||||||
|
cwToneInput.addEventListener("change", () => {
|
||||||
|
if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cwToneCanvas) {
|
||||||
|
cwToneCanvas.addEventListener("click", (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;
|
||||||
|
void setCwTone(tone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function resetCwHistoryView() {
|
||||||
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
|
cwLastAppendTime = 0;
|
||||||
|
cwBarHistory = [];
|
||||||
|
cwBarCurrentLine = null;
|
||||||
|
updateCwBar();
|
||||||
|
drawCwTonePicker();
|
||||||
|
}
|
||||||
|
document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_cw_decode");
|
||||||
|
resetCwHistoryView();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("CW history clear failed", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
function onServerCw(evt) {
|
||||||
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
|
if (evt.text && cwOutputEl) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || 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) {
|
||||||
|
const firstChild = cwOutputEl.firstChild;
|
||||||
|
if (!firstChild) break;
|
||||||
|
cwOutputEl.removeChild(firstChild);
|
||||||
|
}
|
||||||
|
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
||||||
|
}
|
||||||
|
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 = String(clampCwWpm(evt.wpm));
|
||||||
|
}
|
||||||
|
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
||||||
|
cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cwTonePickerRaf != null) return;
|
||||||
|
cwTonePickerRaf = requestAnimationFrame(() => {
|
||||||
|
cwTonePickerRaf = null;
|
||||||
|
drawCwTonePicker();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function restoreCwHistory(events) {
|
||||||
|
if (!Array.isArray(events) || events.length === 0) return;
|
||||||
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
|
for (const evt of events) {
|
||||||
|
onServerCw(evt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cwWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "cw",
|
||||||
|
onMessage: onServerCw,
|
||||||
|
restore: restoreCwHistory,
|
||||||
|
reset: resetCwHistoryView
|
||||||
|
});
|
||||||
|
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
|
ensureCwToneCanvasResolution();
|
||||||
|
drawCwTonePicker();
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
|
||||||
|
});
|
||||||
|
applyCwAutoUi(!!cwAutoInput?.checked);
|
||||||
|
updateCwBar();
|
||||||
|
ensureCwToneCanvasResolution();
|
||||||
|
drawCwTonePicker();
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
"use strict";
|
||||||
|
(() => {
|
||||||
|
// src/decode-history-worker.ts
|
||||||
|
var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||||
|
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||||
|
var workerScope = self;
|
||||||
|
function decodeCborUint(view, bytes, state, additional) {
|
||||||
|
const offset = state.offset;
|
||||||
|
if (additional < 24) return additional;
|
||||||
|
if (additional === 24) {
|
||||||
|
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
state.offset += 1;
|
||||||
|
return bytes[offset] ?? 0;
|
||||||
|
}
|
||||||
|
if (additional === 25) {
|
||||||
|
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
state.offset += 2;
|
||||||
|
return view.getUint16(offset);
|
||||||
|
}
|
||||||
|
if (additional === 26) {
|
||||||
|
if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
state.offset += 4;
|
||||||
|
return view.getUint32(offset);
|
||||||
|
}
|
||||||
|
if (additional === 27) {
|
||||||
|
if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const value = view.getBigUint64(offset);
|
||||||
|
state.offset += 8;
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range");
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported CBOR additional info");
|
||||||
|
}
|
||||||
|
function decodeCborFloat16(bits) {
|
||||||
|
const sign = bits & 32768 ? -1 : 1;
|
||||||
|
const exponent = bits >> 10 & 31;
|
||||||
|
const fraction = bits & 1023;
|
||||||
|
if (exponent === 0) {
|
||||||
|
return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024);
|
||||||
|
}
|
||||||
|
if (exponent === 31) {
|
||||||
|
return fraction === 0 ? sign * Infinity : Number.NaN;
|
||||||
|
}
|
||||||
|
return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024);
|
||||||
|
}
|
||||||
|
function decodeCborItem(view, bytes, state) {
|
||||||
|
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const initial = bytes[state.offset++];
|
||||||
|
if (initial === void 0) throw new Error("CBOR payload truncated");
|
||||||
|
const major = initial >> 5;
|
||||||
|
const additional = initial & 31;
|
||||||
|
if (major === 0) return decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (major === 2) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const chunk = bytes.slice(state.offset, state.offset + length);
|
||||||
|
state.offset += length;
|
||||||
|
return Array.from(chunk);
|
||||||
|
}
|
||||||
|
if (major === 3) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const chunk = bytes.subarray(state.offset, state.offset + length);
|
||||||
|
state.offset += length;
|
||||||
|
return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk);
|
||||||
|
}
|
||||||
|
if (major === 4) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
const items = new Array(length);
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
items[i] = decodeCborItem(view, bytes, state);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
if (major === 5) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
const value = {};
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
const key = decodeCborItem(view, bytes, state);
|
||||||
|
const property = typeof key === "string" || typeof key === "number" || typeof key === "boolean" ? String(key) : JSON.stringify(key);
|
||||||
|
value[property] = decodeCborItem(view, bytes, state);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (major === 6) {
|
||||||
|
decodeCborUint(view, bytes, state, additional);
|
||||||
|
return decodeCborItem(view, bytes, state);
|
||||||
|
}
|
||||||
|
if (major === 7) {
|
||||||
|
if (additional === 20) return false;
|
||||||
|
if (additional === 21) return true;
|
||||||
|
if (additional === 22) return null;
|
||||||
|
if (additional === 23) return void 0;
|
||||||
|
if (additional === 25) {
|
||||||
|
if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const bits = view.getUint16(state.offset);
|
||||||
|
state.offset += 2;
|
||||||
|
return decodeCborFloat16(bits);
|
||||||
|
}
|
||||||
|
if (additional === 26) {
|
||||||
|
if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const value = view.getFloat32(state.offset);
|
||||||
|
state.offset += 4;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (additional === 27) {
|
||||||
|
if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const value = view.getFloat64(state.offset);
|
||||||
|
state.offset += 8;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported CBOR major type");
|
||||||
|
}
|
||||||
|
function decodeCborPayload(buffer) {
|
||||||
|
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
const state = { offset: 0 };
|
||||||
|
const value = decodeCborItem(view, bytes, state);
|
||||||
|
if (state.offset !== bytes.length) {
|
||||||
|
throw new Error("Unexpected trailing bytes in decode history payload");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function isHistory(value) {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
async function fetchAndDecodeHistory(url, batchLimit) {
|
||||||
|
workerScope.postMessage({ type: "status", phase: "fetching" });
|
||||||
|
const resp = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (!resp.ok) throw new Error(`History fetch failed: ${String(resp.status)}`);
|
||||||
|
const payload = await resp.arrayBuffer();
|
||||||
|
if (payload.byteLength === 0) {
|
||||||
|
workerScope.postMessage({ type: "start", total: 0 });
|
||||||
|
workerScope.postMessage({ type: "done", total: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workerScope.postMessage({ type: "status", phase: "decoding" });
|
||||||
|
const history = decodeCborPayload(payload);
|
||||||
|
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
|
||||||
|
const items = isHistory(history) && Array.isArray(history[key]) ? history[key] : [];
|
||||||
|
return sum + items.length;
|
||||||
|
}, 0);
|
||||||
|
workerScope.postMessage({ type: "start", total });
|
||||||
|
let processed = 0;
|
||||||
|
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
|
||||||
|
for (const kind of HISTORY_GROUP_KEYS) {
|
||||||
|
const items = isHistory(history) && Array.isArray(history[kind]) ? history[kind] : [];
|
||||||
|
if (items.length === 0) continue;
|
||||||
|
for (let index = 0; index < items.length; index += safeLimit) {
|
||||||
|
const messages = items.slice(index, index + safeLimit);
|
||||||
|
processed += messages.length;
|
||||||
|
workerScope.postMessage({
|
||||||
|
type: "group",
|
||||||
|
kind,
|
||||||
|
messages,
|
||||||
|
processed,
|
||||||
|
total
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
workerScope.postMessage({ type: "done", total });
|
||||||
|
}
|
||||||
|
function isFetchHistoryRequest(value) {
|
||||||
|
return typeof value === "object" && value !== null && "type" in value && value.type === "fetch-history";
|
||||||
|
}
|
||||||
|
workerScope.onmessage = (event) => {
|
||||||
|
const data = event.data;
|
||||||
|
if (!isFetchHistoryRequest(data)) return;
|
||||||
|
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit).catch((error) => {
|
||||||
|
workerScope.postMessage({
|
||||||
|
type: "error",
|
||||||
|
message: error instanceof Error ? error.message : typeof error === "string" ? error : "unknown worker failure"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import {
|
||||||
|
initializeFtxDecoder
|
||||||
|
} from "./chunk-O2Y7YEVQ.js";
|
||||||
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/ft2.ts
|
||||||
|
initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 });
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import {
|
||||||
|
initializeFtxDecoder
|
||||||
|
} from "./chunk-O2Y7YEVQ.js";
|
||||||
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/ft4.ts
|
||||||
|
initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 });
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import {
|
||||||
|
initializeFt8FamilyBar,
|
||||||
|
initializeFtxDecoder,
|
||||||
|
installFtxCompatibilityHelpers
|
||||||
|
} from "./chunk-O2Y7YEVQ.js";
|
||||||
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/ft8.ts
|
||||||
|
installFtxCompatibilityHelpers();
|
||||||
|
initializeFt8FamilyBar();
|
||||||
|
initializeFtxDecoder({ id: "ft8", label: "FT8", periodMs: 15e3, periodDigits: 0 });
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import {
|
||||||
|
aprsAgeText,
|
||||||
|
aprsCategoryLabel,
|
||||||
|
aprsHexBytes,
|
||||||
|
aprsPacketCategory,
|
||||||
|
collapseAprsDuplicates,
|
||||||
|
normalizeAprsPacket,
|
||||||
|
renderAprsInfo,
|
||||||
|
renderLocalAprsSymbol
|
||||||
|
} from "./chunk-M2I6DH4X.js";
|
||||||
|
import {
|
||||||
|
hostCore,
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/hf-aprs.ts
|
||||||
|
var hfAprsWindow = window;
|
||||||
|
var escapeHfAprsHtml = (input) => hostCore.escapeMapHtml(input);
|
||||||
|
var hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||||
|
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
||||||
|
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
||||||
|
var hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
|
||||||
|
var hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
|
||||||
|
var hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
|
||||||
|
var hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
|
||||||
|
var hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
|
||||||
|
var hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
|
||||||
|
var hfAprsFilterText = "";
|
||||||
|
var hfAprsPacketHistory = [];
|
||||||
|
var hfAprsOnlyPos = false;
|
||||||
|
var hfAprsHideCrc = false;
|
||||||
|
var hfAprsCollapseDup = false;
|
||||||
|
var hfAprsTypeFilter = "all";
|
||||||
|
function currentHfAprsHistoryRetentionMs() {
|
||||||
|
return typeof hfAprsWindow.getDecodeHistoryRetentionMs === "function" ? hfAprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneHfAprsPacketHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
|
||||||
|
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleHfAprsHistoryRender() {
|
||||||
|
if (typeof hfAprsWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
hfAprsWindow.trxScheduleUiFrameJob("hf-aprs-history", () => {
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderHfAprsHistory();
|
||||||
|
}
|
||||||
|
function hfAprsDistanceText(pkt) {
|
||||||
|
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||||
|
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
|
||||||
|
if (!Number.isFinite(distKm)) return "";
|
||||||
|
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||||
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
|
}
|
||||||
|
function hfAprsFilterMatch(pkt) {
|
||||||
|
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||||
|
if (hfAprsHideCrc && !pkt.crcOk) return false;
|
||||||
|
if (hfAprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
|
||||||
|
if (!hfAprsFilterText) return true;
|
||||||
|
const haystack = [
|
||||||
|
pkt.srcCall,
|
||||||
|
pkt.destCall,
|
||||||
|
pkt.path,
|
||||||
|
pkt.info,
|
||||||
|
pkt.type,
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||||
|
aprsPacketCategory(pkt)
|
||||||
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
|
return haystack.includes(hfAprsFilterText);
|
||||||
|
}
|
||||||
|
function hfAprsVisiblePackets() {
|
||||||
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
||||||
|
return packets.filter(hfAprsFilterMatch);
|
||||||
|
}
|
||||||
|
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||||
|
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} ${aprsAgeText(latest._tsMs)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateHfAprsChipState() {
|
||||||
|
document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`);
|
||||||
|
});
|
||||||
|
hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos);
|
||||||
|
hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc);
|
||||||
|
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
||||||
|
}
|
||||||
|
function renderHfAprsRow(pkt, isFresh) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "aprs-packet";
|
||||||
|
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||||
|
if (isFresh) row.classList.add("aprs-packet-new");
|
||||||
|
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const age = aprsAgeText(pkt._tsMs);
|
||||||
|
const category = aprsPacketCategory(pkt);
|
||||||
|
const categoryLabel = aprsCategoryLabel(category);
|
||||||
|
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||||
|
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeHfAprsHtml(pkt.path)}</span>` : "";
|
||||||
|
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||||
|
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
|
||||||
|
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
|
||||||
|
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
|
||||||
|
const distance = hfAprsDistanceText(pkt);
|
||||||
|
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
||||||
|
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + hfBadge + symbolHtml + `<span class="aprs-call">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span><span>>${escapeHfAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeHfAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
||||||
|
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||||
|
el.addEventListener("click", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const raw = el.dataset.aprsMap ?? "";
|
||||||
|
const [lat, lon] = raw.split(",").map(Number);
|
||||||
|
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
|
hfAprsWindow.navigateToAprsMap(lat, lon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||||
|
if (copyBtn) {
|
||||||
|
copyBtn.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
const raw = copyBtn.dataset.aprsCopy ?? "";
|
||||||
|
try {
|
||||||
|
const clipboard = Reflect.get(navigator, "clipboard");
|
||||||
|
if (clipboard) {
|
||||||
|
await clipboard.writeText(raw);
|
||||||
|
hostCore.showHint("Coordinates copied", 1200);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
hostCore.showHint("Copy failed", 1500);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderHfAprsHistory() {
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
if (!hfAprsPacketsEl) {
|
||||||
|
updateHfAprsSummary();
|
||||||
|
updateHfAprsChipState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visible = hfAprsVisiblePackets();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const [index, packet] of visible.entries()) {
|
||||||
|
fragment.appendChild(renderHfAprsRow(packet, index === 0));
|
||||||
|
}
|
||||||
|
hfAprsPacketsEl.replaceChildren(fragment);
|
||||||
|
updateHfAprsSummary();
|
||||||
|
updateHfAprsChipState();
|
||||||
|
}
|
||||||
|
function resetHfAprsHistoryView() {
|
||||||
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
|
hfAprsPacketHistory = [];
|
||||||
|
renderHfAprsHistory();
|
||||||
|
}
|
||||||
|
function pruneHfAprsHistoryView() {
|
||||||
|
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 normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null);
|
||||||
|
}
|
||||||
|
function onServerHfAprsBatch(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();
|
||||||
|
}
|
||||||
|
var hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
|
||||||
|
hfAprsDecodeToggleBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
|
||||||
|
await hostCore.postPath("/toggle_hf_aprs_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("HF APRS toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_hf_aprs_decode");
|
||||||
|
resetHfAprsHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("HF APRS history clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
if (hfAprsOnlyPosBtn) {
|
||||||
|
hfAprsOnlyPosBtn.addEventListener("click", () => {
|
||||||
|
hfAprsOnlyPos = !hfAprsOnlyPos;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hfAprsHideCrcBtn) {
|
||||||
|
hfAprsHideCrcBtn.addEventListener("click", () => {
|
||||||
|
hfAprsHideCrc = !hfAprsHideCrc;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hfAprsCollapseDupBtn) {
|
||||||
|
hfAprsCollapseDupBtn.addEventListener("click", () => {
|
||||||
|
hfAprsCollapseDup = !hfAprsCollapseDup;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||||
|
const btn = document.getElementById(`hf-aprs-type-${type}`);
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
hfAprsTypeFilter = type;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (hfAprsFilterInput) {
|
||||||
|
hfAprsFilterInput.addEventListener("input", () => {
|
||||||
|
hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase();
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onServerHfAprs(pkt) {
|
||||||
|
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||||
|
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
||||||
|
}
|
||||||
|
renderHfAprsHistory();
|
||||||
|
window.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "hf_aprs",
|
||||||
|
onMessage: onServerHfAprs,
|
||||||
|
onBatch: onServerHfAprsBatch,
|
||||||
|
restore: onServerHfAprsBatch,
|
||||||
|
reset: resetHfAprsHistoryView,
|
||||||
|
prune: pruneHfAprsHistoryView
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
|||||||
|
// src/plugins/sat-scheduler.ts
|
||||||
|
var satSchedulerWindow = window;
|
||||||
|
(function() {
|
||||||
|
"use strict";
|
||||||
|
const dom = {
|
||||||
|
enabled: document.getElementById("scheduler-sat-enabled"),
|
||||||
|
pretune: document.getElementById("scheduler-sat-pretune"),
|
||||||
|
body: document.getElementById("scheduler-sat-body"),
|
||||||
|
tbody: document.getElementById("scheduler-sat-tbody"),
|
||||||
|
addBtn: document.getElementById("scheduler-sat-add-btn"),
|
||||||
|
passStatus: document.getElementById("scheduler-sat-pass-status"),
|
||||||
|
formWrap: document.getElementById("sch-sat-form-wrap"),
|
||||||
|
formTitle: document.getElementById("sch-sat-form-title"),
|
||||||
|
form: document.getElementById("sch-sat-form"),
|
||||||
|
formCancel: document.getElementById("sch-sat-form-cancel"),
|
||||||
|
preset: document.getElementById("scheduler-sat-preset"),
|
||||||
|
name: document.getElementById("scheduler-sat-name"),
|
||||||
|
norad: document.getElementById("scheduler-sat-norad"),
|
||||||
|
bookmark: document.getElementById("scheduler-sat-bookmark"),
|
||||||
|
minEl: document.getElementById("scheduler-sat-min-el"),
|
||||||
|
priority: document.getElementById("scheduler-sat-priority"),
|
||||||
|
centerHz: document.getElementById("scheduler-sat-center-hz")
|
||||||
|
};
|
||||||
|
let editIdx = null;
|
||||||
|
let eventsWired = false;
|
||||||
|
function getBridge() {
|
||||||
|
return satSchedulerWindow.trx?.modules?.scheduler ?? null;
|
||||||
|
}
|
||||||
|
function getConfig() {
|
||||||
|
const b = getBridge();
|
||||||
|
return b?.getConfig() ?? null;
|
||||||
|
}
|
||||||
|
function getStatus() {
|
||||||
|
const b = getBridge();
|
||||||
|
return b?.getStatus() ?? null;
|
||||||
|
}
|
||||||
|
function getBookmarks() {
|
||||||
|
const b = getBridge();
|
||||||
|
return b?.getBookmarks() ?? [];
|
||||||
|
}
|
||||||
|
function markDirty() {
|
||||||
|
getBridge()?.markDirty();
|
||||||
|
}
|
||||||
|
function bmName(id) {
|
||||||
|
const bm = getBookmarks().find(function(b) {
|
||||||
|
return b.id === id;
|
||||||
|
});
|
||||||
|
return bm ? bm.name : id;
|
||||||
|
}
|
||||||
|
function escHtml(s) {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function formatFreq(hz) {
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
|
||||||
|
return `${String(hz)} Hz`;
|
||||||
|
}
|
||||||
|
function getSatelliteEntries() {
|
||||||
|
const config = getConfig();
|
||||||
|
return config && config.satellites && Array.isArray(config.satellites.entries) ? config.satellites.entries : [];
|
||||||
|
}
|
||||||
|
function ensureSatelliteConfig() {
|
||||||
|
const config = getConfig();
|
||||||
|
if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
|
||||||
|
if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
|
||||||
|
return config.satellites;
|
||||||
|
}
|
||||||
|
function collectSatelliteConfig() {
|
||||||
|
const enabled = dom.enabled ? dom.enabled.checked : false;
|
||||||
|
const pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
|
||||||
|
entries: getSatelliteEntries()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function renderSection() {
|
||||||
|
const config = getConfig();
|
||||||
|
const satCfg = config?.satellites;
|
||||||
|
const enabled = satCfg?.enabled ?? false;
|
||||||
|
if (dom.enabled) dom.enabled.checked = enabled;
|
||||||
|
if (dom.pretune) dom.pretune.value = String(satCfg?.pretune_secs ?? 60);
|
||||||
|
if (dom.body) dom.body.style.display = enabled ? "" : "none";
|
||||||
|
renderEntries();
|
||||||
|
renderPassStatus();
|
||||||
|
}
|
||||||
|
function renderEntries() {
|
||||||
|
if (!dom.tbody) return;
|
||||||
|
const entries = getSatelliteEntries();
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
entries.forEach(function(entry, idx) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const tdSat = document.createElement("td");
|
||||||
|
tdSat.textContent = entry.satellite || "";
|
||||||
|
tr.appendChild(tdSat);
|
||||||
|
const tdNorad = document.createElement("td");
|
||||||
|
tdNorad.textContent = String(entry.norad_id || "");
|
||||||
|
tr.appendChild(tdNorad);
|
||||||
|
const tdBm = document.createElement("td");
|
||||||
|
tdBm.textContent = bmName(entry.bookmark_id);
|
||||||
|
tr.appendChild(tdBm);
|
||||||
|
const tdEl = document.createElement("td");
|
||||||
|
tdEl.textContent = `${String(entry.min_elevation_deg)}°`;
|
||||||
|
tr.appendChild(tdEl);
|
||||||
|
const tdPrio = document.createElement("td");
|
||||||
|
tdPrio.textContent = String(entry.priority || 0);
|
||||||
|
tr.appendChild(tdPrio);
|
||||||
|
const tdActions = document.createElement("td");
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "sch-write";
|
||||||
|
editBtn.type = "button";
|
||||||
|
editBtn.textContent = "Edit";
|
||||||
|
editBtn.addEventListener("click", function() {
|
||||||
|
openForm(entry, idx);
|
||||||
|
});
|
||||||
|
tdActions.appendChild(editBtn);
|
||||||
|
const removeBtn = document.createElement("button");
|
||||||
|
removeBtn.className = "sch-write";
|
||||||
|
removeBtn.type = "button";
|
||||||
|
removeBtn.textContent = "Remove";
|
||||||
|
removeBtn.addEventListener("click", function() {
|
||||||
|
removeEntry(idx);
|
||||||
|
});
|
||||||
|
tdActions.appendChild(removeBtn);
|
||||||
|
tr.appendChild(tdActions);
|
||||||
|
frag.appendChild(tr);
|
||||||
|
});
|
||||||
|
dom.tbody.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
function renderPassStatus() {
|
||||||
|
if (!dom.passStatus) return;
|
||||||
|
const entries = getSatelliteEntries();
|
||||||
|
if (entries.length === 0) {
|
||||||
|
dom.passStatus.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const status = getStatus();
|
||||||
|
if (status && status.active_satellite) {
|
||||||
|
dom.passStatus.innerHTML = '<span class="sch-sat-active-badge">PASS ACTIVE: ' + escHtml(status.active_satellite) + "</span>";
|
||||||
|
} else {
|
||||||
|
dom.passStatus.innerHTML = '<span style="color:var(--text-muted);font-size:0.8rem;">No satellite pass active. Predictions available in the SAT tab.</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderBookmarkSelect(selectedId) {
|
||||||
|
const bookmarkSelect = dom.bookmark;
|
||||||
|
if (!bookmarkSelect) return;
|
||||||
|
bookmarkSelect.innerHTML = '<option value="">— none —</option>';
|
||||||
|
getBookmarks().forEach(function(bm) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = bm.id;
|
||||||
|
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
|
||||||
|
if (bm.id === selectedId) opt.selected = true;
|
||||||
|
bookmarkSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function removeEntry(idx) {
|
||||||
|
const sat = ensureSatelliteConfig();
|
||||||
|
sat.entries.splice(idx, 1);
|
||||||
|
renderEntries();
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
function openForm(entry, idx) {
|
||||||
|
editIdx = idx != null ? idx : null;
|
||||||
|
if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite";
|
||||||
|
if (dom.preset) dom.preset.value = "";
|
||||||
|
if (dom.name) dom.name.value = entry ? entry.satellite || "" : "";
|
||||||
|
if (dom.norad) dom.norad.value = entry ? String(entry.norad_id || "") : "";
|
||||||
|
if (dom.minEl) dom.minEl.value = String(entry?.min_elevation_deg ?? 5);
|
||||||
|
if (dom.priority) dom.priority.value = String(entry?.priority ?? 0);
|
||||||
|
if (dom.centerHz) dom.centerHz.value = entry?.center_hz ? String(entry.center_hz) : "";
|
||||||
|
renderBookmarkSelect(entry ? entry.bookmark_id : null);
|
||||||
|
if (dom.formWrap) {
|
||||||
|
dom.formWrap.style.display = "flex";
|
||||||
|
if (dom.name) dom.name.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeForm() {
|
||||||
|
if (dom.formWrap) dom.formWrap.style.display = "none";
|
||||||
|
editIdx = null;
|
||||||
|
}
|
||||||
|
function onFormSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const satellite = dom.name ? dom.name.value.trim() : "";
|
||||||
|
const noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
|
||||||
|
const bmId = dom.bookmark ? dom.bookmark.value : "";
|
||||||
|
if (!satellite) {
|
||||||
|
satSchedulerWindow.trxUi?.notify("Enter a satellite name.", { kind: "error" });
|
||||||
|
dom.name?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNaN(noradId) || noradId <= 0) {
|
||||||
|
satSchedulerWindow.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" });
|
||||||
|
dom.norad?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bmId) {
|
||||||
|
satSchedulerWindow.trxUi?.notify("Select a bookmark.", { kind: "error" });
|
||||||
|
dom.bookmark?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
|
||||||
|
const prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
|
||||||
|
const centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
|
||||||
|
const sat = ensureSatelliteConfig();
|
||||||
|
const existing = editIdx !== null ? sat.entries[editIdx] : void 0;
|
||||||
|
const entryData = {
|
||||||
|
id: existing?.id ?? `sat_${Date.now().toString(36)}`,
|
||||||
|
satellite,
|
||||||
|
norad_id: noradId,
|
||||||
|
bookmark_id: bmId,
|
||||||
|
min_elevation_deg: isNaN(minEl) ? 5 : minEl,
|
||||||
|
priority: isNaN(prio) ? 0 : prio,
|
||||||
|
center_hz: !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null,
|
||||||
|
bookmark_ids: []
|
||||||
|
};
|
||||||
|
if (editIdx !== null) {
|
||||||
|
sat.entries[editIdx] = entryData;
|
||||||
|
} else {
|
||||||
|
sat.entries.push(entryData);
|
||||||
|
}
|
||||||
|
closeForm();
|
||||||
|
renderEntries();
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
function onPresetChange() {
|
||||||
|
if (!dom.preset || !dom.preset.value) return;
|
||||||
|
const parts = dom.preset.value.split("|");
|
||||||
|
if (dom.name) dom.name.value = parts[0] || "";
|
||||||
|
if (dom.norad) dom.norad.value = parts[1] || "";
|
||||||
|
}
|
||||||
|
function wireEvents() {
|
||||||
|
if (eventsWired) return;
|
||||||
|
eventsWired = true;
|
||||||
|
if (dom.enabled) {
|
||||||
|
const enabledInput = dom.enabled;
|
||||||
|
dom.enabled.addEventListener("change", function() {
|
||||||
|
if (dom.body) dom.body.style.display = enabledInput.checked ? "" : "none";
|
||||||
|
markDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dom.pretune) {
|
||||||
|
dom.pretune.addEventListener("input", function() {
|
||||||
|
markDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dom.addBtn) dom.addBtn.addEventListener("click", function() {
|
||||||
|
openForm(null, null);
|
||||||
|
});
|
||||||
|
if (dom.form) dom.form.addEventListener("submit", onFormSubmit);
|
||||||
|
if (dom.formCancel) dom.formCancel.addEventListener("click", closeForm);
|
||||||
|
if (dom.preset) dom.preset.addEventListener("change", onPresetChange);
|
||||||
|
}
|
||||||
|
satSchedulerWindow.satScheduler = {
|
||||||
|
wireEvents,
|
||||||
|
renderSection,
|
||||||
|
renderPassStatus,
|
||||||
|
collectSatelliteConfig
|
||||||
|
};
|
||||||
|
if (getBridge()) {
|
||||||
|
wireEvents();
|
||||||
|
renderSection();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
import {
|
||||||
|
hostCore
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/sat.ts
|
||||||
|
var satWindow = window;
|
||||||
|
var satDom = {
|
||||||
|
status: document.getElementById("sat-status"),
|
||||||
|
liveView: document.getElementById("sat-live-view"),
|
||||||
|
historyView: document.getElementById("sat-history-view"),
|
||||||
|
predictionsView: document.getElementById("sat-predictions-view"),
|
||||||
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
|
historyList: document.getElementById("sat-history-list"),
|
||||||
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
|
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")
|
||||||
|
};
|
||||||
|
var satImageHistory = [];
|
||||||
|
var SAT_MAX_IMAGES = 100;
|
||||||
|
var SAT_PRED_PAGE_SIZE = 50;
|
||||||
|
var satPredShowAll = false;
|
||||||
|
var satFilterText = "";
|
||||||
|
var satActiveView = "live";
|
||||||
|
var satPredData = [];
|
||||||
|
var satPredFilterText = "";
|
||||||
|
var satPredMinEl = 0;
|
||||||
|
var satPredCategory = "all";
|
||||||
|
var satPredSatCount = 0;
|
||||||
|
var satPredCountdownTimer = null;
|
||||||
|
function scheduleSatUi(key, job) {
|
||||||
|
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
satWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function switchSatView(view) {
|
||||||
|
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||||
|
satActiveView = view;
|
||||||
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
|
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
|
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
||||||
|
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||||
|
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
|
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
||||||
|
if (leavingPredictions) clearPredictionDom();
|
||||||
|
if (view === "history") {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
} else if (view === "predictions") {
|
||||||
|
satPredShowAll = false;
|
||||||
|
void loadSatPredictions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clearPredictionDom() {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
}
|
||||||
|
satWindow.clearSatPredictionDom = clearPredictionDom;
|
||||||
|
satDom.viewLiveBtn?.addEventListener("click", () => {
|
||||||
|
switchSatView("live");
|
||||||
|
});
|
||||||
|
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
||||||
|
switchSatView("history");
|
||||||
|
});
|
||||||
|
satDom.viewPredBtn?.addEventListener("click", () => {
|
||||||
|
switchSatView("predictions");
|
||||||
|
});
|
||||||
|
var lastSatLrptOn = null;
|
||||||
|
satWindow.updateSatLiveState = function(update) {
|
||||||
|
if (!satDom.lrptState) return;
|
||||||
|
const lrptOn = !!update.lrpt_decode_enabled;
|
||||||
|
if (lrptOn !== lastSatLrptOn) {
|
||||||
|
lastSatLrptOn = lrptOn;
|
||||||
|
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
|
||||||
|
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
|
||||||
|
if (satDom.status) {
|
||||||
|
if (lrptOn) {
|
||||||
|
satDom.status.textContent = "Decoder active — waiting for signal";
|
||||||
|
} else {
|
||||||
|
satDom.status.textContent = "Decoder idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function renderSatLatestCard() {
|
||||||
|
if (!satDom.liveLatest) return;
|
||||||
|
if (satImageHistory.length === 0) {
|
||||||
|
satDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable a decoder and wait for a satellite pass.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = satImageHistory[0];
|
||||||
|
if (!img) return;
|
||||||
|
const typeName = "Meteor LRPT";
|
||||||
|
const satellite = img.satellite || "";
|
||||||
|
const channels = img.channels || img.channel_a || "";
|
||||||
|
const lines = img.mcu_count || img.line_count || 0;
|
||||||
|
const unit = "MCU rows";
|
||||||
|
const ts = img._ts || "--";
|
||||||
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
|
const meta = [typeName];
|
||||||
|
if (satellite) meta.push(satellite);
|
||||||
|
if (channels) meta.push(channels);
|
||||||
|
meta.push(`${lines} ${unit}`);
|
||||||
|
meta.push(`${date} ${ts}`);
|
||||||
|
let html = `<div class="sat-latest-card">`;
|
||||||
|
html += `<div class="sat-latest-title">Latest decoded image</div>`;
|
||||||
|
html += `<div class="sat-latest-meta">${meta.join(" · ")}</div>`;
|
||||||
|
if (img.path) {
|
||||||
|
html += `<a href="${img.path}" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">Download PNG</a>`;
|
||||||
|
}
|
||||||
|
if (img.geo_bounds) {
|
||||||
|
html += ` <button type="button" class="sat-map-btn" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="font-size:0.8rem;margin-top:0.25rem;margin-left:0.5rem;cursor:pointer;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:1px 6px;">Show on Map</button>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
satDom.liveLatest.innerHTML = html;
|
||||||
|
}
|
||||||
|
function getSatFilteredHistory() {
|
||||||
|
let items = satImageHistory;
|
||||||
|
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
|
||||||
|
if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
|
||||||
|
if (satFilterText) {
|
||||||
|
items = items.filter((i) => {
|
||||||
|
const haystack = [
|
||||||
|
"meteor lrpt",
|
||||||
|
i.satellite || "",
|
||||||
|
i.channels || "",
|
||||||
|
i.channel_a || "",
|
||||||
|
i.channel_b || ""
|
||||||
|
].join(" ").toUpperCase();
|
||||||
|
return haystack.includes(satFilterText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest";
|
||||||
|
if (sortVal === "oldest") items = items.slice().reverse();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
function renderSatHistoryRow(img) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-history-row";
|
||||||
|
const typeName = "Meteor LRPT";
|
||||||
|
const typeClass = "sat-type-lrpt";
|
||||||
|
const ts = img._ts || "--";
|
||||||
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
||||||
|
const satellite = img.satellite || "--";
|
||||||
|
const channels = img.channels || "--";
|
||||||
|
const lines = img.mcu_count || img.line_count || 0;
|
||||||
|
const unit = "MCU";
|
||||||
|
let link = img.path ? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>` : "--";
|
||||||
|
if (img.geo_bounds) {
|
||||||
|
link += ` <a href="javascript:void(0)" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="color:var(--accent);">Map</a>`;
|
||||||
|
}
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span>${date} ${ts}</span>`,
|
||||||
|
`<span class="sat-col-type ${typeClass}">${typeName}</span>`,
|
||||||
|
`<span>${satellite}</span>`,
|
||||||
|
`<span>${channels}</span>`,
|
||||||
|
`<span>${lines} ${unit}</span>`,
|
||||||
|
`<span>${link}</span>`
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderSatHistoryTable() {
|
||||||
|
if (!satDom.historyList) return;
|
||||||
|
const items = getSatFilteredHistory();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const image of items) {
|
||||||
|
fragment.appendChild(renderSatHistoryRow(image));
|
||||||
|
}
|
||||||
|
satDom.historyList.replaceChildren(fragment);
|
||||||
|
if (satDom.historyCount) {
|
||||||
|
const total = satImageHistory.length;
|
||||||
|
const shown = items.length;
|
||||||
|
satDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${total} image${total === 1 ? "" : "s"}` : `${shown} of ${total} images`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addSatImage(img, decoder) {
|
||||||
|
const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
|
||||||
|
img._tsMs = tsMs;
|
||||||
|
img._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
img._decoder = decoder;
|
||||||
|
satImageHistory.unshift(img);
|
||||||
|
if (satImageHistory.length > SAT_MAX_IMAGES) {
|
||||||
|
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
|
||||||
|
}
|
||||||
|
scheduleSatUi("sat-latest", () => {
|
||||||
|
renderSatLatestCard();
|
||||||
|
});
|
||||||
|
if (satActiveView === "history") {
|
||||||
|
scheduleSatUi("sat-history", () => {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onServerLrptProgress(msg) {
|
||||||
|
if (satDom.status && (msg.mcu_count ?? 0) > 0) {
|
||||||
|
satDom.status.textContent = `Receiving — ${String(msg.mcu_count ?? 0)} MCU rows decoded`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onServerLrptImage(msg) {
|
||||||
|
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
|
||||||
|
addSatImage(msg, "lrpt");
|
||||||
|
if (msg.geo_bounds && msg.path && satWindow.addSatMapOverlay) {
|
||||||
|
satWindow.addSatMapOverlay(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function resetSatHistoryView() {
|
||||||
|
satImageHistory = [];
|
||||||
|
if (satDom.historyList) satDom.historyList.innerHTML = "";
|
||||||
|
renderSatLatestCard();
|
||||||
|
renderSatHistoryTable();
|
||||||
|
satWindow.clearSatMapOverlays?.();
|
||||||
|
}
|
||||||
|
function pruneSatHistoryView() {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
renderSatLatestCard();
|
||||||
|
}
|
||||||
|
satWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "lrpt_image",
|
||||||
|
onMessage: onServerLrptImage,
|
||||||
|
reset: resetSatHistoryView,
|
||||||
|
prune: pruneSatHistoryView
|
||||||
|
});
|
||||||
|
satWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "lrpt_progress",
|
||||||
|
onMessage: onServerLrptProgress
|
||||||
|
});
|
||||||
|
var lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
||||||
|
lrptDecodeToggleBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
|
||||||
|
await hostCore.postPath("/toggle_lrpt_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("LRPT toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
var satFilterInput = satDom.filterInput;
|
||||||
|
satFilterInput?.addEventListener("input", () => {
|
||||||
|
satFilterText = satFilterInput.value.trim().toUpperCase();
|
||||||
|
renderSatHistoryTable();
|
||||||
|
});
|
||||||
|
satDom.sortSelect?.addEventListener("change", () => {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
});
|
||||||
|
satDom.typeFilter?.addEventListener("change", () => {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
});
|
||||||
|
document.getElementById("settings-clear-sat-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_lrpt_decode");
|
||||||
|
resetSatHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Weather satellite history clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
function azToCardinal(deg) {
|
||||||
|
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||||
|
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
||||||
|
}
|
||||||
|
function formatPredTime(ms) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const now = /* @__PURE__ */ new Date();
|
||||||
|
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
||||||
|
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||||
|
return `${day}${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
function formatPredDuration(s) {
|
||||||
|
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
function formatCountdown(ms) {
|
||||||
|
const totalSec = Math.max(0, Math.floor(ms / 1e3));
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
function elevationClass(deg) {
|
||||||
|
if (deg >= 45) return "sat-pred-el-high";
|
||||||
|
if (deg >= 10) return "sat-pred-el-mid";
|
||||||
|
return "sat-pred-el-low";
|
||||||
|
}
|
||||||
|
function stopCountdownTimer() {
|
||||||
|
if (satPredCountdownTimer) {
|
||||||
|
clearInterval(satPredCountdownTimer);
|
||||||
|
satPredCountdownTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function startCountdownTimer(container) {
|
||||||
|
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
|
||||||
|
if (countdownEls.length === 0) return;
|
||||||
|
satPredCountdownTimer = setInterval(() => {
|
||||||
|
if (satActiveView !== "predictions") {
|
||||||
|
stopCountdownTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Date.now();
|
||||||
|
let anyActive = false;
|
||||||
|
for (const el of countdownEls) {
|
||||||
|
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
||||||
|
const rem = los - n;
|
||||||
|
if (rem > 0) {
|
||||||
|
el.textContent = formatCountdown(rem);
|
||||||
|
anyActive = true;
|
||||||
|
} else {
|
||||||
|
el.textContent = "0:00";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyActive) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
}, 1e3);
|
||||||
|
}
|
||||||
|
function buildCurrentPassRow(pass, now) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row-current";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
const remaining = Math.max(0, pass.los_ms - now);
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function buildUpcomingPassRow(pass) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||||
|
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function getFilteredPredictions() {
|
||||||
|
let items = satPredData;
|
||||||
|
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
||||||
|
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
||||||
|
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
function applyPredFilters() {
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
var satPredictionFilter = satDom.predFilter;
|
||||||
|
satPredictionFilter?.addEventListener("input", () => {
|
||||||
|
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
var satPredictionMinElevation = satDom.predMinEl;
|
||||||
|
satPredictionMinElevation?.addEventListener("change", () => {
|
||||||
|
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
var satPredictionCategory = satDom.predCategory;
|
||||||
|
satPredictionCategory?.addEventListener("change", () => {
|
||||||
|
satPredCategory = satPredictionCategory.value;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
function renderSatPredictions(passes, error) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (error) {
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(passes) || passes.length === 0) {
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||||
|
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
||||||
|
if (satDom.predCurrentList) {
|
||||||
|
if (current.length === 0) {
|
||||||
|
satDom.predCurrentList.innerHTML = "";
|
||||||
|
} else {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||||
|
satDom.predCurrentList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
||||||
|
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||||
|
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
||||||
|
if (satDom.predUpcomingList) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||||
|
if (hiddenCount > 0) {
|
||||||
|
const moreRow = document.createElement("div");
|
||||||
|
moreRow.className = "sat-pred-row";
|
||||||
|
moreRow.style.cursor = "pointer";
|
||||||
|
moreRow.style.textAlign = "center";
|
||||||
|
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
|
||||||
|
moreRow.addEventListener("click", () => {
|
||||||
|
satPredShowAll = true;
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
});
|
||||||
|
frag.appendChild(moreRow);
|
||||||
|
}
|
||||||
|
satDom.predUpcomingList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
if (satDom.predStatus) {
|
||||||
|
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
||||||
|
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
|
||||||
|
satDom.predStatus.textContent = text;
|
||||||
|
}
|
||||||
|
if (current.length > 0 && satActiveView === "predictions") {
|
||||||
|
startCountdownTimer(satDom.predCurrentList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadSatPredictions() {
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…";
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/sat_passes");
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
satPredSatCount = data.satellite_count || 0;
|
||||||
|
if (data.error) {
|
||||||
|
satPredData = [];
|
||||||
|
renderSatPredictions([], data.error);
|
||||||
|
} else {
|
||||||
|
satPredData = data.passes || [];
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
satWindow.satShowOnMap = function(south, west, north, east) {
|
||||||
|
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||||
|
satWindow.enableMapSourceFilter("sat");
|
||||||
|
}
|
||||||
|
const lat = (south + north) / 2;
|
||||||
|
const lon = (west + east) / 2;
|
||||||
|
if (satWindow.navigateToAprsMap) {
|
||||||
|
satWindow.navigateToAprsMap(lat, lon);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
renderSatLatestCard();
|
||||||
|
renderSatHistoryTable();
|
||||||
File diff suppressed because it is too large
Load Diff
+26
-49
@@ -1,13 +1,8 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
// src/screenshot.ts
|
||||||
//
|
var screenshotWindow = window;
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
(function() {
|
||||||
|
|
||||||
// Spectrum screenshot module (loaded on demand when user triggers screenshot).
|
|
||||||
// Communicates with app.js core via window.trx namespace.
|
|
||||||
(function () {
|
|
||||||
"use strict";
|
"use strict";
|
||||||
const T = window.trx;
|
const T = screenshotWindow.trx;
|
||||||
|
|
||||||
function isVisibleForSnapshot(el) {
|
function isVisibleForSnapshot(el) {
|
||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
const style = getComputedStyle(el);
|
const style = getComputedStyle(el);
|
||||||
@@ -17,7 +12,6 @@
|
|||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
return rect.width > 0 && rect.height > 0;
|
return rect.width > 0 && rect.height > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawRoundedRectPath(ctx, x, y, w, h, r) {
|
function drawRoundedRectPath(ctx, x, y, w, h, r) {
|
||||||
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
|
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -32,7 +26,6 @@
|
|||||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
|
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
|
||||||
if (!isVisibleForSnapshot(el)) return null;
|
if (!isVisibleForSnapshot(el)) return null;
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
@@ -45,30 +38,29 @@
|
|||||||
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
|
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
|
||||||
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
|
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
|
||||||
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
|
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
|
||||||
|
|
||||||
const bgAlpha = Math.min(bg[3], maxAlpha);
|
const bgAlpha = Math.min(bg[3], maxAlpha);
|
||||||
if (bgAlpha > 0.01) {
|
if (bgAlpha > 0.01) {
|
||||||
drawRoundedRectPath(ctx, x, y, w, h, radius);
|
drawRoundedRectPath(ctx, x, y, w, h, radius);
|
||||||
ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`;
|
ctx.fillStyle = `rgba(${String(Math.round(bg[0]))}, ${String(Math.round(bg[1]))}, ${String(Math.round(bg[2]))}, ${String(bgAlpha)})`;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
const borderAlpha = Math.min(border[3], maxAlpha);
|
const borderAlpha = Math.min(border[3], maxAlpha);
|
||||||
if (borderWidth > 0 && borderAlpha > 0.01) {
|
if (borderWidth > 0 && borderAlpha > 0.01) {
|
||||||
drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5));
|
drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5));
|
||||||
ctx.lineWidth = borderWidth;
|
ctx.lineWidth = borderWidth;
|
||||||
ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`;
|
ctx.strokeStyle = `rgba(${String(Math.round(border[0]))}, ${String(Math.round(border[1]))}, ${String(Math.round(border[2]))}, ${String(borderAlpha)})`;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
return { x, y, w, h, style };
|
return { x, y, w, h, style };
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
||||||
const words = String(text || "").split(/\s+/).filter(Boolean);
|
const words = (text || "").split(/\s+/).filter(Boolean);
|
||||||
if (!words.length) return;
|
if (!words.length) return;
|
||||||
let line = "";
|
let line = "";
|
||||||
let lineIdx = 0;
|
let lineIdx = 0;
|
||||||
for (let i = 0; i < words.length; i += 1) {
|
for (let i = 0; i < words.length; i += 1) {
|
||||||
const candidate = line ? `${line} ${words[i]}` : words[i];
|
const word = words[i] ?? "";
|
||||||
|
const candidate = line ? `${line} ${word}` : word;
|
||||||
if (ctx.measureText(candidate).width <= maxWidth || !line) {
|
if (ctx.measureText(candidate).width <= maxWidth || !line) {
|
||||||
line = candidate;
|
line = candidate;
|
||||||
continue;
|
continue;
|
||||||
@@ -76,13 +68,12 @@
|
|||||||
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||||
lineIdx += 1;
|
lineIdx += 1;
|
||||||
if (lineIdx >= maxLines) return;
|
if (lineIdx >= maxLines) return;
|
||||||
line = words[i];
|
line = word;
|
||||||
}
|
}
|
||||||
if (line && lineIdx < maxLines) {
|
if (line && lineIdx < maxLines) {
|
||||||
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
|
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
|
||||||
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
|
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
|
||||||
if (!chrome) return;
|
if (!chrome) return;
|
||||||
@@ -91,7 +82,7 @@
|
|||||||
if (!clean) return;
|
if (!clean) return;
|
||||||
const style = chrome.style;
|
const style = chrome.style;
|
||||||
const fontSize = parseFloat(style.fontSize) || 12;
|
const fontSize = parseFloat(style.fontSize) || 12;
|
||||||
const lineHeight = (parseFloat(style.lineHeight) || fontSize * 1.25);
|
const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.25;
|
||||||
const padX = 6;
|
const padX = 6;
|
||||||
const padY = 4;
|
const padY = 4;
|
||||||
const maxWidth = Math.max(20, chrome.w - padX * 2);
|
const maxWidth = Math.max(20, chrome.w - padX * 2);
|
||||||
@@ -110,12 +101,11 @@
|
|||||||
chrome.y + padY + lineCursor * lineHeight,
|
chrome.y + padY + lineCursor * lineHeight,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
maxLines - lineCursor,
|
maxLines - lineCursor
|
||||||
);
|
);
|
||||||
lineCursor += 1;
|
lineCursor += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawAxisLabels(ctx, axisEl, rootRect) {
|
function drawAxisLabels(ctx, axisEl, rootRect) {
|
||||||
if (!isVisibleForSnapshot(axisEl)) return;
|
if (!isVisibleForSnapshot(axisEl)) return;
|
||||||
for (const node of axisEl.children) {
|
for (const node of axisEl.children) {
|
||||||
@@ -132,7 +122,6 @@
|
|||||||
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
|
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSpectrumSnapshotCanvas() {
|
function buildSpectrumSnapshotCanvas() {
|
||||||
const rootEl = document.querySelector(".signal-visual-block");
|
const rootEl = document.querySelector(".signal-visual-block");
|
||||||
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
||||||
@@ -145,8 +134,7 @@
|
|||||||
try {
|
try {
|
||||||
if (typeof gl.flush === "function") gl.flush();
|
if (typeof gl.flush === "function") gl.flush();
|
||||||
if (typeof gl.finish === "function") gl.finish();
|
if (typeof gl.finish === "function") gl.finish();
|
||||||
} catch (_) {
|
} catch {
|
||||||
// Ignore transient WebGL state errors and capture the last good frame.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rootRect = rootEl.getBoundingClientRect();
|
const rootRect = rootEl.getBoundingClientRect();
|
||||||
@@ -157,11 +145,9 @@
|
|||||||
const ctx = out.getContext("2d");
|
const ctx = out.getContext("2d");
|
||||||
if (!ctx) return null;
|
if (!ctx) return null;
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
|
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
|
||||||
ctx.fillStyle = bg;
|
ctx.fillStyle = bg;
|
||||||
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
|
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
|
||||||
|
|
||||||
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
||||||
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
|
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
|
||||||
for (const canvas of canvases) {
|
for (const canvas of canvases) {
|
||||||
@@ -172,28 +158,21 @@
|
|||||||
rect.left - rootRect.left,
|
rect.left - rootRect.left,
|
||||||
rect.top - rootRect.top,
|
rect.top - rootRect.top,
|
||||||
rect.width,
|
rect.width,
|
||||||
rect.height,
|
rect.height
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decoder overlays over the signal view.
|
|
||||||
// Cap background alpha to avoid opaque blocks (backdrop-filter can't be
|
|
||||||
// replicated on canvas, so frosted-glass overlays would otherwise obscure
|
|
||||||
// the spectrum).
|
|
||||||
const decoderOverlayIds = [
|
const decoderOverlayIds = [
|
||||||
"ais-bar-overlay",
|
"ais-bar-overlay",
|
||||||
"vdes-bar-overlay",
|
"vdes-bar-overlay",
|
||||||
"ft8-bar-overlay",
|
"ft8-bar-overlay",
|
||||||
"aprs-bar-overlay",
|
"aprs-bar-overlay",
|
||||||
"rds-ps-overlay",
|
"rds-ps-overlay"
|
||||||
];
|
];
|
||||||
for (const id of decoderOverlayIds) {
|
for (const id of decoderOverlayIds) {
|
||||||
const overlayEl = document.getElementById(id);
|
const overlayEl = document.getElementById(id);
|
||||||
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
|
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
|
||||||
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
|
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spectrum axis labels and bookmark chips (includes freq bar).
|
|
||||||
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||||||
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
|
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
|
||||||
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
|
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
|
||||||
@@ -201,10 +180,8 @@
|
|||||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
|
||||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
|
||||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickCanvasDownload(href, fileName) {
|
function clickCanvasDownload(href, fileName) {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = href;
|
a.href = href;
|
||||||
@@ -213,9 +190,10 @@
|
|||||||
a.style.display = "none";
|
a.style.display = "none";
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
requestAnimationFrame(() => a.remove());
|
requestAnimationFrame(() => {
|
||||||
|
a.remove();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveCanvasAsPng(canvas, fileName) {
|
function saveCanvasAsPng(canvas, fileName) {
|
||||||
if (!canvas) return Promise.resolve(false);
|
if (!canvas) return Promise.resolve(false);
|
||||||
if (typeof canvas.toBlob === "function") {
|
if (typeof canvas.toBlob === "function") {
|
||||||
@@ -228,10 +206,12 @@
|
|||||||
}
|
}
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
clickCanvasDownload(url, fileName);
|
clickCanvasDownload(url, fileName);
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
setTimeout(() => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, 1e3);
|
||||||
resolve(true);
|
resolve(true);
|
||||||
}, "image/png");
|
}, "image/png");
|
||||||
} catch (_) {
|
} catch {
|
||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -239,27 +219,24 @@
|
|||||||
try {
|
try {
|
||||||
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
|
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
} catch (_) {
|
} catch {
|
||||||
return Promise.resolve(false);
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function captureSpectrumScreenshot() {
|
async function captureSpectrumScreenshot() {
|
||||||
const snapshotCanvas = buildSpectrumSnapshotCanvas();
|
const snapshotCanvas = buildSpectrumSnapshotCanvas();
|
||||||
if (!snapshotCanvas) {
|
if (!snapshotCanvas) {
|
||||||
T.showHint("Spectrum view not ready", 1300);
|
T.showHint("Spectrum view not ready", 1300);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
||||||
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
|
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
|
||||||
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
|
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
screenshotWindow.trx.modules.screenshot = {
|
||||||
// Register module API
|
|
||||||
window.trx.screenshot = {
|
|
||||||
captureSpectrumScreenshot,
|
captureSpectrumScreenshot,
|
||||||
buildSpectrumSnapshotCanvas,
|
buildSpectrumSnapshotCanvas,
|
||||||
saveCanvasAsPng,
|
saveCanvasAsPng
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import {
|
||||||
|
hostCore,
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/vchan.ts
|
||||||
|
var vchanWindow = window;
|
||||||
|
var vchanSessionId = null;
|
||||||
|
var vchanRigId = null;
|
||||||
|
var vchanChannels = [];
|
||||||
|
var vchanActiveId = null;
|
||||||
|
var schedulerReleaseState = null;
|
||||||
|
var schedulerReleasePollTimer = null;
|
||||||
|
function vchanFmtFreq(hz) {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
|
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + " GHz";
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + " MHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
||||||
|
return `${String(hz)} Hz`;
|
||||||
|
}
|
||||||
|
function schedulerReleaseSummaryText(state) {
|
||||||
|
if (!state) return "Scheduler is controlling the rig.";
|
||||||
|
const connected = Number(state.connected_sessions) || 0;
|
||||||
|
const released = Number(state.released_sessions) || 0;
|
||||||
|
if (connected === 0) return "Scheduler can control the rig.";
|
||||||
|
if (state.all_released) {
|
||||||
|
return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`;
|
||||||
|
}
|
||||||
|
if (!state.current_session_released) {
|
||||||
|
const othersReleased = Math.max(released, 0);
|
||||||
|
return othersReleased > 0 ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.` : "You are holding control. Release it to return control to the scheduler.";
|
||||||
|
}
|
||||||
|
const blocking = Math.max(connected - released, 0);
|
||||||
|
return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig.";
|
||||||
|
}
|
||||||
|
function vchanRenderSchedulerRelease() {
|
||||||
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
|
const status = document.getElementById("scheduler-release-status");
|
||||||
|
if (!btn || !status) return;
|
||||||
|
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
|
||||||
|
btn.disabled = !vchanSessionId || currentReleased;
|
||||||
|
btn.classList.toggle("active", !currentReleased);
|
||||||
|
btn.textContent = "Release to Scheduler";
|
||||||
|
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
|
||||||
|
}
|
||||||
|
async function vchanPollSchedulerRelease() {
|
||||||
|
if (!vchanSessionId) {
|
||||||
|
schedulerReleaseState = null;
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
schedulerReleaseState = await resp.json();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scheduler release status failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanStartSchedulerReleasePolling() {
|
||||||
|
if (schedulerReleasePollTimer) {
|
||||||
|
clearInterval(schedulerReleasePollTimer);
|
||||||
|
}
|
||||||
|
schedulerReleasePollTimer = setInterval(() => {
|
||||||
|
void vchanPollSchedulerRelease();
|
||||||
|
}, 1e4);
|
||||||
|
}
|
||||||
|
async function vchanToggleSchedulerRelease() {
|
||||||
|
if (!vchanSessionId) return;
|
||||||
|
const rigId = vchanRigId || hostState.lastActiveRigId || null;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/scheduler-control", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId })
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
schedulerReleaseState = await resp.json();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scheduler release toggle failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanTakeSchedulerControl() {
|
||||||
|
if (!vchanSessionId) return;
|
||||||
|
if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/scheduler-control", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId, released: false })
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
schedulerReleaseState = await resp.json();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scheduler control takeover failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanHandleSession(data) {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(data);
|
||||||
|
vchanSessionId = d.session_id || null;
|
||||||
|
void vchanPollSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("vchan: bad session event", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanHandleChannels(data) {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(data);
|
||||||
|
vchanRigId = d.remote || null;
|
||||||
|
vchanChannels = d.channels || [];
|
||||||
|
const ids = new Set(vchanChannels.map((c) => c.id));
|
||||||
|
const primaryChannel = vchanChannels[0];
|
||||||
|
if (!vchanActiveId && primaryChannel && vchanSessionId) {
|
||||||
|
void vchanAutoJoinPrimary(primaryChannel.id);
|
||||||
|
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
|
||||||
|
vchanActiveId = vchanChannels[0]?.id ?? null;
|
||||||
|
vchanReconnectAudio();
|
||||||
|
}
|
||||||
|
vchanRender();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
vchanWindow.renderRdsOverlays?.();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("vchan: bad channels event", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanRender() {
|
||||||
|
const picker = document.getElementById("vchan-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
picker.innerHTML = "";
|
||||||
|
vchanChannels.forEach((ch) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
|
||||||
|
if (ch.id === vchanActiveId) btn.classList.add("active");
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "vchan-label";
|
||||||
|
label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
|
||||||
|
btn.appendChild(label);
|
||||||
|
if (!ch.permanent) {
|
||||||
|
const del = document.createElement("span");
|
||||||
|
del.className = "vchan-del";
|
||||||
|
del.textContent = "×";
|
||||||
|
del.title = "Delete channel";
|
||||||
|
del.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void vchanDelete(ch.id);
|
||||||
|
});
|
||||||
|
btn.appendChild(del);
|
||||||
|
}
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
if (ch.id !== vchanActiveId) void vchanSubscribe(ch.id);
|
||||||
|
});
|
||||||
|
picker.appendChild(btn);
|
||||||
|
});
|
||||||
|
const addBtn = document.createElement("button");
|
||||||
|
addBtn.type = "button";
|
||||||
|
addBtn.className = "vchan-add";
|
||||||
|
addBtn.textContent = "+";
|
||||||
|
addBtn.title = "Allocate new virtual channel at current frequency";
|
||||||
|
addBtn.addEventListener("click", () => {
|
||||||
|
void vchanAllocate();
|
||||||
|
});
|
||||||
|
picker.appendChild(addBtn);
|
||||||
|
vchanSyncAccentUI();
|
||||||
|
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
}
|
||||||
|
async function vchanAllocate() {
|
||||||
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
|
const freqHz = typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0 ? hostState.lastFreqHz : 0;
|
||||||
|
const modeEl = document.getElementById("mode");
|
||||||
|
const mode = modeEl ? modeEl.value || "USB" : "USB";
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode })
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const msg = await resp.text().catch(() => String(resp.status));
|
||||||
|
console.warn("vchan: allocate failed —", msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ch = await resp.json();
|
||||||
|
vchanActiveId = ch.id;
|
||||||
|
vchanRender();
|
||||||
|
vchanReconnectAudio();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: allocate error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanDelete(channelId) {
|
||||||
|
if (!vchanRigId) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("vchan: delete failed", resp.status);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: delete error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanAutoJoinPrimary(channelId) {
|
||||||
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("vchan: auto-join primary failed", resp.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vchanActiveId = channelId;
|
||||||
|
vchanRender();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: auto-join error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanSubscribe(channelId) {
|
||||||
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("vchan: subscribe failed", resp.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vchanActiveId = channelId;
|
||||||
|
vchanRender();
|
||||||
|
vchanSyncModeDisplay();
|
||||||
|
vchanReconnectAudio();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: subscribe error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanReconnectAudio() {
|
||||||
|
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
|
||||||
|
hostState.audioChannelOverride = ch?.id ?? null;
|
||||||
|
if (!hostState.rxActive) return;
|
||||||
|
hostCore.stopRxAudio();
|
||||||
|
setTimeout(() => {
|
||||||
|
hostCore.startRxAudio();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
function vchanApplyCapabilities(caps) {
|
||||||
|
const picker = document.getElementById("vchan-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
picker.style.display = caps && caps.filter_controls ? "" : "none";
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
}
|
||||||
|
function vchanIsOnVirtual() {
|
||||||
|
if (!vchanActiveId || vchanChannels.length === 0) return false;
|
||||||
|
return vchanActiveId !== vchanChannels[0]?.id;
|
||||||
|
}
|
||||||
|
function vchanActiveChannel() {
|
||||||
|
return vchanChannels.find((c) => c.id === vchanActiveId) || null;
|
||||||
|
}
|
||||||
|
function vchanUpdateFreqDisplay() {
|
||||||
|
const ch = vchanActiveChannel();
|
||||||
|
if (!ch) return;
|
||||||
|
const el = document.getElementById("freq");
|
||||||
|
if (!el) return;
|
||||||
|
el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit);
|
||||||
|
}
|
||||||
|
function vchanSyncModeDisplay() {
|
||||||
|
const modeEl = document.getElementById("mode");
|
||||||
|
if (!modeEl) return;
|
||||||
|
if (vchanIsOnVirtual()) {
|
||||||
|
const ch = vchanActiveChannel();
|
||||||
|
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
|
||||||
|
}
|
||||||
|
const modeUpper = (modeEl.value || "").toUpperCase();
|
||||||
|
if (typeof hostState.lastModeName === "string") {
|
||||||
|
if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") {
|
||||||
|
hostCore.setJogDivisor(10);
|
||||||
|
hostCore.resetRdsDisplay();
|
||||||
|
} else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") {
|
||||||
|
hostCore.resetRdsDisplay();
|
||||||
|
}
|
||||||
|
hostState.lastModeName = modeUpper;
|
||||||
|
}
|
||||||
|
hostCore.updateWfmControls();
|
||||||
|
hostCore.updateSdrSquelchControlVisibility();
|
||||||
|
if (vchanWindow.refreshRdsUi) {
|
||||||
|
vchanWindow.refreshRdsUi();
|
||||||
|
} else {
|
||||||
|
hostCore.positionRdsPsOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanSyncBwDisplay() {
|
||||||
|
if (!vchanIsOnVirtual()) return;
|
||||||
|
const ch = vchanActiveChannel();
|
||||||
|
if (!ch) return;
|
||||||
|
const bwEl = document.getElementById("spectrum-bw-input");
|
||||||
|
if (!bwEl) return;
|
||||||
|
let bwHz = ch.bandwidth_hz || 0;
|
||||||
|
if (bwHz === 0) {
|
||||||
|
bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0;
|
||||||
|
}
|
||||||
|
if (bwHz > 0) {
|
||||||
|
bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, "");
|
||||||
|
hostState.currentBandwidthHz = bwHz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanSyncAccentUI() {
|
||||||
|
const onVirtual = vchanIsOnVirtual();
|
||||||
|
const freqEl = document.getElementById("freq");
|
||||||
|
const bwEl = document.getElementById("spectrum-bw-input");
|
||||||
|
if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual);
|
||||||
|
if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual);
|
||||||
|
if (onVirtual) {
|
||||||
|
vchanUpdateFreqDisplay();
|
||||||
|
vchanSyncModeDisplay();
|
||||||
|
vchanSyncBwDisplay();
|
||||||
|
} else {
|
||||||
|
hostCore.refreshFreqDisplay();
|
||||||
|
}
|
||||||
|
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
|
||||||
|
}
|
||||||
|
function vchanSetChannelFreq(freqHz) {
|
||||||
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
|
if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) {
|
||||||
|
const halfSpan = hostState.lastSpectrumData.sample_rate / 2;
|
||||||
|
const center = hostState.lastSpectrumData.center_hz;
|
||||||
|
if (Math.abs(freqHz - center) > halfSpan) {
|
||||||
|
hostCore.showHint(
|
||||||
|
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
||||||
|
3e3
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void vchanTakeSchedulerControl();
|
||||||
|
void fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ freq_hz: Math.round(freqHz) })
|
||||||
|
}
|
||||||
|
).catch((error) => {
|
||||||
|
console.error("vchan: set freq error", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function vchanSetChannelBandwidth(bwHz) {
|
||||||
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) console.warn("vchan: set bw failed", resp.status);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: set bw error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanSetChannelMode(mode) {
|
||||||
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mode })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) console.warn("vchan: set mode failed", resp.status);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: set mode error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanInterceptMode(mode) {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
await vchanSetChannelMode(mode);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async function vchanInterceptBandwidth(bwHz) {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
await vchanSetChannelBandwidth(bwHz);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function vchanInterceptFrequency(freqHz) {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
const targetHz = Math.round(freqHz);
|
||||||
|
hostCore.armOptimisticFrequency(targetHz);
|
||||||
|
hostCore.applyLocalTunedFrequency(targetHz);
|
||||||
|
vchanSetChannelFreq(freqHz);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function vchanInterceptFreqDisplay() {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
vchanUpdateFreqDisplay();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
vchanWindow.trx ??= {};
|
||||||
|
vchanWindow.trx.modules ??= {};
|
||||||
|
vchanWindow.trx.modules.vchan = {
|
||||||
|
get channels() {
|
||||||
|
return vchanChannels;
|
||||||
|
},
|
||||||
|
get activeId() {
|
||||||
|
return vchanActiveId;
|
||||||
|
},
|
||||||
|
activeChannel: vchanActiveChannel,
|
||||||
|
applyCapabilities: vchanApplyCapabilities,
|
||||||
|
handleSession: vchanHandleSession,
|
||||||
|
handleChannels: vchanHandleChannels,
|
||||||
|
isOnVirtual: vchanIsOnVirtual,
|
||||||
|
interceptMode: vchanInterceptMode,
|
||||||
|
interceptBandwidth: vchanInterceptBandwidth,
|
||||||
|
interceptFrequency: vchanInterceptFrequency,
|
||||||
|
interceptFreqDisplay: vchanInterceptFreqDisplay,
|
||||||
|
takeSchedulerControl: vchanTakeSchedulerControl,
|
||||||
|
releaseToScheduler: vchanToggleSchedulerRelease
|
||||||
|
};
|
||||||
|
(function initSchedulerReleaseControl() {
|
||||||
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
void vchanToggleSchedulerRelease();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vchanStartSchedulerReleasePolling();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import {
|
||||||
|
hostCore
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/vdes.ts
|
||||||
|
var vdesWindow = window;
|
||||||
|
var escapeVdesHtml = (input) => hostCore.escapeMapHtml(input);
|
||||||
|
var vdesStatus = document.getElementById("vdes-status");
|
||||||
|
var vdesMessagesEl = document.getElementById("vdes-messages");
|
||||||
|
var vdesFilterInput = document.getElementById("vdes-filter");
|
||||||
|
var vdesBarOverlay = document.getElementById("vdes-bar-overlay");
|
||||||
|
var vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
|
||||||
|
var vdesFrameCountEl = document.getElementById("vdes-frame-count");
|
||||||
|
var vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
|
||||||
|
var VDES_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
|
var vdesFilterText = "";
|
||||||
|
var vdesMessageHistory = [];
|
||||||
|
function currentVdesHistoryRetentionMs() {
|
||||||
|
return typeof vdesWindow.getDecodeHistoryRetentionMs === "function" ? vdesWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneVdesMessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
|
||||||
|
vdesMessageHistory = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleVdesUi(key, job) {
|
||||||
|
if (typeof vdesWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
vdesWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function scheduleVdesHistoryRender() {
|
||||||
|
scheduleVdesUi("vdes-history", () => {
|
||||||
|
renderVdesHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function scheduleVdesBarUpdate() {
|
||||||
|
scheduleVdesUi("vdes-bar", () => {
|
||||||
|
updateVdesBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function currentVdesCenterText() {
|
||||||
|
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||||
|
const hz = raw ? Number(raw) : 0;
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
|
||||||
|
return `100 kHz @ ${(hz / 1e6).toFixed(3)} MHz`;
|
||||||
|
}
|
||||||
|
function vdesAgeText(tsMs) {
|
||||||
|
if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1e3);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
function vdesHexPreview(rawBytes) {
|
||||||
|
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
||||||
|
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
||||||
|
}
|
||||||
|
function 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 renderVdesRow(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "vdes-message";
|
||||||
|
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
const title = msg.vessel_name || "VDES Burst";
|
||||||
|
const label = msg.callsign || "VDES";
|
||||||
|
const info = msg.destination || "";
|
||||||
|
const labelText = msg.message_label || "";
|
||||||
|
const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : "";
|
||||||
|
const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : "";
|
||||||
|
const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : "";
|
||||||
|
const fecText = msg.fec_state || "";
|
||||||
|
const srcText = Number.isFinite(msg.source_id) ? `SRC ${Number(msg.source_id)}` : "";
|
||||||
|
const dstText = Number.isFinite(msg.destination_id) ? `DST ${Number(msg.destination_id)}` : "";
|
||||||
|
const sessionText = Number.isFinite(msg.session_id) ? `S${Number(msg.session_id)}` : "";
|
||||||
|
const asmText = Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : "";
|
||||||
|
const countText = Number.isFinite(msg.data_count) ? `${Number(msg.data_count)} data bits` : "";
|
||||||
|
const ackText = Number.isFinite(msg.ack_nack_mask) ? `ACK 0x${Number(msg.ack_nack_mask).toString(16).toUpperCase().padStart(4, "0")}` : "";
|
||||||
|
const cqiText = Number.isFinite(msg.channel_quality) ? `CQ ${Number(msg.channel_quality)}` : "";
|
||||||
|
const previewText = msg.payload_preview || "";
|
||||||
|
const rawHex = vdesHexPreview(msg.raw_bytes);
|
||||||
|
row.dataset.filterText = [
|
||||||
|
title,
|
||||||
|
label,
|
||||||
|
labelText,
|
||||||
|
info,
|
||||||
|
srcText,
|
||||||
|
dstText,
|
||||||
|
sessionText,
|
||||||
|
asmText,
|
||||||
|
countText,
|
||||||
|
ackText,
|
||||||
|
cqiText,
|
||||||
|
previewText,
|
||||||
|
linkText,
|
||||||
|
syncText,
|
||||||
|
phaseText,
|
||||||
|
fecText,
|
||||||
|
rawHex,
|
||||||
|
msg.message_type,
|
||||||
|
msg.bit_len
|
||||||
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
|
row.innerHTML = `<div class="vdes-row-head"><span class="vdes-time">${ts}</span><span class="vdes-call">${escapeVdesHtml(title)}</span><span class="vdes-badge">${escapeVdesHtml(label)}</span>` + (labelText ? `<span class="vdes-badge">${escapeVdesHtml(labelText)}</span>` : "") + (linkText ? `<span class="vdes-badge">${escapeVdesHtml(linkText)}</span>` : "") + (srcText ? `<span class="vdes-badge">${escapeVdesHtml(srcText)}</span>` : "") + (dstText ? `<span class="vdes-badge">${escapeVdesHtml(dstText)}</span>` : "") + (syncText ? `<span class="vdes-badge">${escapeVdesHtml(syncText)}</span>` : "") + (phaseText ? `<span class="vdes-badge">${escapeVdesHtml(phaseText)}</span>` : "") + `<span class="vdes-badge">T${escapeVdesHtml(String(msg.message_type ?? "--"))}</span></div><div class="vdes-row-meta"><span>${escapeVdesHtml(currentVdesCenterText())}</span><span>${escapeVdesHtml(`${msg.bit_len || 0} bits`)}</span>` + (sessionText ? `<span>${escapeVdesHtml(sessionText)}</span>` : "") + (asmText ? `<span>${escapeVdesHtml(asmText)}</span>` : "") + (countText ? `<span>${escapeVdesHtml(countText)}</span>` : "") + (ackText ? `<span>${escapeVdesHtml(ackText)}</span>` : "") + (cqiText ? `<span>${escapeVdesHtml(cqiText)}</span>` : "") + (info ? `<span>${escapeVdesHtml(info)}</span>` : "") + (fecText ? `<span>${escapeVdesHtml(fecText)}</span>` : "") + `<span>${escapeVdesHtml(vdesAgeText(msg._tsMs))}</span></div><div class="vdes-row-detail">` + (previewText ? `<span>${escapeVdesHtml(previewText)}</span>` : "") + (previewText ? `<span>·</span>` : "") + `<span class="vdes-raw">${escapeVdesHtml(rawHex)}</span></div>`;
|
||||||
|
applyVdesFilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function updateVdesBar() {
|
||||||
|
if (!vdesBarOverlay) return;
|
||||||
|
updateVdesSummary();
|
||||||
|
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
||||||
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
|
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
||||||
|
if (!isVdes || messages.length === 0) {
|
||||||
|
vdesBarOverlay.style.display = "none";
|
||||||
|
vdesBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`;
|
||||||
|
for (const msg of messages) {
|
||||||
|
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||||
|
const label = escapeVdesHtml(msg.callsign || "VDES");
|
||||||
|
const title = escapeVdesHtml(msg.vessel_name || "Burst");
|
||||||
|
const detail = [
|
||||||
|
`${msg.bit_len || 0} bits`,
|
||||||
|
msg.message_label ? escapeVdesHtml(msg.message_label) : null,
|
||||||
|
Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null,
|
||||||
|
Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null,
|
||||||
|
Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null,
|
||||||
|
Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null,
|
||||||
|
Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
|
||||||
|
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
|
||||||
|
msg.destination ? escapeVdesHtml(msg.destination) : null,
|
||||||
|
escapeVdesHtml(vdesAgeText(msg._tsMs))
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="vdes-call">${title}</span> <span class="vdes-badge">${label}</span>: ${detail}</div></div>`;
|
||||||
|
}
|
||||||
|
vdesBarOverlay.innerHTML = html;
|
||||||
|
vdesBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
vdesWindow.updateVdesBar = updateVdesBar;
|
||||||
|
vdesWindow.clearVdesBar = function() {
|
||||||
|
resetVdesHistoryView();
|
||||||
|
};
|
||||||
|
function resetVdesHistoryView() {
|
||||||
|
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
|
||||||
|
vdesMessageHistory = [];
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
}
|
||||||
|
function renderVdesHistory() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
if (!vdesMessagesEl) {
|
||||||
|
updateVdesSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const message of vdesMessageHistory) {
|
||||||
|
fragment.appendChild(renderVdesRow(message));
|
||||||
|
}
|
||||||
|
vdesMessagesEl.replaceChildren(fragment);
|
||||||
|
updateVdesSummary();
|
||||||
|
}
|
||||||
|
function addVdesMessage(msg) {
|
||||||
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
msg._tsMs = tsMs;
|
||||||
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
vdesMessageHistory.unshift(msg);
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
scheduleVdesBarUpdate();
|
||||||
|
scheduleVdesHistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerVdesMessage(msg) {
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
rig_id: msg.rig_id || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function onServerVdesBatch(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
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 && vdesWindow.vdesMapAddPoint) {
|
||||||
|
vdesWindow.vdesMapAddPoint(next);
|
||||||
|
}
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
vdesMessageHistory = normalized.concat(vdesMessageHistory);
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
scheduleVdesBarUpdate();
|
||||||
|
scheduleVdesHistoryRender();
|
||||||
|
}
|
||||||
|
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_vdes_decode");
|
||||||
|
resetVdesHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("VDES history clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
if (vdesFilterInput) {
|
||||||
|
vdesFilterInput.addEventListener("input", () => {
|
||||||
|
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
|
||||||
|
renderVdesHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onServerVdes(msg) {
|
||||||
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
addVdesMessage(next);
|
||||||
|
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
||||||
|
vdesWindow.vdesMapAddPoint(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pruneVdesHistoryView() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
}
|
||||||
|
updateVdesSummary();
|
||||||
|
window.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "vdes",
|
||||||
|
onMessage: onServerVdes,
|
||||||
|
onBatch: onServerVdesBatch,
|
||||||
|
restore: onServerVdesBatch,
|
||||||
|
reset: resetVdesHistoryView,
|
||||||
|
prune: pruneVdesHistoryView
|
||||||
|
});
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import {
|
||||||
|
hostCore
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/wefax.ts
|
||||||
|
var wefaxWindow = window;
|
||||||
|
var wefaxDom = {
|
||||||
|
status: document.getElementById("wefax-status"),
|
||||||
|
liveView: document.getElementById("wefax-live-view"),
|
||||||
|
historyView: document.getElementById("wefax-history-view"),
|
||||||
|
liveContainer: document.getElementById("wefax-live-container"),
|
||||||
|
liveInfo: document.getElementById("wefax-live-info"),
|
||||||
|
liveCanvas: document.getElementById("wefax-live-canvas"),
|
||||||
|
liveLatest: document.getElementById("wefax-live-latest"),
|
||||||
|
historyList: document.getElementById("wefax-history-list"),
|
||||||
|
historyCount: document.getElementById("wefax-history-count"),
|
||||||
|
filterInput: document.getElementById("wefax-filter"),
|
||||||
|
sortSelect: document.getElementById("wefax-sort"),
|
||||||
|
toggleBtn: document.getElementById("wefax-decode-toggle-btn"),
|
||||||
|
clearBtn: document.getElementById("wefax-clear-btn"),
|
||||||
|
viewLiveBtn: document.getElementById("wefax-view-live"),
|
||||||
|
viewHistoryBtn: document.getElementById("wefax-view-history")
|
||||||
|
};
|
||||||
|
var wefaxImageHistory = [];
|
||||||
|
var WEFAX_MAX_IMAGES = 100;
|
||||||
|
var wefaxLiveCtx = null;
|
||||||
|
var wefaxLiveLineCount = 0;
|
||||||
|
var wefaxLivePixelsPerLine = 1809;
|
||||||
|
var wefaxActiveView = "live";
|
||||||
|
var wefaxFilterText = "";
|
||||||
|
function currentWefaxHistoryRetentionMs() {
|
||||||
|
return wefaxWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneWefaxHistory() {
|
||||||
|
const cutoff = Date.now() - currentWefaxHistoryRetentionMs();
|
||||||
|
wefaxImageHistory = wefaxImageHistory.filter(function(m) {
|
||||||
|
return (m._tsMs || 0) > cutoff;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function scheduleWefaxUi(key, job) {
|
||||||
|
if (typeof wefaxWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
wefaxWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function switchWefaxView(view) {
|
||||||
|
wefaxActiveView = view;
|
||||||
|
if (wefaxDom.liveView) wefaxDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
|
if (wefaxDom.historyView) wefaxDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
|
[wefaxDom.viewLiveBtn, wefaxDom.viewHistoryBtn].forEach(function(btn) {
|
||||||
|
if (btn) btn.classList.remove("sat-view-active");
|
||||||
|
});
|
||||||
|
if (view === "live" && wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.classList.add("sat-view-active");
|
||||||
|
if (view === "history" && wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.classList.add("sat-view-active");
|
||||||
|
if (view === "history") renderWefaxHistoryTable();
|
||||||
|
}
|
||||||
|
if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener("click", function() {
|
||||||
|
switchWefaxView("live");
|
||||||
|
});
|
||||||
|
if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener("click", function() {
|
||||||
|
switchWefaxView("history");
|
||||||
|
});
|
||||||
|
function resetLiveCanvas(pixelsPerLine) {
|
||||||
|
const canvas = wefaxDom.liveCanvas;
|
||||||
|
if (!canvas) return;
|
||||||
|
wefaxLivePixelsPerLine = pixelsPerLine;
|
||||||
|
wefaxLiveLineCount = 0;
|
||||||
|
canvas.width = pixelsPerLine;
|
||||||
|
canvas.height = 800;
|
||||||
|
wefaxLiveCtx = canvas.getContext("2d");
|
||||||
|
if (!wefaxLiveCtx) return;
|
||||||
|
wefaxLiveCtx.fillStyle = "#000";
|
||||||
|
wefaxLiveCtx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "";
|
||||||
|
}
|
||||||
|
function paintLine(lineBytes) {
|
||||||
|
const canvas = wefaxDom.liveCanvas;
|
||||||
|
if (!wefaxLiveCtx || !canvas) return;
|
||||||
|
const y = wefaxLiveLineCount;
|
||||||
|
if (y >= canvas.height) {
|
||||||
|
const old = wefaxLiveCtx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
canvas.height *= 2;
|
||||||
|
wefaxLiveCtx = canvas.getContext("2d");
|
||||||
|
if (!wefaxLiveCtx) return;
|
||||||
|
wefaxLiveCtx.putImageData(old, 0, 0);
|
||||||
|
}
|
||||||
|
const w = wefaxLivePixelsPerLine;
|
||||||
|
const imgData = wefaxLiveCtx.createImageData(w, 1);
|
||||||
|
const d = imgData.data;
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const v = lineBytes[x] ?? 0;
|
||||||
|
const i = x * 4;
|
||||||
|
d[i] = v;
|
||||||
|
d[i + 1] = v;
|
||||||
|
d[i + 2] = v;
|
||||||
|
d[i + 3] = 255;
|
||||||
|
}
|
||||||
|
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||||
|
wefaxLiveLineCount++;
|
||||||
|
}
|
||||||
|
function 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;
|
||||||
|
}
|
||||||
|
const img = wefaxImageHistory[0];
|
||||||
|
if (!img) return;
|
||||||
|
const ts = img._ts || "--";
|
||||||
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
|
const meta = [
|
||||||
|
`${String(img.ioc ?? "--")} IOC`,
|
||||||
|
`${String(img.lpm ?? "--")} LPM`,
|
||||||
|
`${String(img.line_count ?? 0)} lines`,
|
||||||
|
`${date} ${ts}`
|
||||||
|
].join(" · ");
|
||||||
|
const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null;
|
||||||
|
let html = '<div class="sat-latest-card">';
|
||||||
|
html += '<div class="sat-latest-title">Latest decoded image</div>';
|
||||||
|
html += '<div class="sat-latest-meta">' + escapeHtml(meta) + "</div>";
|
||||||
|
if (imgSrc) {
|
||||||
|
html += '<a href="' + imgSrc + '" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">View full image</a>';
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
wefaxDom.liveLatest.innerHTML = html;
|
||||||
|
}
|
||||||
|
function getWefaxFilteredHistory() {
|
||||||
|
let items = wefaxImageHistory;
|
||||||
|
if (wefaxFilterText) {
|
||||||
|
items = items.filter(function(i) {
|
||||||
|
const haystack = [
|
||||||
|
String(i.ioc || ""),
|
||||||
|
String(i.lpm || ""),
|
||||||
|
String(i.line_count || "")
|
||||||
|
].join(" ").toUpperCase();
|
||||||
|
return haystack.indexOf(wefaxFilterText) >= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : "newest";
|
||||||
|
if (sortVal === "oldest") items = items.slice().reverse();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
function renderWefaxHistoryRow(img) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-history-row";
|
||||||
|
const ts = img._ts || "--";
|
||||||
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
||||||
|
const ioc = img.ioc || "--";
|
||||||
|
const lpm = img.lpm || "--";
|
||||||
|
const lines = img.line_count || 0;
|
||||||
|
const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null;
|
||||||
|
const link = imgSrc ? '<a href="' + imgSrc + '" target="_blank" style="color:var(--accent);">View</a>' : "--";
|
||||||
|
row.innerHTML = [
|
||||||
|
"<span>" + escapeHtml(date + " " + ts) + "</span>",
|
||||||
|
"<span>" + escapeHtml(String(ioc)) + "</span>",
|
||||||
|
"<span>" + escapeHtml(String(lpm)) + "</span>",
|
||||||
|
`<span>${String(lines)}</span>`,
|
||||||
|
"<span>" + link + "</span>"
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderWefaxHistoryTable() {
|
||||||
|
if (!wefaxDom.historyList) return;
|
||||||
|
pruneWefaxHistory();
|
||||||
|
const items = getWefaxFilteredHistory();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const item of items) {
|
||||||
|
fragment.appendChild(renderWefaxHistoryRow(item));
|
||||||
|
}
|
||||||
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
|
if (wefaxDom.historyCount) {
|
||||||
|
const total = wefaxImageHistory.length;
|
||||||
|
const shown = items.length;
|
||||||
|
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addWefaxImage(msg) {
|
||||||
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
msg._tsMs = tsMs;
|
||||||
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
const canvas = wefaxDom.liveCanvas;
|
||||||
|
if (wefaxLiveCtx && canvas && wefaxLiveLineCount > 0) {
|
||||||
|
const trimmed = wefaxLiveCtx.getImageData(0, 0, canvas.width, wefaxLiveLineCount);
|
||||||
|
canvas.height = wefaxLiveLineCount;
|
||||||
|
wefaxLiveCtx = canvas.getContext("2d");
|
||||||
|
if (!wefaxLiveCtx) return;
|
||||||
|
wefaxLiveCtx.putImageData(trimmed, 0, 0);
|
||||||
|
try {
|
||||||
|
msg._dataUrl = canvas.toDataURL("image/png");
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wefaxImageHistory.unshift(msg);
|
||||||
|
if (wefaxImageHistory.length > WEFAX_MAX_IMAGES) {
|
||||||
|
wefaxImageHistory = wefaxImageHistory.slice(0, WEFAX_MAX_IMAGES);
|
||||||
|
}
|
||||||
|
scheduleWefaxUi("wefax-latest", renderWefaxLatestCard);
|
||||||
|
if (wefaxActiveView === "history") {
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onServerWefaxProgress(msg) {
|
||||||
|
if (msg.state && !msg.line_data) {
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = msg.state;
|
||||||
|
wefaxDom.status.style.color = msg.state.indexOf("Idle") === 0 ? "" : "var(--text-accent)";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((msg.line_count ?? 0) <= 1 || !wefaxLiveCtx) {
|
||||||
|
resetLiveCanvas(msg.pixels_per_line || 1809);
|
||||||
|
}
|
||||||
|
if (msg.line_data) {
|
||||||
|
const binary = atob(msg.line_data);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
paintLine(bytes);
|
||||||
|
}
|
||||||
|
if (wefaxDom.liveInfo) {
|
||||||
|
wefaxDom.liveInfo.textContent = `Line ${String(msg.line_count ?? 0)} · ${String(msg.ioc ?? "--")} IOC · ${String(msg.lpm ?? "--")} LPM`;
|
||||||
|
}
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = `Receiving — line ${String(msg.line_count ?? 0)}`;
|
||||||
|
wefaxDom.status.style.color = "var(--text-accent)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onServerWefax(msg) {
|
||||||
|
addWefaxImage(msg);
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
|
||||||
|
wefaxDom.status.style.color = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function restoreWefaxHistory(messages) {
|
||||||
|
if (!messages.length) return;
|
||||||
|
for (const message of messages) {
|
||||||
|
const tsMs = Number.isFinite(message.ts_ms) ? Number(message.ts_ms) : Date.now();
|
||||||
|
message._tsMs = tsMs;
|
||||||
|
message._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wefaxImageHistory = messages.concat(wefaxImageHistory);
|
||||||
|
pruneWefaxHistory();
|
||||||
|
scheduleWefaxUi("wefax-latest", renderWefaxLatestCard);
|
||||||
|
if (wefaxActiveView === "history") {
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pruneWefaxHistoryView() {
|
||||||
|
pruneWefaxHistory();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
}
|
||||||
|
function resetWefaxHistoryView() {
|
||||||
|
wefaxImageHistory = [];
|
||||||
|
if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = "";
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
||||||
|
wefaxLiveCtx = null;
|
||||||
|
wefaxLiveLineCount = 0;
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = "Idle";
|
||||||
|
wefaxDom.status.style.color = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wefaxDom.filterInput) {
|
||||||
|
const filterInput = wefaxDom.filterInput;
|
||||||
|
wefaxDom.filterInput.addEventListener("input", function() {
|
||||||
|
wefaxFilterText = filterInput.value.trim().toUpperCase();
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (wefaxDom.sortSelect) {
|
||||||
|
wefaxDom.sortSelect.addEventListener("change", function() {
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wefaxWindow.syncWefaxToggle = function(enabled) {
|
||||||
|
if (!wefaxDom.toggleBtn) return;
|
||||||
|
wefaxDom.toggleBtn.dataset.enabled = enabled ? "true" : "false";
|
||||||
|
wefaxDom.toggleBtn.textContent = enabled ? "Disable WEFAX" : "Enable WEFAX";
|
||||||
|
wefaxDom.toggleBtn.style.borderColor = enabled ? "#00d17f" : "";
|
||||||
|
wefaxDom.toggleBtn.style.color = enabled ? "#00d17f" : "";
|
||||||
|
};
|
||||||
|
if (wefaxDom.toggleBtn) {
|
||||||
|
const toggleButton = wefaxDom.toggleBtn;
|
||||||
|
wefaxDom.toggleBtn.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (wefaxWindow.takeSchedulerControlForDecoderDisable) {
|
||||||
|
await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton);
|
||||||
|
}
|
||||||
|
await hostCore.postPath("/toggle_wefax_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WEFAX toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (wefaxDom.clearBtn) {
|
||||||
|
wefaxDom.clearBtn.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_wefax_decode");
|
||||||
|
resetWefaxHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WEFAX clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "wefax",
|
||||||
|
onMessage: onServerWefax,
|
||||||
|
restore: restoreWefaxHistory,
|
||||||
|
prune: pruneWefaxHistoryView,
|
||||||
|
reset: resetWefaxHistoryView
|
||||||
|
});
|
||||||
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "wefax_progress",
|
||||||
|
onMessage: onServerWefaxProgress
|
||||||
|
});
|
||||||
+103
-124
@@ -1,147 +1,131 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
import {
|
||||||
//
|
hostCore
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
// --- WSPR Decoder Plugin (server-side decode) ---
|
// src/plugins/wspr.ts
|
||||||
const wsprStatus = document.getElementById("wspr-status");
|
var wsprWindow = window;
|
||||||
const wsprPeriodEl = document.getElementById("wspr-period");
|
var wsprStatus = document.getElementById("wspr-status");
|
||||||
const wsprMessagesEl = document.getElementById("wspr-messages");
|
var wsprPeriodEl = document.getElementById("wspr-period");
|
||||||
const wsprFilterInput = document.getElementById("wspr-filter");
|
var wsprMessagesEl = document.getElementById("wspr-messages");
|
||||||
const WSPR_PERIOD_SECONDS = 120;
|
var wsprFilterInput = document.getElementById("wspr-filter");
|
||||||
let wsprFilterText = "";
|
var WSPR_PERIOD_SECONDS = 120;
|
||||||
let wsprMessageHistory = [];
|
var wsprFilterText = "";
|
||||||
|
var wsprMessageHistory = [];
|
||||||
|
function finiteNumber(value) {
|
||||||
|
const number = typeof value === "number" ? value : Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
function currentWsprHistoryRetentionMs() {
|
function currentWsprHistoryRetentionMs() {
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
return typeof wsprWindow.getDecodeHistoryRetentionMs === "function" ? wsprWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
? window.getDecodeHistoryRetentionMs()
|
|
||||||
: 24 * 60 * 60 * 1000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneWsprMessageHistory() {
|
function pruneWsprMessageHistory() {
|
||||||
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
||||||
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleWsprHistoryRender() {
|
function scheduleWsprHistoryRender() {
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
if (typeof wsprWindow.trxScheduleUiFrameJob === "function") {
|
||||||
window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory());
|
wsprWindow.trxScheduleUiFrameJob("wspr-history", () => {
|
||||||
|
renderWsprHistory();
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderWsprHistory();
|
renderWsprHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtWsprTime(tsMs) {
|
function fmtWsprTime(tsMs) {
|
||||||
if (!tsMs) return "--:--:--";
|
if (!tsMs) return "--:--:--";
|
||||||
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateWsprPeriodTimer() {
|
function updateWsprPeriodTimer() {
|
||||||
if (!wsprPeriodEl) return;
|
if (!wsprPeriodEl) return;
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1e3);
|
||||||
const remaining = WSPR_PERIOD_SECONDS - (nowSec % WSPR_PERIOD_SECONDS);
|
const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS;
|
||||||
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
||||||
const ss = String(remaining % 60).padStart(2, "0");
|
const ss = String(remaining % 60).padStart(2, "0");
|
||||||
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateWsprPeriodTimer();
|
updateWsprPeriodTimer();
|
||||||
setInterval(updateWsprPeriodTimer, 500);
|
setInterval(updateWsprPeriodTimer, 500);
|
||||||
|
|
||||||
function renderWsprRow(msg) {
|
function renderWsprRow(msg) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "ft8-row";
|
row.className = "ft8-row";
|
||||||
row.dataset.decoder = "wspr";
|
row.dataset.decoder = "wspr";
|
||||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
const snr = finiteNumber(msg.snr_db);
|
||||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
const delta = finiteNumber(msg.dt_s);
|
||||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null;
|
const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
|
||||||
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? (baseHz + msg.freq_hz) : null;
|
const offsetHz = finiteNumber(msg.freq_hz);
|
||||||
const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--";
|
const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : null;
|
||||||
const message = (msg.message || "").toString();
|
const freq = rfHz?.toFixed(0) ?? "--";
|
||||||
|
const message = msg.message ?? "";
|
||||||
row.dataset.message = message.toUpperCase();
|
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>`;
|
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
||||||
applyWsprFilterToRow(row);
|
applyWsprFilterToRow(row);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWsprHistory() {
|
function renderWsprHistory() {
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
if (!wsprMessagesEl) return;
|
if (!wsprMessagesEl) return;
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
||||||
fragment.appendChild(renderWsprRow(wsprMessageHistory[i]));
|
const message = wsprMessageHistory[i];
|
||||||
|
if (message) fragment.appendChild(renderWsprRow(message));
|
||||||
}
|
}
|
||||||
wsprMessagesEl.replaceChildren(fragment);
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addWsprMessage(msg) {
|
function addWsprMessage(msg) {
|
||||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
msg._tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
wsprMessageHistory.unshift(msg);
|
wsprMessageHistory.unshift(msg);
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
scheduleWsprHistoryRender();
|
scheduleWsprHistoryRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerWsprMessage(msg) {
|
function normalizeServerWsprMessage(msg) {
|
||||||
const raw = (msg.message || "").toString();
|
const raw = msg.message ?? "";
|
||||||
const grids = extractAllGrids(raw);
|
const grids = extractAllGrids(raw);
|
||||||
const station = extractLikelyCallsign(raw);
|
const station = extractLikelyCallsign(raw);
|
||||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
|
||||||
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz)
|
const offsetHz = finiteNumber(msg.freq_hz);
|
||||||
? (baseHz + Number(msg.freq_hz))
|
const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : offsetHz;
|
||||||
: (Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null);
|
|
||||||
return {
|
return {
|
||||||
raw,
|
raw,
|
||||||
grids,
|
grids,
|
||||||
station,
|
station,
|
||||||
rfHz,
|
rfHz,
|
||||||
history: {
|
history: {
|
||||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||||
ts_ms: msg.ts_ms,
|
ts_ms: msg.ts_ms,
|
||||||
snr_db: msg.snr_db,
|
snr_db: msg.snr_db,
|
||||||
dt_s: msg.dt_s,
|
dt_s: msg.dt_s,
|
||||||
freq_hz: msg.freq_hz,
|
freq_hz: msg.freq_hz,
|
||||||
message: raw,
|
message: raw
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function onServerWsprBatch(messages) {
|
||||||
window.onServerWsprBatch = function(messages) {
|
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
wsprStatus.textContent = "Receiving";
|
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||||
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
...msg,
|
...msg,
|
||||||
freq_hz: next.rfHz,
|
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
normalized.push(next.history);
|
normalized.push(next.history);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
wsprMessageHistory = normalized.concat(wsprMessageHistory);
|
wsprMessageHistory = normalized.concat(wsprMessageHistory);
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
scheduleWsprHistoryRender();
|
scheduleWsprHistoryRender();
|
||||||
};
|
}
|
||||||
|
function pruneWsprHistoryView() {
|
||||||
window.restoreWsprHistory = function(messages) {
|
|
||||||
window.onServerWsprBatch(messages);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.pruneWsprHistoryView = function() {
|
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
renderWsprHistory();
|
renderWsprHistory();
|
||||||
};
|
|
||||||
|
|
||||||
function escapeWsprHtml(input) {
|
|
||||||
return input
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll("\"", """);
|
|
||||||
}
|
}
|
||||||
|
function escapeWsprHtml(input) {
|
||||||
|
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
function renderWsprMessage(message) {
|
function renderWsprMessage(message) {
|
||||||
let out = "";
|
let out = "";
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -159,16 +143,15 @@ function renderWsprMessage(message) {
|
|||||||
}
|
}
|
||||||
i = j;
|
i = j;
|
||||||
} else {
|
} else {
|
||||||
out += escapeWsprHtml(ch);
|
out += escapeWsprHtml(ch ?? "");
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractAllGrids(message) {
|
function extractAllGrids(message) {
|
||||||
const out = [];
|
const out = [];
|
||||||
const seen = new Set();
|
const seen = /* @__PURE__ */ new Set();
|
||||||
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
|
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
|
||||||
for (const token of parts) {
|
for (const token of parts) {
|
||||||
if (!token) continue;
|
if (!token) continue;
|
||||||
@@ -179,9 +162,8 @@ function extractAllGrids(message) {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractLikelyCallsign(message) {
|
function extractLikelyCallsign(message) {
|
||||||
const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/);
|
const parts = message.toUpperCase().split(/[^A-Z0-9/]+/);
|
||||||
for (const token of parts) {
|
for (const token of parts) {
|
||||||
if (!token) continue;
|
if (!token) continue;
|
||||||
if (token.length < 3 || token.length > 12) continue;
|
if (token.length < 3 || token.length > 12) continue;
|
||||||
@@ -191,32 +173,28 @@ function extractLikelyCallsign(message) {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFtxFarewellToken(token) {
|
function isFtxFarewellToken(token) {
|
||||||
const normalized = String(token || "").trim().toUpperCase();
|
const normalized = token.trim().toUpperCase();
|
||||||
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMaidenheadGridToken(token) {
|
function isMaidenheadGridToken(token) {
|
||||||
const normalized = String(token || "").trim().toUpperCase();
|
const normalized = token.trim().toUpperCase();
|
||||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAlphaNum(ch) {
|
function isAlphaNum(ch) {
|
||||||
return /[A-Za-z0-9]/.test(ch);
|
return ch !== void 0 && /[A-Za-z0-9]/.test(ch);
|
||||||
}
|
}
|
||||||
|
function activateWsprHistoryLocator(target) {
|
||||||
function activateWsprHistoryLocator(targetEl) {
|
if (!(target instanceof Element)) return false;
|
||||||
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
|
const locatorEl = target.closest(".ft8-locator[data-locator-grid]");
|
||||||
if (!locatorEl) return false;
|
if (!locatorEl) return false;
|
||||||
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
|
const grid = (locatorEl.dataset.locatorGrid || "").toUpperCase();
|
||||||
if (!grid) return false;
|
if (!grid) return false;
|
||||||
if (typeof window.navigateToMapLocator === "function") {
|
if (typeof wsprWindow.navigateToMapLocator === "function") {
|
||||||
window.navigateToMapLocator(grid, "wspr");
|
wsprWindow.navigateToMapLocator(grid, "wspr");
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyWsprFilterToRow(row) {
|
function applyWsprFilterToRow(row) {
|
||||||
if (!wsprFilterText) {
|
if (!wsprFilterText) {
|
||||||
row.style.display = "";
|
row.style.display = "";
|
||||||
@@ -225,26 +203,18 @@ function applyWsprFilterToRow(row) {
|
|||||||
const message = row.dataset.message || "";
|
const message = row.dataset.message || "";
|
||||||
row.style.display = message.includes(wsprFilterText) ? "" : "none";
|
row.style.display = message.includes(wsprFilterText) ? "" : "none";
|
||||||
}
|
}
|
||||||
|
function resetWsprHistoryView() {
|
||||||
function applyWsprFilterToAll() {
|
if (wsprMessagesEl) wsprMessagesEl.innerHTML = "";
|
||||||
const rows = wsprMessagesEl.querySelectorAll(".ft8-row");
|
|
||||||
rows.forEach((row) => applyWsprFilterToRow(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
window.resetWsprHistoryView = function() {
|
|
||||||
wsprMessagesEl.innerHTML = "";
|
|
||||||
wsprMessageHistory = [];
|
wsprMessageHistory = [];
|
||||||
renderWsprHistory();
|
renderWsprHistory();
|
||||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr");
|
if (wsprWindow.clearMapMarkersByType) wsprWindow.clearMapMarkersByType("wspr");
|
||||||
};
|
}
|
||||||
|
|
||||||
if (wsprFilterInput) {
|
if (wsprFilterInput) {
|
||||||
wsprFilterInput.addEventListener("input", () => {
|
wsprFilterInput.addEventListener("input", () => {
|
||||||
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
|
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
|
||||||
renderWsprHistory();
|
renderWsprHistory();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wsprMessagesEl) {
|
if (wsprMessagesEl) {
|
||||||
wsprMessagesEl.addEventListener("click", (event) => {
|
wsprMessagesEl.addEventListener("click", (event) => {
|
||||||
if (!activateWsprHistoryLocator(event.target)) return;
|
if (!activateWsprHistoryLocator(event.target)) return;
|
||||||
@@ -258,35 +228,44 @@ if (wsprMessagesEl) {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
var wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
||||||
const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
wsprDecodeToggleBtn?.addEventListener("click", () => {
|
||||||
wsprDecodeToggleBtn?.addEventListener("click", async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
|
await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
|
||||||
await postPath("/toggle_wspr_decode");
|
await hostCore.postPath("/toggle_wspr_decode");
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error("WSPR toggle failed", e);
|
console.error("WSPR toggle failed", error);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
|
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", () => {
|
||||||
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
|
void (async () => {
|
||||||
if (!confirm("Clear all WSPR decode history? This cannot be undone.")) return;
|
if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
try {
|
try {
|
||||||
await postPath("/clear_wspr_decode");
|
await hostCore.postPath("/clear_wspr_decode");
|
||||||
window.resetWsprHistoryView();
|
resetWsprHistoryView();
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error("WSPR history clear failed", e);
|
console.error("WSPR history clear failed", error);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
|
function onServerWspr(msg) {
|
||||||
window.onServerWspr = function(msg) {
|
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||||
wsprStatus.textContent = "Receiving";
|
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||||
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
...msg,
|
...msg,
|
||||||
freq_hz: next.rfHz,
|
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
addWsprMessage(next.history);
|
addWsprMessage(next.history);
|
||||||
};
|
}
|
||||||
|
wsprWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "wspr",
|
||||||
|
onMessage: onServerWspr,
|
||||||
|
onBatch: onServerWsprBatch,
|
||||||
|
restore: onServerWsprBatch,
|
||||||
|
prune: pruneWsprHistoryView,
|
||||||
|
reset: resetWsprHistoryView
|
||||||
|
});
|
||||||
@@ -43,10 +43,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="subtitle" id="location-subtitle" style="display:none;"></div>
|
<div class="subtitle" id="location-subtitle" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-bar-nav">
|
<div class="tab-bar-nav" aria-label="Primary navigation">
|
||||||
<button class="tab active" data-tab="main">
|
<button class="tab active" data-tab="main">
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg>
|
||||||
<span class="tab-label">Main</span>
|
<span class="tab-label">Radio</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" data-tab="bookmarks">
|
<button class="tab" data-tab="bookmarks">
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
||||||
@@ -104,24 +104,24 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Auth gate (hidden by default, shown if auth is required) -->
|
<!-- Auth gate (hidden by default, shown if auth is required) -->
|
||||||
<div id="auth-gate" style="display:none; max-width: 30rem; margin: 0 auto 0.9rem; padding: 1.25rem 0 1.5rem; text-align: center;">
|
<div id="auth-gate" class="auth-gate" style="display:none;">
|
||||||
<div style="margin-bottom: 1.5rem;">
|
<div class="auth-gate-head">
|
||||||
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;">Access Required</div>
|
<div class="auth-gate-title">Access Required</div>
|
||||||
<div style="color: var(--text-muted);">Enter passphrase to continue</div>
|
<div class="auth-gate-sub">Enter passphrase to continue</div>
|
||||||
</div>
|
</div>
|
||||||
<form id="auth-form" style="margin: 1rem 0 0.35rem;">
|
<form id="auth-form" class="auth-form">
|
||||||
<input type="password" id="auth-passphrase" placeholder="Passphrase" autocomplete="off" style="width: 100%; padding: 0.65rem 0.75rem; margin-bottom: 1rem; border: 1px solid var(--border-light); border-radius: 0.45rem; background: var(--input-bg); color: var(--text); font-size: 1rem; box-sizing: border-box;" />
|
<input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" />
|
||||||
<button type="submit" style="width: 100%; padding: 0.65rem 0.75rem; background: var(--accent-green); color: #fff; border: none; border-radius: 0.45rem; font-weight: 700; cursor: pointer; font-size: 1rem; box-sizing: border-box;">Login</button>
|
<button type="submit" class="auth-submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
<button id="auth-guest-btn" type="button" style="width: 100%; padding: 0.65rem 0.75rem; background: var(--btn-bg); color: var(--text); border: 1px solid var(--border-light); border-radius: 0.45rem; font-weight: 600; cursor: pointer; font-size: 1rem; box-sizing: border-box; margin-top: 0; display: none;">Continue as Guest</button>
|
<button id="auth-guest-btn" type="button" class="auth-guest" style="display: none;">Continue as Guest</button>
|
||||||
<div id="auth-error" style="color: #ff6b6b; font-size: 0.9rem; margin-top: 1rem; display: none;"></div>
|
<div id="auth-error" class="auth-error" style="display: none;"></div>
|
||||||
<div id="auth-role" style="margin-top: 1rem; color: var(--text-muted); font-size: 0.85rem; display: none;"></div>
|
<div id="auth-role" class="auth-role" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-main" class="tab-panel">
|
<div id="tab-main" class="tab-panel">
|
||||||
<div id="server-lost-banner" aria-live="assertive"><span class="banner-dot"></span>trx-server connection lost — waiting for reconnect</div>
|
<div id="server-lost-banner" aria-live="assertive"><span class="banner-dot"></span>trx-server connection lost — waiting for reconnect</div>
|
||||||
<div id="loading" role="status" aria-live="polite" style="text-align:center; padding:2rem 0;">
|
<div id="loading" role="status" aria-live="polite" style="text-align:center; padding:2rem 0;">
|
||||||
<div id="loading-title" style="margin-bottom:0.4rem; font-size:1.1rem; font-weight:600;">Initializing (rig)…</div>
|
<div id="loading-title" style="margin-bottom:0.4rem; font-size:1.1rem; font-weight:600;">Initializing (rig)…</div>
|
||||||
<div id="loading-sub" style="color:#9aa4b5;"></div>
|
<div id="loading-sub" style="color:var(--text-muted);"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="content" style="display:none;">
|
<div id="content" style="display:none;">
|
||||||
<div class="signal-visual-block">
|
<div class="signal-visual-block">
|
||||||
@@ -140,13 +140,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="spectrum-wrap">
|
<div class="spectrum-wrap">
|
||||||
<div id="spectrum-bookmark-axis"></div>
|
<div id="spectrum-bookmark-axis"></div>
|
||||||
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
|
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
|
||||||
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display"></canvas>
|
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display" aria-describedby="spectrum-text-summary"></canvas>
|
||||||
|
<p id="spectrum-text-summary" class="visually-hidden">Spectrum data is waiting for the receiver.</p>
|
||||||
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
|
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
|
||||||
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
|
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
|
||||||
<div id="spectrum-db-axis" aria-hidden="true"></div>
|
<div id="spectrum-db-axis" aria-hidden="true"></div>
|
||||||
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
|
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
|
||||||
<div id="spectrum-tooltip"></div>
|
<div id="spectrum-tooltip"></div>
|
||||||
<canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" style="display:none;"></canvas>
|
<canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" aria-describedby="spectrum-text-summary" style="display:none;"></canvas>
|
||||||
<div id="spectrum-freq-axis">
|
<div id="spectrum-freq-axis">
|
||||||
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">‹</button>
|
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">‹</button>
|
||||||
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">›</button>
|
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">›</button>
|
||||||
@@ -204,12 +205,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="label"><span>Signal strength</span></div>
|
<div class="label"><span>Signal strength</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="freq-field frequency-col">
|
<div class="freq-field frequency-col">
|
||||||
<input class="status-input" id="freq" type="text" value="--" />
|
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" />
|
||||||
<div class="label"><span>Frequency</span></div>
|
<div class="label" id="freq-label"><span>Frequency</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
|
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
|
||||||
<input class="status-input" id="center-freq" type="text" value="--" />
|
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" />
|
||||||
<div class="label"><span>Center Frequency</span></div>
|
<div class="label" id="center-freq-label"><span>Center Frequency</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="freq-field unit-col">
|
<div class="freq-field unit-col">
|
||||||
<div class="jog-step" id="jog-step">
|
<div class="jog-step" id="jog-step">
|
||||||
@@ -235,7 +236,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="controls-col label-below-col">
|
<div class="controls-col label-below-col">
|
||||||
<div class="label"><span>Mode</span></div>
|
<div class="label"><span>Mode</span></div>
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<select class="status-input" id="mode"></select>
|
<select class="status-input" id="mode" aria-label="Demodulation mode"></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls-col controls-col-center">
|
<div class="controls-col controls-col-center">
|
||||||
@@ -307,9 +308,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
|
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
|
||||||
<div class="label"><span>Transmit / Power</span></div>
|
<div class="label"><span>Transmit / Power</span></div>
|
||||||
<div class="btn-grid">
|
<div class="btn-grid">
|
||||||
<button id="ptt-btn" type="button">Toggle PTT</button>
|
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
|
||||||
<button id="power-btn" type="button">Toggle Power</button>
|
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
|
||||||
<button id="lock-btn" type="button">Lock</button>
|
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,9 +357,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="full-row label-below-row" id="vchan-row">
|
<div class="full-row label-below-row" id="vchan-row">
|
||||||
<div class="label"><span>Channels / Scheduler</span></div>
|
<div class="label"><span>Channels</span></div>
|
||||||
<div class="channel-scheduler-controls">
|
<div class="channel-scheduler-controls">
|
||||||
<div class="vchan-picker" id="vchan-picker"></div>
|
<div class="vchan-picker" id="vchan-picker"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details id="scheduler-controls" class="advanced-radio-controls scheduler-controls-section">
|
||||||
|
<summary>Scheduler controls</summary>
|
||||||
|
<div class="advanced-radio-body">
|
||||||
<div class="scheduler-control-row" style="display:none">
|
<div class="scheduler-control-row" style="display:none">
|
||||||
<div class="scheduler-release-wrap">
|
<div class="scheduler-release-wrap">
|
||||||
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
|
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
|
||||||
@@ -381,7 +387,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
<div class="full-row label-below-row">
|
<div class="full-row label-below-row">
|
||||||
<div class="label"><span>Signal</span></div>
|
<div class="label"><span>Signal</span></div>
|
||||||
<div class="signal" style="gap: 1rem;">
|
<div class="signal" style="gap: 1rem;">
|
||||||
@@ -412,20 +418,24 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<button id="tx-limit-btn" type="button">Set</button>
|
<button id="tx-limit-btn" type="button">Set</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="full-row label-below-row" id="audio-row">
|
<details id="audio-controls" class="advanced-radio-controls audio-controls-section">
|
||||||
<div class="label"><span>Audio</span></div>
|
<summary>Audio controls</summary>
|
||||||
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
|
<div class="advanced-radio-body">
|
||||||
<button id="rx-audio-btn" type="button">Play Audio</button>
|
<div class="full-row" id="audio-row">
|
||||||
<button id="tx-audio-btn" type="button">Transmit Audio</button>
|
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
|
||||||
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
|
<button id="rx-audio-btn" type="button">Play Audio</button>
|
||||||
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
|
<button id="tx-audio-btn" type="button">Transmit Audio</button>
|
||||||
<label class="vol-label" id="sdr-squelch-wrap" style="display:none;">SQL<input type="range" id="sdr-squelch" min="0" max="100" value="0" class="vol-slider" /><small class="vol-pct" id="sdr-squelch-pct">Open</small><button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set squelch to current noise level">Auto</button></label>
|
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
|
||||||
<div id="audio-level">
|
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
|
||||||
<div id="audio-level-fill"></div>
|
<label class="vol-label" id="sdr-squelch-wrap" style="display:none;">SQL<input type="range" id="sdr-squelch" min="0" max="100" value="0" class="vol-slider" /><small class="vol-pct" id="sdr-squelch-pct">Open</small><button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set squelch to current noise level">Auto</button></label>
|
||||||
|
<div id="audio-level">
|
||||||
|
<div id="audio-level-fill"></div>
|
||||||
|
</div>
|
||||||
|
<small id="audio-status" style="min-width: 60px;">Off</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<small id="audio-status" style="min-width: 60px;">Off</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -495,6 +505,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="bm-form-actions">
|
<div class="bm-form-actions">
|
||||||
|
<div id="bm-form-error" class="form-error" role="alert" aria-live="polite"></div>
|
||||||
<button type="submit" class="bm-save-btn">Save</button>
|
<button type="submit" class="bm-save-btn">Save</button>
|
||||||
<button type="button" id="bm-form-cancel">Cancel</button>
|
<button type="button" id="bm-form-cancel">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -530,7 +541,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-digital-modes" class="tab-panel" style="display:none;">
|
<div id="tab-digital-modes" class="tab-panel" style="display:none;">
|
||||||
<div class="sub-tab-bar">
|
<div class="sub-tab-bar" aria-label="Decoder views">
|
||||||
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
||||||
<button class="sub-tab" data-subtab="ais">AIS</button>
|
<button class="sub-tab" data-subtab="ais">AIS</button>
|
||||||
<button class="sub-tab" data-subtab="vdes">VDES</button>
|
<button class="sub-tab" data-subtab="vdes">VDES</button>
|
||||||
@@ -1040,6 +1051,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
||||||
|
<h2 class="section-heading">Statistics</h2>
|
||||||
<template id="tmpl-statistics">
|
<template id="tmpl-statistics">
|
||||||
<div class="stats-controls">
|
<div class="stats-controls">
|
||||||
<div class="stats-control-group">
|
<div class="stats-control-group">
|
||||||
@@ -1180,6 +1192,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-settings" class="tab-panel" style="display:none;">
|
<div id="tab-settings" class="tab-panel" style="display:none;">
|
||||||
|
<h2 class="section-heading">Settings</h2>
|
||||||
<div class="sub-tab-bar">
|
<div class="sub-tab-bar">
|
||||||
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
|
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
|
||||||
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
|
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
|
||||||
@@ -1475,6 +1488,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-about" class="tab-panel" style="display:none;">
|
<div id="tab-about" class="tab-panel" style="display:none;">
|
||||||
|
<h2 class="section-heading">About</h2>
|
||||||
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
|
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
|
||||||
<template id="tmpl-about">
|
<template id="tmpl-about">
|
||||||
<div class="sub-tab-bar">
|
<div class="sub-tab-bar">
|
||||||
@@ -1581,7 +1595,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<div class="copyright">
|
<div class="copyright">
|
||||||
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · <span class="gh-link-wrap"><a class="gh-link" href="https://github.com/sgrams/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs on GitHub"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0.2a8 8 0 0 0-2.53 15.59c0.4 0.07 0.55-0.17 0.55-0.39l-0.01-1.37c-2.23 0.49-2.7-0.95-2.7-0.95-0.36-0.91-0.89-1.15-0.89-1.15-0.73-0.49 0.06-0.48 0.06-0.48 0.8 0.06 1.22 0.82 1.22 0.82 0.72 1.22 1.88 0.87 2.34 0.67 0.07-0.51 0.28-0.86 0.5-1.06-1.78-0.2-3.64-0.89-3.64-3.95 0-0.87 0.31-1.58 0.81-2.14-0.08-0.2-0.35-1.02 0.08-2.12 0 0 0.67-0.21 2.2 0.82a7.56 7.56 0 0 1 4.01 0c1.53-1.03 2.2-0.82 2.2-0.82 0.43 1.1 0.16 1.92 0.08 2.12 0.51 0.56 0.81 1.27 0.81 2.14 0 3.07-1.87 3.75-3.66 3.95 0.29 0.25 0.54 0.73 0.54 1.48l-0.01 2.2c0 0.22 0.14 0.47 0.55 0.39A8 8 0 0 0 8 0.2Z"></path></svg><span>trx-rs on GitHub</span></a></span> — <span id="copyright-year"></span>
|
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · <span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span> — <span id="copyright-year"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint" id="power-hint" aria-live="polite">Connecting…</div>
|
<div class="hint" id="power-hint" aria-live="polite">Connecting…</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1623,50 +1637,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
|
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.11/dist/opus-decoder.min.js" charset="UTF-8"></script>
|
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
|
||||||
<script defer src="/webgl-renderer.js"></script>
|
|
||||||
<script defer src="/app.js"></script>
|
|
||||||
<script>
|
|
||||||
// Lazy plugin loader: loads plugin scripts when their tab/feature is first activated
|
|
||||||
(function() {
|
|
||||||
var pluginScripts = {
|
|
||||||
'digital-modes': ['/ft8.js', '/ft4.js', '/ft2.js', '/wspr.js', '/cw.js', '/background-decode.js', '/sat.js', '/wefax.js'],
|
|
||||||
'map-data': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js'],
|
|
||||||
'map': ['/map-core.js', '/leaflet-ais-tracksymbol.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'],
|
|
||||||
'statistics': ['/map-core.js'],
|
|
||||||
'bookmarks': ['/bookmarks.js'],
|
|
||||||
'recorder': [],
|
|
||||||
'settings': ['/vchan.js', '/scheduler.js']
|
|
||||||
};
|
|
||||||
var loaded = new Set();
|
|
||||||
function loadPlugins(tab) {
|
|
||||||
var scripts = pluginScripts[tab];
|
|
||||||
if (!scripts) return;
|
|
||||||
scripts.forEach(function(src) {
|
|
||||||
if (loaded.has(src)) return;
|
|
||||||
loaded.add(src);
|
|
||||||
var s = document.createElement('script');
|
|
||||||
s.src = src;
|
|
||||||
s.defer = true;
|
|
||||||
document.body.appendChild(s);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Eager plugin loading is triggered by app.js (after window.trx is set up)
|
|
||||||
// via window.loadEagerPlugins(). Dynamic scripts are effectively async, so
|
|
||||||
// loading them before app.js would cause map-core.js to crash when
|
|
||||||
// window.trx is not yet defined.
|
|
||||||
window.loadEagerPlugins = function() {
|
|
||||||
['digital-modes', 'map-data', 'bookmarks', 'settings'].forEach(loadPlugins);
|
|
||||||
};
|
|
||||||
// Load others on tab switch
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
var tab = e.target.closest('[data-tab]');
|
|
||||||
if (tab) loadPlugins(tab.dataset.tab);
|
|
||||||
});
|
|
||||||
window.loadPluginsForTab = loadPlugins;
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<!-- Template cloning is handled by navigateToTab() in app.js -->
|
|
||||||
<script defer src="/vendor/leaflet.js"></script>
|
<script defer src="/vendor/leaflet.js"></script>
|
||||||
|
<script type="module" src="/app.js"></script>
|
||||||
|
<!-- Template cloning is handled by the typed application bundle. -->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
(function() {
|
|
||||||
if (typeof L === "undefined") return;
|
|
||||||
|
|
||||||
function clamp(value, min, max) {
|
|
||||||
return Math.max(min, Math.min(max, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function finiteAngle(value) {
|
|
||||||
if (!Number.isFinite(value)) return null;
|
|
||||||
const normalized = ((Number(value) % 360) + 360) % 360;
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function svgColor(value, fallback) {
|
|
||||||
const text = String(value || fallback || "");
|
|
||||||
return text.replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSymbolHtml(options, zoom) {
|
|
||||||
const heading = finiteAngle(options.heading);
|
|
||||||
const course = finiteAngle(options.course);
|
|
||||||
const angle = heading != null ? heading : course;
|
|
||||||
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
|
|
||||||
const sizeBase = Number.isFinite(options.size) ? Number(options.size) : 22;
|
|
||||||
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
|
||||||
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
|
||||||
const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
|
|
||||||
const color = svgColor(options.color, "#ff7559");
|
|
||||||
const outline = svgColor(options.outline, "#6b2118");
|
|
||||||
|
|
||||||
const body = angle != null
|
|
||||||
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})">` +
|
|
||||||
`<path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />` +
|
|
||||||
`</g>`
|
|
||||||
: `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
|
|
||||||
|
|
||||||
const courseLine = course != null
|
|
||||||
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})">` +
|
|
||||||
`<line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" />` +
|
|
||||||
`</g>`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` +
|
|
||||||
courseLine +
|
|
||||||
body +
|
|
||||||
`</svg>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
L.TrxAisTrackSymbol = L.Marker.extend({
|
|
||||||
options: {
|
|
||||||
heading: null,
|
|
||||||
course: null,
|
|
||||||
speed: null,
|
|
||||||
color: "#ff7559",
|
|
||||||
outline: "#6b2118",
|
|
||||||
size: 22,
|
|
||||||
interactive: true,
|
|
||||||
keyboard: true,
|
|
||||||
riseOnHover: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
initialize: function(latlng, options) {
|
|
||||||
const merged = L.Util.extend({}, this.options, options || {});
|
|
||||||
merged.icon = L.divIcon({
|
|
||||||
className: "trx-ais-track-symbol-icon",
|
|
||||||
html: "",
|
|
||||||
iconSize: [merged.size, merged.size],
|
|
||||||
iconAnchor: [merged.size / 2, merged.size / 2],
|
|
||||||
});
|
|
||||||
L.Marker.prototype.initialize.call(this, latlng, merged);
|
|
||||||
},
|
|
||||||
|
|
||||||
onAdd: function(map) {
|
|
||||||
L.Marker.prototype.onAdd.call(this, map);
|
|
||||||
this._refreshIcon();
|
|
||||||
this._boundZoomRefresh = this._refreshIcon.bind(this);
|
|
||||||
map.on("zoomend", this._boundZoomRefresh);
|
|
||||||
},
|
|
||||||
|
|
||||||
onRemove: function(map) {
|
|
||||||
if (this._boundZoomRefresh) {
|
|
||||||
map.off("zoomend", this._boundZoomRefresh);
|
|
||||||
this._boundZoomRefresh = null;
|
|
||||||
}
|
|
||||||
L.Marker.prototype.onRemove.call(this, map);
|
|
||||||
},
|
|
||||||
|
|
||||||
setAisState: function(next) {
|
|
||||||
if (next && typeof next === "object") {
|
|
||||||
if ("heading" in next) this.options.heading = next.heading;
|
|
||||||
if ("course" in next) this.options.course = next.course;
|
|
||||||
if ("speed" in next) this.options.speed = next.speed;
|
|
||||||
if ("color" in next) this.options.color = next.color;
|
|
||||||
if ("outline" in next) this.options.outline = next.outline;
|
|
||||||
}
|
|
||||||
this._refreshIcon();
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
|
|
||||||
_refreshIcon: function() {
|
|
||||||
if (!this._icon) return;
|
|
||||||
const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
|
|
||||||
const html = buildSymbolHtml(this.options, zoom);
|
|
||||||
this._icon.innerHTML = html;
|
|
||||||
const sizeBase = Number.isFinite(this.options.size) ? Number(this.options.size) : 22;
|
|
||||||
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
|
||||||
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
|
||||||
this._icon.style.width = `${size}px`;
|
|
||||||
this._icon.style.height = `${size}px`;
|
|
||||||
this._icon.style.marginLeft = `${-size / 2}px`;
|
|
||||||
this._icon.style.marginTop = `${-size / 2}px`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
L.trxAisTrackSymbol = function(latlng, options) {
|
|
||||||
return new L.TrxAisTrackSymbol(latlng, options);
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
// --- FT2 Decoder Plugin (server-side decode) ---
|
|
||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
function ft8RenderMessageFt2(message) {
|
|
||||||
if (typeof renderFt8Message === "function") return renderFt8Message(message);
|
|
||||||
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ft2Status = document.getElementById("ft2-status");
|
|
||||||
const ft2PeriodEl = document.getElementById("ft2-period");
|
|
||||||
const ft2MessagesEl = document.getElementById("ft2-messages");
|
|
||||||
const ft2FilterInput = document.getElementById("ft2-filter");
|
|
||||||
const FT2_PERIOD_MS = 3750;
|
|
||||||
const FT2_MAX_DOM_ROWS = 200;
|
|
||||||
let ft2FilterText = "";
|
|
||||||
let ft2MessageHistory = [];
|
|
||||||
|
|
||||||
function currentFt2HistoryRetentionMs() {
|
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
|
||||||
? window.getDecodeHistoryRetentionMs()
|
|
||||||
: 24 * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneFt2MessageHistory() {
|
|
||||||
const cutoffMs = Date.now() - currentFt2HistoryRetentionMs();
|
|
||||||
ft2MessageHistory = ft2MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt2Ui(key, job) {
|
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
|
||||||
window.trxScheduleUiFrameJob(key, job);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
job();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt2HistoryRender() { scheduleFt2Ui("ft2-history", () => renderFt2History()); }
|
|
||||||
|
|
||||||
function normalizeFt2DisplayFreqHz(freqHz) {
|
|
||||||
const rawHz = Number(freqHz);
|
|
||||||
if (!Number.isFinite(rawHz)) return null;
|
|
||||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
|
||||||
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
|
||||||
return baseHz + rawHz;
|
|
||||||
}
|
|
||||||
return rawHz;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFt2PeriodTimer() {
|
|
||||||
if (!ft2PeriodEl) return;
|
|
||||||
const nowMs = Date.now();
|
|
||||||
const remaining = (FT2_PERIOD_MS - nowMs % FT2_PERIOD_MS) / 1000;
|
|
||||||
ft2PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFt2PeriodTimer();
|
|
||||||
setInterval(updateFt2PeriodTimer, 250);
|
|
||||||
|
|
||||||
function renderFt2Row(msg) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "ft8-row";
|
|
||||||
const rawMessage = (msg.message || "").toString();
|
|
||||||
row.dataset.message = rawMessage.toUpperCase();
|
|
||||||
row.dataset.decoder = "ft2";
|
|
||||||
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
|
||||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
|
||||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
|
||||||
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
|
||||||
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
|
||||||
const renderedMessage = ft8RenderMessageFt2(rawMessage);
|
|
||||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
|
||||||
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
|
|
||||||
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFt2History() {
|
|
||||||
pruneFt2MessageHistory();
|
|
||||||
if (!ft2MessagesEl) return;
|
|
||||||
const filter = ft2FilterText;
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
let rendered = 0;
|
|
||||||
for (let i = 0; i < ft2MessageHistory.length && rendered < FT2_MAX_DOM_ROWS; i++) {
|
|
||||||
const msg = ft2MessageHistory[i];
|
|
||||||
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
|
|
||||||
fragment.appendChild(renderFt2Row(msg));
|
|
||||||
rendered++;
|
|
||||||
}
|
|
||||||
ft2MessagesEl.replaceChildren(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFt2Message(msg) {
|
|
||||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
|
||||||
ft2MessageHistory.unshift(msg);
|
|
||||||
pruneFt2MessageHistory();
|
|
||||||
window.setFt8FamilyBarDecoder?.("ft2");
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
scheduleFt2HistoryRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeServerFt2Message(msg) {
|
|
||||||
const raw = (msg.message || "").toString();
|
|
||||||
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
|
|
||||||
const grids = locatorDetails.length > 0
|
|
||||||
? locatorDetails.map((d) => d.grid)
|
|
||||||
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
|
|
||||||
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
|
|
||||||
const rfHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
|
||||||
return {
|
|
||||||
raw, grids, station, rfHz, locatorDetails,
|
|
||||||
history: {
|
|
||||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
|
||||||
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
|
|
||||||
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
|
||||||
message: msg.message,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onServerFt2Batch = function(messages) {
|
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
||||||
if (ft2Status) ft2Status.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
const next = normalizeServerFt2Message(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
|
||||||
}
|
|
||||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
|
||||||
normalized.push(next.history);
|
|
||||||
}
|
|
||||||
normalized.reverse();
|
|
||||||
ft2MessageHistory = normalized.concat(ft2MessageHistory);
|
|
||||||
pruneFt2MessageHistory();
|
|
||||||
window.setFt8FamilyBarDecoder?.("ft2");
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
scheduleFt2HistoryRender();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.restoreFt2History = function(messages) { window.onServerFt2Batch(messages); };
|
|
||||||
window.pruneFt2HistoryView = function() { pruneFt2MessageHistory(); renderFt2History(); };
|
|
||||||
|
|
||||||
window.resetFt2HistoryView = function() {
|
|
||||||
if (ft2MessagesEl) ft2MessagesEl.innerHTML = "";
|
|
||||||
ft2MessageHistory = [];
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
renderFt2History();
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildFt2BarFrames() {
|
|
||||||
const cutoffMs = Date.now() - 15 * 60 * 1000;
|
|
||||||
const messages = ft2MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
|
|
||||||
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return { count: 0, newestTsMs: 0, html: "" };
|
|
||||||
}
|
|
||||||
let html = "";
|
|
||||||
for (const msg of messages) {
|
|
||||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
|
||||||
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
|
||||||
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
|
||||||
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
|
||||||
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
|
||||||
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
|
|
||||||
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
|
||||||
const text = ft8RenderMessageFt2((msg.message || "").toString());
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
|
||||||
}
|
|
||||||
return { count: messages.length, newestTsMs, html };
|
|
||||||
}
|
|
||||||
window.registerFt8FamilyBarRenderer?.("ft2", buildFt2BarFrames);
|
|
||||||
|
|
||||||
if (ft2FilterInput) {
|
|
||||||
ft2FilterInput.addEventListener("input", () => {
|
|
||||||
ft2FilterText = ft2FilterInput.value.trim().toUpperCase();
|
|
||||||
renderFt2History();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn");
|
|
||||||
ft2DecodeToggleBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await window.takeSchedulerControlForDecoderDisable?.(ft2DecodeToggleBtn);
|
|
||||||
await postPath("/toggle_ft2_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("FT2 toggle failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Clear all FT2 decode history? This cannot be undone.")) return;
|
|
||||||
try {
|
|
||||||
await postPath("/clear_ft2_decode");
|
|
||||||
window.resetFt2HistoryView();
|
|
||||||
} catch (e) { console.error("FT2 history clear failed", e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
window.onServerFt2 = function(msg) {
|
|
||||||
if (ft2Status) ft2Status.textContent = "Receiving";
|
|
||||||
const next = normalizeServerFt2Message(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
|
||||||
}
|
|
||||||
addFt2Message(next.history);
|
|
||||||
};
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
// --- FT4 Decoder Plugin (server-side decode) ---
|
|
||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
function ft8RenderMessage(message) {
|
|
||||||
if (typeof renderFt8Message === "function") return renderFt8Message(message);
|
|
||||||
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ft4Status = document.getElementById("ft4-status");
|
|
||||||
const ft4PeriodEl = document.getElementById("ft4-period");
|
|
||||||
const ft4MessagesEl = document.getElementById("ft4-messages");
|
|
||||||
const ft4FilterInput = document.getElementById("ft4-filter");
|
|
||||||
const FT4_PERIOD_MS = 7500;
|
|
||||||
const FT4_MAX_DOM_ROWS = 200;
|
|
||||||
let ft4FilterText = "";
|
|
||||||
let ft4MessageHistory = [];
|
|
||||||
|
|
||||||
function currentFt4HistoryRetentionMs() {
|
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
|
||||||
? window.getDecodeHistoryRetentionMs()
|
|
||||||
: 24 * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneFt4MessageHistory() {
|
|
||||||
const cutoffMs = Date.now() - currentFt4HistoryRetentionMs();
|
|
||||||
ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt4Ui(key, job) {
|
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
|
||||||
window.trxScheduleUiFrameJob(key, job);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
job();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt4HistoryRender() { scheduleFt4Ui("ft4-history", () => renderFt4History()); }
|
|
||||||
|
|
||||||
function normalizeFt4DisplayFreqHz(freqHz) {
|
|
||||||
const rawHz = Number(freqHz);
|
|
||||||
if (!Number.isFinite(rawHz)) return null;
|
|
||||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
|
||||||
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
|
||||||
return baseHz + rawHz;
|
|
||||||
}
|
|
||||||
return rawHz;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFt4PeriodTimer() {
|
|
||||||
if (!ft4PeriodEl) return;
|
|
||||||
const nowMs = Date.now();
|
|
||||||
const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1000;
|
|
||||||
ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFt4PeriodTimer();
|
|
||||||
setInterval(updateFt4PeriodTimer, 250);
|
|
||||||
|
|
||||||
function renderFt4Row(msg) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "ft8-row";
|
|
||||||
const rawMessage = (msg.message || "").toString();
|
|
||||||
row.dataset.message = rawMessage.toUpperCase();
|
|
||||||
row.dataset.decoder = "ft4";
|
|
||||||
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
|
||||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
|
||||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
|
||||||
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
|
||||||
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
|
||||||
const renderedMessage = ft8RenderMessage(rawMessage);
|
|
||||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
|
||||||
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
|
|
||||||
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFt4History() {
|
|
||||||
pruneFt4MessageHistory();
|
|
||||||
if (!ft4MessagesEl) return;
|
|
||||||
const filter = ft4FilterText;
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
let rendered = 0;
|
|
||||||
for (let i = 0; i < ft4MessageHistory.length && rendered < FT4_MAX_DOM_ROWS; i++) {
|
|
||||||
const msg = ft4MessageHistory[i];
|
|
||||||
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
|
|
||||||
fragment.appendChild(renderFt4Row(msg));
|
|
||||||
rendered++;
|
|
||||||
}
|
|
||||||
ft4MessagesEl.replaceChildren(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFt4Message(msg) {
|
|
||||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
|
||||||
ft4MessageHistory.unshift(msg);
|
|
||||||
pruneFt4MessageHistory();
|
|
||||||
window.setFt8FamilyBarDecoder?.("ft4");
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
scheduleFt4HistoryRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeServerFt4Message(msg) {
|
|
||||||
const raw = (msg.message || "").toString();
|
|
||||||
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
|
|
||||||
const grids = locatorDetails.length > 0
|
|
||||||
? locatorDetails.map((d) => d.grid)
|
|
||||||
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
|
|
||||||
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
|
|
||||||
const rfHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
|
||||||
return {
|
|
||||||
raw, grids, station, rfHz, locatorDetails,
|
|
||||||
history: {
|
|
||||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
|
||||||
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
|
|
||||||
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
|
||||||
message: msg.message,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onServerFt4Batch = function(messages) {
|
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
||||||
if (ft4Status) ft4Status.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
const next = normalizeServerFt4Message(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
|
||||||
}
|
|
||||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
|
||||||
normalized.push(next.history);
|
|
||||||
}
|
|
||||||
normalized.reverse();
|
|
||||||
ft4MessageHistory = normalized.concat(ft4MessageHistory);
|
|
||||||
pruneFt4MessageHistory();
|
|
||||||
window.setFt8FamilyBarDecoder?.("ft4");
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
scheduleFt4HistoryRender();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.restoreFt4History = function(messages) { window.onServerFt4Batch(messages); };
|
|
||||||
window.pruneFt4HistoryView = function() { pruneFt4MessageHistory(); renderFt4History(); };
|
|
||||||
|
|
||||||
window.resetFt4HistoryView = function() {
|
|
||||||
if (ft4MessagesEl) ft4MessagesEl.innerHTML = "";
|
|
||||||
ft4MessageHistory = [];
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
renderFt4History();
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildFt4BarFrames() {
|
|
||||||
const cutoffMs = Date.now() - 15 * 60 * 1000;
|
|
||||||
const messages = ft4MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
|
|
||||||
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return { count: 0, newestTsMs: 0, html: "" };
|
|
||||||
}
|
|
||||||
let html = "";
|
|
||||||
for (const msg of messages) {
|
|
||||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
|
||||||
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
|
||||||
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
|
||||||
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
|
||||||
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
|
||||||
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
|
|
||||||
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
|
||||||
const text = ft8RenderMessage((msg.message || "").toString());
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
|
||||||
}
|
|
||||||
return { count: messages.length, newestTsMs, html };
|
|
||||||
}
|
|
||||||
window.registerFt8FamilyBarRenderer?.("ft4", buildFt4BarFrames);
|
|
||||||
|
|
||||||
if (ft4FilterInput) {
|
|
||||||
ft4FilterInput.addEventListener("input", () => {
|
|
||||||
ft4FilterText = ft4FilterInput.value.trim().toUpperCase();
|
|
||||||
renderFt4History();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn");
|
|
||||||
ft4DecodeToggleBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await window.takeSchedulerControlForDecoderDisable?.(ft4DecodeToggleBtn);
|
|
||||||
await postPath("/toggle_ft4_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("FT4 toggle failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Clear all FT4 decode history? This cannot be undone.")) return;
|
|
||||||
try {
|
|
||||||
await postPath("/clear_ft4_decode");
|
|
||||||
window.resetFt4HistoryView();
|
|
||||||
} catch (e) { console.error("FT4 history clear failed", e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
window.onServerFt4 = function(msg) {
|
|
||||||
if (ft4Status) ft4Status.textContent = "Receiving";
|
|
||||||
const next = normalizeServerFt4Message(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
|
||||||
}
|
|
||||||
addFt4Message(next.history);
|
|
||||||
};
|
|
||||||
@@ -1,486 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
// --- FT8 Decoder Plugin (server-side decode) ---
|
|
||||||
const ft8Status = document.getElementById("ft8-status");
|
|
||||||
const ft8PeriodEl = document.getElementById("ft8-period");
|
|
||||||
const ft8MessagesEl = document.getElementById("ft8-messages");
|
|
||||||
const ft8FilterInput = document.getElementById("ft8-filter");
|
|
||||||
const ft8BarOverlay = document.getElementById("ft8-bar-overlay");
|
|
||||||
const FT8_BAR_WINDOW_MS = 15 * 60 * 1000;
|
|
||||||
const FT8_PERIOD_SECONDS = 15;
|
|
||||||
const FT8_MAX_DOM_ROWS = 200;
|
|
||||||
const FT8_BAR_DECODER_LABELS = {
|
|
||||||
ft8: "FT8",
|
|
||||||
ft4: "FT4",
|
|
||||||
ft2: "FT2",
|
|
||||||
};
|
|
||||||
let ft8FilterText = "";
|
|
||||||
let ft8MessageHistory = [];
|
|
||||||
let ft8BarActiveDecoder = "ft8";
|
|
||||||
const ft8BarBuilders = {};
|
|
||||||
const ft8BarDismissedAtMsByDecoder = {
|
|
||||||
ft8: 0,
|
|
||||||
ft4: 0,
|
|
||||||
ft2: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
function currentFt8HistoryRetentionMs() {
|
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
|
||||||
? window.getDecodeHistoryRetentionMs()
|
|
||||||
: 24 * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneFt8MessageHistory() {
|
|
||||||
const cutoffMs = Date.now() - currentFt8HistoryRetentionMs();
|
|
||||||
ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt8Ui(key, job) {
|
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
|
||||||
window.trxScheduleUiFrameJob(key, job);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
job();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt8HistoryRender() {
|
|
||||||
scheduleFt8Ui("ft8-history", () => renderFt8History());
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt8BarUpdate() {
|
|
||||||
scheduleFt8Ui("ft8-bar", () => updateFt8Bar());
|
|
||||||
}
|
|
||||||
|
|
||||||
window.registerFt8FamilyBarRenderer = function(decoder, builder) {
|
|
||||||
if (!FT8_BAR_DECODER_LABELS[decoder] || typeof builder !== "function") return;
|
|
||||||
ft8BarBuilders[decoder] = builder;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.setFt8FamilyBarDecoder = function(decoder) {
|
|
||||||
if (!FT8_BAR_DECODER_LABELS[decoder]) return;
|
|
||||||
ft8BarActiveDecoder = decoder;
|
|
||||||
scheduleFt8BarUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeFt8DisplayFreqHz(freqHz) {
|
|
||||||
const rawHz = Number(freqHz);
|
|
||||||
if (!Number.isFinite(rawHz)) return null;
|
|
||||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
|
||||||
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
|
||||||
return baseHz + rawHz;
|
|
||||||
}
|
|
||||||
return rawHz;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(tsMs) {
|
|
||||||
if (!tsMs) return "--:--:--";
|
|
||||||
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFt8PeriodTimer() {
|
|
||||||
if (!ft8PeriodEl) return;
|
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
|
||||||
const remaining = FT8_PERIOD_SECONDS - (nowSec % FT8_PERIOD_SECONDS);
|
|
||||||
ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFt8PeriodTimer();
|
|
||||||
setInterval(updateFt8PeriodTimer, 500);
|
|
||||||
|
|
||||||
function renderFt8Row(msg) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "ft8-row";
|
|
||||||
const rawMessage = (msg.message || "").toString();
|
|
||||||
row.dataset.message = rawMessage.toUpperCase();
|
|
||||||
row.dataset.decoder = "ft8";
|
|
||||||
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
|
||||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
|
||||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
|
||||||
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
|
||||||
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
|
||||||
const renderedMessage = renderFt8Message(rawMessage);
|
|
||||||
row.innerHTML = `<span class="ft8-time">${fmtTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
|
||||||
applyFt8FilterToRow(row);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFt8History() {
|
|
||||||
pruneFt8MessageHistory();
|
|
||||||
if (!ft8MessagesEl) return;
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
const limit = Math.min(ft8MessageHistory.length, FT8_MAX_DOM_ROWS);
|
|
||||||
for (let i = 0; i < limit; i += 1) {
|
|
||||||
fragment.appendChild(renderFt8Row(ft8MessageHistory[i]));
|
|
||||||
}
|
|
||||||
ft8MessagesEl.replaceChildren(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFt8Message(msg) {
|
|
||||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
|
||||||
ft8MessageHistory.unshift(msg);
|
|
||||||
pruneFt8MessageHistory();
|
|
||||||
ft8BarActiveDecoder = "ft8";
|
|
||||||
scheduleFt8BarUpdate();
|
|
||||||
scheduleFt8HistoryRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeServerFt8Message(msg) {
|
|
||||||
const raw = (msg.message || "").toString();
|
|
||||||
const locatorDetails = ft8ExtractLocatorDetails(raw);
|
|
||||||
const grids = locatorDetails.length > 0
|
|
||||||
? locatorDetails.map((detail) => detail.grid)
|
|
||||||
: ft8ExtractAllGrids(raw);
|
|
||||||
const station = ft8ExtractLikelyCallsign(raw);
|
|
||||||
const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
|
||||||
return {
|
|
||||||
raw,
|
|
||||||
grids,
|
|
||||||
station,
|
|
||||||
rfHz,
|
|
||||||
locatorDetails,
|
|
||||||
history: {
|
|
||||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
|
||||||
ts_ms: msg.ts_ms,
|
|
||||||
snr_db: msg.snr_db,
|
|
||||||
dt_s: msg.dt_s,
|
|
||||||
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
|
||||||
message: msg.message,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onServerFt8Batch = function(messages) {
|
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
||||||
ft8Status.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
const next = normalizeServerFt8Message(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
|
|
||||||
...msg,
|
|
||||||
freq_hz: next.rfHz,
|
|
||||||
locator_details: next.locatorDetails,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
|
||||||
normalized.push(next.history);
|
|
||||||
}
|
|
||||||
normalized.reverse();
|
|
||||||
ft8MessageHistory = normalized.concat(ft8MessageHistory);
|
|
||||||
pruneFt8MessageHistory();
|
|
||||||
ft8BarActiveDecoder = "ft8";
|
|
||||||
scheduleFt8BarUpdate();
|
|
||||||
scheduleFt8HistoryRender();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.restoreFt8History = function(messages) {
|
|
||||||
window.onServerFt8Batch(messages);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.pruneFt8HistoryView = function() {
|
|
||||||
pruneFt8MessageHistory();
|
|
||||||
updateFt8Bar();
|
|
||||||
renderFt8History();
|
|
||||||
};
|
|
||||||
|
|
||||||
function ft8BarRfText(msg) {
|
|
||||||
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
|
||||||
if (!Number.isFinite(displayFreqHz)) return null;
|
|
||||||
return `${displayFreqHz.toFixed(0)} Hz`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFt8BarFrames() {
|
|
||||||
const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS;
|
|
||||||
const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8);
|
|
||||||
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg.ts_ms) || 0), 0);
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return { count: 0, newestTsMs: 0, html: "" };
|
|
||||||
}
|
|
||||||
let html = "";
|
|
||||||
for (const msg of messages) {
|
|
||||||
const ts = msg.ts_ms ? `<span class="aprs-bar-time">${fmtTime(msg.ts_ms)}</span>` : "";
|
|
||||||
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
|
||||||
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
|
||||||
const rf = ft8BarRfText(msg);
|
|
||||||
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
|
||||||
const text = ft8EscapeHtml((msg.message || "").toString());
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
|
||||||
}
|
|
||||||
return { count: messages.length, newestTsMs, html };
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFt8Bar() {
|
|
||||||
if (!ft8BarOverlay) return;
|
|
||||||
const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase();
|
|
||||||
const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB";
|
|
||||||
const decoder = ft8BarActiveDecoder;
|
|
||||||
const builder = ft8BarBuilders[decoder];
|
|
||||||
const label = FT8_BAR_DECODER_LABELS[decoder] || "FT8";
|
|
||||||
const result = typeof builder === "function" ? builder() : null;
|
|
||||||
const newestTsMs = Number(result?.newestTsMs) || 0;
|
|
||||||
if (!isFt8Mode || !result || result.count === 0 || newestTsMs <= (ft8BarDismissedAtMsByDecoder[decoder] || 0)) {
|
|
||||||
ft8BarOverlay.style.display = "none";
|
|
||||||
ft8BarOverlay.innerHTML = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ft8BarOverlay.innerHTML = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">×</button></span></div>${result.html}`;
|
|
||||||
ft8BarOverlay.style.display = "flex";
|
|
||||||
}
|
|
||||||
window.updateFt8Bar = updateFt8Bar;
|
|
||||||
window.clearFt8Bar = function() {
|
|
||||||
const decoder = ft8BarActiveDecoder;
|
|
||||||
if (decoder === "ft4") {
|
|
||||||
window.resetFt4HistoryView?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (decoder === "ft2") {
|
|
||||||
window.resetFt2HistoryView?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.resetFt8HistoryView?.();
|
|
||||||
};
|
|
||||||
window.closeFt8Bar = function() {
|
|
||||||
ft8BarDismissedAtMsByDecoder[ft8BarActiveDecoder] = Date.now();
|
|
||||||
if (ft8BarOverlay) {
|
|
||||||
ft8BarOverlay.style.display = "none";
|
|
||||||
ft8BarOverlay.innerHTML = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.registerFt8FamilyBarRenderer("ft8", buildFt8BarFrames);
|
|
||||||
|
|
||||||
function renderFt8Message(message) {
|
|
||||||
let out = "";
|
|
||||||
let i = 0;
|
|
||||||
while (i < message.length) {
|
|
||||||
const ch = message[i];
|
|
||||||
if (ft8IsAlphaNum(ch)) {
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
|
||||||
const token = message.slice(i, j);
|
|
||||||
const grid = token.toUpperCase();
|
|
||||||
if (ft8IsMaidenheadGridToken(grid)) {
|
|
||||||
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
|
||||||
} else {
|
|
||||||
out += ft8EscapeHtml(token);
|
|
||||||
}
|
|
||||||
i = j;
|
|
||||||
} else {
|
|
||||||
out += ft8EscapeHtml(ch);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8TokenizeMessage(message) {
|
|
||||||
return String(message || "")
|
|
||||||
.toUpperCase()
|
|
||||||
.split(/[^A-Z0-9/]+/)
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8ExtractAllGrids(message) {
|
|
||||||
const out = [];
|
|
||||||
const seen = new Set();
|
|
||||||
let i = 0;
|
|
||||||
while (i < message.length) {
|
|
||||||
if (ft8IsAlphaNum(message[i])) {
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
|
||||||
const token = message.slice(i, j);
|
|
||||||
const grid = token.toUpperCase();
|
|
||||||
if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) {
|
|
||||||
seen.add(grid);
|
|
||||||
out.push(grid);
|
|
||||||
}
|
|
||||||
i = j;
|
|
||||||
} else {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8ExtractLocatorDetails(message) {
|
|
||||||
const tokens = ft8TokenizeMessage(message);
|
|
||||||
const grids = ft8ExtractAllGrids(String(message || ""));
|
|
||||||
if (tokens.length === 0 || grids.length === 0) return [];
|
|
||||||
const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token));
|
|
||||||
const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length;
|
|
||||||
const callsigns = [];
|
|
||||||
for (let i = 0; i < limit; i += 1) {
|
|
||||||
if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let source = null;
|
|
||||||
let target = null;
|
|
||||||
const head = tokens[0];
|
|
||||||
if (callsigns.length > 0) {
|
|
||||||
if (head === "CQ" || head === "DE" || head === "QRZ") {
|
|
||||||
source = callsigns[0];
|
|
||||||
} else if (callsigns.length >= 2) {
|
|
||||||
target = callsigns[0];
|
|
||||||
source = callsigns[1];
|
|
||||||
} else {
|
|
||||||
source = callsigns[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return grids.map((grid) => ({
|
|
||||||
grid,
|
|
||||||
station: source || null,
|
|
||||||
source: source || null,
|
|
||||||
target: target || null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8ExtractLikelyCallsign(message) {
|
|
||||||
const locatorDetails = ft8ExtractLocatorDetails(message);
|
|
||||||
if (locatorDetails.length > 0 && locatorDetails[0].station) {
|
|
||||||
return locatorDetails[0].station;
|
|
||||||
}
|
|
||||||
const tokens = ft8TokenizeMessage(message);
|
|
||||||
for (const token of tokens) {
|
|
||||||
if (ft8IsLikelyCallsignToken(token)) return token;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8IsLikelyCallsignToken(token) {
|
|
||||||
if (!token) return false;
|
|
||||||
if (token.length < 3 || token.length > 12) return false;
|
|
||||||
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false;
|
|
||||||
if (ft8IsMaidenheadGridToken(token)) return false;
|
|
||||||
return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8IsFarewellToken(token) {
|
|
||||||
const normalized = String(token || "").trim().toUpperCase();
|
|
||||||
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8IsMaidenheadGridToken(token) {
|
|
||||||
const normalized = String(token || "").trim().toUpperCase();
|
|
||||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8EscapeHtml(input) {
|
|
||||||
return input
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll("\"", """);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8IsAlphaNum(ch) {
|
|
||||||
return /[A-Za-z0-9]/.test(ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateFt8HistoryLocator(targetEl) {
|
|
||||||
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
|
|
||||||
if (!locatorEl) return false;
|
|
||||||
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
|
|
||||||
if (!grid) return false;
|
|
||||||
if (typeof window.navigateToMapLocator === "function") {
|
|
||||||
window.navigateToMapLocator(grid, "ft8");
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFt8FilterToRow(row) {
|
|
||||||
if (!ft8FilterText) {
|
|
||||||
row.style.display = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const message = row.dataset.message || "";
|
|
||||||
row.style.display = message.includes(ft8FilterText) ? "" : "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFt8FilterToAll() {
|
|
||||||
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
|
|
||||||
rows.forEach((row) => applyFt8FilterToRow(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFt8RowRf(row) {
|
|
||||||
const freqEl = row.querySelector(".ft8-freq");
|
|
||||||
if (!freqEl) return;
|
|
||||||
const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN;
|
|
||||||
const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz);
|
|
||||||
if (Number.isFinite(displayFreqHz)) {
|
|
||||||
freqEl.textContent = displayFreqHz.toFixed(0);
|
|
||||||
} else {
|
|
||||||
freqEl.textContent = "--";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.updateFt8RfDisplay = function() {
|
|
||||||
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
|
|
||||||
rows.forEach((row) => updateFt8RowRf(row));
|
|
||||||
updateFt8Bar();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.resetFt8HistoryView = function() {
|
|
||||||
ft8MessagesEl.innerHTML = "";
|
|
||||||
ft8MessageHistory = [];
|
|
||||||
updateFt8Bar();
|
|
||||||
renderFt8History();
|
|
||||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (ft8FilterInput) {
|
|
||||||
ft8FilterInput.addEventListener("input", () => {
|
|
||||||
ft8FilterText = ft8FilterInput.value.trim().toUpperCase();
|
|
||||||
renderFt8History();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ft8MessagesEl) {
|
|
||||||
ft8MessagesEl.addEventListener("click", (event) => {
|
|
||||||
if (!activateFt8HistoryLocator(event.target)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
});
|
|
||||||
ft8MessagesEl.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
|
||||||
if (!activateFt8HistoryLocator(event.target)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn");
|
|
||||||
ft8DecodeToggleBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn);
|
|
||||||
await postPath("/toggle_ft8_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("FT8 toggle failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Clear all FT8 decode history? This cannot be undone.")) return;
|
|
||||||
try {
|
|
||||||
await postPath("/clear_ft8_decode");
|
|
||||||
window.resetFt8HistoryView();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("FT8 history clear failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Server-side FT8 decode handler ---
|
|
||||||
window.onServerFt8 = function(msg) {
|
|
||||||
ft8Status.textContent = "Receiving";
|
|
||||||
const next = normalizeServerFt8Message(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
|
|
||||||
...msg,
|
|
||||||
freq_hz: next.rfHz,
|
|
||||||
locator_details: next.locatorDetails,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
addFt8Message(next.history);
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import vm from "node:vm";
|
||||||
|
|
||||||
|
class ClassList {
|
||||||
|
constructor() { this.values = new Set(); }
|
||||||
|
add(...names) { names.forEach(name => this.values.add(name)); }
|
||||||
|
remove(...names) { names.forEach(name => this.values.delete(name)); }
|
||||||
|
contains(name) { return this.values.has(name); }
|
||||||
|
toggle(name, force) {
|
||||||
|
const enabled = force === undefined ? !this.contains(name) : force;
|
||||||
|
if (enabled) this.add(name); else this.remove(name);
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Element {
|
||||||
|
constructor(tagName, document) {
|
||||||
|
this.tagName = tagName.toUpperCase();
|
||||||
|
this.ownerDocument = document;
|
||||||
|
this.children = [];
|
||||||
|
this.dataset = {};
|
||||||
|
this.attributes = new Map();
|
||||||
|
this.classList = new ClassList();
|
||||||
|
this.listeners = new Map();
|
||||||
|
this.style = {};
|
||||||
|
this.options = [];
|
||||||
|
this.value = "";
|
||||||
|
this.textContent = "";
|
||||||
|
}
|
||||||
|
set id(value) { this._id = value; if (value) this.ownerDocument.elements.set(value, this); }
|
||||||
|
get id() { return this._id || ""; }
|
||||||
|
set className(value) { this.classList = new ClassList(); value.split(/\s+/).filter(Boolean).forEach(name => this.classList.add(name)); }
|
||||||
|
set innerHTML(value) {
|
||||||
|
this._innerHTML = value;
|
||||||
|
if (value.includes('value="confirm"')) {
|
||||||
|
const title = new Element("h2", this.ownerDocument); title.id = "ui-confirm-title";
|
||||||
|
const message = new Element("p", this.ownerDocument); message.id = "ui-confirm-message";
|
||||||
|
const confirm = new Element("button", this.ownerDocument); confirm.value = "confirm";
|
||||||
|
this.append(title, message, confirm);
|
||||||
|
this._confirmButton = confirm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get innerHTML() { return this._innerHTML || ""; }
|
||||||
|
appendChild(child) { this.children.push(child); child.parentElement = this; return child; }
|
||||||
|
append(...children) { children.forEach(child => this.appendChild(child)); }
|
||||||
|
insertBefore(child) { return this.appendChild(child); }
|
||||||
|
remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter(child => child !== this); }
|
||||||
|
replaceChildren(...children) { this.children = []; this.options = []; this.append(...children); }
|
||||||
|
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||||
|
getAttribute(name) { return this.attributes.get(name); }
|
||||||
|
addEventListener(type, listener) { this.listeners.set(type, listener); }
|
||||||
|
dispatch(type, event = {}) { this.listeners.get(type)?.({ target: this, preventDefault() {}, ...event }); }
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === '[value="confirm"]') return this._confirmButton || null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
if (selector === ".tab[data-tab]") return this.children.filter(child => child.dataset.tab);
|
||||||
|
if (selector === ".sub-tab[data-subtab]") return this.children.filter(child => child.dataset.subtab);
|
||||||
|
if (selector === '[role="tab"]') return this.children.filter(child => child.getAttribute("role") === "tab");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
add(option) { this.options.push(option); if (!this.value) this.value = option.value; }
|
||||||
|
showModal() { this.open = true; }
|
||||||
|
close(value) { this.returnValue = value; this.open = false; this.dispatch("close"); }
|
||||||
|
focus() { this.focused = true; }
|
||||||
|
click() { this.clicked = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class DocumentFixture {
|
||||||
|
constructor() {
|
||||||
|
this.readyState = "loading";
|
||||||
|
this.elements = new Map();
|
||||||
|
this.body = new Element("body", this);
|
||||||
|
}
|
||||||
|
createElement(tagName) { return new Element(tagName, this); }
|
||||||
|
getElementById(id) { return this.elements.get(id) || null; }
|
||||||
|
querySelector() { return null; }
|
||||||
|
querySelectorAll() { return []; }
|
||||||
|
addEventListener() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = new DocumentFixture();
|
||||||
|
const storage = new Map();
|
||||||
|
const localStorage = {
|
||||||
|
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
|
||||||
|
setItem(key, value) { storage.set(key, String(value)); },
|
||||||
|
};
|
||||||
|
const window = { document, localStorage, addEventListener() {} };
|
||||||
|
const context = vm.createContext({
|
||||||
|
window, document, localStorage,
|
||||||
|
Option: class Option { constructor(label, value) { this.label = label; this.value = value; } },
|
||||||
|
MutationObserver: class MutationObserver { observe() {} },
|
||||||
|
requestAnimationFrame(callback) { callback(); },
|
||||||
|
setTimeout() { return 1; },
|
||||||
|
clearTimeout() {},
|
||||||
|
console,
|
||||||
|
});
|
||||||
|
|
||||||
|
const source = await readFile(new URL("../ui-core.js", import.meta.url), "utf8");
|
||||||
|
new vm.Script(source, { filename: "ui-core.js" }).runInContext(context);
|
||||||
|
const ui = window.trxUi;
|
||||||
|
|
||||||
|
const toast = ui.notify("Saved", { kind: "success" });
|
||||||
|
assert.equal(toast.getAttribute("role"), "status");
|
||||||
|
assert.equal(toast.classList.contains("toast-success"), true);
|
||||||
|
assert.equal(document.getElementById("toast-region").children.length, 1);
|
||||||
|
|
||||||
|
const confirmation = ui.confirm({ title: "Delete?", message: "Permanent", confirmLabel: "Delete" });
|
||||||
|
const dialog = document.getElementById("ui-confirm-dialog");
|
||||||
|
assert.equal(dialog.open, true);
|
||||||
|
assert.equal(document.getElementById("ui-confirm-title").textContent, "Delete?");
|
||||||
|
dialog.close("confirm");
|
||||||
|
assert.equal(await confirmation, true);
|
||||||
|
|
||||||
|
const tabBar = new Element("div", document);
|
||||||
|
const firstTab = new Element("button", document); firstTab.dataset.tab = "main"; firstTab.classList.add("active");
|
||||||
|
const secondTab = new Element("button", document); secondTab.dataset.tab = "map";
|
||||||
|
tabBar.append(firstTab, secondTab);
|
||||||
|
const mainPanel = new Element("section", document); mainPanel.id = "tab-main";
|
||||||
|
const mapPanel = new Element("section", document); mapPanel.id = "tab-map";
|
||||||
|
ui.prepareTabList(tabBar, "primary");
|
||||||
|
assert.equal(firstTab.getAttribute("aria-selected"), "true");
|
||||||
|
tabBar.dispatch("keydown", { target: firstTab, key: "ArrowRight" });
|
||||||
|
assert.equal(secondTab.focused, true);
|
||||||
|
assert.equal(secondTab.clicked, true);
|
||||||
|
|
||||||
|
localStorage.setItem("trxOperatorLayout:rig-a", "broadcast");
|
||||||
|
ui.setActiveRig("rig-a");
|
||||||
|
assert.equal(document.body.dataset.operatorLayout, "compact");
|
||||||
|
ui.setLayoutCapabilities({ broadcast: true });
|
||||||
|
ui.setActiveRig("rig-a");
|
||||||
|
assert.equal(document.body.dataset.operatorLayout, "broadcast");
|
||||||
|
ui.setActiveRig("rig-b");
|
||||||
|
ui.applyLayout("digital");
|
||||||
|
assert.equal(document.body.dataset.operatorLayout, "compact");
|
||||||
|
assert.equal(localStorage.getItem("trxOperatorLayout:rig-b"), "compact");
|
||||||
|
|
||||||
|
console.log("ui-core component tests passed");
|
||||||
+232
File diff suppressed because one or more lines are too long
@@ -2,8 +2,53 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn generate_asset_manifest() {
|
||||||
|
let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir"));
|
||||||
|
let generated_dir = manifest_dir.join("assets/web/generated");
|
||||||
|
println!("cargo:rerun-if-changed={}", generated_dir.display());
|
||||||
|
|
||||||
|
let mut filenames = fs::read_dir(&generated_dir)
|
||||||
|
.expect("read generated frontend assets")
|
||||||
|
.map(|entry| entry.expect("read generated asset entry"))
|
||||||
|
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
|
||||||
|
.map(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.into_string()
|
||||||
|
.expect("UTF-8 asset filename")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
filenames.sort();
|
||||||
|
|
||||||
|
let mut output = String::from(
|
||||||
|
"// Generated by build.rs from committed frontend output.\n\
|
||||||
|
pub const GENERATED_ASSETS: &[(&str, &[u8])] = &[\n",
|
||||||
|
);
|
||||||
|
for filename in filenames {
|
||||||
|
assert!(
|
||||||
|
filename
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)),
|
||||||
|
"unsupported generated asset filename: {filename}"
|
||||||
|
);
|
||||||
|
writeln!(
|
||||||
|
output,
|
||||||
|
" ({filename:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/assets/web/generated/\", {filename:?}))),"
|
||||||
|
)
|
||||||
|
.expect("write asset manifest");
|
||||||
|
}
|
||||||
|
output.push_str("];\n");
|
||||||
|
|
||||||
|
let output_path =
|
||||||
|
PathBuf::from(std::env::var_os("OUT_DIR").expect("out dir")).join("generated_assets.rs");
|
||||||
|
fs::write(output_path, output).expect("write generated asset manifest");
|
||||||
|
}
|
||||||
|
|
||||||
fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) {
|
fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) {
|
||||||
let days = secs.div_euclid(86_400);
|
let days = secs.div_euclid(86_400);
|
||||||
let z = days + 719_468;
|
let z = days + 719_468;
|
||||||
@@ -20,6 +65,7 @@ fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
generate_asset_manifest();
|
||||||
let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||||
Ok(d) => d.as_secs() as i64,
|
Ok(d) => d.as_secs() as i64,
|
||||||
Err(_) => 0,
|
Err(_) => 0,
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use trx_core::radio::freq::{Band, Freq};
|
||||||
|
use trx_core::rig::state::{SpectrumData, VchanRdsEntry};
|
||||||
|
use trx_core::rig::{
|
||||||
|
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
|
||||||
|
RigVfoEntry,
|
||||||
|
};
|
||||||
|
use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel};
|
||||||
|
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
|
||||||
|
use trx_frontend_http::server::api::FrontendMeta;
|
||||||
|
use trx_protocol::{DecoderActivation, DecoderDescriptor};
|
||||||
|
use ts_rs::{Config, TS};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut output = format!(
|
||||||
|
"// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>\n\
|
||||||
|
//\n\
|
||||||
|
// SPDX-License-{}: GPL-2.0-or-later\n\n\
|
||||||
|
// Generated by `cargo run -p trx-frontend-http --example generate_typescript`.\n\
|
||||||
|
// Do not edit manually.\n\n",
|
||||||
|
"Identifier",
|
||||||
|
);
|
||||||
|
let config = Config::default();
|
||||||
|
|
||||||
|
macro_rules! export {
|
||||||
|
($type:ty) => {
|
||||||
|
writeln!(output, "export {}\n", <$type as TS>::decl(&config))?;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export!(Band);
|
||||||
|
export!(Freq);
|
||||||
|
export!(RigMode);
|
||||||
|
export!(RigAccessMethod);
|
||||||
|
export!(RigCapabilities);
|
||||||
|
export!(RigInfo);
|
||||||
|
export!(RigVfoEntry);
|
||||||
|
export!(RigVfo);
|
||||||
|
export!(RigTxStatus);
|
||||||
|
export!(RigRxStatus);
|
||||||
|
export!(RigStatus);
|
||||||
|
export!(DecoderConfig);
|
||||||
|
export!(WfmDenoiseLevel);
|
||||||
|
export!(RigFilterState);
|
||||||
|
export!(RdsData);
|
||||||
|
export!(SpectrumData);
|
||||||
|
export!(VchanRdsEntry);
|
||||||
|
export!(RigSnapshot);
|
||||||
|
export!(RigListItem);
|
||||||
|
export!(RigListResponse);
|
||||||
|
export!(FrontendMeta);
|
||||||
|
export!(DecoderActivation);
|
||||||
|
export!(DecoderDescriptor);
|
||||||
|
|
||||||
|
let output_path =
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("frontend/src/api/generated.ts");
|
||||||
|
fs::create_dir_all(output_path.parent().expect("generated file has a parent"))?;
|
||||||
|
fs::write(output_path, output)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import { build } from "esbuild";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const frontendDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sourceDir = path.join(frontendDir, "src");
|
||||||
|
const outputDir = path.join(frontendDir, "..", "assets", "web", "generated");
|
||||||
|
|
||||||
|
await rm(outputDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: {
|
||||||
|
app: path.join(sourceDir, "bootstrap.ts"),
|
||||||
|
ft2: path.join(sourceDir, "plugins", "ft2.ts"),
|
||||||
|
ft4: path.join(sourceDir, "plugins", "ft4.ts"),
|
||||||
|
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
|
||||||
|
cw: path.join(sourceDir, "plugins", "cw.ts"),
|
||||||
|
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
|
||||||
|
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
||||||
|
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
||||||
|
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
||||||
|
ais: path.join(sourceDir, "plugins", "ais.ts"),
|
||||||
|
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
|
||||||
|
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
||||||
|
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
||||||
|
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
|
||||||
|
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
||||||
|
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
||||||
|
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
||||||
|
"map-core": path.join(sourceDir, "map-core.ts"),
|
||||||
|
screenshot: path.join(sourceDir, "screenshot.ts"),
|
||||||
|
},
|
||||||
|
outdir: outputDir,
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
splitting: true,
|
||||||
|
entryNames: "[name]",
|
||||||
|
chunkNames: "chunk-[hash]",
|
||||||
|
platform: "browser",
|
||||||
|
target: "es2022",
|
||||||
|
sourcemap: false,
|
||||||
|
legalComments: "inline",
|
||||||
|
charset: "utf8",
|
||||||
|
logLevel: "info",
|
||||||
|
});
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: [path.join(sourceDir, "decode-history-worker.ts")],
|
||||||
|
outfile: path.join(outputDir, "decode-history-worker.js"),
|
||||||
|
bundle: true,
|
||||||
|
format: "iife",
|
||||||
|
platform: "browser",
|
||||||
|
target: "es2022",
|
||||||
|
sourcemap: false,
|
||||||
|
legalComments: "inline",
|
||||||
|
charset: "utf8",
|
||||||
|
logLevel: "info",
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import eslint from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"../assets/web/generated/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.mjs"],
|
||||||
|
...eslint.configs.recommended,
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...tseslint.configs.recommendedTypeChecked.map((config) => ({
|
||||||
|
...config,
|
||||||
|
files: ["src/**/*.ts", "tests/**/*.ts"],
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
files: ["src/**/*.ts", "tests/**/*.ts"],
|
||||||
|
ignores: ["src/decode-history-worker.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-undef": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", {
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
caughtErrors: "none",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
}],
|
||||||
|
// DOM event targets intentionally ignore listener return values. Async
|
||||||
|
// listeners handle their own failures; keep the rule for all other
|
||||||
|
// promise misuse sites.
|
||||||
|
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
|
||||||
|
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["src/decode-history-worker.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.worker,
|
||||||
|
parserOptions: {
|
||||||
|
project: "./tsconfig.worker.json",
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-undef": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "trx-frontend-http-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"generate-types": "cargo run -p trx-frontend-http --example generate_typescript",
|
||||||
|
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||||
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
|
"test:browser": "node tests/browser-smoke.mjs",
|
||||||
|
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "9.39.2",
|
||||||
|
"@types/leaflet": "1.9.22",
|
||||||
|
"esbuild": "0.25.12",
|
||||||
|
"eslint": "9.39.2",
|
||||||
|
"globals": "16.5.0",
|
||||||
|
"playwright-core": "1.62.1",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"typescript-eslint": "8.51.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Frontend source
|
||||||
|
|
||||||
|
This directory contains the strict TypeScript source for the browser assets
|
||||||
|
embedded by `trx-frontend-http`. `bootstrap.ts` is the production entry point;
|
||||||
|
esbuild follows its imports and emits `/app.js`. Feature plugins are ESM entry
|
||||||
|
points loaded on demand by `plugin-loader.ts`, and the decode-history worker is
|
||||||
|
built with its own Web Worker TypeScript configuration.
|
||||||
|
|
||||||
|
Run all frontend commands from the parent `frontend` directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run test:browser
|
||||||
|
npm run build
|
||||||
|
npm run verify-generated
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run verify-generated` regenerates Rust wire contracts and browser bundles,
|
||||||
|
then rejects drift from the committed files. The browser smoke test needs a
|
||||||
|
local Chromium-family executable; set `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`
|
||||||
|
when it is not installed at `/usr/bin/chromium`.
|
||||||
|
|
||||||
|
Cargo consumes committed output under `../assets/web/generated`. It never
|
||||||
|
invokes Node.js, installs packages, or accesses the network. After changing a
|
||||||
|
source file, commit the deterministic generated asset changes together with the
|
||||||
|
source change.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- `api/generated.ts` is generated from Rust and must not be edited manually.
|
||||||
|
- `api/client.ts` owns runtime validation at version-sensitive HTTP and SSE
|
||||||
|
boundaries.
|
||||||
|
- `core/` contains dependency-light shared helpers.
|
||||||
|
- `features/` contains application behavior grouped by responsibility.
|
||||||
|
- `plugins/` contains lazy decoder and high-coupling feature entries.
|
||||||
|
- `plugins/host.ts` declares the typed `window.trx` state and services that
|
||||||
|
feature bundles read; feature entries import it rather than reaching for bare
|
||||||
|
`window` properties, which the module graph does not publish.
|
||||||
|
- Files under `assets/web/vendor/` are vendored JavaScript and are not part of
|
||||||
|
the TypeScript migration.
|
||||||
|
|
||||||
|
First-party features communicate through imports, the typed plugin runtime, or
|
||||||
|
the documented `window.trx` host interface. New standalone `window` callbacks
|
||||||
|
are not permitted.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
export type AuthRole = "rx" | "control";
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
authenticated: boolean;
|
||||||
|
role?: AuthRole;
|
||||||
|
auth_disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeAuthSession(value: unknown): AuthSession {
|
||||||
|
if (typeof value !== "object" || value === null) {
|
||||||
|
throw new TypeError("The authentication response is malformed");
|
||||||
|
}
|
||||||
|
const session = value as Record<string, unknown>;
|
||||||
|
if (typeof session.authenticated !== "boolean") {
|
||||||
|
throw new TypeError("The authentication response has no authenticated flag");
|
||||||
|
}
|
||||||
|
if (session.role !== undefined && session.role !== "rx" && session.role !== "control") {
|
||||||
|
throw new TypeError("The authentication response has an invalid role");
|
||||||
|
}
|
||||||
|
if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
|
||||||
|
throw new TypeError("The authentication response has an invalid auth_disabled flag");
|
||||||
|
}
|
||||||
|
const decoded: AuthSession = { authenticated: session.authenticated };
|
||||||
|
if (session.role !== undefined) decoded.role = session.role;
|
||||||
|
if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled;
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDisabledSession: AuthSession = {
|
||||||
|
authenticated: true,
|
||||||
|
role: "control",
|
||||||
|
auth_disabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchAuthSession(): Promise<AuthSession> {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/auth/session");
|
||||||
|
if (response.status === 404) return authDisabledSession;
|
||||||
|
if (!response.ok) return { authenticated: false };
|
||||||
|
return decodeAuthSession(await response.json());
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("Auth check failed:", error);
|
||||||
|
return { authenticated: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(passphrase: string): Promise<AuthSession> {
|
||||||
|
const response = await fetch("/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ passphrase }),
|
||||||
|
});
|
||||||
|
if (response.status === 404) return authDisabledSession;
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text();
|
||||||
|
throw new Error(message || "Login failed");
|
||||||
|
}
|
||||||
|
return decodeAuthSession(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
const response = await fetch("/auth/logout", { method: "POST" });
|
||||||
|
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DecoderDescriptor,
|
||||||
|
RigListResponse,
|
||||||
|
RigSnapshot,
|
||||||
|
} from "./generated";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
public constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Validator<T> = (value: unknown) => value is T;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRigSnapshot(value: unknown): value is RigSnapshot {
|
||||||
|
if (!isRecord(value) || !isRecord(value.info) || !isRecord(value.status)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { info, status } = value;
|
||||||
|
return (
|
||||||
|
typeof value.initialized === "boolean" &&
|
||||||
|
typeof info.manufacturer === "string" &&
|
||||||
|
typeof info.model === "string" &&
|
||||||
|
isRecord(status.freq) &&
|
||||||
|
typeof status.freq.hz === "number" &&
|
||||||
|
(typeof status.mode === "string" || isRecord(status.mode)) &&
|
||||||
|
typeof status.tx_en === "boolean"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRigListResponse(value: unknown): value is RigListResponse {
|
||||||
|
return (
|
||||||
|
isRecord(value) &&
|
||||||
|
(value.active_remote === null || typeof value.active_remote === "string") &&
|
||||||
|
Array.isArray(value.rigs) &&
|
||||||
|
value.rigs.every(
|
||||||
|
(rig) =>
|
||||||
|
isRecord(rig) &&
|
||||||
|
typeof rig.remote === "string" &&
|
||||||
|
typeof rig.manufacturer === "string" &&
|
||||||
|
typeof rig.model === "string" &&
|
||||||
|
Array.isArray(rig.supported_modes) &&
|
||||||
|
typeof rig.tx === "boolean" &&
|
||||||
|
typeof rig.filter_controls === "boolean" &&
|
||||||
|
typeof rig.initialized === "boolean",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDecoderRegistry(value: unknown): value is DecoderDescriptor[] {
|
||||||
|
return (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.every(
|
||||||
|
(decoder) =>
|
||||||
|
isRecord(decoder) &&
|
||||||
|
typeof decoder.id === "string" &&
|
||||||
|
typeof decoder.label === "string" &&
|
||||||
|
(decoder.activation === "mode_bound" || decoder.activation === "toggle") &&
|
||||||
|
Array.isArray(decoder.active_modes) &&
|
||||||
|
decoder.active_modes.every((mode) => typeof mode === "string") &&
|
||||||
|
typeof decoder.background_decode === "boolean" &&
|
||||||
|
typeof decoder.bookmark_selectable === "boolean",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TrxApi {
|
||||||
|
public constructor(private readonly baseUrl = "") {}
|
||||||
|
|
||||||
|
public async get<T>(path: string, validate: Validator<T>): Promise<T> {
|
||||||
|
return this.request(path, { cache: "no-store" }, validate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async post<T>(
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
validate: Validator<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return this.request(
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
validate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit,
|
||||||
|
validate: Validator<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(`${this.baseUrl}${path}`, init);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text();
|
||||||
|
throw new ApiError(response.status, detail || response.statusText);
|
||||||
|
}
|
||||||
|
const value: unknown = await response.json();
|
||||||
|
if (!validate(value)) {
|
||||||
|
throw new ApiError(response.status, `Malformed response from ${path}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeServerEvent<T>(
|
||||||
|
event: MessageEvent<string>,
|
||||||
|
validate: Validator<T>,
|
||||||
|
): T {
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(event.data) as unknown;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new TypeError("Server event is not valid JSON", { cause: error });
|
||||||
|
}
|
||||||
|
if (!validate(value)) {
|
||||||
|
throw new TypeError("Server event has an unexpected shape");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// Generated by `cargo run -p trx-frontend-http --example generate_typescript`.
|
||||||
|
// Do not edit manually.
|
||||||
|
|
||||||
|
export type Band = { low_hz: number, high_hz: number, tx_allowed: boolean, };
|
||||||
|
|
||||||
|
export type Freq = { hz: number, };
|
||||||
|
|
||||||
|
export type RigMode = "LSB" | "USB" | "CW" | "CWR" | "AM" | "SAM" | "WFM" | "FM" | "AIS" | "VDES" | "DIG" | "PKT" | { "Other": string };
|
||||||
|
|
||||||
|
export type RigAccessMethod = { "Serial": { path: string, baud: number, } } | { "Tcp": { addr: string, } };
|
||||||
|
|
||||||
|
export type RigCapabilities = { min_freq_step_hz: number, supported_bands: Array<Band>, supported_modes: Array<RigMode>, num_vfos: number, lock: boolean, lockable: boolean, attenuator: boolean, preamp: boolean, rit: boolean, rpt: boolean, split: boolean,
|
||||||
|
/**
|
||||||
|
* Backend supports transmit: PTT, power on/off, TX meters, TX audio.
|
||||||
|
*/
|
||||||
|
tx: boolean,
|
||||||
|
/**
|
||||||
|
* Backend supports get_tx_limit / set_tx_limit.
|
||||||
|
*/
|
||||||
|
tx_limit: boolean,
|
||||||
|
/**
|
||||||
|
* Backend supports toggle_vfo.
|
||||||
|
*/
|
||||||
|
vfo_switch: boolean,
|
||||||
|
/**
|
||||||
|
* Backend supports runtime filter adjustment (bandwidth).
|
||||||
|
*/
|
||||||
|
filter_controls: boolean,
|
||||||
|
/**
|
||||||
|
* Backend returns a meaningful RX signal strength value.
|
||||||
|
*/
|
||||||
|
signal_meter: boolean, };
|
||||||
|
|
||||||
|
export type RigInfo = { manufacturer: string, model: string, revision: string, capabilities: RigCapabilities, access: RigAccessMethod, };
|
||||||
|
|
||||||
|
export type RigVfoEntry = { name: string, freq: Freq, mode: RigMode | null, };
|
||||||
|
|
||||||
|
export type RigVfo = { entries: Array<RigVfoEntry>,
|
||||||
|
/**
|
||||||
|
* Index into `entries` for the active VFO, if known.
|
||||||
|
*/
|
||||||
|
active: number | null, };
|
||||||
|
|
||||||
|
export type RigTxStatus = { power: number | null, limit: number | null, swr: number | null, alc: number | null, };
|
||||||
|
|
||||||
|
export type RigRxStatus = { sig: number | null, };
|
||||||
|
|
||||||
|
export type RigStatus = { freq: Freq, mode: RigMode, tx_en: boolean, vfo: RigVfo | null, tx: RigTxStatus | null, rx: RigRxStatus | null, lock: boolean | null, };
|
||||||
|
|
||||||
|
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||||
|
|
||||||
|
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
||||||
|
|
||||||
|
export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
|
||||||
|
/**
|
||||||
|
* Co-Channel Interference level (0–100 scale).
|
||||||
|
*/
|
||||||
|
wfm_cci: number,
|
||||||
|
/**
|
||||||
|
* Adjacent Channel Interference level (0–100 scale).
|
||||||
|
*/
|
||||||
|
wfm_aci: number,
|
||||||
|
/**
|
||||||
|
* SAM stereo width (0.0 = mono, 1.0 = full stereo).
|
||||||
|
*/
|
||||||
|
sam_stereo_width: number,
|
||||||
|
/**
|
||||||
|
* SAM carrier synchronization enabled.
|
||||||
|
*/
|
||||||
|
sam_carrier_sync: boolean, };
|
||||||
|
|
||||||
|
export type RdsData = { pi?: number | null, program_service?: string | null, radio_text?: string | null, program_type_name_long?: string | null, pty?: number | null, pty_name?: string | null, traffic_program?: boolean | null, traffic_announcement?: boolean | null, music?: boolean | null, stereo?: boolean | null, artificial_head?: boolean | null, compressed?: boolean | null, dynamic_pty?: boolean | null, alternative_frequencies_hz?: Array<number> | null, };
|
||||||
|
|
||||||
|
export type SpectrumData = {
|
||||||
|
/**
|
||||||
|
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
|
||||||
|
*/
|
||||||
|
bins: Array<number>,
|
||||||
|
/**
|
||||||
|
* Centre frequency of the SDR capture in Hz.
|
||||||
|
*/
|
||||||
|
center_hz: number,
|
||||||
|
/**
|
||||||
|
* SDR capture sample rate in Hz; the displayed span is ±sample_rate/2.
|
||||||
|
*/
|
||||||
|
sample_rate: number,
|
||||||
|
/**
|
||||||
|
* Decoded Radio Data System state, when available for WFM.
|
||||||
|
*/
|
||||||
|
rds?: RdsData | null, };
|
||||||
|
|
||||||
|
export type VchanRdsEntry = {
|
||||||
|
/**
|
||||||
|
* Virtual channel UUID.
|
||||||
|
*/
|
||||||
|
id: string,
|
||||||
|
/**
|
||||||
|
* Latest RDS data, if decoded.
|
||||||
|
*/
|
||||||
|
rds?: RdsData | null,
|
||||||
|
/**
|
||||||
|
* Channel signal level in dBFS.
|
||||||
|
*/
|
||||||
|
signal_db?: number | null, };
|
||||||
|
|
||||||
|
export type RigSnapshot = { info: RigInfo, status: RigStatus, band: string | null, enabled: boolean | null, initialized: boolean, server_callsign?: string | null, server_version?: string | null, server_build_date?: string | null, server_latitude?: number | null, server_longitude?: number | null, pskreporter_status?: string | null, aprs_is_status?: string | null, cw_auto: boolean, cw_wpm: number, cw_tone_hz: number, filter?: RigFilterState | null, spectrum?: SpectrumData | null,
|
||||||
|
/**
|
||||||
|
* Per-virtual-channel RDS snapshots, when available.
|
||||||
|
*/
|
||||||
|
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||||
|
|
||||||
|
export type RigListItem = { remote: string, display_name: string | null, manufacturer: string, model: string, supported_modes: Array<RigMode>, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, };
|
||||||
|
|
||||||
|
export type RigListResponse = { active_remote: string | null, rigs: Array<RigListItem>, };
|
||||||
|
|
||||||
|
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, };
|
||||||
|
|
||||||
|
export type DecoderActivation = "mode_bound" | "toggle";
|
||||||
|
|
||||||
|
export type DecoderDescriptor = {
|
||||||
|
/**
|
||||||
|
* Machine identifier, e.g. `"ft8"`, `"aprs"`.
|
||||||
|
*/
|
||||||
|
id: string,
|
||||||
|
/**
|
||||||
|
* Human-readable label, e.g. `"FT8"`, `"APRS"`.
|
||||||
|
*/
|
||||||
|
label: string,
|
||||||
|
/**
|
||||||
|
* How the decoder is activated.
|
||||||
|
*/
|
||||||
|
activation: DecoderActivation,
|
||||||
|
/**
|
||||||
|
* Rig modes where this decoder operates (upper-case).
|
||||||
|
*/
|
||||||
|
active_modes: Array<string>,
|
||||||
|
/**
|
||||||
|
* Whether the decoder can run on SDR virtual channels
|
||||||
|
* (background-decode / scheduler).
|
||||||
|
*/
|
||||||
|
background_decode: boolean,
|
||||||
|
/**
|
||||||
|
* Whether this decoder should appear in bookmark forms.
|
||||||
|
*/
|
||||||
|
bookmark_selectable: boolean, };
|
||||||
|
|
||||||
+1720
-1787
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import "./webgl-renderer.js";
|
||||||
|
import "./ui-core.js";
|
||||||
|
import "./plugin-runtime.js";
|
||||||
|
import "./leaflet-ais-tracksymbol.js";
|
||||||
|
import "./app.js";
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
export type CborValue = number | string | boolean | null | undefined
|
||||||
|
| CborValue[] | { [key: string]: CborValue };
|
||||||
|
|
||||||
|
interface DecodeState { offset: number }
|
||||||
|
|
||||||
|
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||||
|
|
||||||
|
function decodeUint(
|
||||||
|
view: DataView,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
state: DecodeState,
|
||||||
|
additional: number,
|
||||||
|
): number {
|
||||||
|
const offset = state.offset;
|
||||||
|
if (additional < 24) return additional;
|
||||||
|
const widths: Partial<Record<number, number>> = { 24: 1, 25: 2, 26: 4, 27: 8 };
|
||||||
|
const width = widths[additional];
|
||||||
|
if (width === undefined) throw new Error("Unsupported CBOR additional info");
|
||||||
|
if (offset + width > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
state.offset += width;
|
||||||
|
if (additional === 24) return bytes[offset] ?? 0;
|
||||||
|
if (additional === 25) return view.getUint16(offset);
|
||||||
|
if (additional === 26) return view.getUint32(offset);
|
||||||
|
const numeric = Number(view.getBigUint64(offset));
|
||||||
|
if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range");
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeFloat16(bits: number): number {
|
||||||
|
const sign = (bits & 0x8000) ? -1 : 1;
|
||||||
|
const exponent = (bits >> 10) & 0x1f;
|
||||||
|
const fraction = bits & 0x03ff;
|
||||||
|
if (exponent === 0) return fraction === 0 ? sign * 0 : sign * 2 ** -14 * (fraction / 1024);
|
||||||
|
if (exponent === 0x1f) return fraction === 0 ? sign * Infinity : Number.NaN;
|
||||||
|
return sign * 2 ** (exponent - 15) * (1 + fraction / 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeItem(view: DataView, bytes: Uint8Array, state: DecodeState): CborValue {
|
||||||
|
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const initial = bytes[state.offset++];
|
||||||
|
if (initial === undefined) throw new Error("CBOR payload truncated");
|
||||||
|
const major = initial >> 5;
|
||||||
|
const additional = initial & 0x1f;
|
||||||
|
if (major === 0) return decodeUint(view, bytes, state, additional);
|
||||||
|
if (major === 1) return -1 - decodeUint(view, bytes, state, additional);
|
||||||
|
if (major === 2 || major === 3) {
|
||||||
|
const length = decodeUint(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;
|
||||||
|
if (major === 2) return Array.from(chunk);
|
||||||
|
return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk);
|
||||||
|
}
|
||||||
|
if (major === 4) {
|
||||||
|
const length = decodeUint(view, bytes, state, additional);
|
||||||
|
return Array.from({ length }, () => decodeItem(view, bytes, state));
|
||||||
|
}
|
||||||
|
if (major === 5) {
|
||||||
|
const length = decodeUint(view, bytes, state, additional);
|
||||||
|
const value: Record<string, CborValue> = {};
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const key = decodeItem(view, bytes, state);
|
||||||
|
if (typeof key !== "string" && typeof key !== "number" && typeof key !== "boolean"
|
||||||
|
&& key !== null) {
|
||||||
|
throw new Error("Unsupported composite CBOR map key");
|
||||||
|
}
|
||||||
|
value[String(key)] = decodeItem(view, bytes, state);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (major === 6) {
|
||||||
|
decodeUint(view, bytes, state, additional);
|
||||||
|
return decodeItem(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;
|
||||||
|
const widths: Partial<Record<number, number>> = { 25: 2, 26: 4, 27: 8 };
|
||||||
|
const width = widths[additional];
|
||||||
|
if (width === undefined) throw new Error("Unsupported CBOR major type");
|
||||||
|
if (state.offset + width > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const offset = state.offset;
|
||||||
|
state.offset += width;
|
||||||
|
if (additional === 25) return decodeFloat16(view.getUint16(offset));
|
||||||
|
if (additional === 26) return view.getFloat32(offset);
|
||||||
|
return view.getFloat64(offset);
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported CBOR major type");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeCbor(buffer: ArrayBuffer | Uint8Array): CborValue {
|
||||||
|
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 = decodeItem(view, bytes, state);
|
||||||
|
if (state.offset !== bytes.length) throw new Error("Unexpected trailing bytes in CBOR payload");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
export interface DecoderDescriptor {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
activation: string;
|
||||||
|
active_modes: string[];
|
||||||
|
background_decode: boolean;
|
||||||
|
bookmark_selectable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecoderRegistryBridge {
|
||||||
|
decoderRegistry?: DecoderDescriptor[];
|
||||||
|
onDecoderRegistryReady?: (callback: () => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridge = window as DecoderRegistryBridge;
|
||||||
|
const readyCallbacks: Array<() => void> = [];
|
||||||
|
|
||||||
|
export let decoderRegistry: DecoderDescriptor[] = [];
|
||||||
|
|
||||||
|
export function onDecoderRegistryReady(callback: () => void): void {
|
||||||
|
if (decoderRegistry.length > 0) callback();
|
||||||
|
else readyCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDecoderRegistryVisibility(): void {
|
||||||
|
const knownIds = new Set(decoderRegistry.map(({ id }) => id));
|
||||||
|
const alwaysShow = new Set(["overview", "rds", "sat"]);
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLElement>(
|
||||||
|
"#tab-digital-modes > .sub-tab-bar > .sub-tab[data-subtab]",
|
||||||
|
).forEach((button) => {
|
||||||
|
const id = button.dataset.subtab;
|
||||||
|
if (!id || alwaysShow.has(id) || knownIds.has(id)) return;
|
||||||
|
button.style.display = "none";
|
||||||
|
const panel = document.getElementById(`subtab-${id}`);
|
||||||
|
if (panel) panel.style.display = "none";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLElement>('[id^="about-dec-"]').forEach((element) => {
|
||||||
|
const id = element.id.replace("about-dec-", "");
|
||||||
|
if (alwaysShow.has(id) || knownIds.has(id)) return;
|
||||||
|
const row = element.closest<HTMLElement>("tr");
|
||||||
|
if (row) row.style.display = "none";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLElement>('[id^="settings-clear-"][id$="-history"]')
|
||||||
|
.forEach((element) => {
|
||||||
|
const match = /^settings-clear-(.+)-history$/.exec(element.id);
|
||||||
|
const id = match?.[1];
|
||||||
|
if (id && !alwaysShow.has(id) && !knownIds.has(id)) element.style.display = "none";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLElement>("#subtab-overview .plugin-item[data-decoder]")
|
||||||
|
.forEach((element) => {
|
||||||
|
const id = element.dataset.decoder;
|
||||||
|
if (id && !alwaysShow.has(id) && !knownIds.has(id)) element.style.display = "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDecoderDescriptor(value: unknown): value is DecoderDescriptor {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
const item = value as Partial<DecoderDescriptor>;
|
||||||
|
return typeof item.id === "string"
|
||||||
|
&& typeof item.label === "string"
|
||||||
|
&& typeof item.activation === "string"
|
||||||
|
&& Array.isArray(item.active_modes)
|
||||||
|
&& item.active_modes.every((mode) => typeof mode === "string")
|
||||||
|
&& typeof item.background_decode === "boolean"
|
||||||
|
&& typeof item.bookmark_selectable === "boolean";
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeRegistry(value: unknown): DecoderDescriptor[] {
|
||||||
|
if (!Array.isArray(value) || !value.every(isDecoderDescriptor)) {
|
||||||
|
throw new TypeError("The decoder registry response is malformed");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDecoderRegistry(onLoaded: () => void): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/decoders");
|
||||||
|
if (!response.ok) return;
|
||||||
|
decoderRegistry = decodeRegistry(await response.json());
|
||||||
|
bridge.decoderRegistry = decoderRegistry;
|
||||||
|
readyCallbacks.splice(0).forEach((callback) => { callback(); });
|
||||||
|
applyDecoderRegistryVisibility();
|
||||||
|
onLoaded();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("Failed to fetch decoder registry:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporary compatibility boundary for lazy modules that have not yet been
|
||||||
|
// changed to receive the registry service through their plugin context.
|
||||||
|
bridge.decoderRegistry = decoderRegistry;
|
||||||
|
bridge.onDecoderRegistryReady = onDecoderRegistryReady;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
export function escapeHtml(input: unknown): string {
|
||||||
|
return String(input)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
export function formatDuration(milliseconds: number): string {
|
||||||
|
const seconds = Math.floor(milliseconds / 1000);
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const remainder = seconds % 60;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (days > 0) parts.push(`${days}d`);
|
||||||
|
if (hours > 0 || days > 0) parts.push(`${hours}h`);
|
||||||
|
parts.push(`${minutes}m`, `${remainder}s`);
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFrequency(frequencyHz: number): string {
|
||||||
|
if (!Number.isFinite(frequencyHz)) return "--";
|
||||||
|
if (frequencyHz >= 1_000_000_000) return `${(frequencyHz / 1_000_000_000).toFixed(3)} GHz`;
|
||||||
|
if (frequencyHz >= 10_000_000) return `${(frequencyHz / 1_000_000).toFixed(3)} MHz`;
|
||||||
|
return `${(frequencyHz / 1_000).toFixed(1)} kHz`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFrequencyForStep(frequencyHz: number, stepHz: number): string {
|
||||||
|
if (!Number.isFinite(frequencyHz)) return "--";
|
||||||
|
if (stepHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(6);
|
||||||
|
if (stepHz >= 1_000) return (frequencyHz / 1_000).toFixed(3);
|
||||||
|
if (stepHz >= 1) return String(Math.round(frequencyHz));
|
||||||
|
return formatFrequency(frequencyHz);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFrequencyForHumans(frequencyHz: number): string {
|
||||||
|
if (!Number.isFinite(frequencyHz)) return "--";
|
||||||
|
if (frequencyHz >= 1_000_000_000) return `${(frequencyHz / 1_000_000_000).toFixed(3)} GHz`;
|
||||||
|
if (frequencyHz >= 1_000_000) return `${(frequencyHz / 1_000_000).toFixed(3)} MHz`;
|
||||||
|
if (frequencyHz >= 1_000) return `${(frequencyHz / 1_000).toFixed(3)} kHz`;
|
||||||
|
return `${Math.round(frequencyHz)} Hz`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWavelength(frequencyHz: number): string {
|
||||||
|
if (!Number.isFinite(frequencyHz) || frequencyHz <= 0) return "--";
|
||||||
|
const meters = 299_792_458 / frequencyHz;
|
||||||
|
return meters >= 1 ? `${Math.round(meters)} m` : `${Math.round(meters * 100)} cm`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFrequencyInput(
|
||||||
|
value: string,
|
||||||
|
defaultStepHz: number,
|
||||||
|
mode: string,
|
||||||
|
): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const match = /^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/
|
||||||
|
.exec(value.trim().toLowerCase());
|
||||||
|
if (!match?.[1]) return null;
|
||||||
|
const rawNumber = match[1];
|
||||||
|
let frequency = Number.parseFloat(rawNumber.replace(",", "."));
|
||||||
|
const unit = match[2] ?? "";
|
||||||
|
if (Number.isNaN(frequency)) return null;
|
||||||
|
|
||||||
|
if (unit.startsWith("gh") || unit === "g") frequency *= 1_000_000_000;
|
||||||
|
else if (unit.startsWith("mh") || unit === "m") frequency *= 1_000_000;
|
||||||
|
else if (unit.startsWith("kh") || unit === "k") frequency *= 1_000;
|
||||||
|
else if (!unit) {
|
||||||
|
const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(",");
|
||||||
|
if (mode.toUpperCase() === "WFM") {
|
||||||
|
if (hasDecimalSeparator && frequency >= 50 && frequency < 200) {
|
||||||
|
return Math.round(frequency * 1_000_000);
|
||||||
|
}
|
||||||
|
if (!hasDecimalSeparator && frequency >= 875 && frequency <= 1080) {
|
||||||
|
return Math.round((frequency / 10) * 1_000_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (defaultStepHz >= 1_000_000) frequency *= 1_000_000;
|
||||||
|
else if (defaultStepHz >= 1_000) frequency *= 1_000;
|
||||||
|
else if (defaultStepHz < 1) {
|
||||||
|
if (frequency < 1_000) frequency *= 1_000_000;
|
||||||
|
else if (frequency < 1_000_000) frequency *= 1_000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.round(frequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatByteSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1_048_576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
export interface LatLon {
|
||||||
|
readonly lat: number;
|
||||||
|
readonly lon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const radiusKm = 6371;
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
const a = Math.sin(dLat / 2) ** 2
|
||||||
|
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return radiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function locatorToLatLon(locator: unknown): LatLon | null {
|
||||||
|
const raw = typeof locator === "string" ? locator.trim().toUpperCase() : "";
|
||||||
|
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(raw)) return null;
|
||||||
|
let lon = -180 + (raw.charCodeAt(0) - 65) * 20 + Number(raw.slice(2, 3)) * 2;
|
||||||
|
let lat = -90 + (raw.charCodeAt(1) - 65) * 10 + Number(raw.slice(3, 4));
|
||||||
|
if (raw.length >= 6) {
|
||||||
|
lon += (raw.charCodeAt(4) - 65) * (5 / 60) + 2.5 / 60;
|
||||||
|
lat += (raw.charCodeAt(5) - 65) * (2.5 / 60) + 1.25 / 60;
|
||||||
|
} else {
|
||||||
|
lon += 1;
|
||||||
|
lat += 0.5;
|
||||||
|
}
|
||||||
|
return { lat, lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDistanceKm(distanceKm: number): string | null {
|
||||||
|
if (!Number.isFinite(distanceKm)) return null;
|
||||||
|
return distanceKm < 1 ? `${Math.round(distanceKm * 1000)} m` : `${distanceKm.toFixed(1)} km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimeAgo(timestampMs: number): string | null {
|
||||||
|
if (!timestampMs) return null;
|
||||||
|
const seconds = Math.round((Date.now() - timestampMs) / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes} min ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}min ago` : `${hours}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function latLonToMaidenhead(lat: number, lon: number): string {
|
||||||
|
const adjustedLon = lon + 180;
|
||||||
|
const adjustedLat = lat + 90;
|
||||||
|
const upperA = "A".charCodeAt(0);
|
||||||
|
const lowerA = "a".charCodeAt(0);
|
||||||
|
const field1 = String.fromCharCode(upperA + Math.floor(adjustedLon / 20));
|
||||||
|
const field2 = String.fromCharCode(upperA + Math.floor(adjustedLat / 10));
|
||||||
|
const square1 = Math.floor((adjustedLon % 20) / 2);
|
||||||
|
const square2 = Math.floor(adjustedLat % 10);
|
||||||
|
const sub1 = String.fromCharCode(lowerA + Math.floor((adjustedLon % 2) * 12));
|
||||||
|
const sub2 = String.fromCharCode(lowerA + Math.floor((adjustedLat % 1) * 24));
|
||||||
|
return `${field1}${field2}${square1}${square2}${sub1}${sub2}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = "trx_";
|
||||||
|
|
||||||
|
export function saveSetting(key: string, value: unknown): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable in private browsing or under a strict policy.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSetting<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(`${STORAGE_PREFIX}${key}`);
|
||||||
|
return value === null ? fallback : JSON.parse(value) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-28
@@ -2,16 +2,27 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
export {};
|
||||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
|
||||||
|
|
||||||
function decodeCborUint(view, bytes, state, additional) {
|
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||||
|
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"] as const;
|
||||||
|
type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number];
|
||||||
|
type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
|
||||||
|
interface DecodeState { offset: number }
|
||||||
|
interface FetchHistoryRequest { type: "fetch-history"; url?: string; batchLimit?: number }
|
||||||
|
interface WorkerScope {
|
||||||
|
postMessage(message: unknown): void;
|
||||||
|
onmessage: ((event: MessageEvent<unknown>) => void) | null;
|
||||||
|
}
|
||||||
|
const workerScope = self as unknown as WorkerScope;
|
||||||
|
|
||||||
|
function decodeCborUint(view: DataView, bytes: Uint8Array, state: DecodeState, additional: number): number {
|
||||||
const offset = state.offset;
|
const offset = state.offset;
|
||||||
if (additional < 24) return additional;
|
if (additional < 24) return additional;
|
||||||
if (additional === 24) {
|
if (additional === 24) {
|
||||||
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
|
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
state.offset += 1;
|
state.offset += 1;
|
||||||
return bytes[offset];
|
return bytes[offset] ?? 0;
|
||||||
}
|
}
|
||||||
if (additional === 25) {
|
if (additional === 25) {
|
||||||
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
@@ -34,7 +45,7 @@ function decodeCborUint(view, bytes, state, additional) {
|
|||||||
throw new Error("Unsupported CBOR additional info");
|
throw new Error("Unsupported CBOR additional info");
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeCborFloat16(bits) {
|
function decodeCborFloat16(bits: number): number {
|
||||||
const sign = (bits & 0x8000) ? -1 : 1;
|
const sign = (bits & 0x8000) ? -1 : 1;
|
||||||
const exponent = (bits >> 10) & 0x1f;
|
const exponent = (bits >> 10) & 0x1f;
|
||||||
const fraction = bits & 0x03ff;
|
const fraction = bits & 0x03ff;
|
||||||
@@ -47,9 +58,10 @@ function decodeCborFloat16(bits) {
|
|||||||
return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024));
|
return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024));
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeCborItem(view, bytes, state) {
|
function decodeCborItem(view: DataView, bytes: Uint8Array, state: DecodeState): CborValue {
|
||||||
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
||||||
const initial = bytes[state.offset++];
|
const initial = bytes[state.offset++];
|
||||||
|
if (initial === undefined) throw new Error("CBOR payload truncated");
|
||||||
const major = initial >> 5;
|
const major = initial >> 5;
|
||||||
const additional = initial & 0x1f;
|
const additional = initial & 0x1f;
|
||||||
if (major === 0) return decodeCborUint(view, bytes, state, additional);
|
if (major === 0) return decodeCborUint(view, bytes, state, additional);
|
||||||
@@ -70,7 +82,7 @@ function decodeCborItem(view, bytes, state) {
|
|||||||
}
|
}
|
||||||
if (major === 4) {
|
if (major === 4) {
|
||||||
const length = decodeCborUint(view, bytes, state, additional);
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
const items = new Array(length);
|
const items: CborValue[] = new Array<CborValue>(length);
|
||||||
for (let i = 0; i < length; i += 1) {
|
for (let i = 0; i < length; i += 1) {
|
||||||
items[i] = decodeCborItem(view, bytes, state);
|
items[i] = decodeCborItem(view, bytes, state);
|
||||||
}
|
}
|
||||||
@@ -78,10 +90,13 @@ function decodeCborItem(view, bytes, state) {
|
|||||||
}
|
}
|
||||||
if (major === 5) {
|
if (major === 5) {
|
||||||
const length = decodeCborUint(view, bytes, state, additional);
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
const value = {};
|
const value: Record<string, CborValue> = {};
|
||||||
for (let i = 0; i < length; i += 1) {
|
for (let i = 0; i < length; i += 1) {
|
||||||
const key = decodeCborItem(view, bytes, state);
|
const key = decodeCborItem(view, bytes, state);
|
||||||
value[String(key)] = decodeCborItem(view, bytes, state);
|
const property = typeof key === "string" || typeof key === "number" || typeof key === "boolean"
|
||||||
|
? String(key)
|
||||||
|
: JSON.stringify(key);
|
||||||
|
value[property] = decodeCborItem(view, bytes, state);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -116,7 +131,7 @@ function decodeCborItem(view, bytes, state) {
|
|||||||
throw new Error("Unsupported CBOR major type");
|
throw new Error("Unsupported CBOR major type");
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeCborPayload(buffer) {
|
function decodeCborPayload(buffer: ArrayBuffer | Uint8Array): CborValue {
|
||||||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
const state = { offset: 0 };
|
const state = { offset: 0 };
|
||||||
@@ -127,35 +142,39 @@ function decodeCborPayload(buffer) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAndDecodeHistory(url, batchLimit) {
|
function isHistory(value: CborValue): value is Partial<Record<HistoryGroup, CborValue[]>> {
|
||||||
self.postMessage({ type: "status", phase: "fetching" });
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndDecodeHistory(url: string, batchLimit?: number) {
|
||||||
|
workerScope.postMessage({ type: "status", phase: "fetching" });
|
||||||
const resp = await fetch(url, { credentials: "same-origin" });
|
const resp = await fetch(url, { credentials: "same-origin" });
|
||||||
if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`);
|
if (!resp.ok) throw new Error(`History fetch failed: ${String(resp.status)}`);
|
||||||
const payload = await resp.arrayBuffer();
|
const payload = await resp.arrayBuffer();
|
||||||
if (!payload || payload.byteLength === 0) {
|
if (payload.byteLength === 0) {
|
||||||
self.postMessage({ type: "start", total: 0 });
|
workerScope.postMessage({ type: "start", total: 0 });
|
||||||
self.postMessage({ type: "done", total: 0 });
|
workerScope.postMessage({ type: "done", total: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.postMessage({ type: "status", phase: "decoding" });
|
workerScope.postMessage({ type: "status", phase: "decoding" });
|
||||||
const history = decodeCborPayload(payload);
|
const history = decodeCborPayload(payload);
|
||||||
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
|
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
|
||||||
const items = history && Array.isArray(history[key]) ? history[key] : [];
|
const items = isHistory(history) && Array.isArray(history[key]) ? history[key] : [];
|
||||||
return sum + items.length;
|
return sum + items.length;
|
||||||
}, 0);
|
}, 0);
|
||||||
self.postMessage({ type: "start", total });
|
workerScope.postMessage({ type: "start", total });
|
||||||
|
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
|
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
|
||||||
|
|
||||||
for (const kind of HISTORY_GROUP_KEYS) {
|
for (const kind of HISTORY_GROUP_KEYS) {
|
||||||
const items = history && Array.isArray(history[kind]) ? history[kind] : [];
|
const items = isHistory(history) && Array.isArray(history[kind]) ? history[kind] : [];
|
||||||
if (items.length === 0) continue;
|
if (items.length === 0) continue;
|
||||||
for (let index = 0; index < items.length; index += safeLimit) {
|
for (let index = 0; index < items.length; index += safeLimit) {
|
||||||
const messages = items.slice(index, index + safeLimit);
|
const messages = items.slice(index, index + safeLimit);
|
||||||
processed += messages.length;
|
processed += messages.length;
|
||||||
self.postMessage({
|
workerScope.postMessage({
|
||||||
type: "group",
|
type: "group",
|
||||||
kind,
|
kind,
|
||||||
messages,
|
messages,
|
||||||
@@ -164,17 +183,22 @@ async function fetchAndDecodeHistory(url, batchLimit) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.postMessage({ type: "done", total });
|
workerScope.postMessage({ type: "done", total });
|
||||||
}
|
}
|
||||||
|
|
||||||
self.onmessage = (event) => {
|
function isFetchHistoryRequest(value: unknown): value is FetchHistoryRequest {
|
||||||
const data = event?.data || {};
|
return typeof value === "object" && value !== null && "type" in value && value.type === "fetch-history";
|
||||||
if (data?.type !== "fetch-history") return;
|
}
|
||||||
|
|
||||||
|
workerScope.onmessage = (event) => {
|
||||||
|
const data = event.data;
|
||||||
|
if (!isFetchHistoryRequest(data)) return;
|
||||||
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit)
|
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit)
|
||||||
.catch((err) => {
|
.catch((error: unknown) => {
|
||||||
self.postMessage({
|
workerScope.postMessage({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: err && err.message ? err.message : String(err || "unknown worker failure"),
|
message: error instanceof Error ? error.message
|
||||||
|
: typeof error === "string" ? error : "unknown worker failure",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
export const TAB_ORDER = [
|
||||||
|
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TabName = typeof TAB_ORDER[number];
|
||||||
|
|
||||||
|
export const TAB_PATHS: Readonly<Record<TabName, string>> = {
|
||||||
|
main: "/",
|
||||||
|
bookmarks: "/bookmarks",
|
||||||
|
"digital-modes": "/digital-modes",
|
||||||
|
map: "/map",
|
||||||
|
statistics: "/statistics",
|
||||||
|
recorder: "/recorder",
|
||||||
|
settings: "/settings",
|
||||||
|
about: "/about",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeTabPath(pathname: string): string {
|
||||||
|
const raw = pathname.length > 0 ? pathname : "/";
|
||||||
|
return raw === "/" ? "/" : raw.replace(/\/+$/, "") || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tabFromPath(pathname: string): TabName {
|
||||||
|
const normalized = normalizeTabPath(pathname);
|
||||||
|
const match = Object.entries(TAB_PATHS).find(([, path]) => path === normalized);
|
||||||
|
return match ? match[0] as TabName : "main";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTabHistory(name: TabName, replace = false): void {
|
||||||
|
const targetPath = TAB_PATHS[name];
|
||||||
|
if (normalizeTabPath(window.location.pathname) === targetPath) return;
|
||||||
|
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
|
||||||
|
if (replace) window.history.replaceState({}, "", nextUrl);
|
||||||
|
else window.history.pushState({}, "", nextUrl);
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
export interface SpectrumFrame {
|
||||||
|
bins: readonly number[] | ArrayBufferView;
|
||||||
|
center_hz: number;
|
||||||
|
sample_rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InterferenceLevels { aci?: number; cci?: number }
|
||||||
|
export type BandwidthLimits = readonly [defaultHz: number, minHz: number, maxHz: number, stepHz: number];
|
||||||
|
|
||||||
|
function clampPercent(value: unknown): number {
|
||||||
|
const numeric = Number(value) || 0;
|
||||||
|
return Math.max(0, Math.min(100, numeric)) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateOccupiedBandwidth(
|
||||||
|
data: SpectrumFrame | null,
|
||||||
|
centerHz: number,
|
||||||
|
mode: string,
|
||||||
|
limits: BandwidthLimits,
|
||||||
|
interference: InterferenceLevels = {},
|
||||||
|
): number | null {
|
||||||
|
if (!data || !Array.isArray(data.bins) && !ArrayBuffer.isView(data.bins)
|
||||||
|
|| !Number.isFinite(centerHz)) return null;
|
||||||
|
|
||||||
|
const bins = Array.from(data.bins as ArrayLike<number>);
|
||||||
|
if (bins.length < 3) return null;
|
||||||
|
const maxIdx = bins.length - 1;
|
||||||
|
const hzPerBin = data.sample_rate / maxIdx;
|
||||||
|
const fullLoHz = data.center_hz - data.sample_rate / 2;
|
||||||
|
const centerIdx = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)),
|
||||||
|
);
|
||||||
|
const normalizedMode = mode.toUpperCase();
|
||||||
|
const [defaultBw, minBw, maxBw, stepBw] = limits;
|
||||||
|
const oneSided = ["USB", "DIG", "CW"].includes(normalizedMode)
|
||||||
|
? 1 : ["LSB", "CWR"].includes(normalizedMode) ? -1 : 0;
|
||||||
|
const isWfm = normalizedMode === "WFM";
|
||||||
|
const smoothRadius = isWfm ? 3 : 1;
|
||||||
|
const smoothed = bins.map((_, index) => {
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let adjacent = Math.max(0, index - smoothRadius);
|
||||||
|
adjacent <= Math.min(maxIdx, index + smoothRadius); adjacent += 1) {
|
||||||
|
sum += bins[adjacent] ?? 0;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return sum / count;
|
||||||
|
});
|
||||||
|
const sorted = [...bins].sort((left, right) => left - right);
|
||||||
|
const noise = sorted[Math.floor(sorted.length * 0.2)] ?? -Infinity;
|
||||||
|
const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin));
|
||||||
|
const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins;
|
||||||
|
const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins));
|
||||||
|
const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins));
|
||||||
|
let peak = -Infinity;
|
||||||
|
for (let index = searchLo; index <= searchHi; index += 1) {
|
||||||
|
peak = Math.max(peak, smoothed[index] ?? -Infinity);
|
||||||
|
}
|
||||||
|
const snr = peak - noise;
|
||||||
|
if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw;
|
||||||
|
|
||||||
|
const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28)));
|
||||||
|
const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12_000 : stepBw) / hzPerBin));
|
||||||
|
const occupiedExtent = (direction: -1 | 1, limitBins: number): number => {
|
||||||
|
let lastOccupied = centerIdx;
|
||||||
|
let gap = 0;
|
||||||
|
for (let offset = 0; offset <= limitBins; offset += 1) {
|
||||||
|
const index = centerIdx + direction * offset;
|
||||||
|
if (index <= 0 || index >= maxIdx) break;
|
||||||
|
if ((smoothed[index] ?? -Infinity) >= threshold) {
|
||||||
|
lastOccupied = index;
|
||||||
|
gap = 0;
|
||||||
|
} else if (++gap > allowedGap) break;
|
||||||
|
}
|
||||||
|
return Math.abs(lastOccupied - centerIdx) * hzPerBin;
|
||||||
|
};
|
||||||
|
|
||||||
|
let rawBw = oneSided !== 0
|
||||||
|
? occupiedExtent(oneSided, maxSpanBins)
|
||||||
|
: 2 * Math.max(occupiedExtent(-1, searchHalfBins), occupiedExtent(1, searchHalfBins));
|
||||||
|
rawBw *= isWfm ? 1.08 : 1.12;
|
||||||
|
if (isWfm) {
|
||||||
|
const aci = clampPercent(interference.aci);
|
||||||
|
const cci = clampPercent(interference.cci);
|
||||||
|
const aciCap = maxBw - (maxBw - minBw) * aci;
|
||||||
|
const cciFloor = minBw + (defaultBw - minBw) * 0.65;
|
||||||
|
const cciCap = maxBw - (maxBw - cciFloor) * cci;
|
||||||
|
rawBw = Math.min(rawBw, aciCap, cciCap);
|
||||||
|
}
|
||||||
|
const clamped = Math.max(minBw, Math.min(maxBw, rawBw));
|
||||||
|
return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
export type NumericBins = number[] | Int8Array | Uint8Array | Float32Array | Float64Array;
|
||||||
|
|
||||||
|
export function isNumericBins(value: unknown): value is NumericBins {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.every((item) => typeof item === "number")
|
||||||
|
: ArrayBuffer.isView(value) && !(value instanceof DataView);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Lookup = new Uint8Array(128).fill(255);
|
||||||
|
const base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
for (let index = 0; index < base64Alphabet.length; index += 1) {
|
||||||
|
base64Lookup[base64Alphabet.charCodeAt(index)] = index;
|
||||||
|
}
|
||||||
|
let spectrumBinBuffer = new Int8Array(0);
|
||||||
|
|
||||||
|
export function decodeBase64Int8(value: string): Int8Array {
|
||||||
|
let end = value.length;
|
||||||
|
while (end > 0 && value.charCodeAt(end - 1) === 61) end -= 1;
|
||||||
|
const outputLength = end * 3 >>> 2;
|
||||||
|
if (spectrumBinBuffer.length !== outputLength) spectrumBinBuffer = new Int8Array(outputLength);
|
||||||
|
let outputIndex = 0;
|
||||||
|
for (let index = 0; index < end;) {
|
||||||
|
const sextets = [0, 0, 0, 0];
|
||||||
|
for (let offset = 0; offset < 4 && index < end; offset += 1, index += 1) {
|
||||||
|
const code = value.charCodeAt(index);
|
||||||
|
const decoded = code < base64Lookup.length ? base64Lookup[code] : undefined;
|
||||||
|
if (decoded === undefined || decoded === 255) throw new TypeError("Invalid base64 spectrum frame");
|
||||||
|
sextets[offset] = decoded;
|
||||||
|
}
|
||||||
|
const packed = ((sextets[0] ?? 0) << 18) | ((sextets[1] ?? 0) << 12)
|
||||||
|
| ((sextets[2] ?? 0) << 6) | (sextets[3] ?? 0);
|
||||||
|
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 16 & 0xff;
|
||||||
|
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 8 & 0xff;
|
||||||
|
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed & 0xff;
|
||||||
|
}
|
||||||
|
return spectrumBinBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nthScratch = new Float64Array(0);
|
||||||
|
|
||||||
|
export function nthElement(values: NumericBins, target: number): number | null {
|
||||||
|
if (values.length === 0 || target < 0 || target >= values.length) return null;
|
||||||
|
if (nthScratch.length < values.length) nthScratch = new Float64Array(values.length);
|
||||||
|
for (let index = 0; index < values.length; index += 1) nthScratch[index] = values[index] ?? 0;
|
||||||
|
let low = 0;
|
||||||
|
let high = values.length - 1;
|
||||||
|
while (low < high) {
|
||||||
|
const pivot = nthScratch[low + ((high - low) >> 1)] ?? 0;
|
||||||
|
let left = low;
|
||||||
|
let right = high;
|
||||||
|
while (left <= right) {
|
||||||
|
while ((nthScratch[left] ?? Infinity) < pivot) left += 1;
|
||||||
|
while ((nthScratch[right] ?? -Infinity) > pivot) right -= 1;
|
||||||
|
if (left <= right) {
|
||||||
|
const temporary = nthScratch[left] ?? 0;
|
||||||
|
nthScratch[left] = nthScratch[right] ?? 0;
|
||||||
|
nthScratch[right] = temporary;
|
||||||
|
left += 1;
|
||||||
|
right -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (right < target) low = left;
|
||||||
|
if (target < left) high = right;
|
||||||
|
}
|
||||||
|
return nthScratch[target] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateNoiseFloorDb(bins: unknown): number | null {
|
||||||
|
if (!isNumericBins(bins) || bins.length === 0) return null;
|
||||||
|
return nthElement(bins, Math.floor(bins.length * 0.15));
|
||||||
|
}
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
interface AisSymbolOptions {
|
||||||
|
heading: number | null;
|
||||||
|
course: number | null;
|
||||||
|
speed: number | null;
|
||||||
|
color: string;
|
||||||
|
outline: string;
|
||||||
|
size: number;
|
||||||
|
interactive: boolean;
|
||||||
|
keyboard: boolean;
|
||||||
|
riseOnHover: boolean;
|
||||||
|
icon?: unknown;
|
||||||
|
}
|
||||||
|
interface LeafletMapAdapter { on(event: string, listener: () => void): void; off(event: string, listener: () => void): void; getZoom(): number }
|
||||||
|
interface TrackSymbolInstance {
|
||||||
|
options: AisSymbolOptions;
|
||||||
|
_icon?: HTMLElement;
|
||||||
|
_map?: LeafletMapAdapter;
|
||||||
|
_boundZoomRefresh?: (() => void) | null;
|
||||||
|
_refreshIcon(): void;
|
||||||
|
}
|
||||||
|
interface TrackSymbolConstructor { new(latlng: unknown, options?: Partial<AisSymbolOptions>): TrackSymbolInstance }
|
||||||
|
interface LeafletAdapter {
|
||||||
|
Marker: {
|
||||||
|
extend(definition: object & ThisType<TrackSymbolInstance>): TrackSymbolConstructor;
|
||||||
|
prototype: {
|
||||||
|
initialize(this: TrackSymbolInstance, latlng: unknown, options: AisSymbolOptions): void;
|
||||||
|
onAdd(this: TrackSymbolInstance, map: LeafletMapAdapter): void;
|
||||||
|
onRemove(this: TrackSymbolInstance, map: LeafletMapAdapter): void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
Util: { extend<T extends object>(target: T, ...sources: object[]): T };
|
||||||
|
divIcon(options: object): unknown;
|
||||||
|
TrxAisTrackSymbol?: TrackSymbolConstructor;
|
||||||
|
trxAisTrackSymbol?: (latlng: unknown, options?: Partial<AisSymbolOptions>) => TrackSymbolInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const leaflet = (globalThis as unknown as { L?: LeafletAdapter }).L;
|
||||||
|
if (!leaflet) return;
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteAngle(value: number | null): number | null {
|
||||||
|
if (value === null || !Number.isFinite(value)) return null;
|
||||||
|
const normalized = ((value % 360) + 360) % 360;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgColor(value: string, fallback: string): string {
|
||||||
|
const text = value || fallback || "";
|
||||||
|
return text.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSymbolHtml(options: AisSymbolOptions, zoom: number): string {
|
||||||
|
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) ? 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>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
leaflet.TrxAisTrackSymbol = leaflet.Marker.extend({
|
||||||
|
options: {
|
||||||
|
heading: null,
|
||||||
|
course: null,
|
||||||
|
speed: null,
|
||||||
|
color: "#ff7559",
|
||||||
|
outline: "#6b2118",
|
||||||
|
size: 22,
|
||||||
|
interactive: true,
|
||||||
|
keyboard: true,
|
||||||
|
riseOnHover: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
initialize: function(latlng: unknown, options?: Partial<AisSymbolOptions>) {
|
||||||
|
const merged = leaflet.Util.extend({} as AisSymbolOptions, this.options, options || {});
|
||||||
|
merged.icon = leaflet.divIcon({
|
||||||
|
className: "trx-ais-track-symbol-icon",
|
||||||
|
html: "",
|
||||||
|
iconSize: [merged.size, merged.size],
|
||||||
|
iconAnchor: [merged.size / 2, merged.size / 2],
|
||||||
|
});
|
||||||
|
leaflet.Marker.prototype.initialize.call(this, latlng, merged);
|
||||||
|
},
|
||||||
|
|
||||||
|
onAdd: function(map: LeafletMapAdapter) {
|
||||||
|
leaflet.Marker.prototype.onAdd.call(this, map);
|
||||||
|
this._refreshIcon();
|
||||||
|
this._boundZoomRefresh = this._refreshIcon.bind(this);
|
||||||
|
map.on("zoomend", this._boundZoomRefresh);
|
||||||
|
},
|
||||||
|
|
||||||
|
onRemove: function(map: LeafletMapAdapter) {
|
||||||
|
if (this._boundZoomRefresh) {
|
||||||
|
map.off("zoomend", this._boundZoomRefresh);
|
||||||
|
this._boundZoomRefresh = null;
|
||||||
|
}
|
||||||
|
leaflet.Marker.prototype.onRemove.call(this, map);
|
||||||
|
},
|
||||||
|
|
||||||
|
setAisState: function(next: Partial<AisSymbolOptions>) {
|
||||||
|
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) ? 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`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
leaflet.trxAisTrackSymbol = function(latlng: unknown, options?: Partial<AisSymbolOptions>) {
|
||||||
|
const Constructor = leaflet.TrxAisTrackSymbol;
|
||||||
|
if (!Constructor) throw new Error("AIS track symbol constructor is unavailable");
|
||||||
|
return new Constructor(latlng, options);
|
||||||
|
};
|
||||||
|
})();
|
||||||
+564
-402
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
|
||||||
|
|
||||||
|
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
||||||
|
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
|
||||||
|
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||||
|
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||||
|
statistics: ["/map-core.js"],
|
||||||
|
bookmarks: ["/bookmarks.js"],
|
||||||
|
recorder: [],
|
||||||
|
settings: ["/vchan.js", "/scheduler.js"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const loaded = new Set<string>();
|
||||||
|
const loading = new Map<string, Promise<void>>();
|
||||||
|
async function loadPlugin(path: string): Promise<void> {
|
||||||
|
if (loaded.has(path)) return;
|
||||||
|
const pending = loading.get(path);
|
||||||
|
if (pending) return pending;
|
||||||
|
const request = import(path).then(() => {
|
||||||
|
loaded.add(path);
|
||||||
|
loading.delete(path);
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
loading.delete(path);
|
||||||
|
throw new Error(`Failed to load plugin module: ${path}`, { cause: error });
|
||||||
|
});
|
||||||
|
loading.set(path, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlugins(group: string): Promise<void> {
|
||||||
|
if (!(group in pluginGroups)) return;
|
||||||
|
for (const path of pluginGroups[group as PluginGroup]) await loadPlugin(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEagerPlugins(): Promise<void> {
|
||||||
|
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPluginsForTab(tab: string): Promise<void> {
|
||||||
|
await loadPlugins(tab);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import type { DecoderPlugin, PluginRuntimeWindow, TrxPluginRuntime } from "./plugins/runtime-contract";
|
||||||
|
|
||||||
|
type QueuedAction =
|
||||||
|
| { kind: "message"; payload: unknown }
|
||||||
|
| { kind: "batch" | "restore"; payload: unknown[] };
|
||||||
|
|
||||||
|
const decoders = new Map<string, DecoderPlugin>();
|
||||||
|
const queued = new Map<string, QueuedAction[]>();
|
||||||
|
const MAX_QUEUED_ACTIONS_PER_DECODER = 512;
|
||||||
|
|
||||||
|
function enqueue(id: string, action: QueuedAction): void {
|
||||||
|
const actions = queued.get(id) ?? [];
|
||||||
|
actions.push(action);
|
||||||
|
if (actions.length > MAX_QUEUED_ACTIONS_PER_DECODER) actions.splice(0, actions.length - MAX_QUEUED_ACTIONS_PER_DECODER);
|
||||||
|
queued.set(id, actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliver(plugin: DecoderPlugin, action: QueuedAction): boolean {
|
||||||
|
if (action.kind === "message" && plugin.onMessage) {
|
||||||
|
plugin.onMessage(action.payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action.kind === "batch" && plugin.onBatch) {
|
||||||
|
plugin.onBatch(action.payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action.kind === "restore" && plugin.restore) {
|
||||||
|
plugin.restore(action.payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchOrQueue(id: string, action: QueuedAction): boolean {
|
||||||
|
const plugin = decoders.get(id);
|
||||||
|
if (!plugin) {
|
||||||
|
enqueue(id, action);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!deliver(plugin, action)) {
|
||||||
|
if (action.kind === "batch" && plugin.onMessage) {
|
||||||
|
for (const message of action.payload) plugin.onMessage(message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action.kind === "restore" && plugin.onBatch) {
|
||||||
|
plugin.onBatch(action.payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime: TrxPluginRuntime = {
|
||||||
|
registerDecoder<TMessage>(plugin: DecoderPlugin<TMessage>): () => void {
|
||||||
|
if (decoders.has(plugin.id)) throw new Error(`Decoder plugin already registered: ${plugin.id}`);
|
||||||
|
const erased = plugin as DecoderPlugin;
|
||||||
|
decoders.set(plugin.id, erased);
|
||||||
|
const pending = queued.get(plugin.id) ?? [];
|
||||||
|
queued.delete(plugin.id);
|
||||||
|
for (const action of pending) deliver(erased, action);
|
||||||
|
return () => { if (decoders.get(plugin.id) === erased) decoders.delete(plugin.id); };
|
||||||
|
},
|
||||||
|
dispatch: (id, message) => dispatchOrQueue(id, { kind: "message", payload: message }),
|
||||||
|
dispatchBatch: (id, messages) => dispatchOrQueue(id, { kind: "batch", payload: messages }),
|
||||||
|
restore: (id, messages) => dispatchOrQueue(id, { kind: "restore", payload: messages }),
|
||||||
|
reset(id) {
|
||||||
|
const plugin = decoders.get(id);
|
||||||
|
if (!plugin?.reset) return false;
|
||||||
|
plugin.reset();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
resetAll() { for (const plugin of decoders.values()) plugin.reset?.(); },
|
||||||
|
prune(id) {
|
||||||
|
const plugin = decoders.get(id);
|
||||||
|
if (!plugin?.prune) return false;
|
||||||
|
plugin.prune();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
clearQueued() { queued.clear(); },
|
||||||
|
hasDecoder: (id) => decoders.has(id),
|
||||||
|
};
|
||||||
|
|
||||||
|
(window as unknown as PluginRuntimeWindow).trxPluginRuntime = runtime;
|
||||||
+124
-101
@@ -2,10 +2,47 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||||
|
|
||||||
|
interface AisMessage {
|
||||||
|
rig_id?: string | null;
|
||||||
|
channel?: string | null;
|
||||||
|
message_type?: number | null;
|
||||||
|
mmsi?: number | null;
|
||||||
|
lat?: number | null;
|
||||||
|
lon?: number | null;
|
||||||
|
sog_knots?: number | null;
|
||||||
|
cog_deg?: number | null;
|
||||||
|
heading_deg?: number | null;
|
||||||
|
vessel_name?: string | null;
|
||||||
|
callsign?: string | null;
|
||||||
|
destination?: string | null;
|
||||||
|
ts_ms?: number | null;
|
||||||
|
_tsMs?: number;
|
||||||
|
_ts?: string;
|
||||||
|
}
|
||||||
|
interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
|
||||||
|
interface AisBridge {
|
||||||
|
getDecodeHistoryRetentionMs?: () => number;
|
||||||
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
|
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
|
||||||
|
aisMapAddVessel?: (message: AisMessage) => void;
|
||||||
|
clearMapMarkersByType?: (type: string) => void;
|
||||||
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
|
updateAisBar?: () => void;
|
||||||
|
clearAisBar?: () => void;
|
||||||
|
}
|
||||||
|
const aisWindow = window as unknown as AisBridge;
|
||||||
|
const escapeAisHtml = (input: string): string => hostCore.escapeMapHtml(input);
|
||||||
|
|
||||||
// --- AIS Decoder Plugin (server-side decode) ---
|
// --- AIS Decoder Plugin (server-side decode) ---
|
||||||
const aisStatus = document.getElementById("ais-status");
|
const aisStatus = document.getElementById("ais-status");
|
||||||
const aisMessagesEl = document.getElementById("ais-messages");
|
const aisMessagesEl = document.getElementById("ais-messages");
|
||||||
const aisFilterInput = document.getElementById("ais-filter");
|
const aisFilterInput = document.getElementById("ais-filter") as HTMLInputElement | null;
|
||||||
const aisBarOverlay = document.getElementById("ais-bar-overlay");
|
const aisBarOverlay = document.getElementById("ais-bar-overlay");
|
||||||
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
||||||
const aisVesselCountEl = document.getElementById("ais-vessel-count");
|
const aisVesselCountEl = document.getElementById("ais-vessel-count");
|
||||||
@@ -14,41 +51,41 @@ const AIS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
|||||||
const AIS_DEFAULT_A_HZ = 161_975_000;
|
const AIS_DEFAULT_A_HZ = 161_975_000;
|
||||||
const AIS_CHANNEL_SPACING_HZ = 50_000;
|
const AIS_CHANNEL_SPACING_HZ = 50_000;
|
||||||
let aisFilterText = "";
|
let aisFilterText = "";
|
||||||
let aisMessageHistory = [];
|
let aisMessageHistory: AisMessage[] = [];
|
||||||
|
|
||||||
function currentAisHistoryRetentionMs() {
|
function currentAisHistoryRetentionMs(): number {
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
return typeof aisWindow.getDecodeHistoryRetentionMs === "function"
|
||||||
? window.getDecodeHistoryRetentionMs()
|
? aisWindow.getDecodeHistoryRetentionMs()
|
||||||
: 24 * 60 * 60 * 1000;
|
: 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneAisMessageHistory() {
|
function pruneAisMessageHistory() {
|
||||||
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
||||||
aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
aisMessageHistory = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAisUi(key, job) {
|
function scheduleAisUi(key: string, job: () => void): void {
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
if (typeof aisWindow.trxScheduleUiFrameJob === "function") {
|
||||||
window.trxScheduleUiFrameJob(key, job);
|
aisWindow.trxScheduleUiFrameJob(key, job);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAisHistoryRender() {
|
function scheduleAisHistoryRender() {
|
||||||
scheduleAisUi("ais-history", () => renderAisHistory());
|
scheduleAisUi("ais-history", () => { renderAisHistory(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAisBarUpdate() {
|
function scheduleAisBarUpdate() {
|
||||||
scheduleAisUi("ais-bar", () => updateAisBar());
|
scheduleAisUi("ais-bar", () => { updateAisBar(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAisMhz(freqHz) {
|
function formatAisMhz(freqHz: number): string {
|
||||||
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
|
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentAisChannelPlan() {
|
function currentAisChannelPlan(): { aHz: number; bHz: number } {
|
||||||
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
const raw = ((document.getElementById("freq") as HTMLInputElement | null)?.value || "").replace(/[^\d]/g, "");
|
||||||
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
||||||
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
||||||
return {
|
return {
|
||||||
@@ -57,9 +94,9 @@ function currentAisChannelPlan() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisChannelInfo(channel) {
|
function aisChannelInfo(channel: string | null | undefined): AisChannelInfo {
|
||||||
const plan = currentAisChannelPlan();
|
const plan = currentAisChannelPlan();
|
||||||
const ch = String(channel || "").trim().toUpperCase();
|
const ch = (channel ?? "").trim().toUpperCase();
|
||||||
if (ch === "B") {
|
if (ch === "B") {
|
||||||
return {
|
return {
|
||||||
label: "AIS-B",
|
label: "AIS-B",
|
||||||
@@ -74,18 +111,18 @@ function aisChannelInfo(channel) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisDisplayName(msg) {
|
function aisDisplayName(msg: AisMessage): string {
|
||||||
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisDisplayNameHtml(msg) {
|
function aisDisplayNameHtml(msg: AisMessage): string {
|
||||||
const label = escapeMapHtml(aisDisplayName(msg));
|
const label = escapeAisHtml(aisDisplayName(msg));
|
||||||
const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
|
const url = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
||||||
if (!url) return label;
|
if (!url) return label;
|
||||||
return `<a class="title-link" href="${escapeMapHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
return `<a class="title-link" href="${escapeAisHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisTypeLabel(type) {
|
function aisTypeLabel(type: number | null | undefined): string {
|
||||||
switch (Number(type)) {
|
switch (Number(type)) {
|
||||||
case 1:
|
case 1:
|
||||||
case 2:
|
case 2:
|
||||||
@@ -108,8 +145,8 @@ function aisTypeLabel(type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisAgeText(tsMs) {
|
function aisAgeText(tsMs: number | undefined): string {
|
||||||
if (!Number.isFinite(tsMs)) return "just now";
|
if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
|
||||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
const seconds = Math.round(deltaMs / 1000);
|
const seconds = Math.round(deltaMs / 1000);
|
||||||
if (seconds < 5) return "just now";
|
if (seconds < 5) return "just now";
|
||||||
@@ -120,31 +157,31 @@ function aisAgeText(tsMs) {
|
|||||||
return `${hours}h ago`;
|
return `${hours}h ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisMotionText(msg) {
|
function aisMotionText(msg: AisMessage): string {
|
||||||
const parts = [
|
const parts = [
|
||||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
||||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null,
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}° COG` : null,
|
||||||
msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}° HDG` : null,
|
msg.heading_deg != null ? `${msg.heading_deg.toFixed(0)}° HDG` : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisRouteText(msg) {
|
function aisRouteText(msg: AisMessage): string {
|
||||||
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisDistanceText(msg) {
|
function aisDistanceText(msg: AisMessage): string {
|
||||||
if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) {
|
if (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon);
|
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, msg.lat, msg.lon);
|
||||||
if (!Number.isFinite(distKm)) return "";
|
if (!Number.isFinite(distKm)) return "";
|
||||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||||
return `${distKm.toFixed(1)} km from TRX`;
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisLatestByVessel(messages) {
|
function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
|
||||||
const byMmsi = new Map();
|
const byMmsi = new Map<string, AisMessage>();
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
|
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
|
||||||
if (!byMmsi.has(key)) byMmsi.set(key, msg);
|
if (!byMmsi.has(key)) byMmsi.set(key, msg);
|
||||||
@@ -175,7 +212,7 @@ function updateAisSummary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAisRow(msg) {
|
function renderAisRow(msg: AisMessage): HTMLElement {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "ais-message";
|
row.className = "ais-message";
|
||||||
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||||
@@ -209,25 +246,25 @@ function renderAisRow(msg) {
|
|||||||
`<div class="ais-row-head">` +
|
`<div class="ais-row-head">` +
|
||||||
`<span class="ais-time">${ts}</span>` +
|
`<span class="ais-time">${ts}</span>` +
|
||||||
`<span class="ais-call">${nameHtml}</span>` +
|
`<span class="ais-call">${nameHtml}</span>` +
|
||||||
`<span class="${channel.badgeClass}">${escapeMapHtml(channel.label)}</span>` +
|
`<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
|
||||||
`<span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span>` +
|
`<span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="ais-row-meta">` +
|
`<div class="ais-row-meta">` +
|
||||||
`<span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` +
|
`<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
|
||||||
(route ? `<span class="ais-meta-text">${escapeMapHtml(route)}</span>` : "") +
|
(route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") +
|
||||||
`<span class="ais-meta-text">${escapeMapHtml(channel.freqText)}</span>` +
|
`<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="ais-row-detail">` +
|
`<div class="ais-row-detail">` +
|
||||||
(motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
(motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
||||||
(distance ? `<span>${escapeMapHtml(distance)}</span>` : "") +
|
(distance ? `<span>${escapeAisHtml(distance)}</span>` : "") +
|
||||||
(pos ? `<span>${pos}</span>` : "") +
|
(pos ? `<span>${pos}</span>` : "") +
|
||||||
`<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span>` +
|
`<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
applyAisFilterToRow(row);
|
applyAisFilterToRow(row);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyAisFilterToRow(row) {
|
function applyAisFilterToRow(row: HTMLElement): void {
|
||||||
if (!aisFilterText) {
|
if (!aisFilterText) {
|
||||||
row.style.display = "";
|
row.style.display = "";
|
||||||
return;
|
return;
|
||||||
@@ -236,19 +273,13 @@ function applyAisFilterToRow(row) {
|
|||||||
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
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() {
|
function updateAisBar() {
|
||||||
if (!aisBarOverlay) return;
|
if (!aisBarOverlay) return;
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
|
|
||||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
if (!isAis || messages.length === 0) {
|
if (!isAis || messages.length === 0) {
|
||||||
aisBarOverlay.style.display = "none";
|
aisBarOverlay.style.display = "none";
|
||||||
@@ -266,12 +297,12 @@ function updateAisBar() {
|
|||||||
const channel = aisChannelInfo(msg.channel);
|
const channel = aisChannelInfo(msg.channel);
|
||||||
const distance = aisDistanceText(msg);
|
const distance = aisDistanceText(msg);
|
||||||
const details = [
|
const details = [
|
||||||
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
|
`MMSI ${escapeAisHtml(String(msg.mmsi))}`,
|
||||||
escapeMapHtml(channel.label),
|
escapeAisHtml(channel.label),
|
||||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
||||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}°` : null,
|
||||||
distance ? escapeMapHtml(distance) : null,
|
distance ? escapeAisHtml(distance) : null,
|
||||||
escapeMapHtml(aisAgeText(msg._tsMs)),
|
escapeAisHtml(aisAgeText(msg._tsMs)),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
@@ -282,18 +313,18 @@ function updateAisBar() {
|
|||||||
aisBarOverlay.innerHTML = html;
|
aisBarOverlay.innerHTML = html;
|
||||||
aisBarOverlay.style.display = "flex";
|
aisBarOverlay.style.display = "flex";
|
||||||
}
|
}
|
||||||
window.updateAisBar = updateAisBar;
|
aisWindow.updateAisBar = updateAisBar;
|
||||||
window.clearAisBar = function() {
|
aisWindow.clearAisBar = function() {
|
||||||
window.resetAisHistoryView();
|
resetAisHistoryView();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.resetAisHistoryView = function() {
|
function resetAisHistoryView(): void {
|
||||||
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
||||||
aisMessageHistory = [];
|
aisMessageHistory = [];
|
||||||
updateAisBar();
|
updateAisBar();
|
||||||
renderAisHistory();
|
renderAisHistory();
|
||||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
|
aisWindow.clearMapMarkersByType?.("ais");
|
||||||
};
|
}
|
||||||
|
|
||||||
function renderAisHistory() {
|
function renderAisHistory() {
|
||||||
pruneAisMessageHistory();
|
pruneAisMessageHistory();
|
||||||
@@ -302,14 +333,14 @@ function renderAisHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (let i = 0; i < aisMessageHistory.length; i += 1) {
|
for (const message of aisMessageHistory) {
|
||||||
fragment.appendChild(renderAisRow(aisMessageHistory[i]));
|
fragment.appendChild(renderAisRow(message));
|
||||||
}
|
}
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addAisMessage(msg) {
|
function addAisMessage(msg: AisMessage): void {
|
||||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
msg._tsMs = tsMs;
|
msg._tsMs = tsMs;
|
||||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -323,33 +354,22 @@ function addAisMessage(msg) {
|
|||||||
scheduleAisBarUpdate();
|
scheduleAisBarUpdate();
|
||||||
scheduleAisHistoryRender();
|
scheduleAisHistoryRender();
|
||||||
|
|
||||||
if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) {
|
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
|
||||||
window.aisMapAddVessel(msg);
|
aisWindow.aisMapAddVessel(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerAisMessage(msg) {
|
function normalizeServerAisMessage(msg: AisMessage): AisMessage {
|
||||||
return {
|
return {
|
||||||
|
...msg,
|
||||||
rig_id: msg.rig_id || null,
|
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) {
|
function onServerAisBatch(messages: AisMessage[]): void {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
const normalized = [];
|
const normalized: AisMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerAisMessage(msg);
|
const next = normalizeServerAisMessage(msg);
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
@@ -359,8 +379,8 @@ window.onServerAisBatch = function(messages) {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
if (next.lat != null && next.lon != null && window.aisMapAddVessel) {
|
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
|
||||||
window.aisMapAddVessel(next);
|
aisWindow.aisMapAddVessel(next);
|
||||||
}
|
}
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
@@ -369,27 +389,23 @@ window.onServerAisBatch = function(messages) {
|
|||||||
pruneAisMessageHistory();
|
pruneAisMessageHistory();
|
||||||
scheduleAisBarUpdate();
|
scheduleAisBarUpdate();
|
||||||
scheduleAisHistoryRender();
|
scheduleAisHistoryRender();
|
||||||
};
|
}
|
||||||
|
|
||||||
window.restoreAisHistory = function(messages) {
|
function pruneAisHistoryView(): void {
|
||||||
window.onServerAisBatch(messages);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.pruneAisHistoryView = function() {
|
|
||||||
pruneAisMessageHistory();
|
pruneAisMessageHistory();
|
||||||
updateAisBar();
|
updateAisBar();
|
||||||
renderAisHistory();
|
renderAisHistory();
|
||||||
};
|
}
|
||||||
|
|
||||||
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
|
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => { void (async () => {
|
||||||
if (!confirm("Clear all AIS decode history? This cannot be undone.")) return;
|
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
try {
|
try {
|
||||||
await postPath("/clear_ais_decode");
|
await hostCore.postPath("/clear_ais_decode");
|
||||||
window.resetAisHistoryView();
|
resetAisHistoryView();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("AIS history clear failed", e);
|
console.error("AIS history clear failed", e);
|
||||||
}
|
}
|
||||||
});
|
})(); });
|
||||||
|
|
||||||
if (aisFilterInput) {
|
if (aisFilterInput) {
|
||||||
aisFilterInput.addEventListener("input", () => {
|
aisFilterInput.addEventListener("input", () => {
|
||||||
@@ -398,10 +414,17 @@ if (aisFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onServerAis = function(msg) {
|
function onServerAis(msg: AisMessage): void {
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
addAisMessage(normalizeServerAisMessage(msg));
|
addAisMessage(normalizeServerAisMessage(msg));
|
||||||
};
|
}
|
||||||
|
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais");
|
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder<AisMessage>({
|
||||||
|
id: "ais",
|
||||||
|
onMessage: onServerAis,
|
||||||
|
onBatch: onServerAisBatch,
|
||||||
|
restore: onServerAisBatch,
|
||||||
|
reset: resetAisHistoryView,
|
||||||
|
prune: pruneAisHistoryView,
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
export type AprsCategory = "position" | "message" | "weather" | "telemetry" | "other";
|
||||||
|
export type AprsTypeFilter = "all" | AprsCategory;
|
||||||
|
|
||||||
|
export interface AprsPacket {
|
||||||
|
rig_id?: string | null;
|
||||||
|
receiver?: unknown;
|
||||||
|
srcCall?: string;
|
||||||
|
destCall?: string;
|
||||||
|
path?: string;
|
||||||
|
info?: string;
|
||||||
|
info_bytes?: number[];
|
||||||
|
type?: string;
|
||||||
|
crcOk?: boolean;
|
||||||
|
ts_ms?: number | null;
|
||||||
|
lat?: number | null;
|
||||||
|
lon?: number | null;
|
||||||
|
symbolTable?: string | null;
|
||||||
|
symbolCode?: string | null;
|
||||||
|
_tsMs?: number;
|
||||||
|
_ts?: string;
|
||||||
|
src_call?: string;
|
||||||
|
dest_call?: string;
|
||||||
|
packet_type?: string;
|
||||||
|
crc_ok?: boolean;
|
||||||
|
symbol_table?: string | null;
|
||||||
|
symbol_code?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aprsPacketCategory(packet: AprsPacket): AprsCategory {
|
||||||
|
const type = (packet.type ?? "").toLowerCase();
|
||||||
|
const info = (packet.info ?? "").toLowerCase();
|
||||||
|
if ((packet.lat != null && packet.lon != null) || type.includes("position")) return "position";
|
||||||
|
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||||
|
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||||
|
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aprsCategoryLabel(category: AprsCategory): string {
|
||||||
|
switch (category) {
|
||||||
|
case "position": return "Position";
|
||||||
|
case "message": return "Message";
|
||||||
|
case "weather": return "Weather";
|
||||||
|
case "telemetry": return "Telemetry";
|
||||||
|
default: return "Other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aprsAgeText(timestampMs: number | undefined): string {
|
||||||
|
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
|
||||||
|
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1000);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${String(seconds)}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${String(minutes)}m ago`;
|
||||||
|
return `${String(Math.round(minutes / 60))}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aprsPacketSignature(packet: AprsPacket): string {
|
||||||
|
return [packet.srcCall ?? "", packet.destCall ?? "", packet.path ?? "", packet.info ?? "", packet.type ?? "",
|
||||||
|
packet.lat?.toFixed(4) ?? "", packet.lon?.toFixed(4) ?? ""].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collapseAprsDuplicates(packets: AprsPacket[]): AprsPacket[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return packets.filter((packet) => {
|
||||||
|
const signature = aprsPacketSignature(packet);
|
||||||
|
if (seen.has(signature)) return false;
|
||||||
|
seen.add(signature);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aprsHexBytes(bytes: number[] | undefined): string {
|
||||||
|
if (!bytes?.length) return "--";
|
||||||
|
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAprsInfo(packet: AprsPacket): string {
|
||||||
|
if (packet.info_bytes?.length) {
|
||||||
|
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
|
||||||
|
}
|
||||||
|
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAprsByte(byte: number): string {
|
||||||
|
return byte >= 0x20 && byte <= 0x7e
|
||||||
|
? escapeAprsCharacter(String.fromCharCode(byte))
|
||||||
|
: `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAprsCharacter(character: string): string {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
return code >= 0x20 && code <= 0x7e
|
||||||
|
? escapeAprsCharacter(character)
|
||||||
|
: `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAprsCharacter(character: string): string {
|
||||||
|
if (character === "<") return "<";
|
||||||
|
if (character === ">") return ">";
|
||||||
|
if (character === "&") return "&";
|
||||||
|
if (character === '"') return """;
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string {
|
||||||
|
if (!packet.symbolTable || !packet.symbolCode) return "";
|
||||||
|
const symbol = escapeHtml(packet.symbolCode);
|
||||||
|
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
|
||||||
|
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAprsPacket(packet: AprsPacket, receiver: unknown): AprsPacket {
|
||||||
|
return {
|
||||||
|
rig_id: packet.rig_id || null,
|
||||||
|
receiver,
|
||||||
|
srcCall: packet.src_call ?? "",
|
||||||
|
destCall: packet.dest_call ?? "",
|
||||||
|
path: packet.path ?? "",
|
||||||
|
info: packet.info ?? "",
|
||||||
|
info_bytes: packet.info_bytes ?? [],
|
||||||
|
type: packet.packet_type ?? "",
|
||||||
|
crcOk: packet.crc_ok ?? false,
|
||||||
|
ts_ms: packet.ts_ms ?? null,
|
||||||
|
lat: packet.lat ?? null,
|
||||||
|
lon: packet.lon ?? null,
|
||||||
|
symbolTable: packet.symbol_table ?? null,
|
||||||
|
symbolCode: packet.symbol_code ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
+123
-212
@@ -2,10 +2,41 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
aprsAgeText,
|
||||||
|
aprsCategoryLabel,
|
||||||
|
aprsHexBytes,
|
||||||
|
aprsPacketCategory,
|
||||||
|
collapseAprsDuplicates,
|
||||||
|
normalizeAprsPacket,
|
||||||
|
renderAprsInfo,
|
||||||
|
renderLocalAprsSymbol,
|
||||||
|
type AprsPacket,
|
||||||
|
type AprsTypeFilter,
|
||||||
|
} from "./aprs-shared";
|
||||||
|
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||||
|
interface AprsBridge {
|
||||||
|
getDecodeHistoryRetentionMs?: () => number;
|
||||||
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
|
navigateToAprsMap?: (lat: number, lon: number) => void;
|
||||||
|
clearMapMarkersByType?: (type: string) => void;
|
||||||
|
aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
|
||||||
|
getDecodeRigMeta?: () => unknown;
|
||||||
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
|
updateAprsBar?: () => void;
|
||||||
|
clearAprsBar?: () => void;
|
||||||
|
closeAprsBar?: () => void;
|
||||||
|
}
|
||||||
|
const aprsWindow = window as unknown as AprsBridge;
|
||||||
|
const escapeAprsHtml = (input: string): string => hostCore.escapeMapHtml(input);
|
||||||
|
const showAprsHint = (message: string, durationMs: number): void => { hostCore.showHint(message, durationMs); };
|
||||||
|
|
||||||
// --- APRS Decoder Plugin (server-side decode) ---
|
// --- APRS Decoder Plugin (server-side decode) ---
|
||||||
const aprsStatus = document.getElementById("aprs-status");
|
const aprsStatus = document.getElementById("aprs-status");
|
||||||
const aprsPacketsEl = document.getElementById("aprs-packets");
|
const aprsPacketsEl = document.getElementById("aprs-packets");
|
||||||
const aprsFilterInput = document.getElementById("aprs-filter");
|
const aprsFilterInput = document.getElementById("aprs-filter") as HTMLInputElement | null;
|
||||||
const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
|
const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
|
||||||
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
|
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
|
||||||
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
|
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
|
||||||
@@ -15,137 +46,49 @@ const aprsVisibleCountEl = document.getElementById("aprs-visible-count");
|
|||||||
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
|
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
|
||||||
const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||||
let aprsFilterText = "";
|
let aprsFilterText = "";
|
||||||
let aprsPacketHistory = [];
|
let aprsPacketHistory: AprsPacket[] = [];
|
||||||
let aprsBarDismissedAtMs = 0;
|
let aprsBarDismissedAtMs = 0;
|
||||||
let aprsOnlyPos = false;
|
let aprsOnlyPos = false;
|
||||||
let aprsHideCrc = false;
|
let aprsHideCrc = false;
|
||||||
let aprsCollapseDup = false;
|
let aprsCollapseDup = false;
|
||||||
let aprsTypeFilter = "all";
|
let aprsTypeFilter: AprsTypeFilter = "all";
|
||||||
|
|
||||||
function currentAprsHistoryRetentionMs() {
|
function currentAprsHistoryRetentionMs(): number {
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
return typeof aprsWindow.getDecodeHistoryRetentionMs === "function"
|
||||||
? window.getDecodeHistoryRetentionMs()
|
? aprsWindow.getDecodeHistoryRetentionMs()
|
||||||
: 24 * 60 * 60 * 1000;
|
: 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneAprsPacketHistory() {
|
function pruneAprsPacketHistory() {
|
||||||
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
|
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
|
||||||
aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
|
aprsPacketHistory = aprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAprsUi(key, job) {
|
function scheduleAprsUi(key: string, job: () => void): void {
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
if (typeof aprsWindow.trxScheduleUiFrameJob === "function") {
|
||||||
window.trxScheduleUiFrameJob(key, job);
|
aprsWindow.trxScheduleUiFrameJob(key, job);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAprsHistoryRender() {
|
function scheduleAprsHistoryRender() {
|
||||||
scheduleAprsUi("aprs-history", () => renderAprsHistory());
|
scheduleAprsUi("aprs-history", () => { renderAprsHistory(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAprsBarUpdate() {
|
function scheduleAprsBarUpdate() {
|
||||||
scheduleAprsUi("aprs-bar", () => updateAprsBar());
|
scheduleAprsUi("aprs-bar", () => { updateAprsBar(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAprsInfo(pkt) {
|
function aprsDistanceText(pkt: AprsPacket): string {
|
||||||
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
|
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||||
if (bytes && bytes.length > 0) {
|
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
|
||||||
let out = "";
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
|
||||||
const b = bytes[i];
|
|
||||||
if (b >= 0x20 && b <= 0x7e) {
|
|
||||||
const ch = String.fromCharCode(b);
|
|
||||||
if (ch === "<") out += "<";
|
|
||||||
else if (ch === ">") out += ">";
|
|
||||||
else if (ch === "&") out += "&";
|
|
||||||
else if (ch === '"') out += """;
|
|
||||||
else out += ch;
|
|
||||||
} else {
|
|
||||||
const hex = b.toString(16).toUpperCase().padStart(2, "0");
|
|
||||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
const str = pkt.info || "";
|
|
||||||
let out = "";
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
const code = str.charCodeAt(i);
|
|
||||||
if (code >= 0x20 && code <= 0x7e) {
|
|
||||||
const ch = str[i];
|
|
||||||
if (ch === "<") out += "<";
|
|
||||||
else if (ch === ">") out += ">";
|
|
||||||
else if (ch === "&") out += "&";
|
|
||||||
else if (ch === '"') out += """;
|
|
||||||
else out += ch;
|
|
||||||
} else {
|
|
||||||
const hex = code.toString(16).toUpperCase().padStart(2, "0");
|
|
||||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aprsPacketCategory(pkt) {
|
|
||||||
const type = String(pkt.type || "").toLowerCase();
|
|
||||||
const info = String(pkt.info || "").toLowerCase();
|
|
||||||
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
|
|
||||||
if (type.includes("message") || info.startsWith(":")) return "message";
|
|
||||||
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
|
||||||
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
function aprsCategoryLabel(category) {
|
|
||||||
switch (category) {
|
|
||||||
case "position": return "Position";
|
|
||||||
case "message": return "Message";
|
|
||||||
case "weather": return "Weather";
|
|
||||||
case "telemetry": return "Telemetry";
|
|
||||||
default: return "Other";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function aprsAgeText(tsMs) {
|
|
||||||
if (!Number.isFinite(tsMs)) return "just now";
|
|
||||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
|
||||||
const seconds = Math.round(deltaMs / 1000);
|
|
||||||
if (seconds < 5) return "just now";
|
|
||||||
if (seconds < 60) return `${seconds}s ago`;
|
|
||||||
const minutes = Math.round(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.round(minutes / 60);
|
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aprsDistanceText(pkt) {
|
|
||||||
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
|
||||||
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
|
|
||||||
if (!Number.isFinite(distKm)) return "";
|
if (!Number.isFinite(distKm)) return "";
|
||||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||||
return `${distKm.toFixed(1)} km from TRX`;
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aprsPacketSignature(pkt) {
|
function aprsFilterMatch(pkt: AprsPacket): boolean {
|
||||||
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 (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||||
if (aprsHideCrc && !pkt.crcOk) return false;
|
if (aprsHideCrc && !pkt.crcOk) return false;
|
||||||
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
|
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
|
||||||
@@ -166,23 +109,11 @@ function aprsFilterMatch(pkt) {
|
|||||||
return haystack.includes(aprsFilterText);
|
return haystack.includes(aprsFilterText);
|
||||||
}
|
}
|
||||||
|
|
||||||
function aprsVisiblePackets() {
|
function aprsVisiblePackets(): AprsPacket[] {
|
||||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
||||||
return packets.filter(aprsFilterMatch);
|
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() {
|
function updateAprsSummary() {
|
||||||
const visible = aprsVisiblePackets();
|
const visible = aprsVisiblePackets();
|
||||||
if (aprsTotalCountEl) {
|
if (aprsTotalCountEl) {
|
||||||
@@ -210,7 +141,7 @@ function updateAprsChipState() {
|
|||||||
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAprsRow(pkt, isFresh) {
|
function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "aprs-packet";
|
row.className = "aprs-packet";
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||||
@@ -221,18 +152,9 @@ function renderAprsRow(pkt, isFresh) {
|
|||||||
const category = aprsPacketCategory(pkt);
|
const category = aprsPacketCategory(pkt);
|
||||||
const categoryLabel = aprsCategoryLabel(category);
|
const categoryLabel = aprsCategoryLabel(category);
|
||||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${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 pathBadge = pkt.path ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||||
let symbolHtml = "";
|
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
|
||||||
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
|
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>`
|
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
||||||
: "";
|
: "";
|
||||||
@@ -243,19 +165,19 @@ function renderAprsRow(pkt, isFresh) {
|
|||||||
`<div class="aprs-row-head">` +
|
`<div class="aprs-row-head">` +
|
||||||
`<span class="aprs-time">${ts}</span>` +
|
`<span class="aprs-time">${ts}</span>` +
|
||||||
symbolHtml +
|
symbolHtml +
|
||||||
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
|
`<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>` +
|
||||||
`<span>>${escapeMapHtml(pkt.destCall || "")}</span>` +
|
`<span>>${escapeAprsHtml(pkt.destCall || "")}</span>` +
|
||||||
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
|
`<span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` +
|
||||||
pathBadge +
|
pathBadge +
|
||||||
crcBadge +
|
crcBadge +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="aprs-row-meta">` +
|
`<div class="aprs-row-meta">` +
|
||||||
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
|
`<span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` +
|
||||||
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
|
(distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") +
|
||||||
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
|
`<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="aprs-row-detail">` +
|
`<div class="aprs-row-detail">` +
|
||||||
`<span title="${escapeMapHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
|
`<span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
|
||||||
(posLink ? `<span>${posLink}</span>` : "") +
|
(posLink ? `<span>${posLink}</span>` : "") +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="aprs-row-actions">` +
|
`<div class="aprs-row-actions">` +
|
||||||
@@ -266,42 +188,43 @@ function renderAprsRow(pkt, isFresh) {
|
|||||||
`<details class="aprs-details">` +
|
`<details class="aprs-details">` +
|
||||||
`<summary>Details</summary>` +
|
`<summary>Details</summary>` +
|
||||||
`<div class="aprs-details-grid">` +
|
`<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">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
|
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
|
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
|
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
|
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span>` +
|
||||||
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
`<span class="aprs-detail-label">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">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</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
|
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`</details>`;
|
`</details>`;
|
||||||
|
|
||||||
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
|
||||||
el.addEventListener("click", (evt) => {
|
el.addEventListener("click", (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
const raw = String(el.dataset.aprsMap || "");
|
const raw = el.dataset.aprsMap ?? "";
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
const [lat, lon] = raw.split(",").map(Number);
|
||||||
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
|
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
window.navigateToAprsMap(lat, lon);
|
aprsWindow.navigateToAprsMap(lat, lon);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyBtn = row.querySelector("[data-aprs-copy]");
|
const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
|
||||||
if (copyBtn) {
|
if (copyBtn) {
|
||||||
copyBtn.addEventListener("click", async () => {
|
copyBtn.addEventListener("click", () => { void (async () => {
|
||||||
const raw = String(copyBtn.dataset.aprsCopy || "");
|
const raw = copyBtn.dataset.aprsCopy ?? "";
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard?.writeText) {
|
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
|
||||||
await navigator.clipboard.writeText(raw);
|
if (clipboard) {
|
||||||
showHint("Coordinates copied", 1200);
|
await clipboard.writeText(raw);
|
||||||
|
showAprsHint("Coordinates copied", 1200);
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch {
|
||||||
showHint("Copy failed", 1500);
|
showAprsHint("Copy failed", 1500);
|
||||||
}
|
}
|
||||||
});
|
})(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
@@ -316,8 +239,8 @@ function renderAprsHistory() {
|
|||||||
}
|
}
|
||||||
const visible = aprsVisiblePackets();
|
const visible = aprsVisiblePackets();
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (let i = 0; i < visible.length; i++) {
|
for (const [index, packet] of visible.entries()) {
|
||||||
fragment.appendChild(renderAprsRow(visible[i], i === 0));
|
fragment.appendChild(renderAprsRow(packet, index === 0));
|
||||||
}
|
}
|
||||||
aprsPacketsEl.replaceChildren(fragment);
|
aprsPacketsEl.replaceChildren(fragment);
|
||||||
updateAprsSummary();
|
updateAprsSummary();
|
||||||
@@ -326,9 +249,9 @@ function renderAprsHistory() {
|
|||||||
|
|
||||||
function updateAprsBar() {
|
function updateAprsBar() {
|
||||||
if (!aprsBarOverlay) return;
|
if (!aprsBarOverlay) return;
|
||||||
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
|
||||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs);
|
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
||||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
@@ -339,9 +262,9 @@ function updateAprsBar() {
|
|||||||
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">×</button></span></div>';
|
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">×</button></span></div>';
|
||||||
for (const pkt of frames) {
|
for (const pkt of frames) {
|
||||||
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
|
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
|
||||||
const call = `<span class="aprs-bar-call">${escapeMapHtml(pkt.srcCall)}</span>`;
|
const call = `<span class="aprs-bar-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>`;
|
||||||
const dest = escapeMapHtml(pkt.destCall || "");
|
const dest = escapeAprsHtml(pkt.destCall || "");
|
||||||
const info = escapeMapHtml(pkt.info || "");
|
const info = escapeAprsHtml(pkt.info || "");
|
||||||
const pin = pkt.lat != null && pkt.lon != null
|
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>`
|
? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>`
|
||||||
: "";
|
: "";
|
||||||
@@ -352,11 +275,11 @@ function updateAprsBar() {
|
|||||||
aprsBarOverlay.innerHTML = html;
|
aprsBarOverlay.innerHTML = html;
|
||||||
aprsBarOverlay.style.display = "flex";
|
aprsBarOverlay.style.display = "flex";
|
||||||
}
|
}
|
||||||
window.updateAprsBar = updateAprsBar;
|
aprsWindow.updateAprsBar = updateAprsBar;
|
||||||
window.clearAprsBar = function() {
|
aprsWindow.clearAprsBar = function() {
|
||||||
window.resetAprsHistoryView();
|
resetAprsHistoryView();
|
||||||
};
|
};
|
||||||
window.closeAprsBar = function() {
|
aprsWindow.closeAprsBar = function() {
|
||||||
aprsBarDismissedAtMs = Date.now();
|
aprsBarDismissedAtMs = Date.now();
|
||||||
if (aprsBarOverlay) {
|
if (aprsBarOverlay) {
|
||||||
aprsBarOverlay.style.display = "none";
|
aprsBarOverlay.style.display = "none";
|
||||||
@@ -364,21 +287,21 @@ window.closeAprsBar = function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.resetAprsHistoryView = function() {
|
function resetAprsHistoryView(): void {
|
||||||
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
|
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
|
||||||
aprsPacketHistory = [];
|
aprsPacketHistory = [];
|
||||||
updateAprsBar();
|
updateAprsBar();
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs");
|
aprsWindow.clearMapMarkersByType?.("aprs");
|
||||||
};
|
}
|
||||||
|
|
||||||
window.pruneAprsHistoryView = function() {
|
function pruneAprsHistoryView(): void {
|
||||||
pruneAprsPacketHistory();
|
pruneAprsPacketHistory();
|
||||||
updateAprsBar();
|
updateAprsBar();
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
};
|
}
|
||||||
|
|
||||||
function addAprsPacket(pkt) {
|
function addAprsPacket(pkt: AprsPacket): void {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
@@ -386,8 +309,8 @@ function addAprsPacket(pkt) {
|
|||||||
aprsPacketHistory.unshift(pkt);
|
aprsPacketHistory.unshift(pkt);
|
||||||
pruneAprsPacketHistory();
|
pruneAprsPacketHistory();
|
||||||
|
|
||||||
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
|
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
|
||||||
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt);
|
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pkt.crcOk) scheduleAprsBarUpdate();
|
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||||
@@ -395,37 +318,22 @@ function addAprsPacket(pkt) {
|
|||||||
scheduleAprsHistoryRender();
|
scheduleAprsHistoryRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerAprsPacket(pkt) {
|
function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
|
||||||
return {
|
return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null);
|
||||||
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) {
|
function onServerAprsBatch(packets: AprsPacket[]): void {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
aprsStatus.textContent = "Receiving";
|
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||||
const normalized = [];
|
const normalized: AprsPacket[] = [];
|
||||||
let hasCrcOk = false;
|
let hasCrcOk = false;
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerAprsPacket(pkt);
|
const next = normalizeServerAprsPacket(pkt);
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
if (next.lat != null && next.lon != null && window.aprsMapAddStation) {
|
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
|
||||||
window.aprsMapAddStation(next.srcCall, next.lat, next.lon, next.info, next.symbolTable, next.symbolCode, next);
|
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
|
||||||
}
|
}
|
||||||
if (next.crcOk) hasCrcOk = true;
|
if (next.crcOk) hasCrcOk = true;
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
@@ -435,21 +343,17 @@ window.onServerAprsBatch = function(packets) {
|
|||||||
pruneAprsPacketHistory();
|
pruneAprsPacketHistory();
|
||||||
if (hasCrcOk) scheduleAprsBarUpdate();
|
if (hasCrcOk) scheduleAprsBarUpdate();
|
||||||
scheduleAprsHistoryRender();
|
scheduleAprsHistoryRender();
|
||||||
};
|
}
|
||||||
|
|
||||||
window.restoreAprsHistory = function(packets) {
|
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => { void (async () => {
|
||||||
window.onServerAprsBatch(packets);
|
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Clear all APRS decode history? This cannot be undone.")) return;
|
|
||||||
try {
|
try {
|
||||||
await postPath("/clear_aprs_decode");
|
await hostCore.postPath("/clear_aprs_decode");
|
||||||
window.resetAprsHistoryView();
|
resetAprsHistoryView();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("APRS history clear failed", e);
|
console.error("APRS history clear failed", e);
|
||||||
}
|
}
|
||||||
});
|
})(); });
|
||||||
|
|
||||||
if (aprsOnlyPosBtn) {
|
if (aprsOnlyPosBtn) {
|
||||||
aprsOnlyPosBtn.addEventListener("click", () => {
|
aprsOnlyPosBtn.addEventListener("click", () => {
|
||||||
@@ -472,7 +376,7 @@ if (aprsCollapseDupBtn) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
(["all", "position", "message", "weather", "telemetry", "other"] as const).forEach((type) => {
|
||||||
const btn = document.getElementById(`aprs-type-${type}`);
|
const btn = document.getElementById(`aprs-type-${type}`);
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
@@ -489,10 +393,17 @@ if (aprsFilterInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Server-side APRS decode handler ---
|
// --- Server-side APRS decode handler ---
|
||||||
window.onServerAprs = function(pkt) {
|
function onServerAprs(pkt: AprsPacket): void {
|
||||||
aprsStatus.textContent = "Receiving";
|
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||||
};
|
}
|
||||||
|
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs");
|
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder<AprsPacket>({
|
||||||
|
id: "aprs",
|
||||||
|
onMessage: onServerAprs,
|
||||||
|
onBatch: onServerAprsBatch,
|
||||||
|
restore: onServerAprsBatch,
|
||||||
|
reset: resetAprsHistoryView,
|
||||||
|
prune: pruneAprsHistoryView,
|
||||||
|
});
|
||||||
+146
-86
@@ -2,30 +2,79 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import { hostState } from "./host.js";
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
interface DecoderDescriptor {
|
||||||
|
id: string;
|
||||||
|
background_decode?: boolean;
|
||||||
|
activation?: string;
|
||||||
|
active_modes: string[];
|
||||||
|
}
|
||||||
|
interface Bookmark {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
freq_hz: number;
|
||||||
|
mode: string;
|
||||||
|
decoders?: string[];
|
||||||
|
}
|
||||||
|
interface BackgroundDecodeConfig {
|
||||||
|
remote: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
bookmark_ids: string[];
|
||||||
|
}
|
||||||
|
interface BackgroundStatusEntry {
|
||||||
|
bookmark_name?: string;
|
||||||
|
bookmark_id?: string;
|
||||||
|
freq_hz?: number;
|
||||||
|
mode?: string;
|
||||||
|
decoder_kinds?: string[];
|
||||||
|
state?: string;
|
||||||
|
}
|
||||||
|
interface BackgroundDecodeStatus {
|
||||||
|
entries?: BackgroundStatusEntry[];
|
||||||
|
active_rig?: boolean;
|
||||||
|
center_hz?: number;
|
||||||
|
sample_rate?: number;
|
||||||
|
}
|
||||||
|
interface BackgroundBridge {
|
||||||
|
decoderRegistry?: DecoderDescriptor[];
|
||||||
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
|
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
|
||||||
|
}
|
||||||
|
interface BackgroundDecodeService {
|
||||||
|
initialize(rigId: string | null, role: string | null): void;
|
||||||
|
wireEvents(): void;
|
||||||
|
setRig(rigId: string | null): void;
|
||||||
|
}
|
||||||
|
interface WiredElement extends HTMLElement { _wired?: boolean }
|
||||||
|
const bgdWindow = window as unknown as BackgroundBridge;
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
function bgdSupportedIds() {
|
function bgdSupportedIds(): string[] {
|
||||||
return (window.decoderRegistry || [])
|
return (bgdWindow.decoderRegistry || [])
|
||||||
.filter(function (d) { return d.background_decode; })
|
.filter(function (d) { return d.background_decode; })
|
||||||
.map(function (d) { return d.id; });
|
.map(function (d) { return d.id; });
|
||||||
}
|
}
|
||||||
|
|
||||||
let backgroundDecodeRole = null;
|
let backgroundDecodeRole: string | null = null;
|
||||||
let currentRigId = null;
|
let currentRigId: string | null = null;
|
||||||
let currentConfig = null;
|
let currentConfig: BackgroundDecodeConfig | null = null;
|
||||||
let bookmarkList = [];
|
let bookmarkList: Bookmark[] = [];
|
||||||
let statusInterval = null;
|
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let bgdDirty = false;
|
let bgdDirty = false;
|
||||||
|
|
||||||
function initBackgroundDecode(rigId, role) {
|
function initBackgroundDecode(rigId: string | null, role: string | null): void {
|
||||||
backgroundDecodeRole = role;
|
backgroundDecodeRole = role;
|
||||||
currentRigId = rigId || null;
|
currentRigId = rigId || null;
|
||||||
if (currentRigId) loadBackgroundDecode();
|
if (currentRigId) loadBackgroundDecode();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBackgroundDecodeRig(rigId) {
|
function setBackgroundDecodeRig(rigId: string | null): void {
|
||||||
const nextRigId = rigId || null;
|
const nextRigId = rigId || null;
|
||||||
if (nextRigId === currentRigId) return;
|
if (nextRigId === currentRigId) return;
|
||||||
currentRigId = nextRigId;
|
currentRigId = nextRigId;
|
||||||
@@ -33,44 +82,44 @@
|
|||||||
loadBackgroundDecode();
|
loadBackgroundDecode();
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiGetConfig(rigId) {
|
function apiGetConfig(rigId: string): Promise<BackgroundDecodeConfig> {
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) {
|
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) {
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
return r.json();
|
return r.json() as Promise<BackgroundDecodeConfig>;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiPutConfig(rigId, config) {
|
function apiPutConfig(rigId: string, config: BackgroundDecodeConfig): Promise<BackgroundDecodeConfig> {
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
return r.json();
|
return r.json() as Promise<BackgroundDecodeConfig>;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiResetConfig(rigId) {
|
function apiResetConfig(rigId: string): Promise<BackgroundDecodeConfig> {
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
return r.json();
|
return r.json() as Promise<BackgroundDecodeConfig>;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiGetStatus(rigId) {
|
function apiGetStatus(rigId: string): Promise<BackgroundDecodeStatus> {
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) {
|
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) {
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
return r.json();
|
return r.json() as Promise<BackgroundDecodeStatus>;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiGetBookmarks() {
|
function apiGetBookmarks(): Promise<Bookmark[]> {
|
||||||
return fetch("/bookmarks").then(function (r) {
|
return fetch("/bookmarks").then(function (r) {
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
return r.json();
|
return r.json() as Promise<Bookmark[]>;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,35 +128,35 @@
|
|||||||
if (!rigId) return;
|
if (!rigId) return;
|
||||||
Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
|
Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
|
||||||
.then(function ([config, bookmarks]) {
|
.then(function ([config, bookmarks]) {
|
||||||
currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] };
|
currentConfig = config;
|
||||||
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
|
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
|
||||||
renderBackgroundDecode();
|
renderBackgroundDecode();
|
||||||
clearBgdDirty();
|
clearBgdDirty();
|
||||||
pollBackgroundDecodeStatus();
|
pollBackgroundDecodeStatus();
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err: unknown) {
|
||||||
console.error("background decode load failed", err);
|
console.error("background decode load failed", err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function supportedBookmarks() {
|
function supportedBookmarks(): Bookmark[] {
|
||||||
return bookmarkList.filter(function (bookmark) {
|
return bookmarkList.filter(function (bookmark) {
|
||||||
return bookmarkDecoderKinds(bookmark).length > 0;
|
return bookmarkDecoderKinds(bookmark).length > 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function bookmarkDecoderKinds(bookmark) {
|
function bookmarkDecoderKinds(bookmark: Bookmark): string[] {
|
||||||
var ids = bgdSupportedIds();
|
const ids = bgdSupportedIds();
|
||||||
var decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : [];
|
const decoders = bookmark.decoders ?? [];
|
||||||
var explicit = decoders
|
const explicit = decoders
|
||||||
.map(function (item) { return String(item || "").trim().toLowerCase(); })
|
.map(function (item) { return item.trim().toLowerCase(); })
|
||||||
.filter(function (item, index, arr) {
|
.filter(function (item, index, arr) {
|
||||||
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
|
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
|
||||||
});
|
});
|
||||||
if (explicit.length > 0) return explicit;
|
if (explicit.length > 0) return explicit;
|
||||||
// Fall back: infer from mode via mode-bound entries in the registry.
|
// Fall back: infer from mode via mode-bound entries in the registry.
|
||||||
var mode = String(bookmark && bookmark.mode || "").trim().toUpperCase();
|
const mode = bookmark.mode.trim().toUpperCase();
|
||||||
return (window.decoderRegistry || [])
|
return (bgdWindow.decoderRegistry || [])
|
||||||
.filter(function (d) {
|
.filter(function (d) {
|
||||||
return d.activation === "mode_bound" && d.background_decode
|
return d.activation === "mode_bound" && d.background_decode
|
||||||
&& d.active_modes.indexOf(mode) >= 0;
|
&& d.active_modes.indexOf(mode) >= 0;
|
||||||
@@ -119,13 +168,13 @@
|
|||||||
if (!currentConfig) {
|
if (!currentConfig) {
|
||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
setCheckbox("background-decode-enabled", !!currentConfig.enabled);
|
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||||
renderBookmarkChecklist();
|
renderBookmarkChecklist();
|
||||||
|
|
||||||
const isControl = backgroundDecodeRole === "control" || (typeof authEnabled !== "undefined" && !authEnabled);
|
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||||
const panel = document.getElementById("background-decode-panel");
|
const panel = document.getElementById("background-decode-panel");
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.querySelectorAll("input, select, button.sch-write").forEach(function (el) {
|
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
|
||||||
el.disabled = !isControl;
|
el.disabled = !isControl;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -135,7 +184,7 @@
|
|||||||
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBookmarkChecklist(filterText) {
|
function renderBookmarkChecklist(filterText = ""): void {
|
||||||
const container = document.getElementById("bgd-bookmark-checklist");
|
const container = document.getElementById("bgd-bookmark-checklist");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
@@ -148,7 +197,7 @@
|
|||||||
|
|
||||||
const filtered = filter
|
const filtered = filter
|
||||||
? all.filter(function (bm) {
|
? all.filter(function (bm) {
|
||||||
var text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
|
const text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
|
||||||
return text.indexOf(filter) >= 0;
|
return text.indexOf(filter) >= 0;
|
||||||
})
|
})
|
||||||
: all;
|
: all;
|
||||||
@@ -161,22 +210,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
filtered.forEach(function (bookmark) {
|
filtered.forEach(function (bookmark) {
|
||||||
var row = document.createElement("label");
|
const row = document.createElement("label");
|
||||||
row.className = "bgd-checklist-row";
|
row.className = "bgd-checklist-row";
|
||||||
var decoders = bookmarkDecoderKinds(bookmark);
|
const decoders = bookmarkDecoderKinds(bookmark);
|
||||||
var checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
||||||
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
||||||
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
|
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
|
||||||
row.querySelector("input").addEventListener("change", function (e) {
|
row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) {
|
||||||
onChecklistToggle(bookmark.id, e.target.checked);
|
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
|
||||||
});
|
});
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onChecklistToggle(bookmarkId, checked) {
|
function onChecklistToggle(bookmarkId: string, checked: boolean): void {
|
||||||
if (!currentConfig) {
|
if (!currentConfig) {
|
||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
@@ -192,12 +241,12 @@
|
|||||||
function saveBackgroundDecode() {
|
function saveBackgroundDecode() {
|
||||||
const rigId = currentRigId;
|
const rigId = currentRigId;
|
||||||
if (!rigId) return;
|
if (!rigId) return;
|
||||||
const payload = {
|
const payload: BackgroundDecodeConfig = {
|
||||||
remote: rigId,
|
remote: rigId,
|
||||||
enabled: !!document.getElementById("background-decode-enabled").checked,
|
enabled: (document.getElementById("background-decode-enabled") as HTMLInputElement | null)?.checked ?? false,
|
||||||
bookmark_ids: Array.isArray(currentConfig && currentConfig.bookmark_ids) ? currentConfig.bookmark_ids.slice() : [],
|
bookmark_ids: currentConfig?.bookmark_ids.slice() ?? [],
|
||||||
};
|
};
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
const btn = document.getElementById("background-decode-save-btn") as HTMLButtonElement | null;
|
||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
apiPutConfig(rigId, payload)
|
apiPutConfig(rigId, payload)
|
||||||
.then(function (saved) {
|
.then(function (saved) {
|
||||||
@@ -205,30 +254,30 @@
|
|||||||
renderBackgroundDecode();
|
renderBackgroundDecode();
|
||||||
clearBgdDirty();
|
clearBgdDirty();
|
||||||
pollBackgroundDecodeStatus();
|
pollBackgroundDecodeStatus();
|
||||||
showToast("Background decode saved.");
|
showToast("Background decode saved.", false);
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err: unknown) {
|
||||||
showToast("Save failed: " + err.message, true);
|
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||||
})
|
})
|
||||||
.finally(function () {
|
.finally(function () {
|
||||||
if (btn) btn.disabled = false;
|
if (btn) btn.disabled = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetBackgroundDecode() {
|
async function resetBackgroundDecode() {
|
||||||
const rigId = currentRigId;
|
const rigId = currentRigId;
|
||||||
if (!rigId) return;
|
if (!rigId) return;
|
||||||
if (!confirm("Reset background decode configuration? This cannot be undone.")) return;
|
if (!await bgdWindow.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return;
|
||||||
apiResetConfig(rigId)
|
apiResetConfig(rigId)
|
||||||
.then(function (saved) {
|
.then(function (saved) {
|
||||||
currentConfig = saved;
|
currentConfig = saved;
|
||||||
renderBackgroundDecode();
|
renderBackgroundDecode();
|
||||||
clearBgdDirty();
|
clearBgdDirty();
|
||||||
pollBackgroundDecodeStatus();
|
pollBackgroundDecodeStatus();
|
||||||
showToast("Background decode reset.");
|
showToast("Background decode reset.", false);
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err: unknown) {
|
||||||
showToast("Reset failed: " + err.message, true);
|
showToast(`Reset failed: ${errorMessage(err)}`, true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,18 +294,18 @@
|
|||||||
.catch(function () {});
|
.catch(function () {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus(status) {
|
function renderStatus(status: BackgroundDecodeStatus): void {
|
||||||
const card = document.getElementById("background-decode-status-card");
|
const card = document.getElementById("background-decode-status-card");
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const entries = Array.isArray(status && status.entries) ? status.entries : [];
|
const entries = status.entries ?? [];
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
card.textContent = "No background decode bookmarks configured.";
|
card.textContent = "No background decode bookmarks configured.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const summary = [];
|
const summary = [];
|
||||||
if (status.active_rig) {
|
if (status.active_rig) {
|
||||||
if (Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
if (typeof status.center_hz === "number" && 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));
|
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
||||||
} else {
|
} else {
|
||||||
summary.push("This rig is not currently selected for audio.");
|
summary.push("This rig is not currently selected for audio.");
|
||||||
}
|
}
|
||||||
@@ -265,7 +314,7 @@
|
|||||||
entries.forEach(function (entry) {
|
entries.forEach(function (entry) {
|
||||||
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
||||||
if (entry.mode) parts.push(entry.mode);
|
if (entry.mode) parts.push(entry.mode);
|
||||||
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
||||||
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
||||||
@@ -285,7 +334,7 @@
|
|||||||
card.innerHTML = html;
|
card.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prettyState(state) {
|
function prettyState(state: string | undefined): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "active": return "\u2713 Active";
|
case "active": return "\u2713 Active";
|
||||||
case "out_of_span": return "\u25B3 Out of span";
|
case "out_of_span": return "\u25B3 Out of span";
|
||||||
@@ -301,40 +350,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCheckbox(id, value) {
|
function setCheckbox(id: string, value: boolean): void {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||||
if (el) el.checked = !!value;
|
if (el) el.checked = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFreq(hz) {
|
function formatFreq(hz: number): string {
|
||||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
|
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
|
||||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
||||||
return hz + " Hz";
|
return `${String(hz)} Hz`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(value) {
|
function escHtml(value: unknown): string {
|
||||||
return String(value == null ? "" : value)
|
const text = typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||||
|
? String(value)
|
||||||
|
: "";
|
||||||
|
return text
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
function markBgdDirty() {
|
function markBgdDirty() {
|
||||||
if (bgdDirty) return;
|
if (bgdDirty) return;
|
||||||
bgdDirty = true;
|
bgdDirty = true;
|
||||||
var btn = document.getElementById("background-decode-save-btn");
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
if (btn) btn.classList.add("sch-dirty");
|
if (btn) btn.classList.add("sch-dirty");
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearBgdDirty() {
|
function clearBgdDirty() {
|
||||||
bgdDirty = false;
|
bgdDirty = false;
|
||||||
var btn = document.getElementById("background-decode-save-btn");
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
if (btn) btn.classList.remove("sch-dirty");
|
if (btn) btn.classList.remove("sch-dirty");
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToast(msg, isError) {
|
function showToast(msg: string, isError: boolean): void {
|
||||||
const el = document.getElementById("background-decode-toast");
|
const el = document.getElementById("background-decode-toast");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.textContent = msg;
|
el.textContent = msg;
|
||||||
@@ -349,9 +405,9 @@
|
|||||||
if (!currentConfig) {
|
if (!currentConfig) {
|
||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
var ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
const ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
||||||
currentConfig.bookmark_ids = ids;
|
currentConfig.bookmark_ids = ids;
|
||||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,12 +416,12 @@
|
|||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
currentConfig.bookmark_ids = [];
|
currentConfig.bookmark_ids = [];
|
||||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireBackgroundDecodeEvents() {
|
function wireBackgroundDecodeEvents() {
|
||||||
const filterInput = document.getElementById("bgd-bookmark-filter");
|
const filterInput = document.getElementById("bgd-bookmark-filter") as (HTMLInputElement & WiredElement) | null;
|
||||||
if (filterInput && !filterInput._wired) {
|
if (filterInput && !filterInput._wired) {
|
||||||
filterInput._wired = true;
|
filterInput._wired = true;
|
||||||
filterInput.addEventListener("input", function () {
|
filterInput.addEventListener("input", function () {
|
||||||
@@ -373,38 +429,42 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledCb = document.getElementById("background-decode-enabled");
|
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
|
||||||
if (enabledCb && !enabledCb._wired) {
|
if (enabledCb && !enabledCb._wired) {
|
||||||
enabledCb._wired = true;
|
enabledCb._wired = true;
|
||||||
enabledCb.addEventListener("change", function () { markBgdDirty(); });
|
enabledCb.addEventListener("change", function () { markBgdDirty(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
|
||||||
if (selectAllBtn && !selectAllBtn._wired) {
|
if (selectAllBtn && !selectAllBtn._wired) {
|
||||||
selectAllBtn._wired = true;
|
selectAllBtn._wired = true;
|
||||||
selectAllBtn.addEventListener("click", selectAllBookmarks);
|
selectAllBtn.addEventListener("click", selectAllBookmarks);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deselectAllBtn = document.getElementById("bgd-deselect-all-btn");
|
const deselectAllBtn = document.getElementById("bgd-deselect-all-btn") as WiredElement | null;
|
||||||
if (deselectAllBtn && !deselectAllBtn._wired) {
|
if (deselectAllBtn && !deselectAllBtn._wired) {
|
||||||
deselectAllBtn._wired = true;
|
deselectAllBtn._wired = true;
|
||||||
deselectAllBtn.addEventListener("click", deselectAllBookmarks);
|
deselectAllBtn.addEventListener("click", deselectAllBookmarks);
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveBtn = document.getElementById("background-decode-save-btn");
|
const saveBtn = document.getElementById("background-decode-save-btn") as WiredElement | null;
|
||||||
if (saveBtn && !saveBtn._wired) {
|
if (saveBtn && !saveBtn._wired) {
|
||||||
saveBtn._wired = true;
|
saveBtn._wired = true;
|
||||||
saveBtn.addEventListener("click", saveBackgroundDecode);
|
saveBtn.addEventListener("click", saveBackgroundDecode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
const resetBtn = document.getElementById("background-decode-reset-btn") as WiredElement | null;
|
||||||
if (resetBtn && !resetBtn._wired) {
|
if (resetBtn && !resetBtn._wired) {
|
||||||
resetBtn._wired = true;
|
resetBtn._wired = true;
|
||||||
resetBtn.addEventListener("click", resetBackgroundDecode);
|
resetBtn.addEventListener("click", () => { void resetBackgroundDecode(); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.initBackgroundDecode = initBackgroundDecode;
|
bgdWindow.trx ??= {};
|
||||||
window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
|
bgdWindow.trx.modules ??= {};
|
||||||
window.setBackgroundDecodeRig = setBackgroundDecodeRig;
|
bgdWindow.trx.modules.backgroundDecode = {
|
||||||
|
initialize: initBackgroundDecode,
|
||||||
|
wireEvents: wireBackgroundDecodeEvents,
|
||||||
|
setRig: setBackgroundDecodeRig,
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
+286
-221
@@ -2,83 +2,139 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
/* DOM IDs in the server-owned page are required by this feature; bmEl throws
|
||||||
|
* during initialization if that contract is broken. */
|
||||||
|
interface Bookmark {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
freq_hz: number;
|
||||||
|
mode: string;
|
||||||
|
bandwidth_hz?: number | null;
|
||||||
|
locator?: string | null;
|
||||||
|
category?: string | null;
|
||||||
|
comment?: string | null;
|
||||||
|
decoders?: string[];
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookmarkService {
|
||||||
|
readonly overlayList: readonly Bookmark[];
|
||||||
|
readonly overlayRevision: number;
|
||||||
|
refreshOverlay(): Promise<void>;
|
||||||
|
invalidateColors(): void;
|
||||||
|
apply(bookmark: Bookmark): void;
|
||||||
|
formatFrequency(frequencyHz: number): string;
|
||||||
|
fetch(categoryFilter: string): Promise<void>;
|
||||||
|
populateScopePicker(): void;
|
||||||
|
}
|
||||||
|
interface VirtualChannelService {
|
||||||
|
interceptMode(mode: string): Promise<boolean>;
|
||||||
|
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
|
||||||
|
takeSchedulerControl(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookmarkBridge {
|
||||||
|
trx: { modules: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } };
|
||||||
|
trxUi: {
|
||||||
|
confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>;
|
||||||
|
notify?(message: string, options: { kind: "error" }): void;
|
||||||
|
};
|
||||||
|
syncBookmarkMapLocators?(bookmarks: readonly Bookmark[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement;
|
||||||
|
const bridge = window as unknown as BookmarkBridge;
|
||||||
|
function bmEl(id: string): BookmarkElement {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (!element) throw new Error(`Missing bookmark element #${id}`);
|
||||||
|
return element as BookmarkElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decoder checkboxes and decoder toggle buttons are built from the runtime
|
||||||
|
* registry, so their elements are legitimately absent for unbuilt decoders. */
|
||||||
|
function bmOptionalEl(id: string): BookmarkElement | null {
|
||||||
|
return document.getElementById(id) as BookmarkElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Bookmarks Tab ---
|
// --- Bookmarks Tab ---
|
||||||
|
|
||||||
/** Current bookmark scope: "general" or a rig remote name. */
|
/** Current bookmark scope: "general" or a rig remote name. */
|
||||||
let bmScope = "general";
|
let bmScope = "general";
|
||||||
|
|
||||||
/** Build the ?scope= query string for a given or current bookmark scope. */
|
/** Build the ?scope= query string for a given or current bookmark scope. */
|
||||||
function bmScopeParam(prefix, scope) {
|
function bmScopeParam(prefix: boolean, scope?: string | null): string {
|
||||||
const sep = prefix ? "&" : "?";
|
const sep = prefix ? "&" : "?";
|
||||||
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
|
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
|
||||||
}
|
}
|
||||||
|
|
||||||
var bmList = [];
|
let bmList: Bookmark[] = [];
|
||||||
var bmRevision = 0;
|
|
||||||
/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
|
/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
|
||||||
var bmOverlayList = [];
|
let bmOverlayList: Bookmark[] = [];
|
||||||
var bmOverlayRevision = 0;
|
let bmOverlayRevision = 0;
|
||||||
let bmFilteredList = [];
|
let bmFilteredList: Bookmark[] = [];
|
||||||
let bmEditId = null;
|
let bmEditScope: string | null = null;
|
||||||
let bmEditScope = null;
|
|
||||||
let bmCurrentPage = 1;
|
let bmCurrentPage = 1;
|
||||||
const BM_PAGE_SIZE = 25;
|
const BM_PAGE_SIZE = 25;
|
||||||
const bmSelected = new Set();
|
const bmSelected = new Set<string>();
|
||||||
|
|
||||||
function bmFmtFreq(hz) {
|
function bmFmtFreq(hz: number): string {
|
||||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
|
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
|
||||||
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
|
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
|
||||||
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
|
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
|
||||||
return hz + "\u202fHz";
|
return `${hz}\u202fHz`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmEsc(str) {
|
function bmEsc(str: unknown): string {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
d.appendChild(document.createTextNode(String(str)));
|
d.appendChild(document.createTextNode(String(str)));
|
||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmCanControl() {
|
function bmCanControl() {
|
||||||
return (
|
return !hostState.authEnabled || hostState.authRole === "control";
|
||||||
(typeof authEnabled !== "undefined" && !authEnabled) ||
|
|
||||||
(typeof authRole !== "undefined" && authRole === "control")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
||||||
function bmSyncAccess() {
|
function bmSyncAccess() {
|
||||||
const canCtrl = bmCanControl();
|
const canCtrl = bmCanControl();
|
||||||
const addBtn = document.getElementById("bm-add-btn");
|
const addBtn = bmEl("bm-add-btn");
|
||||||
const selectAllBtn = document.getElementById("bm-select-all-btn");
|
const selectAllBtn = bmEl("bm-select-all-btn");
|
||||||
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
|
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
|
||||||
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
|
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
|
||||||
function bmListScope() {
|
function bmListScope() {
|
||||||
const rig = (typeof lastActiveRigId !== "undefined") ? lastActiveRigId : null;
|
return hostState.lastActiveRigId || "general";
|
||||||
return rig || "general";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmFetchOverlay() {
|
async function bmFetchOverlay() {
|
||||||
const overlayScope = bmListScope();
|
const overlayScope = bmListScope();
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
|
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
|
||||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
bmOverlayList = await resp.json();
|
bmOverlayList = await resp.json() as Bookmark[];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch overlay bookmarks:", e);
|
console.error("Failed to fetch overlay bookmarks:", e);
|
||||||
bmOverlayList = [];
|
bmOverlayList = [];
|
||||||
}
|
}
|
||||||
bmOverlayRevision++;
|
bmOverlayRevision++;
|
||||||
if (typeof window.syncBookmarkMapLocators === "function") {
|
if (typeof bridge.syncBookmarkMapLocators === "function") {
|
||||||
window.syncBookmarkMapLocators(bmOverlayList);
|
bridge.syncBookmarkMapLocators(bmOverlayList);
|
||||||
}
|
}
|
||||||
if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw();
|
hostCore.scheduleSpectrumDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmFetch(categoryFilter) {
|
async function bmFetch(categoryFilter: string): Promise<void> {
|
||||||
let url = "/bookmarks";
|
let url = "/bookmarks";
|
||||||
let hasQuery = false;
|
let hasQuery = false;
|
||||||
if (categoryFilter && categoryFilter !== "") {
|
if (categoryFilter && categoryFilter !== "") {
|
||||||
@@ -89,26 +145,25 @@ async function bmFetch(categoryFilter) {
|
|||||||
const overlayPromise = bmFetchOverlay();
|
const overlayPromise = bmFetchOverlay();
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url);
|
const resp = await fetch(url);
|
||||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
bmList = await resp.json();
|
bmList = await resp.json() as Bookmark[];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch bookmarks:", e);
|
console.error("Failed to fetch bookmarks:", e);
|
||||||
bmList = [];
|
bmList = [];
|
||||||
}
|
}
|
||||||
bmRevision++;
|
|
||||||
bmSelected.clear();
|
bmSelected.clear();
|
||||||
bmUpdateSelectionUi();
|
bmUpdateSelectionUi();
|
||||||
bmSyncAccess();
|
bmSyncAccess();
|
||||||
bmApplyFilters();
|
bmApplyFilters();
|
||||||
bmRefreshCategoryFilter(categoryFilter);
|
void bmRefreshCategoryFilter(categoryFilter);
|
||||||
await overlayPromise;
|
await overlayPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmApplyFilters() {
|
function bmApplyFilters() {
|
||||||
const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase();
|
const text = (bmEl("bm-text-filter")?.value || "").trim().toLowerCase();
|
||||||
const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase();
|
const modeFilter = (bmEl("bm-mode-filter")?.value || "").trim().toUpperCase();
|
||||||
let filtered = modeFilter
|
let filtered = modeFilter
|
||||||
? bmList.filter((bm) => String(bm.mode || "").toUpperCase() === modeFilter)
|
? bmList.filter((bm) => (bm.mode || "").toUpperCase() === modeFilter)
|
||||||
: bmList;
|
: bmList;
|
||||||
filtered = text
|
filtered = text
|
||||||
? filtered.filter((bm) =>
|
? filtered.filter((bm) =>
|
||||||
@@ -123,14 +178,14 @@ function bmApplyFilters() {
|
|||||||
bmRender(filtered);
|
bmRender(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmRefreshCategoryFilter(keepValue) {
|
async function bmRefreshCategoryFilter(keepValue: string): Promise<void> {
|
||||||
const sel = document.getElementById("bm-category-filter");
|
const sel = bmEl("bm-category-filter");
|
||||||
const modeSel = document.getElementById("bm-mode-filter");
|
const modeSel = bmEl("bm-mode-filter");
|
||||||
if (!sel && !modeSel) return;
|
if (!sel && !modeSel) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/bookmarks" + bmScopeParam(false));
|
const resp = await fetch("/bookmarks" + bmScopeParam(false));
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const all = await resp.json();
|
const all = await resp.json() as Bookmark[];
|
||||||
if (sel) {
|
if (sel) {
|
||||||
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
|
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
|
||||||
while (sel.options.length > 1) sel.remove(1);
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
@@ -144,7 +199,7 @@ async function bmRefreshCategoryFilter(keepValue) {
|
|||||||
}
|
}
|
||||||
if (modeSel) {
|
if (modeSel) {
|
||||||
const keepMode = modeSel.value;
|
const keepMode = modeSel.value;
|
||||||
const modes = [...new Set(all.map((b) => String(b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
|
const modes = [...new Set(all.map((b) => (b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
|
||||||
while (modeSel.options.length > 1) modeSel.remove(1);
|
while (modeSel.options.length > 1) modeSel.remove(1);
|
||||||
modes.forEach((mode) => {
|
modes.forEach((mode) => {
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
@@ -154,17 +209,17 @@ async function bmRefreshCategoryFilter(keepValue) {
|
|||||||
});
|
});
|
||||||
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
|
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmRender(list) {
|
function bmRender(list: Bookmark[]): void {
|
||||||
const tbody = document.getElementById("bm-tbody");
|
const tbody = bmEl("bm-tbody");
|
||||||
const emptyEl = document.getElementById("bm-empty");
|
const emptyEl = bmEl("bm-empty");
|
||||||
const paginatorEl = document.getElementById("bm-paginator");
|
const paginatorEl = bmEl("bm-paginator");
|
||||||
const pageSummaryEl = document.getElementById("bm-page-summary");
|
const pageSummaryEl = bmEl("bm-page-summary");
|
||||||
const pageIndicatorEl = document.getElementById("bm-page-indicator");
|
const pageIndicatorEl = bmEl("bm-page-indicator");
|
||||||
const prevBtn = document.getElementById("bm-page-prev");
|
const prevBtn = bmEl("bm-page-prev");
|
||||||
const nextBtn = document.getElementById("bm-page-next");
|
const nextBtn = bmEl("bm-page-next");
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
|
|
||||||
@@ -222,7 +277,7 @@ function bmRender(list) {
|
|||||||
if (nextBtn) nextBtn.disabled = page >= totalPages;
|
if (nextBtn) nextBtn.disabled = page >= totalPages;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmChangePage(delta) {
|
function bmChangePage(delta: number): void {
|
||||||
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
|
||||||
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
|
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
|
||||||
if (nextPage === bmCurrentPage) return;
|
if (nextPage === bmCurrentPage) return;
|
||||||
@@ -231,30 +286,30 @@ function bmChangePage(delta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read decoder checkboxes and return an array of selected decoder names.
|
// Read decoder checkboxes and return an array of selected decoder names.
|
||||||
function bmReadDecoders() {
|
function bmReadDecoders(): string[] {
|
||||||
return (window.decoderRegistry || [])
|
return hostState.decoderRegistry
|
||||||
.filter(d => d.bookmark_selectable)
|
.filter(d => d.bookmark_selectable)
|
||||||
.filter(d => document.getElementById("bm-dec-" + d.id)?.checked)
|
.filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked)
|
||||||
.map(d => d.id);
|
.map(d => d.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set decoder checkboxes to match the given array.
|
// Set decoder checkboxes to match the given array.
|
||||||
function bmWriteDecoders(decoders) {
|
function bmWriteDecoders(decoders: readonly string[]): void {
|
||||||
const set = new Set(decoders || []);
|
const set = new Set(decoders || []);
|
||||||
(window.decoderRegistry || [])
|
hostState.decoderRegistry
|
||||||
.filter(d => d.bookmark_selectable)
|
.filter(d => d.bookmark_selectable)
|
||||||
.forEach(d => {
|
.forEach(d => {
|
||||||
const el = document.getElementById("bm-dec-" + d.id);
|
const el = bmOptionalEl("bm-dec-" + d.id);
|
||||||
if (el) el.checked = set.has(d.id);
|
if (el) el.checked = set.has(d.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build decoder checkboxes dynamically from the registry.
|
// Build decoder checkboxes dynamically from the registry.
|
||||||
function bmBuildDecoderCheckboxes() {
|
function bmBuildDecoderCheckboxes() {
|
||||||
const container = document.getElementById("bm-decoder-checkboxes");
|
const container = bmEl("bm-decoder-checkboxes");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
(window.decoderRegistry || [])
|
hostState.decoderRegistry
|
||||||
.filter(d => d.bookmark_selectable)
|
.filter(d => d.bookmark_selectable)
|
||||||
.forEach(d => {
|
.forEach(d => {
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
@@ -264,75 +319,76 @@ function bmBuildDecoderCheckboxes() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmOpenForm(bm) {
|
function bmOpenForm(bm: Bookmark | null): void {
|
||||||
const wrap = document.getElementById("bm-form-wrap");
|
const wrap = bmEl("bm-form-wrap");
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
bmEditId = bm ? bm.id : null;
|
|
||||||
bmEditScope = bm ? (bm.scope || bmScope) : null;
|
bmEditScope = bm ? (bm.scope || bmScope) : null;
|
||||||
|
|
||||||
// Rebuild decoder checkboxes from registry (handles race where registry
|
// Rebuild decoder checkboxes from registry (handles race where registry
|
||||||
// loaded after initial build).
|
// loaded after initial build).
|
||||||
bmBuildDecoderCheckboxes();
|
bmBuildDecoderCheckboxes();
|
||||||
|
|
||||||
document.getElementById("bm-id").value = bm ? bm.id : "";
|
bmEl("bm-id").value = bm ? bm.id : "";
|
||||||
document.getElementById("bm-name").value = bm ? bm.name : "";
|
bmEl("bm-name").value = bm ? bm.name : "";
|
||||||
document.getElementById("bm-freq").value = bm ? bm.freq_hz : "";
|
bmEl("bm-freq").value = bm ? String(bm.freq_hz) : "";
|
||||||
document.getElementById("bm-mode").value = bm ? bm.mode : "";
|
bmEl("bm-mode").value = bm ? bm.mode : "";
|
||||||
document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
|
bmEl("bm-bw").value = bm?.bandwidth_hz ? String(bm.bandwidth_hz) : "";
|
||||||
document.getElementById("bm-locator").value = bm ? (bm.locator || "") : "";
|
bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
|
||||||
document.getElementById("bm-category-input").value = bm ? (bm.category || "") : "";
|
bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
|
||||||
document.getElementById("bm-comment").value = bm ? (bm.comment || "") : "";
|
bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
|
||||||
bmWriteDecoders(bm ? bm.decoders : []);
|
bmWriteDecoders(bm?.decoders ?? []);
|
||||||
document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
||||||
|
|
||||||
wrap.style.display = "flex";
|
wrap.style.display = "flex";
|
||||||
document.getElementById("bm-name").focus();
|
bmEl("bm-name").focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmCloseForm() {
|
function bmCloseForm() {
|
||||||
const wrap = document.getElementById("bm-form-wrap");
|
const wrap = bmEl("bm-form-wrap");
|
||||||
if (wrap) wrap.style.display = "none";
|
if (wrap) wrap.style.display = "none";
|
||||||
bmEditId = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmPrefillFromStatus() {
|
function bmPrefillFromStatus() {
|
||||||
// Use globals maintained by app.js (updated by SSE stream)
|
// Read live rig state from the host contract (updated by the SSE stream).
|
||||||
if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) {
|
const freqHz = hostState.lastFreqHz;
|
||||||
document.getElementById("bm-freq").value = Math.round(lastFreqHz);
|
if (freqHz != null && Number.isFinite(freqHz)) {
|
||||||
|
bmEl("bm-freq").value = String(Math.round(freqHz));
|
||||||
}
|
}
|
||||||
if (typeof lastModeName === "string" && lastModeName) {
|
if (hostState.lastModeName) {
|
||||||
document.getElementById("bm-mode").value = lastModeName;
|
bmEl("bm-mode").value = hostState.lastModeName;
|
||||||
}
|
}
|
||||||
if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) {
|
if (hostState.currentBandwidthHz > 0) {
|
||||||
document.getElementById("bm-bw").value = Math.round(currentBandwidthHz);
|
bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
|
||||||
}
|
}
|
||||||
// Prefill decoder checkboxes from current toggle button state.
|
// Prefill decoder checkboxes from current toggle button state.
|
||||||
const activeDecoders = (window.decoderRegistry || [])
|
const activeDecoders = hostState.decoderRegistry
|
||||||
.filter(d => d.bookmark_selectable && d.activation === "toggle")
|
.filter(d => d.bookmark_selectable && d.activation === "toggle")
|
||||||
.filter(d => {
|
.filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true")
|
||||||
const btn = document.getElementById(d.id + "-decode-toggle-btn");
|
|
||||||
return btn && btn.dataset.enabled === "true";
|
|
||||||
})
|
|
||||||
.map(d => d.id);
|
.map(d => d.id);
|
||||||
bmWriteDecoders(activeDecoders);
|
bmWriteDecoders(activeDecoders);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmSave(e) {
|
async function bmSave(e: Event): Promise<void> {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const id = document.getElementById("bm-id").value;
|
const id = bmEl("bm-id").value;
|
||||||
const name = document.getElementById("bm-name").value.trim();
|
const name = bmEl("bm-name").value.trim();
|
||||||
const freqStr = document.getElementById("bm-freq").value;
|
const freqStr = bmEl("bm-freq").value;
|
||||||
const freq_hz = parseInt(freqStr, 10);
|
const freq_hz = parseInt(freqStr, 10);
|
||||||
const mode = document.getElementById("bm-mode").value.trim();
|
const mode = bmEl("bm-mode").value.trim();
|
||||||
const bwStr = document.getElementById("bm-bw").value;
|
const bwStr = bmEl("bm-bw").value;
|
||||||
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
|
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
|
||||||
const locator = document.getElementById("bm-locator").value.trim().toUpperCase();
|
const locator = bmEl("bm-locator").value.trim().toUpperCase();
|
||||||
const category = document.getElementById("bm-category-input").value.trim();
|
const category = bmEl("bm-category-input").value.trim();
|
||||||
const comment = document.getElementById("bm-comment").value.trim();
|
const comment = bmEl("bm-comment").value.trim();
|
||||||
const decoders = bmReadDecoders();
|
const decoders = bmReadDecoders();
|
||||||
|
|
||||||
|
const formError = bmEl("bm-form-error");
|
||||||
|
if (formError) formError.textContent = "";
|
||||||
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
||||||
alert("Name, Frequency, and Mode are required.");
|
if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
|
||||||
|
const invalid = !name ? bmEl("bm-name")
|
||||||
|
: !Number.isFinite(freq_hz) ? bmEl("bm-freq") : bmEl("bm-mode");
|
||||||
|
invalid?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,90 +423,74 @@ async function bmSave(e) {
|
|||||||
if (resp.status === 409) {
|
if (resp.status === 409) {
|
||||||
throw new Error("A bookmark for that frequency already exists.");
|
throw new Error("A bookmark for that frequency already exists.");
|
||||||
}
|
}
|
||||||
throw new Error(text || "HTTP " + resp.status);
|
throw new Error(text || `HTTP ${resp.status}`);
|
||||||
}
|
}
|
||||||
bmCloseForm();
|
bmCloseForm();
|
||||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
await bmFetch(bmEl("bm-category-filter").value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save bookmark:", err);
|
console.error("Failed to save bookmark:", err);
|
||||||
alert("Failed to save bookmark: " + err.message);
|
if (formError) formError.textContent = "Failed to save bookmark: " + errorMessage(err);
|
||||||
|
bridge.trxUi.notify?.("Bookmark could not be saved", { kind: "error" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmDelete(id) {
|
async function bmDelete(id: string): Promise<void> {
|
||||||
if (!confirm("Delete this bookmark?")) return;
|
if (!await bridge.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
|
||||||
const bm = bmList.find((b) => b.id === id);
|
const bm = bmList.find((b) => b.id === id);
|
||||||
const scope = bm ? bm.scope : undefined;
|
const scope = bm ? bm.scope : undefined;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
|
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
await bmFetch(bmEl("bm-category-filter").value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete bookmark:", err);
|
console.error("Failed to delete bookmark:", err);
|
||||||
alert("Failed to delete bookmark: " + err.message);
|
bridge.trxUi.notify?.("Failed to delete bookmark: " + errorMessage(err), { kind: "error" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmApply(bm) {
|
function bmApply(bm: Bookmark): void {
|
||||||
try {
|
try {
|
||||||
// --- Optimistic UI updates (instant, before any network round-trips) ---
|
// --- Optimistic UI updates (instant, before any network round-trips) ---
|
||||||
if (typeof modeEl !== "undefined" && modeEl) {
|
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
|
||||||
modeEl.value = String(bm.mode || "").toUpperCase();
|
if (modeEl) {
|
||||||
|
modeEl.value = (bm.mode || "").toUpperCase();
|
||||||
}
|
}
|
||||||
if (bm.bandwidth_hz) {
|
if (bm.bandwidth_hz) {
|
||||||
if (typeof currentBandwidthHz !== "undefined") {
|
hostState.currentBandwidthHz = bm.bandwidth_hz;
|
||||||
currentBandwidthHz = bm.bandwidth_hz;
|
hostCore.syncBandwidthInput(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.
|
||||||
// Set optimistic guard before applying so SSE cannot snap back.
|
hostCore.armOptimisticFrequency(bm.freq_hz);
|
||||||
if (typeof _freqOptimisticSeq !== "undefined") {
|
// Force display so the BW overlay is repositioned even when freq is unchanged.
|
||||||
++_freqOptimisticSeq;
|
hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
|
||||||
_freqOptimisticHz = bm.freq_hz;
|
if (hostState.lastSpectrumData) {
|
||||||
}
|
hostCore.scheduleSpectrumDraw();
|
||||||
// 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
|
// Take scheduler control up front, then apply mode before bandwidth so a
|
||||||
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
|
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
|
||||||
const tunePromise = (async () => {
|
const tunePromise = (async () => {
|
||||||
if (typeof vchanTakeSchedulerControl === "function") {
|
await bridge.trx.modules.vchan?.takeSchedulerControl();
|
||||||
await vchanTakeSchedulerControl();
|
|
||||||
}
|
|
||||||
|
|
||||||
const onVirtual = typeof vchanInterceptMode === "function"
|
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
|
||||||
&& await vchanInterceptMode(bm.mode);
|
|
||||||
if (!onVirtual) {
|
if (!onVirtual) {
|
||||||
await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bm.bandwidth_hz) {
|
if (bm.bandwidth_hz) {
|
||||||
const bwHandledByVchan = typeof vchanInterceptBandwidth === "function"
|
const bwHandledByVchan =
|
||||||
&& await vchanInterceptBandwidth(bm.bandwidth_hz);
|
await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
||||||
if (!bwHandledByVchan) {
|
if (!bwHandledByVchan) {
|
||||||
await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz);
|
await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setRigFrequency is wrapped by vchan.js to redirect to the channel API
|
// setRigFrequency redirects to the channel API when a virtual channel is
|
||||||
// when on a virtual channel, so this call works correctly in both cases.
|
// active. It repeats the optimistic update applied above, which is a
|
||||||
// It also does its own optimistic update (applyLocalTunedFrequency) but
|
// no-op because the value is unchanged.
|
||||||
// that's a no-op since we already set the same value above.
|
hostCore.setRigFrequency(bm.freq_hz);
|
||||||
if (typeof setRigFrequency === "function") {
|
|
||||||
await setRigFrequency(bm.freq_hz);
|
|
||||||
} else {
|
|
||||||
await postPath("/set_freq?hz=" + bm.freq_hz);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
// Decoder toggles — fire-and-forget.
|
// Decoder toggles — fire-and-forget.
|
||||||
// - Decoders incompatible with the new mode are always turned off
|
// - Decoders incompatible with the new mode are always turned off
|
||||||
@@ -460,18 +500,19 @@ async function bmApply(bm) {
|
|||||||
// alone.
|
// alone.
|
||||||
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||||
const modeUp = (bm.mode || "").toUpperCase();
|
const modeUp = (bm.mode || "").toUpperCase();
|
||||||
const allToggleDecoders = (window.decoderRegistry || []).filter(d =>
|
const allToggleDecoders = hostState.decoderRegistry.filter(d =>
|
||||||
d.activation === "toggle"
|
d.activation === "toggle"
|
||||||
);
|
);
|
||||||
const decoderPromise = allToggleDecoders.length ? (async () => {
|
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||||
let statusUrl = "/status";
|
let statusUrl = "/status";
|
||||||
if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) {
|
const rigId = hostState.lastActiveRigId;
|
||||||
statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId);
|
if (rigId) {
|
||||||
|
statusUrl += "?remote=" + encodeURIComponent(rigId);
|
||||||
}
|
}
|
||||||
const statusResp = await fetch(statusUrl);
|
const statusResp = await fetch(statusUrl);
|
||||||
if (!statusResp.ok) return;
|
if (!statusResp.ok) return;
|
||||||
const st = await statusResp.json();
|
const st = await statusResp.json() as Record<string, unknown>;
|
||||||
const toggles = [];
|
const toggles: Promise<unknown>[] = [];
|
||||||
for (const d of allToggleDecoders) {
|
for (const d of allToggleDecoders) {
|
||||||
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
|
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||||||
const currentlyOn = !!st[statusKey];
|
const currentlyOn = !!st[statusKey];
|
||||||
@@ -482,41 +523,52 @@ async function bmApply(bm) {
|
|||||||
// Always disable decoders that don't apply to the new mode.
|
// Always disable decoders that don't apply to the new mode.
|
||||||
wanted = false;
|
wanted = false;
|
||||||
} else if (hasDecoders) {
|
} else if (hasDecoders) {
|
||||||
wanted = bm.decoders.includes(d.id);
|
wanted = bm.decoders?.includes(d.id) ?? false;
|
||||||
} else {
|
} else {
|
||||||
// Mode-compatible and no bookmark selection: leave as-is.
|
// Mode-compatible and no bookmark selection: leave as-is.
|
||||||
wanted = currentlyOn;
|
wanted = currentlyOn;
|
||||||
}
|
}
|
||||||
if (wanted !== currentlyOn) {
|
if (wanted !== currentlyOn) {
|
||||||
toggles.push(postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (toggles.length) await Promise.all(toggles);
|
if (toggles.length) await Promise.all(toggles);
|
||||||
})() : Promise.resolve();
|
})() : Promise.resolve();
|
||||||
// Don't await — let the network calls settle in the background.
|
// Don't await — let the network calls settle in the background.
|
||||||
// Errors are logged but don't block the UI.
|
// Errors are logged but don't block the UI.
|
||||||
Promise.all([tunePromise, decoderPromise]).catch(
|
void Promise.all([tunePromise, decoderPromise]).catch((error: unknown) => {
|
||||||
(err) => console.error("Bookmark apply background error:", err)
|
console.error("Bookmark apply background error:", error);
|
||||||
);
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to apply bookmark:", err);
|
console.error("Failed to apply bookmark:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bridge.trx.modules.bookmarks = {
|
||||||
|
get overlayList() { return bmOverlayList; },
|
||||||
|
get overlayRevision() { return bmOverlayRevision; },
|
||||||
|
refreshOverlay: bmFetchOverlay,
|
||||||
|
invalidateColors() { bmOverlayRevision += 1; },
|
||||||
|
apply: bmApply,
|
||||||
|
formatFrequency: bmFmtFreq,
|
||||||
|
fetch: bmFetch,
|
||||||
|
populateScopePicker: bmPopulateScopePicker,
|
||||||
|
};
|
||||||
|
|
||||||
function bmUpdateSelectionUi() {
|
function bmUpdateSelectionUi() {
|
||||||
const count = bmSelected.size;
|
const count = bmSelected.size;
|
||||||
const canCtrl = bmCanControl();
|
const canCtrl = bmCanControl();
|
||||||
const visible = count > 0 && canCtrl;
|
const visible = count > 0 && canCtrl;
|
||||||
const btn = document.getElementById("bm-del-selected-btn");
|
const btn = bmEl("bm-del-selected-btn");
|
||||||
const countEl = document.getElementById("bm-del-selected-count");
|
const countEl = bmEl("bm-del-selected-count");
|
||||||
if (btn) btn.style.display = visible ? "" : "none";
|
if (btn) btn.style.display = visible ? "" : "none";
|
||||||
if (countEl) countEl.textContent = count;
|
if (countEl) countEl.textContent = String(count);
|
||||||
const moveWrap = document.getElementById("bm-move-selected-wrap");
|
const moveWrap = bmEl("bm-move-selected-wrap");
|
||||||
const moveCountEl = document.getElementById("bm-move-selected-count");
|
const moveCountEl = bmEl("bm-move-selected-count");
|
||||||
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
|
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
|
||||||
if (moveCountEl) moveCountEl.textContent = count;
|
if (moveCountEl) moveCountEl.textContent = String(count);
|
||||||
if (visible) bmPopulateMoveTarget();
|
if (visible) bmPopulateMoveTarget();
|
||||||
const selectAllBtn = document.getElementById("bm-select-all-btn");
|
const selectAllBtn = bmEl("bm-select-all-btn");
|
||||||
if (selectAllBtn && bmCanControl()) {
|
if (selectAllBtn && bmCanControl()) {
|
||||||
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||||
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
|
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
|
||||||
@@ -525,10 +577,10 @@ function bmUpdateSelectionUi() {
|
|||||||
|
|
||||||
/** Populate the move-target dropdown with all scopes except the current one. */
|
/** Populate the move-target dropdown with all scopes except the current one. */
|
||||||
function bmPopulateMoveTarget() {
|
function bmPopulateMoveTarget() {
|
||||||
const sel = document.getElementById("bm-move-target");
|
const sel = bmEl("bm-move-target");
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
|
const rigIds = hostState.lastRigIds;
|
||||||
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
|
const displayNames = hostState.lastRigDisplayNames;
|
||||||
const prev = sel.value;
|
const prev = sel.value;
|
||||||
sel.innerHTML = "";
|
sel.innerHTML = "";
|
||||||
if (bmScope !== "general") {
|
if (bmScope !== "general") {
|
||||||
@@ -552,13 +604,18 @@ function bmPopulateMoveTarget() {
|
|||||||
async function bmMoveSelected() {
|
async function bmMoveSelected() {
|
||||||
const ids = Array.from(bmSelected);
|
const ids = Array.from(bmSelected);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
const target = document.getElementById("bm-move-target")?.value;
|
const target = bmEl("bm-move-target")?.value;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
|
const targetLabel = bmEl("bm-move-target")?.selectedOptions[0]?.textContent || target;
|
||||||
if (!confirm(`Move ${ids.length} bookmark${ids.length > 1 ? "s" : ""} to "${targetLabel}"?`)) return;
|
if (!await bridge.trxUi.confirm({
|
||||||
|
title: "Move selected bookmarks?",
|
||||||
|
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
|
||||||
|
confirmLabel: "Move",
|
||||||
|
danger: false,
|
||||||
|
})) return;
|
||||||
try {
|
try {
|
||||||
// Group selected IDs by their owning scope (skip if already in target).
|
// Group selected IDs by their owning scope (skip if already in target).
|
||||||
const byScope = {};
|
const byScope: Record<string, string[]> = {};
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const bm = bmList.find((b) => b.id === id);
|
const bm = bmList.find((b) => b.id === id);
|
||||||
const scope = bm?.scope || bmScope;
|
const scope = bm?.scope || bmScope;
|
||||||
@@ -570,21 +627,21 @@ async function bmMoveSelected() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ids: scopeIds, to: target }),
|
body: JSON.stringify({ ids: scopeIds, to: target }),
|
||||||
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
|
}).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
|
||||||
));
|
));
|
||||||
bmSelected.clear();
|
bmSelected.clear();
|
||||||
bmUpdateSelectionUi();
|
bmUpdateSelectionUi();
|
||||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
await bmFetch(bmEl("bm-category-filter").value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to move bookmarks:", err);
|
console.error("Failed to move bookmarks:", err);
|
||||||
alert("Failed to move bookmarks: " + err.message);
|
bridge.trxUi.notify?.("Failed to move bookmarks: " + errorMessage(err), { kind: "error" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmSyncSelectAllCheckbox() {
|
function bmSyncSelectAllCheckbox() {
|
||||||
const selectAll = document.getElementById("bm-select-all");
|
const selectAll = bmEl("bm-select-all");
|
||||||
if (!selectAll) return;
|
if (!selectAll) return;
|
||||||
const checkboxes = document.querySelectorAll(".bm-row-sel");
|
const checkboxes = document.querySelectorAll<HTMLInputElement>(".bm-row-sel");
|
||||||
if (checkboxes.length === 0) {
|
if (checkboxes.length === 0) {
|
||||||
selectAll.checked = false;
|
selectAll.checked = false;
|
||||||
selectAll.indeterminate = false;
|
selectAll.indeterminate = false;
|
||||||
@@ -598,10 +655,14 @@ function bmSyncSelectAllCheckbox() {
|
|||||||
async function bmDeleteSelected() {
|
async function bmDeleteSelected() {
|
||||||
const ids = Array.from(bmSelected);
|
const ids = Array.from(bmSelected);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
if (!confirm(`Delete ${ids.length} selected bookmark${ids.length > 1 ? "s" : ""}?`)) return;
|
if (!await bridge.trxUi.confirm({
|
||||||
|
title: "Delete selected bookmarks?",
|
||||||
|
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
|
||||||
|
confirmLabel: "Delete",
|
||||||
|
})) return;
|
||||||
try {
|
try {
|
||||||
// Group selected IDs by their owning scope.
|
// Group selected IDs by their owning scope.
|
||||||
const byScope = {};
|
const byScope: Record<string, string[]> = {};
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const bm = bmList.find((b) => b.id === id);
|
const bm = bmList.find((b) => b.id === id);
|
||||||
const scope = bm?.scope || bmScope;
|
const scope = bm?.scope || bmScope;
|
||||||
@@ -612,23 +673,23 @@ async function bmDeleteSelected() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ids: scopeIds }),
|
body: JSON.stringify({ ids: scopeIds }),
|
||||||
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
|
}).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
|
||||||
));
|
));
|
||||||
bmSelected.clear();
|
bmSelected.clear();
|
||||||
bmUpdateSelectionUi();
|
bmUpdateSelectionUi();
|
||||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
await bmFetch(bmEl("bm-category-filter").value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete bookmarks:", err);
|
console.error("Failed to delete bookmarks:", err);
|
||||||
alert("Failed to delete bookmarks: " + err.message);
|
bridge.trxUi.notify?.("Failed to delete bookmarks: " + errorMessage(err), { kind: "error" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Populate the scope picker with "General" + one option per rig. */
|
/** Populate the scope picker with "General" + one option per rig. */
|
||||||
function bmPopulateScopePicker() {
|
function bmPopulateScopePicker() {
|
||||||
const picker = document.getElementById("bm-scope-picker");
|
const picker = bmEl("bm-scope-picker");
|
||||||
if (!picker) return;
|
if (!picker) return;
|
||||||
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
|
const rigIds = hostState.lastRigIds;
|
||||||
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
|
const displayNames = hostState.lastRigDisplayNames;
|
||||||
// Preserve current selection if still valid.
|
// Preserve current selection if still valid.
|
||||||
const prev = picker.value;
|
const prev = picker.value;
|
||||||
while (picker.options.length > 1) picker.remove(1);
|
while (picker.options.length > 1) picker.remove(1);
|
||||||
@@ -655,63 +716,61 @@ function bmPopulateScopePicker() {
|
|||||||
// Build decoder checkboxes from registry. The registry is fetched async
|
// Build decoder checkboxes from registry. The registry is fetched async
|
||||||
// so we rebuild once it arrives to ensure checkboxes are present.
|
// so we rebuild once it arrives to ensure checkboxes are present.
|
||||||
bmBuildDecoderCheckboxes();
|
bmBuildDecoderCheckboxes();
|
||||||
if (typeof window.onDecoderRegistryReady === "function") {
|
hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||||
window.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scope picker
|
// Scope picker
|
||||||
bmPopulateScopePicker();
|
bmPopulateScopePicker();
|
||||||
const scopePicker = document.getElementById("bm-scope-picker");
|
const scopePicker = bmEl("bm-scope-picker");
|
||||||
if (scopePicker) {
|
if (scopePicker) {
|
||||||
scopePicker.addEventListener("change", (e) => {
|
scopePicker.addEventListener("change", (e) => {
|
||||||
bmScope = e.target.value;
|
bmScope = (e.currentTarget as HTMLSelectElement).value;
|
||||||
bmFetch(document.getElementById("bm-category-filter")?.value || "");
|
void bmFetch(bmEl("bm-category-filter").value || "");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh list and sync access when the Bookmarks tab is activated
|
// Refresh list and sync access when the Bookmarks tab is activated
|
||||||
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
document.querySelector(".tab-bar")?.addEventListener("click", (e) => {
|
||||||
const btn = e.target.closest('.tab[data-tab="bookmarks"]');
|
const btn = e.target instanceof Element ? e.target.closest('.tab[data-tab="bookmarks"]') : null;
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
bmFetch(document.getElementById("bm-category-filter").value);
|
void bmFetch(bmEl("bm-category-filter").value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add Bookmark button — open form and prefill from current rig state
|
// Add Bookmark button — open form and prefill from current rig state
|
||||||
document.getElementById("bm-add-btn").addEventListener("click", () => {
|
bmEl("bm-add-btn").addEventListener("click", () => {
|
||||||
bmOpenForm(null);
|
bmOpenForm(null);
|
||||||
bmPrefillFromStatus();
|
bmPrefillFromStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Category filter dropdown
|
// Category filter dropdown
|
||||||
document.getElementById("bm-category-filter").addEventListener("change", (e) => {
|
bmEl("bm-category-filter").addEventListener("change", (e) => {
|
||||||
bmFetch(e.target.value);
|
void bmFetch((e.currentTarget as HTMLSelectElement).value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mode filter dropdown (client-side, no re-fetch)
|
// Mode filter dropdown (client-side, no re-fetch)
|
||||||
document.getElementById("bm-mode-filter").addEventListener("change", () => {
|
bmEl("bm-mode-filter").addEventListener("change", () => {
|
||||||
bmApplyFilters();
|
bmApplyFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Text search filter (client-side, no re-fetch)
|
// Text search filter (client-side, no re-fetch)
|
||||||
document.getElementById("bm-text-filter").addEventListener("input", () => {
|
bmEl("bm-text-filter").addEventListener("input", () => {
|
||||||
bmApplyFilters();
|
bmApplyFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("bm-page-prev").addEventListener("click", () => {
|
bmEl("bm-page-prev").addEventListener("click", () => {
|
||||||
bmChangePage(-1);
|
bmChangePage(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("bm-page-next").addEventListener("click", () => {
|
bmEl("bm-page-next").addEventListener("click", () => {
|
||||||
bmChangePage(1);
|
bmChangePage(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form submit
|
// Form submit
|
||||||
document.getElementById("bm-form").addEventListener("submit", bmSave);
|
bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); });
|
||||||
|
|
||||||
// Form cancel
|
// Form cancel
|
||||||
document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm);
|
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
|
||||||
|
|
||||||
const formWrap = document.getElementById("bm-form-wrap");
|
const formWrap = bmEl("bm-form-wrap");
|
||||||
if (formWrap) {
|
if (formWrap) {
|
||||||
formWrap.addEventListener("click", (e) => {
|
formWrap.addEventListener("click", (e) => {
|
||||||
if (e.target === formWrap) bmCloseForm();
|
if (e.target === formWrap) bmCloseForm();
|
||||||
@@ -719,24 +778,26 @@ function bmPopulateScopePicker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") {
|
if (e.key === "Escape" && bmEl("bm-form-wrap")?.style.display === "flex") {
|
||||||
bmCloseForm();
|
bmCloseForm();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Select-all checkbox
|
// Select-all checkbox
|
||||||
document.getElementById("bm-select-all").addEventListener("change", (e) => {
|
bmEl("bm-select-all").addEventListener("change", (e) => {
|
||||||
const checked = e.target.checked;
|
const checked = (e.currentTarget as HTMLInputElement).checked;
|
||||||
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
document.querySelectorAll<HTMLInputElement>(".bm-row-sel").forEach((cb) => {
|
||||||
cb.checked = checked;
|
cb.checked = checked;
|
||||||
if (checked) bmSelected.add(cb.dataset.bmId);
|
const id = cb.dataset.bmId;
|
||||||
else bmSelected.delete(cb.dataset.bmId);
|
if (!id) return;
|
||||||
|
if (checked) bmSelected.add(id);
|
||||||
|
else bmSelected.delete(id);
|
||||||
});
|
});
|
||||||
bmUpdateSelectionUi();
|
bmUpdateSelectionUi();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Select All (across all pages) button
|
// Select All (across all pages) button
|
||||||
document.getElementById("bm-select-all-btn").addEventListener("click", () => {
|
bmEl("bm-select-all-btn").addEventListener("click", () => {
|
||||||
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
bmSelected.clear();
|
bmSelected.clear();
|
||||||
@@ -744,49 +805,53 @@ function bmPopulateScopePicker() {
|
|||||||
bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
|
bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
|
||||||
}
|
}
|
||||||
// Sync visible page checkboxes
|
// Sync visible page checkboxes
|
||||||
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
document.querySelectorAll<HTMLInputElement>(".bm-row-sel").forEach((cb) => {
|
||||||
cb.checked = bmSelected.has(cb.dataset.bmId);
|
cb.checked = !!cb.dataset.bmId && bmSelected.has(cb.dataset.bmId);
|
||||||
});
|
});
|
||||||
bmSyncSelectAllCheckbox();
|
bmSyncSelectAllCheckbox();
|
||||||
bmUpdateSelectionUi();
|
bmUpdateSelectionUi();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete Selected button
|
// Delete Selected button
|
||||||
document.getElementById("bm-del-selected-btn").addEventListener("click", () => {
|
bmEl("bm-del-selected-btn").addEventListener("click", () => {
|
||||||
bmDeleteSelected();
|
void bmDeleteSelected();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Move Selected button
|
// Move Selected button
|
||||||
document.getElementById("bm-move-selected-btn").addEventListener("click", () => {
|
bmEl("bm-move-selected-btn").addEventListener("click", () => {
|
||||||
bmMoveSelected();
|
void bmMoveSelected();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Table action buttons and row checkboxes (event delegation)
|
// Table action buttons and row checkboxes (event delegation)
|
||||||
document.getElementById("bm-tbody").addEventListener("click", async (e) => {
|
bmEl("bm-tbody").addEventListener("click", (e) => { void (async () => {
|
||||||
const checkbox = e.target.closest(".bm-row-sel");
|
if (!(e.target instanceof Element)) return;
|
||||||
|
const checkbox = e.target.closest<HTMLInputElement>(".bm-row-sel");
|
||||||
if (checkbox) {
|
if (checkbox) {
|
||||||
if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId);
|
const id = checkbox.dataset.bmId;
|
||||||
else bmSelected.delete(checkbox.dataset.bmId);
|
if (!id) return;
|
||||||
|
if (checkbox.checked) bmSelected.add(id);
|
||||||
|
else bmSelected.delete(id);
|
||||||
bmSyncSelectAllCheckbox();
|
bmSyncSelectAllCheckbox();
|
||||||
bmUpdateSelectionUi();
|
bmUpdateSelectionUi();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tuneBtn = e.target.closest(".bm-tune-btn");
|
const tuneBtn = e.target.closest<HTMLElement>(".bm-tune-btn");
|
||||||
const editBtn = e.target.closest(".bm-edit-btn");
|
const editBtn = e.target.closest<HTMLElement>(".bm-edit-btn");
|
||||||
const delBtn = e.target.closest(".bm-del-btn");
|
const delBtn = e.target.closest<HTMLElement>(".bm-del-btn");
|
||||||
|
|
||||||
if (tuneBtn) {
|
if (tuneBtn) {
|
||||||
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
|
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
|
||||||
if (bm) await bmApply(bm);
|
if (bm) bmApply(bm);
|
||||||
} else if (editBtn) {
|
} else if (editBtn) {
|
||||||
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
|
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
|
||||||
if (bm) bmOpenForm(bm);
|
if (bm) bmOpenForm(bm);
|
||||||
} else if (delBtn) {
|
} else if (delBtn) {
|
||||||
await bmDelete(delBtn.dataset.bmId);
|
const id = delBtn.dataset.bmId;
|
||||||
|
if (id) await bmDelete(id);
|
||||||
}
|
}
|
||||||
});
|
})(); });
|
||||||
|
|
||||||
// Pre-load bookmarks so spectrum markers are visible immediately.
|
// Pre-load bookmarks so spectrum markers are visible immediately.
|
||||||
bmFetch("");
|
void bmFetch("");
|
||||||
})();
|
})();
|
||||||
+159
-104
@@ -2,16 +2,56 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
type Rgba = [number, number, number, number];
|
||||||
|
interface CwRenderer {
|
||||||
|
ready: boolean;
|
||||||
|
ensureSize(width: number, height: number, dpr: number): boolean;
|
||||||
|
clear(color: Rgba): void;
|
||||||
|
fillRect(x: number, y: number, width: number, height: number, color: Rgba): void;
|
||||||
|
drawSegments(points: number[], color: Rgba, width: number): void;
|
||||||
|
drawFilledArea(points: number[], baseline: number, color: Rgba): void;
|
||||||
|
drawPolyline(points: number[], color: Rgba, width: number): void;
|
||||||
|
drawPoints(points: number[], size: number, color: Rgba): void;
|
||||||
|
}
|
||||||
|
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
|
||||||
|
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
|
||||||
|
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number }
|
||||||
|
interface CwToneRange {
|
||||||
|
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
|
||||||
|
toneSpanHz: number; lowerSideband: boolean; mode: string;
|
||||||
|
}
|
||||||
|
interface CwBridge {
|
||||||
|
createTrxWebGlRenderer?: (canvas: HTMLCanvasElement, options: WebGLContextAttributes) => CwRenderer;
|
||||||
|
trxParseCssColor?: (color: string) => Rgba;
|
||||||
|
lastFreqHz?: number;
|
||||||
|
currentBandwidthHz?: number;
|
||||||
|
lastSpectrumData?: CwSpectrum;
|
||||||
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
|
applyCwAutoUi?: (enabled: boolean) => void;
|
||||||
|
applyCwAutoUiFromServer?: (enabled: boolean) => void;
|
||||||
|
updateCwBar?: () => void;
|
||||||
|
clearCwBar?: () => void;
|
||||||
|
closeCwBar?: () => void;
|
||||||
|
refreshCwTonePicker?: () => void;
|
||||||
|
}
|
||||||
|
const cwWindow = window as unknown as CwBridge & PluginRuntimeWindow;
|
||||||
|
|
||||||
// --- CW (Morse) Decoder Plugin (server-side decode) ---
|
// --- CW (Morse) Decoder Plugin (server-side decode) ---
|
||||||
const cwStatusEl = document.getElementById("cw-status");
|
const cwStatusEl = document.getElementById("cw-status");
|
||||||
const cwOutputEl = document.getElementById("cw-output");
|
const cwOutputEl = document.getElementById("cw-output");
|
||||||
const cwAutoInput = document.getElementById("cw-auto");
|
const cwAutoInput = document.getElementById("cw-auto") as HTMLInputElement | null;
|
||||||
const cwWpmInput = document.getElementById("cw-wpm");
|
const cwWpmInput = document.getElementById("cw-wpm") as HTMLInputElement | null;
|
||||||
const cwToneInput = document.getElementById("cw-tone");
|
const cwToneInput = document.getElementById("cw-tone") as HTMLInputElement | null;
|
||||||
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
||||||
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
const cwToneCanvas = document.getElementById("cw-tone-waterfall") as HTMLCanvasElement | null;
|
||||||
const cwToneGl = typeof createTrxWebGlRenderer === "function"
|
const cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer
|
||||||
? createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
|
? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
|
||||||
: null;
|
: null;
|
||||||
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
||||||
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
||||||
@@ -24,17 +64,21 @@ const CW_WPM_MAX = 40;
|
|||||||
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
|
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const CW_BAR_LINE_GAP_MS = 5000;
|
const CW_BAR_LINE_GAP_MS = 5000;
|
||||||
let cwLastAppendTime = 0;
|
let cwLastAppendTime = 0;
|
||||||
let cwTonePickerRaf = null;
|
let cwTonePickerRaf: number | null = null;
|
||||||
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}]
|
let cwBarHistory: CwLine[] = [];
|
||||||
let cwBarCurrentLine = null; // accumulates chars until gap/newline
|
let cwBarCurrentLine: CwLine | null = null;
|
||||||
let cwBarDismissedAtMs = 0;
|
let cwBarDismissedAtMs = 0;
|
||||||
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
||||||
// acknowledged). While set, server-state updates must not override the
|
// acknowledged). While set, server-state updates must not override the
|
||||||
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
|
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
|
||||||
// does not immediately undo the user's choice.
|
// does not immediately undo the user's choice.
|
||||||
let cwAutoLocalOverride = null;
|
let cwAutoLocalOverride: boolean | null = null;
|
||||||
|
|
||||||
function applyCwAutoUi(enabled) {
|
function escapeCwHtml(input: string): string {
|
||||||
|
return hostCore.escapeMapHtml(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCwAutoUi(enabled: boolean): void {
|
||||||
if (cwAutoInput) cwAutoInput.checked = enabled;
|
if (cwAutoInput) cwAutoInput.checked = enabled;
|
||||||
if (cwWpmInput) {
|
if (cwWpmInput) {
|
||||||
cwWpmInput.disabled = enabled;
|
cwWpmInput.disabled = enabled;
|
||||||
@@ -48,16 +92,16 @@ function applyCwAutoUi(enabled) {
|
|||||||
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.applyCwAutoUi = applyCwAutoUi;
|
cwWindow.applyCwAutoUi = applyCwAutoUi;
|
||||||
|
|
||||||
// Called by app.js render() when a server-state snapshot arrives. Ignores
|
// Called by app.js render() when a server-state snapshot arrives. Ignores
|
||||||
// the update while cwAutoLocalOverride is set (user change still in-flight).
|
// the update while cwAutoLocalOverride is set (user change still in-flight).
|
||||||
window.applyCwAutoUiFromServer = function(enabled) {
|
cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
|
||||||
if (cwAutoLocalOverride !== null) return;
|
if (cwAutoLocalOverride !== null) return;
|
||||||
applyCwAutoUi(enabled);
|
applyCwAutoUi(enabled);
|
||||||
};
|
};
|
||||||
|
|
||||||
function cwBarFlushCurrentLine() {
|
function cwBarFlushCurrentLine(): void {
|
||||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||||
cwBarHistory.unshift(cwBarCurrentLine);
|
cwBarHistory.unshift(cwBarCurrentLine);
|
||||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
@@ -65,15 +109,15 @@ function cwBarFlushCurrentLine() {
|
|||||||
cwBarCurrentLine = null;
|
cwBarCurrentLine = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCwBar() {
|
function updateCwBar(): void {
|
||||||
if (!cwBarOverlay) return;
|
if (!cwBarOverlay) return;
|
||||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
||||||
const isCw = mode === "CW" || mode === "CWR";
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||||
// Prepend the in-progress line so characters appear immediately
|
// Prepend the in-progress line so characters appear immediately
|
||||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
cwBarOverlay.innerHTML = "";
|
cwBarOverlay.innerHTML = "";
|
||||||
@@ -94,22 +138,22 @@ function updateCwBar() {
|
|||||||
for (const line of liveLines.slice(0, 8)) {
|
for (const line of liveLines.slice(0, 8)) {
|
||||||
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
||||||
const meta = [
|
const meta = [
|
||||||
line.wpm ? `${line.wpm} WPM` : null,
|
line.wpm ? `${String(line.wpm)} WPM` : null,
|
||||||
line.tone_hz ? `${line.tone_hz} Hz` : null,
|
line.tone_hz ? `${String(line.tone_hz)} Hz` : null,
|
||||||
].filter(Boolean).join(" · ");
|
].filter(Boolean).join(" · ");
|
||||||
html += `<div class="aprs-bar-frame">` +
|
html += `<div class="aprs-bar-frame">` +
|
||||||
`<div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` +
|
`<div class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` +
|
||||||
(meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") +
|
(meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") +
|
||||||
`</div></div>`;
|
`</div></div>`;
|
||||||
}
|
}
|
||||||
cwBarOverlay.innerHTML = html;
|
cwBarOverlay.innerHTML = html;
|
||||||
cwBarOverlay.style.display = "flex";
|
cwBarOverlay.style.display = "flex";
|
||||||
}
|
}
|
||||||
window.updateCwBar = updateCwBar;
|
cwWindow.updateCwBar = updateCwBar;
|
||||||
window.clearCwBar = function() {
|
cwWindow.clearCwBar = function() {
|
||||||
window.resetCwHistoryView();
|
resetCwHistoryView();
|
||||||
};
|
};
|
||||||
window.closeCwBar = function() {
|
cwWindow.closeCwBar = function() {
|
||||||
cwBarDismissedAtMs = Date.now();
|
cwBarDismissedAtMs = Date.now();
|
||||||
if (cwBarOverlay) {
|
if (cwBarOverlay) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
@@ -117,34 +161,31 @@ window.closeCwBar = function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampCwWpm(wpm) {
|
function clampCwWpm(wpm: unknown): number {
|
||||||
const numeric = Number(wpm);
|
const numeric = Number(wpm);
|
||||||
if (!Number.isFinite(numeric)) return 15;
|
if (!Number.isFinite(numeric)) return 15;
|
||||||
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampCwTone(tone) {
|
function clampCwTone(tone: unknown): number {
|
||||||
const numeric = Number(tone);
|
const numeric = Number(tone);
|
||||||
if (!Number.isFinite(numeric)) return 700;
|
if (!Number.isFinite(numeric)) return 700;
|
||||||
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentCwToneRange() {
|
function currentCwToneRange(): CwToneRange | null {
|
||||||
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
|
const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
|
||||||
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
|
const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
|
||||||
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
||||||
const lowerSideband = mode === "CWR";
|
const lowerSideband = mode === "CWR";
|
||||||
const upperSideband = mode === "CW";
|
const upperSideband = mode === "CW";
|
||||||
if (!lowerSideband && !upperSideband) return null;
|
if (!lowerSideband && !upperSideband) return null;
|
||||||
|
|
||||||
const toneMinHz = CW_TONE_MIN_HZ;
|
const toneMinHz = CW_TONE_MIN_HZ;
|
||||||
const toneMaxHz = CW_TONE_MAX_HZ;
|
const toneMaxHz = CW_TONE_MAX_HZ;
|
||||||
if (toneMaxHz < toneMinHz) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
tunedHz,
|
tunedHz,
|
||||||
bandwidthHz,
|
bandwidthHz,
|
||||||
@@ -156,20 +197,20 @@ function currentCwToneRange() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function cwToneToRfHz(range, toneHz) {
|
function cwToneToRfHz(range: CwToneRange | null, toneHz: number): number {
|
||||||
if (!range) return NaN;
|
if (!range) return NaN;
|
||||||
return range.lowerSideband
|
return range.lowerSideband
|
||||||
? range.tunedHz - toneHz
|
? range.tunedHz - toneHz
|
||||||
: range.tunedHz + toneHz;
|
: range.tunedHz + toneHz;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toneClampForRange(tone, range) {
|
function toneClampForRange(tone: unknown, range: CwToneRange | null): number {
|
||||||
const clamped = clampCwTone(tone);
|
const clamped = clampCwTone(tone);
|
||||||
if (!range) return clamped;
|
if (!range) return clamped;
|
||||||
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureCwToneCanvasResolution() {
|
function ensureCwToneCanvasResolution(): boolean {
|
||||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
||||||
const rect = cwToneCanvas.getBoundingClientRect();
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
const cssWidth = Math.round(rect.width);
|
const cssWidth = Math.round(rect.width);
|
||||||
@@ -181,7 +222,7 @@ function ensureCwToneCanvasResolution() {
|
|||||||
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawCwTonePicker() {
|
function drawCwTonePicker(): void {
|
||||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
||||||
@@ -190,12 +231,12 @@ function drawCwTonePicker() {
|
|||||||
cwToneGl.clear([0, 0, 0, 0]);
|
cwToneGl.clear([0, 0, 0, 0]);
|
||||||
|
|
||||||
const range = currentCwToneRange();
|
const range = currentCwToneRange();
|
||||||
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
|
if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) {
|
||||||
if (cwToneRangeEl) {
|
if (cwToneRangeEl) {
|
||||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
||||||
if (mode !== "CW" && mode !== "CWR") {
|
if (mode !== "CW" && mode !== "CWR") {
|
||||||
cwToneRangeEl.textContent = "CW/CWR mode required";
|
cwToneRangeEl.textContent = "CW/CWR mode required";
|
||||||
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
|
} else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) {
|
||||||
cwToneRangeEl.textContent = "Waiting for spectrum";
|
cwToneRangeEl.textContent = "Waiting for spectrum";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,15 +246,15 @@ function drawCwTonePicker() {
|
|||||||
|
|
||||||
if (cwToneRangeEl) {
|
if (cwToneRangeEl) {
|
||||||
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
||||||
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
|
cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bins = window.lastSpectrumData.bins;
|
const bins = cwWindow.lastSpectrumData.bins;
|
||||||
const sampleRate = Number(window.lastSpectrumData.sample_rate);
|
const sampleRate = cwWindow.lastSpectrumData.sample_rate;
|
||||||
const centerHz = Number(window.lastSpectrumData.center_hz);
|
const centerHz = cwWindow.lastSpectrumData.center_hz;
|
||||||
const maxIdx = Math.max(1, bins.length - 1);
|
const maxIdx = Math.max(1, bins.length - 1);
|
||||||
const fullLoHz = centerHz - sampleRate / 2;
|
const fullLoHz = centerHz - sampleRate / 2;
|
||||||
const tones = new Array(width).fill(-140);
|
const tones: number[] = new Array<number>(width).fill(-140);
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
const frac = width <= 1 ? 0 : x / (width - 1);
|
const frac = width <= 1 ? 0 : x / (width - 1);
|
||||||
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
@@ -223,17 +264,17 @@ function drawCwTonePicker() {
|
|||||||
tones[x] = power;
|
tones[x] = power;
|
||||||
}
|
}
|
||||||
|
|
||||||
const smoothed = new Array(width).fill(-140);
|
const smoothed: number[] = new Array<number>(width).fill(-140);
|
||||||
const smoothRadius = Math.max(1, Math.round(width / 180));
|
const smoothRadius = Math.max(1, Math.round(width / 180));
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
||||||
if (i < 0 || i >= width) continue;
|
if (i < 0 || i >= width) continue;
|
||||||
sum += tones[i];
|
sum += tones[i] ?? -140;
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
smoothed[x] = count > 0 ? sum / count : tones[x];
|
smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = smoothed.slice().sort((a, b) => a - b);
|
const sorted = smoothed.slice().sort((a, b) => a - b);
|
||||||
@@ -242,23 +283,23 @@ function drawCwTonePicker() {
|
|||||||
const floorDb = Math.min(q20 - 2, q95 - 10);
|
const floorDb = Math.min(q20 - 2, q95 - 10);
|
||||||
const ceilDb = Math.max(floorDb + 18, q95 + 2);
|
const ceilDb = Math.max(floorDb + 18, q95 + 2);
|
||||||
const dbSpan = Math.max(1, ceilDb - floorDb);
|
const dbSpan = Math.max(1, ceilDb - floorDb);
|
||||||
const yForDb = (db) => {
|
const yForDb = (db: number): number => {
|
||||||
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
||||||
return Math.round((1 - n) * (height - 1));
|
return Math.round((1 - n) * (height - 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const rootStyle = getComputedStyle(document.documentElement);
|
const rootStyle = getComputedStyle(document.documentElement);
|
||||||
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
||||||
const parseColor = typeof window.trxParseCssColor === "function"
|
const parseColor = typeof cwWindow.trxParseCssColor === "function"
|
||||||
? window.trxParseCssColor
|
? cwWindow.trxParseCssColor
|
||||||
: null;
|
: null;
|
||||||
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
const accentRgba: Rgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
||||||
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
const axisColor: Rgba = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
||||||
|
|
||||||
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
||||||
|
|
||||||
const hGridCount = 4;
|
const hGridCount = 4;
|
||||||
const gridSegments = [];
|
const gridSegments: number[] = [];
|
||||||
for (let i = 1; i <= hGridCount; i += 1) {
|
for (let i = 1; i <= hGridCount; i += 1) {
|
||||||
const y = Math.round((i / (hGridCount + 1)) * (height - 1));
|
const y = Math.round((i / (hGridCount + 1)) * (height - 1));
|
||||||
gridSegments.push(0, y, width, y);
|
gridSegments.push(0, y, width, y);
|
||||||
@@ -267,7 +308,7 @@ function drawCwTonePicker() {
|
|||||||
|
|
||||||
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
|
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
|
||||||
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
||||||
const tickSegments = [];
|
const tickSegments: number[] = [];
|
||||||
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
||||||
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
||||||
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
|
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
|
||||||
@@ -275,9 +316,9 @@ function drawCwTonePicker() {
|
|||||||
}
|
}
|
||||||
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
||||||
|
|
||||||
const linePoints = [];
|
const linePoints: number[] = [];
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
linePoints.push(x, yForDb(smoothed[x]));
|
linePoints.push(x, yForDb(smoothed[x] ?? -140));
|
||||||
}
|
}
|
||||||
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
||||||
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
||||||
@@ -285,7 +326,7 @@ function drawCwTonePicker() {
|
|||||||
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
||||||
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
||||||
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
||||||
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
|
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))] ?? -140);
|
||||||
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
||||||
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
||||||
|
|
||||||
@@ -294,14 +335,14 @@ function drawCwTonePicker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setCwTone(tone, { syncInput = true } = {}) {
|
async function setCwTone(tone: unknown, { syncInput = true }: { syncInput?: boolean } = {}): Promise<void> {
|
||||||
const range = currentCwToneRange();
|
const range = currentCwToneRange();
|
||||||
const clamped = toneClampForRange(tone, range);
|
const clamped = toneClampForRange(tone, range);
|
||||||
if (cwToneInput && syncInput) {
|
if (cwToneInput && syncInput) {
|
||||||
cwToneInput.value = clamped;
|
cwToneInput.value = String(clamped);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("CW tone set failed", e);
|
console.error("CW tone set failed", e);
|
||||||
}
|
}
|
||||||
@@ -309,40 +350,43 @@ async function setCwTone(tone, { syncInput = true } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cwAutoInput) {
|
if (cwAutoInput) {
|
||||||
cwAutoInput.addEventListener("change", async () => {
|
cwAutoInput.addEventListener("change", () => {
|
||||||
const enabled = cwAutoInput.checked;
|
void (async () => {
|
||||||
cwAutoLocalOverride = enabled;
|
const enabled = cwAutoInput.checked;
|
||||||
applyCwAutoUi(enabled);
|
cwAutoLocalOverride = enabled;
|
||||||
try {
|
applyCwAutoUi(enabled);
|
||||||
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
try {
|
||||||
drawCwTonePicker();
|
await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||||
} catch (e) {
|
drawCwTonePicker();
|
||||||
console.error("CW auto toggle failed", e);
|
} catch (error: unknown) {
|
||||||
} finally {
|
console.error("CW auto toggle failed", error);
|
||||||
cwAutoLocalOverride = null;
|
} finally {
|
||||||
}
|
cwAutoLocalOverride = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwWpmInput) {
|
if (cwWpmInput) {
|
||||||
cwWpmInput.addEventListener("change", async () => {
|
cwWpmInput.addEventListener("change", () => {
|
||||||
if (cwAutoInput && cwAutoInput.checked) return;
|
void (async () => {
|
||||||
const wpm = clampCwWpm(cwWpmInput.value);
|
if (cwAutoInput?.checked) return;
|
||||||
cwWpmInput.value = wpm;
|
const wpm = clampCwWpm(cwWpmInput.value);
|
||||||
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
cwWpmInput.value = String(wpm);
|
||||||
catch (e) { console.error("CW WPM set failed", e); }
|
try { await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
||||||
|
catch (error: unknown) { console.error("CW WPM set failed", error); }
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwToneInput) {
|
if (cwToneInput) {
|
||||||
cwToneInput.addEventListener("change", async () => {
|
cwToneInput.addEventListener("change", () => {
|
||||||
if (cwAutoInput?.checked) return;
|
if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
|
||||||
await setCwTone(cwToneInput.value);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwToneCanvas) {
|
if (cwToneCanvas) {
|
||||||
cwToneCanvas.addEventListener("click", async (event) => {
|
cwToneCanvas.addEventListener("click", (event) => {
|
||||||
if (cwAutoInput?.checked) return;
|
if (cwAutoInput?.checked) return;
|
||||||
const rect = cwToneCanvas.getBoundingClientRect();
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
if (rect.width <= 0) return;
|
if (rect.width <= 0) return;
|
||||||
@@ -350,31 +394,33 @@ if (cwToneCanvas) {
|
|||||||
if (!range) return;
|
if (!range) return;
|
||||||
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||||
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
await setCwTone(tone);
|
void setCwTone(tone);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.resetCwHistoryView = function() {
|
function resetCwHistoryView(): void {
|
||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
cwLastAppendTime = 0;
|
cwLastAppendTime = 0;
|
||||||
cwBarHistory = [];
|
cwBarHistory = [];
|
||||||
cwBarCurrentLine = null;
|
cwBarCurrentLine = null;
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
};
|
}
|
||||||
|
|
||||||
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
|
document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => {
|
||||||
if (!confirm("Clear all CW decode history? This cannot be undone.")) return;
|
void (async () => {
|
||||||
try {
|
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
await postPath("/clear_cw_decode");
|
try {
|
||||||
window.resetCwHistoryView();
|
await hostCore.postPath("/clear_cw_decode");
|
||||||
} catch (e) {
|
resetCwHistoryView();
|
||||||
console.error("CW history clear failed", e);
|
} catch (error: unknown) {
|
||||||
}
|
console.error("CW history clear failed", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Server-side CW decode handler ---
|
// --- Server-side CW decode handler ---
|
||||||
window.onServerCw = function(evt) {
|
function onServerCw(evt: CwEvent): void {
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
if (evt.text && cwOutputEl) {
|
if (evt.text && cwOutputEl) {
|
||||||
// Append decoded text to output
|
// Append decoded text to output
|
||||||
@@ -390,7 +436,9 @@ window.onServerCw = function(evt) {
|
|||||||
lastLine.textContent += evt.text;
|
lastLine.textContent += evt.text;
|
||||||
}
|
}
|
||||||
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
||||||
cwOutputEl.removeChild(cwOutputEl.firstChild);
|
const firstChild = cwOutputEl.firstChild;
|
||||||
|
if (!firstChild) break;
|
||||||
|
cwOutputEl.removeChild(firstChild);
|
||||||
}
|
}
|
||||||
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
||||||
}
|
}
|
||||||
@@ -417,10 +465,10 @@ window.onServerCw = function(evt) {
|
|||||||
}
|
}
|
||||||
if (!cwAutoInput || cwAutoInput.checked) {
|
if (!cwAutoInput || cwAutoInput.checked) {
|
||||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
cwWpmInput.value = clampCwWpm(evt.wpm);
|
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||||
}
|
}
|
||||||
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
||||||
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
|
cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cwTonePickerRaf != null) return;
|
if (cwTonePickerRaf != null) return;
|
||||||
@@ -428,17 +476,24 @@ window.onServerCw = function(evt) {
|
|||||||
cwTonePickerRaf = null;
|
cwTonePickerRaf = null;
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
window.restoreCwHistory = function(events) {
|
function restoreCwHistory(events: CwEvent[]): void {
|
||||||
if (!Array.isArray(events) || events.length === 0) return;
|
if (!Array.isArray(events) || events.length === 0) return;
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
for (const evt of events) {
|
for (const evt of events) {
|
||||||
window.onServerCw(evt);
|
onServerCw(evt);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
window.refreshCwTonePicker = function refreshCwTonePicker() {
|
cwWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "cw",
|
||||||
|
onMessage: onServerCw,
|
||||||
|
restore: restoreCwHistory,
|
||||||
|
reset: resetCwHistoryView,
|
||||||
|
});
|
||||||
|
|
||||||
|
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import { initializeFtxDecoder } from "./ftx-family";
|
||||||
|
|
||||||
|
initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3_750 });
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user