Compare commits

..
Author SHA1 Message Date
sjg 99ae2a5fd7 [chore](trx-rs): add sccache compilation cache
CI / reuse (pull_request) Successful in 5s
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 4s
Bake sccache into the SDK image and enable it via RUSTC_WRAPPER in CI and
the devcontainer (not repo-wide, so non-SDK builds are unaffected).

- container/Containerfile: install the sccache musl binary.
- ci.yml: RUSTC_WRAPPER=sccache, CARGO_INCREMENTAL=0, SCCACHE_DIR=/sccache,
  cache size cap, plus a `sccache --show-stats` step per job.
- runner-config.example.yaml: bind-mount /var/cache/sccache into job
  containers so the cache persists across runs and is shared between jobs.
- .devcontainer: enable sccache with a named cache volume.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-19 16:43:59 +02:00
sjg 9da6118a35 [chore](trx-rs): add OpenRC service for act_runner on Alpine
CI / lint (pull_request) Successful in 5m11s
CI / test (pull_request) Successful in 13m48s
CI / reuse (pull_request) Successful in 46s
The runner host is Alpine (OpenRC, no systemd). Add an OpenRC init script
for act_runner (supervise-daemon, depends on docker) plus a conf.d
example for running one instance per project, and rewrite the runner
section of the README with Alpine setup steps (apk docker, dedicated user
in the docker group, register, service install).

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-19 13:14:17 +02:00
sjg efd82c6284 [chore](trx-rs): use nested SDK image path trx-rs/sdk
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 4s
Match the image name that was pushed to the registry
(git.haxx.space/sjg/trx-rs/sdk) across the workflow, devcontainer and
README.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-19 13:06:43 +02:00
sjg e9cf5e8739 [chore](trx-rs): shared SDK image for CI and developers
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 4s
Rework container/ from a host-executor act_runner image into a single
"SDK" build image used everywhere: as the CI job container (Docker
executor) and by developers locally / via .devcontainer. It bakes in a
pinned Rust toolchain and all build dependencies, so CI and every
developer share the exact same rustc/clippy.

- container/Containerfile: SDK image (Debian + deps + pinned Rust + Node).
- rust-toolchain.toml: pin the toolchain to match the image; also ends the
  "CI clippy newer than local" version skew.
- .gitea/workflows/ci.yml: lint/test run inside the SDK image via
  `container:`; reuse returns to fsfe/reuse-action (Docker executor runs
  it as a sibling container, so nothing REUSE-related is baked in).
- .devcontainer/devcontainer.json: dev use of the same image.
- container/runner-config.example.yaml: Docker-executor runner config for
  the CI VM, capped for a 2-thread budget.
- Drop the obsolete host-executor entrypoint/config/Quadlet units.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-19 00:22:27 +02:00
48 changed files with 1006 additions and 2543 deletions
+22
View File
@@ -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"
]
}
}
}
+20 -9
View File
@@ -2,10 +2,10 @@
# #
# SPDX-License-Identifier: GPL-2.0-or-later # SPDX-License-Identifier: GPL-2.0-or-later
# CI for the self-hosted, host-executor Podman runners (see container/). # CI for the Docker-executor runner (VM). The lint/test jobs run inside the
# The runner image bakes in the Rust toolchain and all build dependencies, # shared trx-rs SDK image (container/Containerfile), which bakes in the pinned
# so jobs go straight to cargo — no apt/rustup setup steps (which also # Rust toolchain and all build dependencies. The reuse job uses the upstream
# collided on the dpkg lock when jobs ran concurrently in the same runner). # Docker action, which the Docker executor launches as a sibling container.
name: CI name: CI
@@ -16,32 +16,43 @@ on:
env: env:
CARGO_TERM_COLOR: always 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: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: rustfmt - name: rustfmt
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
- name: clippy - name: clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: sccache stats
if: always()
run: sccache --show-stats
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build - name: Build
run: cargo build --workspace --all-targets --locked run: cargo build --workspace --all-targets --locked
- name: Test - name: Test
run: cargo test --workspace --locked run: cargo test --workspace --locked
- name: sccache stats
if: always()
run: sccache --show-stats
reuse: reuse:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: REUSE compliance - uses: fsfe/reuse-action@v5
# `reuse` CLI instead of fsfe/reuse-action: the latter is a Docker
# action, which the host-executor runners cannot run. `reuse` is baked
# into the runner image (see container/Containerfile).
run: reuse lint
-21
View File
@@ -1,21 +0,0 @@
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.
+3 -3
View File
@@ -142,6 +142,6 @@ a unified set of frontends.
## 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 retain their bundled third-party license files. Bundled third-party components (Leaflet and
original licenses: Leaflet is BSD-2-Clause, DSEG is OFL-1.1, and opus-decoder the Leaflet AIS tracksymbol plugin under `assets/web/vendor/`) retain their
is MIT. original BSD-2-Clause license.
+2 -8
View File
@@ -12,6 +12,8 @@ path = [
"trx-rs.toml.example", "trx-rs.toml.example",
"docs/**", "docs/**",
"aidocs/**", "aidocs/**",
"container/**",
".devcontainer/**",
"src/decoders/trx-ftx/README.md", "src/decoders/trx-ftx/README.md",
"src/decoders/trx-wxsat/README.md", "src/decoders/trx-wxsat/README.md",
"assets/trx-logo.png", "assets/trx-logo.png",
@@ -42,11 +44,3 @@ SPDX-License-Identifier = "BSD-2-Clause"
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/dseg14-classic-latin-400-normal.woff2"] 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-FileCopyrightText = "2020 The DSEG Authors (https://github.com/keshikan/DSEG)"
SPDX-License-Identifier = "OFL-1.1" 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"
+32 -38
View File
@@ -2,60 +2,54 @@
# #
# SPDX-License-Identifier: GPL-2.0-or-later # SPDX-License-Identifier: GPL-2.0-or-later
# Gitea Actions runner image for trx-rs CI (host-executor / "Pattern B"). # trx-rs SDK / build image.
# #
# All build dependencies, the Rust toolchain, Node.js (for JS actions such as # Single source of truth for the build environment. Used two ways:
# actions/checkout and actions/cache) and the `reuse` tool are baked in, so CI # * CI — as the job container for the lint/test jobs (Docker executor).
# runs skip the per-run apt/rustup install cost. `sudo` is present so the # * Dev — run locally or via .devcontainer for a reproducible toolchain.
# existing workflow's `sudo apt-get ...` / rustup steps remain valid — they #
# just become fast no-ops because everything is already installed. # 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 FROM docker.io/library/debian:bookworm-slim
ARG ACT_RUNNER_VERSION=0.2.11 # Keep in sync with rust-toolchain.toml.
ARG RUST_VERSION=1.97.1
ARG NODE_MAJOR=20 ARG NODE_MAJOR=20
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
RUSTUP_HOME=/opt/rustup \ RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/opt/cargo \ CARGO_HOME=/usr/local/cargo \
PATH=/opt/cargo/bin:/usr/local/bin:/usr/bin:/bin PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin
# Base tooling + trx-rs build dependencies (mirrors .gitea/workflows/ci.yml). # Build dependencies (mirror README's manual instructions).
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils git sudo pipx \ ca-certificates curl git \
build-essential pkg-config cmake clang libclang-dev \ build-essential pkg-config cmake clang libclang-dev \
libopus-dev libasound2-dev libsoapysdr-dev \ libopus-dev libasound2-dev libsoapysdr-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Node.js (JS-based actions need node in PATH under the host executor). # Node.js JS-based actions (actions/checkout, actions/cache) run *inside*
# the job container under the Docker executor, so node must be present.
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \ RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# REUSE >= 3 (Debian's packaged reuse is too old for REUSE.toml). # Pinned Rust toolchain, installed world-readable so any UID the runner or a
# The [charset-normalizer] extra provides an encoding-detection backend; # devcontainer uses can invoke cargo.
# without it (and without libmagic) reuse fails to import at runtime.
RUN PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install 'reuse[charset-normalizer]'
# Rust stable with rustfmt + clippy, installed system-wide.
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \ | sh -s -- -y --no-modify-path \
--component rustfmt --component clippy \ --default-toolchain "${RUST_VERSION}" --profile minimal \
&& chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME" --component rustfmt --component clippy \
&& chmod -R a+rwX "$RUSTUP_HOME" "$CARGO_HOME"
# act_runner binary. # sccache — shared compilation cache. Enabled at build time via
RUN arch="$(dpkg --print-architecture)"; \ # RUSTC_WRAPPER (see the CI workflow and .devcontainer), not repo-wide, so
case "$arch" in amd64) rarch=amd64;; arm64) rarch=arm64;; *) echo "unsupported arch $arch" >&2; exit 1;; esac; \ # non-SDK builds are unaffected. musl build is static and runs anywhere.
curl -fsSL -o /usr/local/bin/act_runner \ ARG SCCACHE_VERSION=0.8.2
"https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-${rarch}" \ RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& chmod +x /usr/local/bin/act_runner | tar -xz -C /tmp \
&& install -m755 "/tmp/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl/sccache" /usr/local/bin/sccache \
&& rm -rf /tmp/sccache-*
# Default config template (seeded into the /data volume on first boot). WORKDIR /work
COPY config.yaml /etc/act_runner/config.yaml
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# /data holds the .runner registration, cache and workflow workspaces.
VOLUME /data
WORKDIR /data
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+102 -90
View File
@@ -3,116 +3,128 @@ SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later SPDX-License-Identifier: GPL-2.0-or-later
--> -->
# Podman-based Gitea Actions runners # trx-rs SDK image
Run two independent Gitea Actions runners on one host as rootless Podman A single container image that is the canonical build environment for trx-rs,
containers managed by systemd (Quadlet) — one per project — instead of two used **both** by CI and by developers. It bakes in the pinned Rust toolchain
VMs. Uses the **host executor**: workflow steps run directly inside a (matching `rust-toolchain.toml`) and every build dependency, so the compiler
purpose-built runner image that already has the Rust toolchain and all build and `clippy` are identical everywhere — no "works on my machine".
dependencies baked in, so CI runs skip the per-run install cost and no
Docker/Podman socket is needed.
## Files
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `Containerfile` | Runner image: Debian + build deps + clang + Rust + Node + `reuse` + `act_runner`. | | `Containerfile` | The SDK image (Debian + build deps + pinned Rust + Node + git). |
| `entrypoint.sh` | Registers on first boot (if needed), then runs the daemon. | | `runner-config.example.yaml` | Example act_runner config for the CI VM (Docker executor). |
| `config.yaml` | act_runner config template (seeded into each runner's volume). |
| `trx-rs-runner.container` | Quadlet unit for the trx-rs runner. |
| `project2-runner.container` | Quadlet unit for the second project's runner. |
## Prerequisites (once per host) ## Build and publish
Rootless Podman with cgroups v2 (default on modern distros). As the unprivileged
user that will own the runners:
```bash ```bash
# Survive logout / start on boot without an interactive session. # from the repo root
loginctl enable-linger "$USER" podman build -t git.haxx.space/sjg/trx-rs/sdk:latest container
podman login git.haxx.space
podman push git.haxx.space/sjg/trx-rs/sdk:latest
``` ```
No `podman.socket` is required for the host executor. 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.
## 1. Build the image ## Developer use
Reproducible one-off build, no local toolchain needed:
```bash ```bash
cd container podman run --rm -it -v "$PWD":/work -w /work \
podman build -t trx-rs-ci:latest . git.haxx.space/sjg/trx-rs/sdk:latest \
cargo build --release
``` ```
## 2. Get a registration token Or open the repo in the image via VS Code / JetBrains "Reopen in Container"
(`.devcontainer/devcontainer.json` points at the same image).
For **each** repo: *Settings → Actions → Runners → Create new Runner* and copy Building outside the container? `rust-toolchain.toml` pins the same rustc, so
the token. (Org- or instance-level tokens work too if you prefer wider scope.) `rustup` installs the matching toolchain automatically.
## 3. Install and start the runners ## CI use
`.gitea/workflows/ci.yml` runs the `lint` and `test` jobs *inside* this image
via the `container:` key, so they skip all setup and go straight to `cargo`.
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.
## 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 ```bash
mkdir -p ~/.config/containers/systemd # 1. Docker + a dedicated user with socket access
cp trx-rs-runner.container project2-runner.container ~/.config/containers/systemd/ 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
# Paste each repo's token for the FIRST boot only: # 2. act_runner binary (static Go build, works on musl)
# Environment=GITEA_RUNNER_REGISTRATION_TOKEN=xxxx… curl -fsSL -o /usr/local/bin/act_runner \
$EDITOR ~/.config/containers/systemd/trx-rs-runner.container https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64
$EDITOR ~/.config/containers/systemd/project2-runner.container chmod +x /usr/local/bin/act_runner
systemctl --user daemon-reload # 3. Config + register one runner per project (scope keeps their jobs apart)
systemctl --user start trx-rs-runner install -Dm644 container/runner-config.example.yaml /etc/act_runner/trx-rs.yaml
systemctl --user start project2-runner 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"'
systemctl --user status trx-rs-runner # 4. OpenRC service (repeat the symlink+conf.d for the second project)
podman logs -f gitea-runner-trx-rs 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
``` ```
Once each runner shows **online** in the repo's runner list, blank out the Check it with `rc-service act_runner.trx-rs status` and
`GITEA_RUNNER_REGISTRATION_TOKEN` line again (the registration is persisted in `tail -f /var/log/act_runner.trx-rs.log`.
the `…-data` volume) and `systemctl --user daemon-reload`.
## Required workflow change: the `reuse` job
The host executor runs steps directly in the container and therefore **cannot
run Docker-based actions**. The current `reuse` job uses `fsfe/reuse-action@v5`,
which is a Docker action. `reuse` is baked into the image, so replace that job
with a plain command:
```yaml
reuse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: REUSE compliance
run: reuse lint
```
The `lint` and `test` jobs need no changes: their `sudo apt-get …` and rustup
steps still run, but become fast no-ops because the image already has those
packages and the toolchain. (`sudo` is included in the image for exactly this
reason.)
> If you would rather keep Docker-based actions and per-run images, use the
> **Docker executor** instead: drop the `:host` suffix from the label in
> `config.yaml`, enable `systemctl --user --now enable podman.socket`, mount it
> into the container, and set `container.docker_host` to the socket path. That
> trades the baked-in speed for stronger per-job isolation.
## Tuning
- **`capacity`** (in `config.yaml`) — concurrent jobs per runner. Rust builds
are heavy; 12 is sensible when two runners share a host.
- **`PodmanArgs=--cpus/--memory`** (in each `.container`) — hard resource caps
so one project cannot starve the other.
- **SELinux** — the `:Z` volume flag is already set; keep it if SELinux is
enforcing.
## Committing these files
If you add this directory to a REUSE-checked repo, register the markdown in
`REUSE.toml` (the other files carry inline SPDX headers):
```toml
[[annotations]]
path = ["container/**"]
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
SPDX-License-Identifier = "GPL-2.0-or-later"
```
+14
View File
@@ -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"
+44
View File
@@ -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}"
}
-32
View File
@@ -1,32 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# act_runner configuration template. Seeded into /data/config.yaml on first
# boot; edit the copy inside the volume to change settings per runner.
log:
level: info
runner:
# Registration state. Relative to the daemon's working directory (/data).
file: .runner
# Concurrent jobs this runner will pick up. Rust builds are heavy — keep this
# modest, especially if two runners share one host. The trx-rs workflow has
# three parallel jobs (lint, test, reuse); capacity 2 lets two overlap.
capacity: 2
timeout: 3h
# Map the workflow's `runs-on: ubuntu-latest` to the HOST executor, i.e. run
# steps directly inside THIS container (which already has all the toolchain).
# No Docker/Podman socket is required in this mode.
labels:
- "ubuntu-latest:host"
cache:
# Built-in actions cache server (used by actions/cache). Stored in the volume.
enabled: true
dir: "/data/cache"
host:
# Where per-job workspaces are created.
workdir_parent: /data/workflows
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Registers the runner on first boot (if no .runner state exists in /data),
# then runs the act_runner daemon. Idempotent: on subsequent boots it reuses
# the stored registration and ignores the token.
set -euo pipefail
CONFIG_FILE="${CONFIG_FILE:-/data/config.yaml}"
cd /data
# Seed the config from the image's template on first boot so it lives in the
# persistent volume and can be edited there.
if [ ! -f "$CONFIG_FILE" ]; then
cp /etc/act_runner/config.yaml "$CONFIG_FILE"
fi
# runner.file in config.yaml is ".runner" (relative to this CWD => /data/.runner).
if [ ! -f /data/.runner ]; then
if [ -z "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" ]; then
echo "ERROR: no /data/.runner registration and GITEA_RUNNER_REGISTRATION_TOKEN is empty." >&2
echo " Grab a token from the repo's Settings -> Actions -> Runners and set it" >&2
echo " in the Quadlet unit for the first boot only." >&2
exit 1
fi
echo "Registering runner '${GITEA_RUNNER_NAME:-podman}' with ${GITEA_INSTANCE_URL} ..."
act_runner register --no-interactive \
--config "$CONFIG_FILE" \
--instance "${GITEA_INSTANCE_URL:?set GITEA_INSTANCE_URL}" \
--token "$GITEA_RUNNER_REGISTRATION_TOKEN" \
--name "${GITEA_RUNNER_NAME:-podman}" \
--labels "${GITEA_RUNNER_LABELS:-ubuntu-latest:host}"
fi
exec act_runner daemon --config "$CONFIG_FILE"
-40
View File
@@ -1,40 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Rootless Podman Quadlet for the SECOND project's Gitea Actions runner,
# co-located on the same host as the trx-rs runner.
#
# It has its own name, its own data volume and its own registration token, so
# the two runners are fully independent. They share the `ubuntu-latest` label,
# but registration SCOPE (which repo each token came from) keeps their jobs
# separate — neither will pick up the other's work.
#
# If project 2 needs different build dependencies, build it its own image from
# an adjusted Containerfile and point Image= at that instead of reusing the
# trx-rs image below.
[Unit]
Description=Gitea Actions runner — project 2
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/gitea-act-runner:latest
ContainerName=gitea-runner-project2
Volume=gitea-runner-project2-data:/data:Z
Environment=CONFIG_FILE=/data/config.yaml
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
Environment=GITEA_RUNNER_NAME=project2-podman
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
PodmanArgs=--cpus=4.0 --memory=6g
[Service]
Restart=always
TimeoutStartSec=0
[Install]
WantedBy=default.target
+35
View File
@@ -0,0 +1,35 @@
# 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"
# Reuse the host VM's Docker network for the built-in cache/artifact server.
network: "host"
-41
View File
@@ -1,41 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Rootless Podman Quadlet for the trx-rs Gitea Actions runner.
# Install to ~/.config/containers/systemd/trx-rs-runner.container then:
# systemctl --user daemon-reload
# systemctl --user start trx-rs-runner
#
# First boot only: paste a registration token (repo Settings -> Actions ->
# Runners) into GITEA_RUNNER_REGISTRATION_TOKEN. After the runner appears
# online you can blank it again — the registration is persisted in the volume.
[Unit]
Description=Gitea Actions runner — trx-rs
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/trx-rs-ci:latest
ContainerName=gitea-runner-trx-rs
# Persistent state: .runner registration, cache, workspaces.
Volume=gitea-runner-trx-rs-data:/data:Z
Environment=CONFIG_FILE=/data/config.yaml
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
Environment=GITEA_RUNNER_NAME=trx-rs-podman
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
# Resource caps so a heavy Rust build here cannot starve the other project's
# runner on the same host. Tune to your box.
PodmanArgs=--cpus=4.0 --memory=6g
[Service]
Restart=always
# A cold Rust build can be slow; don't let systemd consider startup failed.
TimeoutStartSec=0
[Install]
WantedBy=default.target
+11
View File
@@ -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"]
+1 -2
View File
@@ -274,8 +274,7 @@ 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 as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345)) .map(|i| ((i * 1103515245 + 12345) as u32 >> 8 & 0xff) as u8)
.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!(
@@ -23,7 +23,6 @@ window.onDecoderRegistryReady = function (fn) {
for (const fn of _decoderRegistryReadyCallbacks) fn(); for (const fn of _decoderRegistryReadyCallbacks) fn();
_decoderRegistryReadyCallbacks.length = 0; _decoderRegistryReadyCallbacks.length = 0;
hideUnsupportedDecoderTabs(); hideUnsupportedDecoderTabs();
refreshOperatorLayoutCapabilities();
} }
} catch (e) { } catch (e) {
console.error("Failed to fetch decoder registry:", e); console.error("Failed to fetch decoder registry:", e);
@@ -334,19 +333,8 @@ function applyCapabilities(caps) {
const txAudioBtn = document.getElementById("tx-audio-btn"); const txAudioBtn = document.getElementById("tx-audio-btn");
const txVolSlider = document.getElementById("tx-vol"); const txVolSlider = document.getElementById("tx-vol");
const txVolControl = txVolSlider ? txVolSlider.closest(".vol-label") : null; const txVolControl = txVolSlider ? txVolSlider.closest(".vol-label") : null;
const hasPowerControl = !caps.filter_controls; if (txPowerCol) txPowerCol.style.display = caps.tx ? "" : "none";
if (txPowerCol) {
txPowerCol.style.display = (caps.tx || hasPowerControl || caps.lockable) ? "" : "none";
const label = txPowerCol.querySelector(".label span");
if (label) {
label.textContent = caps.tx && hasPowerControl ? "Transmit / Power"
: caps.tx ? "Transmit / Tuning"
: hasPowerControl ? "Power / Tuning" : "Tuning";
}
}
if (pttBtn) pttBtn.style.display = caps.tx ? "" : "none"; if (pttBtn) pttBtn.style.display = caps.tx ? "" : "none";
if (powerBtn) powerBtn.style.display = hasPowerControl ? "" : "none";
if (lockBtn) lockBtn.style.display = caps.lockable ? "" : "none";
if (txMetersRow) txMetersRow.style.display = caps.tx ? "" : "none"; if (txMetersRow) txMetersRow.style.display = caps.tx ? "" : "none";
if (txAudioBtn) txAudioBtn.style.display = caps.tx ? "" : "none"; if (txAudioBtn) txAudioBtn.style.display = caps.tx ? "" : "none";
if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none"; if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none";
@@ -356,7 +344,7 @@ function applyCapabilities(caps) {
// TX limit row // TX limit row
const txLimitRow = document.getElementById("tx-limit-row"); const txLimitRow = document.getElementById("tx-limit-row");
if (txLimitRow) txLimitRow.style.display = caps.tx_limit ? "" : "none"; if (txLimitRow && !caps.tx_limit) txLimitRow.style.display = "none";
// VFO row // VFO row
const vfoRow = document.getElementById("vfo-row"); const vfoRow = document.getElementById("vfo-row");
@@ -448,7 +436,6 @@ const signalSplitValueEl = document.getElementById("signal-split-value");
const overviewPeakHoldEl = document.getElementById("overview-peak-hold"); const overviewPeakHoldEl = document.getElementById("overview-peak-hold");
const themeToggleBtn = document.getElementById("theme-toggle"); const themeToggleBtn = document.getElementById("theme-toggle");
const headerRigSwitchSelect = document.getElementById("header-rig-switch-select"); const headerRigSwitchSelect = document.getElementById("header-rig-switch-select");
const headerRigSummary = document.getElementById("header-rig-summary");
const headerStylePickSelect = document.getElementById("header-style-pick-select"); const headerStylePickSelect = document.getElementById("header-style-pick-select");
const rdsPsOverlay = document.getElementById("rds-ps-overlay"); const rdsPsOverlay = document.getElementById("rds-ps-overlay");
const tabMainEl = document.getElementById("tab-main"); const tabMainEl = document.getElementById("tab-main");
@@ -658,7 +645,7 @@ function flushDeferredDecodeMapSync() {
if (!decodeMapSyncPending || decodeHistoryReplayActive || !window.trx?.map?.aprsMap) return; if (!decodeMapSyncPending || decodeHistoryReplayActive || !window.trx?.map?.aprsMap) return;
decodeMapSyncPending = false; decodeMapSyncPending = false;
scheduleUiFrameJob("decode-map-maintenance", () => { scheduleUiFrameJob("decode-map-maintenance", () => {
window.trx.modules.map?.pruneMapHistory(); window.trx.map?.pruneMapHistory();
}); });
} }
@@ -853,8 +840,6 @@ let jogMult = loadSetting("jogMult", 1); // divisor: 1, 10, 100
let jogStep = Math.max(Math.round(jogUnit / jogMult), 1); let jogStep = Math.max(Math.round(jogUnit / jogMult), 1);
let minFreqStepHz = 1; let minFreqStepHz = 1;
let lastModeName = ""; let lastModeName = "";
let lastWfmCci = 0;
let lastWfmAci = 0;
const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"]; const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"];
function vfoColor(idx) { function vfoColor(idx) {
if (idx < VFO_COLORS.length) return VFO_COLORS[idx]; if (idx < VFO_COLORS.length) return VFO_COLORS[idx];
@@ -908,7 +893,6 @@ async function restorePreviousTuneState() {
let lastRigIds = []; let lastRigIds = [];
let lastRigDisplayNames = {}; let lastRigDisplayNames = {};
let lastActiveRigId = null; let lastActiveRigId = null;
let rigSwitchInProgress = false;
let lastCityLabel = ""; let lastCityLabel = "";
let sseSessionId = null; let sseSessionId = null;
const originalTitle = document.title; const originalTitle = document.title;
@@ -1253,20 +1237,6 @@ function populateRigPicker(selectEl, rigIds, activeRigId, disabled) {
selectEl.disabled = disabled; selectEl.disabled = disabled;
} }
function updateRigIdentitySummary(rigId, pending = false) {
if (!headerRigSummary) return;
const rig = serverRigs.find((entry) => entry?.remote === rigId);
if (!rig) {
headerRigSummary.textContent = pending ? "Switching rigs…" : "No rig details available";
return;
}
const hardware = [rig.manufacturer, rig.model].map(value => String(value || "").trim()).filter(Boolean).join(" ") || rig.remote;
const modes = Array.isArray(rig.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : [];
const features = [rig.tx ? "TX" : "RX", rig.filter_controls ? "SDR filters" : null, ...modes.slice(0, 5)];
if (modes.length > 5) features.push(`+${modes.length - 5} modes`);
headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`;
}
function updateRigSubtitle(activeRigId) { function updateRigSubtitle(activeRigId) {
if (!rigSubtitle) return; if (!rigSubtitle) return;
const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--"; const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--";
@@ -1303,15 +1273,13 @@ function applyRigList(activeRigId, rigIds, displayNames) {
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
if (rigListChanged) { if (rigListChanged) {
if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId);
if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId);
if (typeof bmPopulateScopePicker === "function") bmPopulateScopePicker(); if (typeof bmPopulateScopePicker === "function") bmPopulateScopePicker();
if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || ""); if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || "");
} }
window.trx.modules.map?.updateMapRigFilter(); window.trx.map?.updateMapRigFilter();
} }
@@ -1335,35 +1303,18 @@ async function refreshRigList() {
} }
}); });
serverRigs = rigs; serverRigs = rigs;
refreshOperatorLayoutCapabilities();
serverActiveRigId = data.active_remote || null; serverActiveRigId = data.active_remote || null;
applyRigList(data.active_remote, rigIds, displayNames); applyRigList(data.active_remote, rigIds, displayNames);
window.trx.modules.map?.syncAprsReceiverMarker(); window.trx.map?.syncAprsReceiverMarker();
} catch (e) { } catch (e) {
// Non-fatal: SSE/status path still drives main UI. // Non-fatal: SSE/status path still drives main UI.
} }
} }
function refreshOperatorLayoutCapabilities() {
const rigModes = serverRigs.map((rig) =>
Array.isArray(rig?.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : []
);
const decoderModes = new Set(decoderRegistry.flatMap((decoder) =>
Array.isArray(decoder?.active_modes) ? decoder.active_modes.map(normalizeMode).filter(Boolean) : []
));
window.trxUi?.setLayoutCapabilities({
broadcast: rigModes.some((modes) => modes.includes("WFM")),
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))),
});
}
function showHint(msg, duration) { function showHint(msg, duration) {
powerHint.textContent = msg; powerHint.textContent = msg;
if (hintTimer) clearTimeout(hintTimer); if (hintTimer) clearTimeout(hintTimer);
if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration); if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration);
if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) {
window.trxUi?.notify(msg, { kind: "error" });
}
} }
let supportedModes = []; let supportedModes = [];
let supportedBands = []; let supportedBands = [];
@@ -2878,7 +2829,7 @@ function showUnsupportedFreqPopup(hz) {
const now = Date.now(); const now = Date.now();
if (now - lastUnsupportedFreqPopupAt < 1200) return; if (now - lastUnsupportedFreqPopupAt < 1200) return;
lastUnsupportedFreqPopupAt = now; lastUnsupportedFreqPopupAt = now;
window.trxUi?.notify(message.replaceAll("\n", " "), { kind: "error", duration: 7000 }); window.alert(message);
} }
// Convert dBm (wire format) to S-units (S1=-121dBm, S9=-73dBm, 6dB/S-unit). // Convert dBm (wire format) to S-units (S1=-121dBm, S9=-73dBm, 6dB/S-unit).
@@ -3192,9 +3143,9 @@ function render(update) {
const grid = latLonToMaidenhead(serverLat, serverLon); const grid = latLonToMaidenhead(serverLat, serverLon);
locationSubtitle.textContent = `Location: ${grid}`; locationSubtitle.textContent = `Location: ${grid}`;
locationSubtitle.style.display = ""; locationSubtitle.style.display = "";
window.trx.modules.map?.reverseGeocodeLocation(serverLat, serverLon, grid); window.trx.map?.reverseGeocodeLocation(serverLat, serverLon, grid);
} }
window.trx.modules.map?.syncAprsReceiverMarker(); window.trx.map?.syncAprsReceiverMarker();
if (typeof update.initial_map_zoom === "number" && Number.isFinite(update.initial_map_zoom)) { if (typeof update.initial_map_zoom === "number" && Number.isFinite(update.initial_map_zoom)) {
initialMapZoom = Math.max(1, Math.round(update.initial_map_zoom)); initialMapZoom = Math.max(1, Math.round(update.initial_map_zoom));
} }
@@ -3342,14 +3293,8 @@ function render(update) {
wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected); wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected);
wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected); wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected);
} }
if (typeof update.filter.wfm_cci === "number") { if (typeof update.filter.wfm_cci === "number") updateIntfBar(wfmCciFillEl, wfmCciValEl, update.filter.wfm_cci);
lastWfmCci = Math.max(0, Math.min(100, update.filter.wfm_cci)); if (typeof update.filter.wfm_aci === "number") updateIntfBar(wfmAciFillEl, wfmAciValEl, update.filter.wfm_aci);
updateIntfBar(wfmCciFillEl, wfmCciValEl, lastWfmCci);
}
if (typeof update.filter.wfm_aci === "number") {
lastWfmAci = Math.max(0, Math.min(100, update.filter.wfm_aci));
updateIntfBar(wfmAciFillEl, wfmAciValEl, lastWfmAci);
}
if (samStereoWidthEl && typeof update.filter.sam_stereo_width === "number") { if (samStereoWidthEl && typeof update.filter.sam_stereo_width === "number") {
samStereoWidthEl.value = String(Math.round(update.filter.sam_stereo_width * 100)); samStereoWidthEl.value = String(Math.round(update.filter.sam_stereo_width * 100));
} }
@@ -3476,11 +3421,7 @@ function render(update) {
if (update.status && typeof update.status.tx_en === "boolean" && update.status.tx_en !== prevRenderData.txEn) { if (update.status && typeof update.status.tx_en === "boolean" && update.status.tx_en !== prevRenderData.txEn) {
prevRenderData.txEn = update.status.tx_en; prevRenderData.txEn = update.status.tx_en;
lastTxEn = update.status.tx_en; lastTxEn = update.status.tx_en;
window.trxUi?.setButtonState(pttBtn, { pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off";
active: update.status.tx_en,
activeLabel: "Stop TX",
inactiveLabel: "Start TX",
});
if (update.status.tx_en) { if (update.status.tx_en) {
pttBtn.style.background = "var(--accent-red)"; pttBtn.style.background = "var(--accent-red)";
pttBtn.style.borderColor = "var(--accent-red)"; pttBtn.style.borderColor = "var(--accent-red)";
@@ -3589,15 +3530,11 @@ function render(update) {
bandLabel.textContent = typeof update.band === "string" ? update.band : "--"; bandLabel.textContent = typeof update.band === "string" ? update.band : "--";
} }
if (typeof update.enabled === "boolean") { if (typeof update.enabled === "boolean") {
window.trxUi?.setButtonState(powerBtn, { powerBtn.disabled = false;
active: update.enabled, powerBtn.textContent = update.enabled ? "Power Off" : "Power On";
activeLabel: "Power Off",
inactiveLabel: "Power On",
});
} else { } else {
powerBtn.disabled = true; powerBtn.disabled = true;
powerBtn.textContent = "Power unavailable"; powerBtn.textContent = "Toggle Power";
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown"; powerHint.textContent = "State unknown";
} }
lastControl = update.enabled; lastControl = update.enabled;
@@ -3712,11 +3649,7 @@ function render(update) {
} }
powerHint.textContent = readyText(); powerHint.textContent = readyText();
lastLocked = update.status && update.status.lock === true; lastLocked = update.status && update.status.lock === true;
window.trxUi?.setButtonState(lockBtn, { lockBtn.textContent = lastLocked ? "Unlock" : "Lock";
active: lastLocked,
activeLabel: "Unlock Tuning",
inactiveLabel: "Lock Tuning",
});
const tx = update.status && update.status.tx ? update.status.tx : null; const tx = update.status && update.status.tx ? update.status.tx : null;
txMeters.style.display = lastHasTx ? "" : "none"; txMeters.style.display = lastHasTx ? "" : "none";
@@ -3903,16 +3836,12 @@ function scheduleUiFrameJob(key, job) {
window.trxScheduleUiFrameJob = scheduleUiFrameJob; window.trxScheduleUiFrameJob = scheduleUiFrameJob;
async function postPath(path, options = {}) { async function postPath(path) {
if (rigSwitchInProgress && !options.allowDuringRigSwitch) {
throw new Error("Wait for the rig switch to finish");
}
const targetRigId = options.remote === undefined ? lastActiveRigId : options.remote;
// Auto-append remote so each tab targets its own rig. // Auto-append remote so each tab targets its own rig.
// Skip when the caller already included remote (e.g. /select_rig). // Skip when the caller already included remote (e.g. /select_rig).
if (targetRigId && !path.includes("remote=")) { if (lastActiveRigId && !path.includes("remote=")) {
const sep = path.includes("?") ? "&" : "?"; const sep = path.includes("?") ? "&" : "?";
path = `${path}${sep}remote=${encodeURIComponent(targetRigId)}`; path = `${path}${sep}remote=${encodeURIComponent(lastActiveRigId)}`;
} }
const resp = await fetch(path, { method: "POST" }); const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) { if (authEnabled && resp.status === 401) {
@@ -3957,60 +3886,44 @@ async function switchRigFromSelect(selectEl) {
return; return;
} }
const prevRig = lastActiveRigId; const prevRig = lastActiveRigId;
const nextRig = selectEl.value; lastActiveRigId = selectEl.value;
if (nextRig === prevRig || rigSwitchInProgress) return; if (prevRig && prevRig !== lastActiveRigId) {
rigSwitchInProgress = true; resetDecoderStateOnRigSwitch();
setControlPending(selectEl, true); }
selectEl.closest(".header-rig-switch")?.classList.add("is-switching"); updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(nextRig, true); if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId);
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`); if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId);
if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || "");
window.trx.map?.syncAprsReceiverMarker();
// Switch this session's rig and reconnect SSE to the new rig's
// state channel.
try { try {
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : ""; const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : "";
await postPath(`/select_rig?remote=${encodeURIComponent(nextRig)}${sidParam}`, { allowDuringRigSwitch: true, remote: null }); await postPath(`/select_rig?remote=${encodeURIComponent(selectEl.value)}${sidParam}`);
lastActiveRigId = nextRig;
resetDecoderStateOnRigSwitch();
updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId);
if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId);
if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || "");
window.trx.modules.map?.syncAprsReceiverMarker();
connect(); connect();
stopSpectrumStreaming();
startSpectrumStreaming();
stopMeterStreaming();
startMeterStreaming();
if (rxActive) {
stopRxAudio();
startRxAudio();
}
showHint(`Rig: ${lastRigDisplayNames[lastActiveRigId] || lastActiveRigId}`, 1500);
} catch (err) { } catch (err) {
console.error("select_rig failed:", err); console.error("select_rig failed:", err);
selectEl.value = prevRig || "";
updateRigIdentitySummary(prevRig);
window.trxUi?.notify("Rig could not be switched", { kind: "error" });
} finally {
rigSwitchInProgress = false;
setControlPending(selectEl, false);
selectEl.closest(".header-rig-switch")?.classList.remove("is-switching");
} }
// Reconnect spectrum SSE to the new rig's spectrum channel.
stopSpectrumStreaming();
startSpectrumStreaming();
// Reconnect meter SSE to the new rig's meter channel.
stopMeterStreaming();
startMeterStreaming();
// Reconnect audio to the new rig if audio is active.
if (rxActive) {
stopRxAudio();
startRxAudio();
}
showHint(`Rig: ${lastActiveRigId}`, 1500);
} }
if (headerRigSwitchSelect) { if (headerRigSwitchSelect) {
headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(headerRigSwitchSelect); }); headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(headerRigSwitchSelect); });
} }
function setControlPending(control, pending) {
if (!control) return;
control.disabled = pending;
control.classList.toggle("is-busy", pending);
control.setAttribute("aria-busy", String(pending));
}
powerBtn.addEventListener("click", async () => { powerBtn.addEventListener("click", async () => {
setControlPending(powerBtn, true); powerBtn.disabled = true;
showHint("Sending..."); showHint("Sending...");
try { try {
await postPath("/toggle_power"); await postPath("/toggle_power");
@@ -4019,12 +3932,12 @@ powerBtn.addEventListener("click", async () => {
showHint("Toggle failed", 2000); showHint("Toggle failed", 2000);
console.error(err); console.error(err);
} finally { } finally {
setControlPending(powerBtn, false); powerBtn.disabled = false;
} }
}); });
pttBtn.addEventListener("click", async () => { pttBtn.addEventListener("click", async () => {
setControlPending(pttBtn, true); pttBtn.disabled = true;
showHint("Toggling PTT…"); showHint("Toggling PTT…");
try { try {
const desired = lastTxEn ? "false" : "true"; const desired = lastTxEn ? "false" : "true";
@@ -4034,7 +3947,7 @@ pttBtn.addEventListener("click", async () => {
showHint("PTT toggle failed", 2000); showHint("PTT toggle failed", 2000);
console.error(err); console.error(err);
} finally { } finally {
setControlPending(pttBtn, false); pttBtn.disabled = false;
} }
}); });
@@ -4067,7 +3980,7 @@ async function applyCenterFreqFromInput() {
return; return;
} }
centerFreqDirty = false; centerFreqDirty = false;
setControlPending(centerFreqEl, true); centerFreqEl.disabled = true;
showHint("Setting central frequency…"); showHint("Setting central frequency…");
try { try {
await postPath(`/set_center_freq?hz=${parsed}`); await postPath(`/set_center_freq?hz=${parsed}`);
@@ -4076,7 +3989,7 @@ async function applyCenterFreqFromInput() {
showHint("Set central freq failed", 2000); showHint("Set central freq failed", 2000);
console.error(err); console.error(err);
} finally { } finally {
setControlPending(centerFreqEl, false); centerFreqEl.disabled = false;
} }
} }
@@ -4277,7 +4190,7 @@ async function applyModeFromPicker() {
return; return;
} }
updateWfmControls(); updateWfmControls();
setControlPending(modeEl, true); modeEl.disabled = true;
showHint("Setting mode…"); showHint("Setting mode…");
try { try {
if (typeof vchanInterceptMode === "function" && await vchanInterceptMode(mode)) { if (typeof vchanInterceptMode === "function" && await vchanInterceptMode(mode)) {
@@ -4295,7 +4208,7 @@ async function applyModeFromPicker() {
showHint("Set mode failed", 2000); showHint("Set mode failed", 2000);
console.error(err); console.error(err);
} finally { } finally {
setControlPending(modeEl, false); modeEl.disabled = false;
} }
} }
@@ -4314,7 +4227,7 @@ txLimitBtn.addEventListener("click", async () => {
showHint("Limit missing", 1500); showHint("Limit missing", 1500);
return; return;
} }
setControlPending(txLimitBtn, true); txLimitBtn.disabled = true;
showHint("Setting TX limit…"); showHint("Setting TX limit…");
try { try {
await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`); await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`);
@@ -4323,22 +4236,22 @@ txLimitBtn.addEventListener("click", async () => {
showHint("TX limit failed", 2000); showHint("TX limit failed", 2000);
console.error(err); console.error(err);
} finally { } finally {
setControlPending(txLimitBtn, false); txLimitBtn.disabled = false;
} }
}); });
lockBtn.addEventListener("click", async () => { lockBtn.addEventListener("click", async () => {
setControlPending(lockBtn, true); lockBtn.disabled = true;
showHint("Toggling lock…"); showHint("Toggling lock…");
try { try {
const nextLock = !lastLocked; const nextLock = lockBtn.textContent === "Lock";
await postPath(nextLock ? "/lock" : "/unlock"); await postPath(nextLock ? "/lock" : "/unlock");
showHint("Lock toggled", 1500); showHint("Lock toggled", 1500);
} catch (err) { } catch (err) {
showHint("Lock toggle failed", 2000); showHint("Lock toggle failed", 2000);
console.error(err); console.error(err);
} finally { } finally {
setControlPending(lockBtn, false); lockBtn.disabled = false;
} }
}); });
@@ -4355,7 +4268,7 @@ const MODE_BW_DEFAULTS = {
FM: [12_500, 2_500, 25_000, 500], FM: [12_500, 2_500, 25_000, 500],
AIS: [25_000, 12_500, 50_000, 500], AIS: [25_000, 12_500, 50_000, 500],
VDES: [100_000, 25_000, 200_000, 1_000], VDES: [100_000, 25_000, 200_000, 1_000],
WFM: [180_000, 60_000,300_000,5_000], WFM: [180_000, 50_000,300_000,5_000],
DIG: [3_000, 300, 6_000, 100], DIG: [3_000, 300, 6_000, 100],
PKT: [25_000, 300, 50_000, 500], PKT: [25_000, 300, 50_000, 500],
}; };
@@ -4405,8 +4318,7 @@ async function applyBwDefaultForMode(mode, sendToServer) {
scheduleSpectrumDraw(); scheduleSpectrumDraw();
} }
if (sendToServer) { if (sendToServer) {
try { await postPath(`/set_bandwidth?hz=${def}`); } try { await postPath(`/set_bandwidth?hz=${def}`); } catch (_) {}
catch (error) { window.trxUi?.notify("Default bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: () => applyBwDefaultForMode(mode, true) } }); }
} }
} }
@@ -4433,115 +4345,67 @@ async function applyBandwidthFromInput() {
if (Number.isFinite(lastFreqHz)) { if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz); await ensureTunedBandwidthCoverage(lastFreqHz);
} }
} catch (error) { } catch (_) {}
window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } });
}
} }
function estimateOccupiedBandwidth(data, centerHz, interference = {}) { function estimateBandwidthAroundPeak(data, centerHz) {
if (!data || !isBinsArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) { if (!data || !isBinsArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) {
return null; return null;
} }
const bins = data.bins; const bins = data.bins;
const maxIdx = bins.length - 1; const maxIdx = bins.length - 1;
const hzPerBin = data.sample_rate / maxIdx;
const fullLoHz = data.center_hz - data.sample_rate / 2; const fullLoHz = data.center_hz - data.sample_rate / 2;
const centerIdx = Math.max( const centerIdx = Math.max(
1, 1,
Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)), Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)),
); );
const mode = (modeEl ? modeEl.value : "USB").toUpperCase(); const searchRadius = Math.max(6, Math.min(120, Math.round(maxIdx * 0.03)));
const [defaultBw, minBw, maxBw, stepBw] = mwDefaultsForMode(mode); const searchLo = Math.max(1, centerIdx - searchRadius);
const oneSided = mode === "USB" || mode === "DIG" || mode === "CW" const searchHi = Math.min(maxIdx - 1, centerIdx + searchRadius);
? 1
: mode === "LSB" || mode === "CWR" ? -1 : 0; let peakIdx = centerIdx;
const isWfm = mode === "WFM"; for (let i = searchLo; i <= searchHi; i++) {
if (bins[i] > bins[peakIdx]) peakIdx = i;
}
// Reduce single-bin peaks and holes before finding occupied-channel edges.
// WFM needs a wider smoothing window because its energy is noise-like and
// spread across the entire channel rather than concentrated at a carrier.
const smoothRadius = isWfm ? 3 : 1;
const smoothed = bins.map((_, i) => {
let sum = 0;
let count = 0;
for (let j = Math.max(0, i - smoothRadius); j <= Math.min(maxIdx, i + smoothRadius); j++) {
sum += bins[j];
count += 1;
}
return sum / count;
});
const sorted = [...bins].sort((a, b) => a - b); const sorted = [...bins].sort((a, b) => a - b);
const noise = sorted[Math.floor(sorted.length * 0.2)]; const noise = sorted[Math.floor(sorted.length * 0.2)];
const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin)); const peak = bins[peakIdx];
const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins; const threshold = Math.max(noise + 4, peak - Math.max(8, (peak - noise) * 0.35));
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 i = searchLo; i <= searchHi; i++) peak = Math.max(peak, smoothed[i]);
const snr = peak - noise;
if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw;
// A threshold relative to the noise floor finds occupied bandwidth much let left = peakIdx;
// more reliably than one relative to the peak. The latter fails for WFM, let right = peakIdx;
// whose multiplex spectrum has peaks, notches, and no narrow centre carrier. let belowCount = 0;
const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28))); for (let i = peakIdx; i > 1; i--) {
const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12_000 : stepBw) / hzPerBin)); if (bins[i] < threshold) belowCount += 1;
else belowCount = 0;
function occupiedExtent(direction, limitBins) { if (belowCount >= 2) break;
let lastOccupied = centerIdx; left = i;
let gap = 0;
for (let n = 0; n <= limitBins; n++) {
const i = centerIdx + direction * n;
if (i <= 0 || i >= maxIdx) break;
if (smoothed[i] >= threshold) {
lastOccupied = i;
gap = 0;
} else if (++gap > allowedGap) {
break;
}
}
return Math.abs(lastOccupied - centerIdx) * hzPerBin;
} }
let rawBw; belowCount = 0;
if (oneSided !== 0) { for (let i = peakIdx; i < maxIdx - 1; i++) {
rawBw = occupiedExtent(oneSided, maxSpanBins); if (bins[i] < threshold) belowCount += 1;
} else { else belowCount = 0;
const leftHz = occupiedExtent(-1, searchHalfBins); if (belowCount >= 2) break;
const rightHz = occupiedExtent(1, searchHalfBins); right = i;
// A symmetric RF filter must contain the larger of the two sidebands.
rawBw = 2 * Math.max(leftHz, rightHz);
} }
// Add a transition-band margin. Weak WFM deliberately falls back to the const shoulderPad = Math.max(1, Math.round((right - left) * 0.08));
// 60 kHz mode floor above: a narrower filter trades stereo/RDS content for left = Math.max(0, left - shoulderPad);
// a useful improvement in intelligibility when the signal is very poor. right = Math.min(maxIdx, right + shoulderPad);
rawBw *= isWfm ? 1.08 : 1.12;
if (isWfm) { const hzPerBin = data.sample_rate / maxIdx;
const aci = Math.max(0, Math.min(100, Number(interference.aci) || 0)) / 100; const rawBw = Math.max(hzPerBin, (right - left) * hzPerBin);
const cci = Math.max(0, Math.min(100, Number(interference.cci) || 0)) / 100; const [, minBw, maxBw, stepBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
// Adjacent-channel energy is outside the wanted modulation, so ACI can
// safely drive the cap all the way from the 300 kHz ceiling to 60 kHz.
const aciCap = maxBw - (maxBw - minBw) * aci;
// CCI overlaps the wanted station and cannot be removed by an RF filter.
// Only distrust the widest edge estimates, retaining at least 65% of the
// useful range between the weak-signal floor and nominal WFM bandwidth.
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)); const clamped = Math.max(minBw, Math.min(maxBw, rawBw));
return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw); return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
} }
async function applyAutoBandwidth() { async function applyAutoBandwidth() {
if (!lastSpectrumData || lastFreqHz == null) return; if (!lastSpectrumData || lastFreqHz == null) return;
// WFM interference telemetry belongs to the primary DSP channel. Do not const estimated = estimateBandwidthAroundPeak(lastSpectrumData, lastFreqHz);
// apply it to a virtual channel, where it would describe the wrong signal.
const onVirtual = typeof vchanIsOnVirtual === "function" && vchanIsOnVirtual();
const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci };
const estimated = estimateOccupiedBandwidth(lastSpectrumData, lastFreqHz, interference);
if (!Number.isFinite(estimated) || estimated <= 0) { if (!Number.isFinite(estimated) || estimated <= 0) {
syncBandwidthInput(currentBandwidthHz); syncBandwidthInput(currentBandwidthHz);
return; return;
@@ -4553,24 +4417,13 @@ async function applyAutoBandwidth() {
if (lastSpectrumData) { if (lastSpectrumData) {
scheduleSpectrumDraw(); scheduleSpectrumDraw();
} }
const mode = (modeEl?.value || "").toUpperCase();
let reason = "measured occupied spectrum";
if (mode === "WFM") {
if (estimated === 60_000 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`;
else if (estimated === 60_000) reason = "weak-signal noise rejection";
else if (lastWfmAci >= lastWfmCci && lastWfmAci >= 10) reason = `${Math.round(lastWfmAci)}% ACI cap`;
else if (lastWfmCci >= 10) reason = `${Math.round(lastWfmCci)}% CCI confidence cap`;
}
window.trxUi?.notify(`Auto BW: ${formatBwLabel(estimated)}${reason}`, { kind: "success", duration: 5000 });
try { try {
if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return; if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return;
await postPath(`/set_bandwidth?hz=${estimated}`); await postPath(`/set_bandwidth?hz=${estimated}`);
if (Number.isFinite(lastFreqHz)) { if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz); await ensureTunedBandwidthCoverage(lastFreqHz);
} }
} catch (error) { } catch (_) {}
window.trxUi?.notify("Automatic bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: applyAutoBandwidth } });
}
} }
if (spectrumBwInput) { if (spectrumBwInput) {
@@ -4627,23 +4480,23 @@ function updateTabHistory(name, replaceHistory = false) {
} }
// Initialise the Leaflet map, waiting for both Leaflet (L) and map-core.js // Initialise the Leaflet map, waiting for both Leaflet (L) and map-core.js
// (window.trx.modules.map) if they haven't loaded yet. // (window.trx.map) if they haven't loaded yet.
let _mapInitTimer = null; let _mapInitTimer = null;
function _initMapWhenReady() { function _initMapWhenReady() {
const loadingEl = document.getElementById("map-loading"); const loadingEl = document.getElementById("map-loading");
if (window.trx.modules.map && typeof L !== "undefined") { if (window.trx.map && typeof L !== "undefined") {
if (_mapInitTimer) { clearInterval(_mapInitTimer); _mapInitTimer = null; } if (_mapInitTimer) { clearInterval(_mapInitTimer); _mapInitTimer = null; }
if (loadingEl) loadingEl.classList.add("is-hidden"); if (loadingEl) loadingEl.classList.add("is-hidden");
window.trx.modules.map.initAprsMap(); window.trx.map.initAprsMap();
window.trx.modules.map.sizeAprsMapToViewport(); window.trx.map.sizeAprsMapToViewport();
// The map panel was just made visible (display:none → ""); the browser // The map panel was just made visible (display:none → ""); the browser
// may not have laid it out yet, so getBoundingClientRect() can return // may not have laid it out yet, so getBoundingClientRect() can return
// stale/zero dimensions. Double-rAF ensures a full layout pass has // stale/zero dimensions. Double-rAF ensures a full layout pass has
// completed before we re-measure and tell Leaflet about its real size. // completed before we re-measure and tell Leaflet about its real size.
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
window.trx.modules.map.sizeAprsMapToViewport(); window.trx.map.sizeAprsMapToViewport();
if (window.trx.modules.map.aprsMap) window.trx.modules.map.aprsMap.invalidateSize(); if (window.trx.map.aprsMap) window.trx.map.aprsMap.invalidateSize();
}); });
}); });
return; return;
@@ -4659,7 +4512,6 @@ function _initMapWhenReady() {
} }
function navigateToTab(name, options = {}) { function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.();
const { updateHistory = true, replaceHistory = false } = options; const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") { if (authEnabled && !authRole && name !== "main") {
showAuthGate(false); showAuthGate(false);
@@ -4670,7 +4522,6 @@ function navigateToTab(name, options = {}) {
_activeTab = name; _activeTab = name;
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active"); btn.classList.add("active");
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`); const panel = document.getElementById(`tab-${name}`);
panel.style.display = ""; panel.style.display = "";
@@ -4693,13 +4544,12 @@ function navigateToTab(name, options = {}) {
_initMapWhenReady(); _initMapWhenReady();
} }
if (name === "statistics") { if (name === "statistics") {
window.trx.modules.map?.scheduleStatsRender(); window.trx.map?.scheduleStatsRender();
} }
if (name === "recorder") { if (name === "recorder") {
refreshRecorderStatus(); refreshRecorderStatus();
} }
} }
window.navigateToTab = navigateToTab;
document.querySelector(".tab-bar").addEventListener("click", (e) => { document.querySelector(".tab-bar").addEventListener("click", (e) => {
const btn = e.target.closest(".tab[data-tab]"); const btn = e.target.closest(".tab[data-tab]");
@@ -4858,7 +4708,7 @@ if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => { headerAuthBtn.addEventListener("click", async () => {
if (authRole) { if (authRole) {
// Logged in - show logout confirmation // Logged in - show logout confirmation
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) { if (confirm("Are you sure you want to logout?")) {
await authLogout(); await authLogout();
} }
} else { } else {
@@ -4871,11 +4721,10 @@ if (headerAuthBtn) {
// ── Shared namespace for lazy-loaded modules ──────────────────────────────── // ── Shared namespace for lazy-loaded modules ────────────────────────────────
// Modules (map-core.js, screenshot.js) access core state and utilities via // Modules (map-core.js, screenshot.js) access core state and utilities via
// window.trx. Modules register their own APIs as sub-namespaces // window.trx. Modules register their own APIs as sub-namespaces
// (e.g. window.trx.modules.map, window.trx.modules.screenshot). // (e.g. window.trx.map, window.trx.screenshot).
const trxState = Object.create(null); window.trx = Object.create(null);
const trxModules = Object.create(null);
// -- State getters (backed by core-scoped variables) -- // -- State getters (backed by core-scoped variables) --
Object.defineProperties(trxState, { Object.defineProperties(window.trx, {
serverLat: { get() { return serverLat; }, set(v) { serverLat = v; } }, serverLat: { get() { return serverLat; }, set(v) { serverLat = v; } },
serverLon: { get() { return serverLon; }, set(v) { serverLon = v; } }, serverLon: { get() { return serverLon; }, set(v) { serverLon = v; } },
lastFreqHz: { get() { return lastFreqHz; } }, lastFreqHz: { get() { return lastFreqHz; } },
@@ -4913,7 +4762,7 @@ Object.defineProperties(trxState, {
signalOverlayGl: { get() { return signalOverlayGl; } }, signalOverlayGl: { get() { return signalOverlayGl; } },
}); });
// -- Shared utility functions -- // -- Shared utility functions --
const trxCore = Object.freeze({ Object.assign(window.trx, {
saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans, saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans,
formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal, formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal,
postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor, postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
@@ -4923,19 +4772,18 @@ const trxCore = Object.freeze({
currentTheme, canvasPalette, currentStyle, currentTheme, canvasPalette, currentStyle,
cssColorToRgba, rgbaWithAlpha, isBinsArray, estimateNoiseFloorDb, cssColorToRgba, rgbaWithAlpha, isBinsArray, estimateNoiseFloorDb,
spectrumVisibleRange, drawSpectrum, spectrumVisibleRange, drawSpectrum,
bandForHz: function(hz) { return trxModules.map?.bandForHz?.(hz); }, bandForHz: function(hz) { return window.trx.map?.bandForHz?.(hz); },
markDecodeMapSyncPending, markDecodeMapSyncPending,
decodeHistoryMapRenderingDeferred, decodeHistoryMapRenderingDeferred,
updateDocumentTitle, updateDocumentTitle,
activeChannelRds, activeChannelRds,
}); });
Object.defineProperties(trxState, { Object.defineProperties(window.trx, {
decodeHistoryReplayActive: { get() { return decodeHistoryReplayActive; } }, decodeHistoryReplayActive: { get() { return decodeHistoryReplayActive; } },
decodeMapSyncPending: { get() { return decodeMapSyncPending; } }, decodeMapSyncPending: { get() { return decodeMapSyncPending; } },
_activeTab: { get() { return _activeTab; } }, _activeTab: { get() { return _activeTab; } },
locationSubtitle: { get() { return locationSubtitle; } }, locationSubtitle: { get() { return locationSubtitle; } },
}); });
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
// Load plugin scripts now that window.trx is populated. Dynamic scripts are // Load plugin scripts now that window.trx is populated. Dynamic scripts are
// async so they must not be created before the namespace they depend on exists. // async so they must not be created before the namespace they depend on exists.
@@ -4949,7 +4797,7 @@ window.addEventListener("resize", resizeHeaderSignalCanvas);
// ── Map module (extracted to map-core.js, lazy-loaded) ────────────────────── // ── Map module (extracted to map-core.js, lazy-loaded) ──────────────────────
// The map, statistics, and geolocation code (~3,450 lines) has been moved to // The map, statistics, and geolocation code (~3,450 lines) has been moved to
// map-core.js and is loaded on demand when the Map tab is first activated. // map-core.js and is loaded on demand when the Map tab is first activated.
// Core communicates with the map module via window.trx.modules.map.* namespace. // Core communicates with the map module via window.trx.map.* namespace.
// ── Geo utilities (shared with map-core.js via window.trx) ───────────────── // ── Geo utilities (shared with map-core.js via window.trx) ─────────────────
function haversineKm(lat1, lon1, lat2, lon2) { function haversineKm(lat1, lon1, lat2, lon2) {
@@ -5059,15 +4907,11 @@ function latLonToMaidenhead(lat, lon) {
function _wireSubTabBar(bar) { function _wireSubTabBar(bar) {
if (bar._subtabWired) return; if (bar._subtabWired) return;
bar._subtabWired = true; bar._subtabWired = true;
window.trxUi?.prepareTabList(bar, "secondary");
bar.addEventListener("click", (e) => { bar.addEventListener("click", (e) => {
const btn = e.target.closest(".sub-tab[data-subtab]"); const btn = e.target.closest(".sub-tab[data-subtab]");
if (!btn) return; if (!btn) return;
bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active")); bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active"); btn.classList.add("active");
window.trxUi?.syncSelectedTab(bar, btn);
const decoderPicker = document.getElementById("decoder-tab-select");
if (decoderPicker && btn.closest("#tab-digital-modes")) decoderPicker.value = btn.dataset.subtab;
const parent = bar.parentElement; const parent = bar.parentElement;
parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none"); parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`); const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`);
@@ -5088,7 +4932,7 @@ document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
const mapTab = document.getElementById("tab-map"); const mapTab = document.getElementById("tab-map");
if (!mapTab || mapTab.style.display === "none") return; if (!mapTab || mapTab.style.display === "none") return;
window.trx.modules.map?.sizeAprsMapToViewport(); window.trx.map?.sizeAprsMapToViewport();
}); });
// --- Signal measurement --- // --- Signal measurement ---
@@ -5558,7 +5402,6 @@ function configureRxStream(nextInfo) {
ensureRxAudioContext(nextSampleRate); ensureRxAudioContext(nextSampleRate);
rxGainNode.gain.value = rxVolSlider.value / 100; rxGainNode.gain.value = rxVolSlider.value / 100;
rxActive = true; rxActive = true;
window.trxUi?.setButtonState(rxAudioBtn, { active: true, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
setAudioLevel(0); setAudioLevel(0);
rxAudioBtn.style.borderColor = "#00d17f"; rxAudioBtn.style.borderColor = "#00d17f";
rxAudioBtn.style.color = "#00d17f"; rxAudioBtn.style.color = "#00d17f";
@@ -5760,7 +5603,6 @@ function startRxAudio() {
// If TX was active when WS closed, release PTT // If TX was active when WS closed, release PTT
if (txActive) { stopTxAudio(); } if (txActive) { stopTxAudio(); }
rxActive = false; rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null; streamInfo = null;
updateWfmControls(); updateWfmControls();
rxAudioBtn.style.borderColor = ""; rxAudioBtn.style.borderColor = "";
@@ -5787,7 +5629,6 @@ function startRxAudio() {
function stopRxAudio() { function stopRxAudio() {
rxActive = false; rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null; streamInfo = null;
if (audioWs) { audioWs.close(); audioWs = null; } if (audioWs) { audioWs.close(); audioWs = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; } if (audioCtx) { audioCtx.close(); audioCtx = null; }
@@ -5826,7 +5667,6 @@ function startTxAudio() {
}).then(async (stream) => { }).then(async (stream) => {
txStream = stream; txStream = stream;
txActive = true; txActive = true;
window.trxUi?.setButtonState(txAudioBtn, { active: true, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
txAudioBtn.style.borderColor = "#e55353"; txAudioBtn.style.borderColor = "#e55353";
txAudioBtn.style.color = "#e55353"; txAudioBtn.style.color = "#e55353";
audioStatus.textContent = "RX+TX"; audioStatus.textContent = "RX+TX";
@@ -5904,7 +5744,6 @@ function startTxAudio() {
async function stopTxAudio() { async function stopTxAudio() {
if (!txActive) return; if (!txActive) return;
txActive = false; txActive = false;
window.trxUi?.setButtonState(txAudioBtn, { active: false, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
clearTxTimeout(); clearTxTimeout();
// Release PTT automatically // Release PTT automatically
@@ -6150,7 +5989,7 @@ function renderRecorderFiles() {
el.querySelectorAll(".rec-delete-btn").forEach(function (btn) { el.querySelectorAll(".rec-delete-btn").forEach(function (btn) {
btn.addEventListener("click", async function () { btn.addEventListener("click", async function () {
const name = btn.dataset.name; const name = btn.dataset.name;
if (!await window.trxUi.confirm({ title: "Delete recording?", message: `${name} will be permanently removed.`, confirmLabel: "Delete" })) return; if (!confirm("Delete recording " + name + "?")) return;
try { try {
const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" }); const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" });
if (!resp.ok) throw new Error("HTTP " + resp.status); if (!resp.ok) throw new Error("HTTP " + resp.status);
@@ -6158,7 +5997,6 @@ function renderRecorderFiles() {
renderRecorderFiles(); renderRecorderFiles();
} catch (e) { } catch (e) {
console.error("Delete failed", e); console.error("Delete failed", e);
window.trxUi?.notify("Recording could not be deleted", { kind: "error" });
} }
}); });
}); });
@@ -6264,8 +6102,8 @@ function dispatchDecodeMessage(msg, skipStats) {
if (msg.type === "wefax" && window.onServerWefax) window.onServerWefax(msg); if (msg.type === "wefax" && window.onServerWefax) window.onServerWefax(msg);
if (msg.type === "wefax_progress" && window.onServerWefaxProgress) window.onServerWefaxProgress(msg); if (msg.type === "wefax_progress" && window.onServerWefaxProgress) window.onServerWefaxProgress(msg);
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") { if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null); window.trx.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender(); window.trx.map?.scheduleStatsRender();
} }
} }
@@ -6274,10 +6112,10 @@ function dispatchDecodeBatch(batch) {
// Record statistics for every message in the batch regardless of dispatch path. // Record statistics for every message in the batch regardless of dispatch path.
for (const msg of batch) { for (const msg of batch) {
if (msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") { if (msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null); window.trx.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
} }
} }
window.trx.modules.map?.scheduleStatsRender(); window.trx.map?.scheduleStatsRender();
const type = String(batch[0]?.type || ""); const type = String(batch[0]?.type || "");
const uniformType = batch.every((msg) => String(msg?.type || "") === type); const uniformType = batch.every((msg) => String(msg?.type || "") === type);
if (uniformType) { if (uniformType) {
@@ -6362,9 +6200,9 @@ function restoreDecodeHistoryGroup(kind, messages) {
// Record statistics for restored history messages. // Record statistics for restored history messages.
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") { if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
for (const msg of messages) { for (const msg of messages) {
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined); window.trx.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
} }
window.trx.modules.map?.scheduleStatsRender(); window.trx.map?.scheduleStatsRender();
} }
if (kind === "ais") { if (kind === "ais") {
if (window.restoreAisHistory) { window.restoreAisHistory(messages); } if (window.restoreAisHistory) { window.restoreAisHistory(messages); }
@@ -7095,13 +6933,6 @@ function startSpectrumStreaming() {
const rds = lastSpectrumData?.rds; const rds = lastSpectrumData?.rds;
lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds }; lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds };
window.lastSpectrumData = lastSpectrumData; window.lastSpectrumData = lastSpectrumData;
const spectrumSummary = document.getElementById("spectrum-text-summary");
if (spectrumSummary && bins.length) {
let peakIndex = 0;
for (let i = 1; i < bins.length; i += 1) if (bins[i] > bins[peakIndex]) peakIndex = i;
const peakHz = centerHz - sampleRate / 2 + (peakIndex / Math.max(1, bins.length - 1)) * sampleRate;
spectrumSummary.textContent = `Spectrum centered at ${formatFreqForHumans(centerHz)}, spanning ${formatFreqForHumans(sampleRate)}. Strongest visible bin near ${formatFreqForHumans(peakHz)} at ${bins[peakIndex]} dB.`;
}
// Server confirmed a new center — clear optimistic pending value. // Server confirmed a new center — clear optimistic pending value.
if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1000) { if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1000) {
spectrumCenterPendingHz = null; spectrumCenterPendingHz = null;
@@ -8169,12 +8000,12 @@ window.addEventListener("keydown", (event) => {
// S — spectrum screenshot (lazy-loads screenshot.js on first use) // S — spectrum screenshot (lazy-loads screenshot.js on first use)
if (key === "s") { if (key === "s") {
event.preventDefault(); event.preventDefault();
if (window.trx.modules.screenshot) { if (window.trx.screenshot) {
void window.trx.modules.screenshot.captureSpectrumScreenshot(); void window.trx.screenshot.captureSpectrumScreenshot();
} else { } else {
const s = document.createElement("script"); const s = document.createElement("script");
s.src = "/screenshot.js"; s.src = "/screenshot.js";
s.onload = () => { void window.trx.modules.screenshot?.captureSpectrumScreenshot(); }; s.onload = () => { void window.trx.screenshot?.captureSpectrumScreenshot(); };
document.body.appendChild(s); document.body.appendChild(s);
} }
return; return;
@@ -8564,12 +8395,7 @@ if (spectrumCanvas || overviewCanvas) {
await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz); await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz);
} }
} }
} catch (error) { } catch (_) {}
window.trxUi?.notify("Bandwidth could not be changed", {
kind: "error",
action: { label: "Retry", run: () => postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`) },
});
}
_bwDragEdge = null; _bwDragEdge = null;
_bwDragCanvas = null; _bwDragCanvas = null;
return; return;
@@ -43,7 +43,7 @@ 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" aria-label="Primary navigation"> <div class="tab-bar-nav">
<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">Main</span>
@@ -85,7 +85,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="header-rec-btn" class="header-bar-btn header-rec-btn" type="button" aria-label="Toggle recording" title="Toggle recording">REC</button> <button id="header-rec-btn" class="header-bar-btn header-rec-btn" type="button" aria-label="Toggle recording" title="Toggle recording">REC</button>
<div class="header-rig-switch"> <div class="header-rig-switch">
<select id="header-rig-switch-select" aria-label="Select active rig"></select> <select id="header-rig-switch-select" aria-label="Select active rig"></select>
<span id="header-rig-summary" class="header-rig-summary" aria-live="polite"></span>
</div> </div>
<div class="header-style-pick"> <div class="header-style-pick">
<select id="header-style-pick-select" aria-label="Select UI style"> <select id="header-style-pick-select" aria-label="Select UI style">
@@ -141,14 +140,13 @@ 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" aria-describedby="spectrum-text-summary"></canvas> <canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display"></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" aria-describedby="spectrum-text-summary" style="display:none;"></canvas> <canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" 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">&lsaquo;</button> <button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">&lsaquo;</button>
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">&rsaquo;</button> <button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">&rsaquo;</button>
@@ -206,12 +204,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="--" aria-describedby="freq-label" aria-label="Tuned frequency" /> <input class="status-input" id="freq" type="text" value="--" />
<div class="label" id="freq-label"><span>Frequency</span></div> <div class="label"><span>Frequency</span></div>
</div> </div>
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;"> <div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" /> <input class="status-input" id="center-freq" type="text" value="--" />
<div class="label" id="center-freq-label"><span>Center Frequency</span></div> <div class="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">
@@ -237,7 +235,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" aria-label="Demodulation mode"></select> <select class="status-input" id="mode"></select>
</div> </div>
</div> </div>
<div class="controls-col controls-col-center"> <div class="controls-col controls-col-center">
@@ -309,9 +307,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" aria-pressed="false">Start TX</button> <button id="ptt-btn" type="button">Toggle PTT</button>
<button id="power-btn" type="button" aria-pressed="false">Power On</button> <button id="power-btn" type="button">Toggle Power</button>
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button> <button id="lock-btn" type="button">Lock</button>
</div> </div>
</div> </div>
</div> </div>
@@ -358,14 +356,9 @@ 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</span></div> <div class="label"><span>Channels / Scheduler</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>
@@ -388,7 +381,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
</div> </div>
</details> </div>
<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;">
@@ -502,7 +495,6 @@ 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>
@@ -538,7 +530,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" aria-label="Decoder views"> <div class="sub-tab-bar">
<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>
@@ -1631,11 +1623,8 @@ 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="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script> <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/leaflet.js"></script>
<script defer src="/leaflet-ais-tracksymbol.js"></script>
<script defer src="/webgl-renderer.js"></script> <script defer src="/webgl-renderer.js"></script>
<script defer src="/ui-core.js"></script>
<script defer src="/app.js"></script> <script defer src="/app.js"></script>
<script> <script>
// Lazy plugin loader: loads plugin scripts when their tab/feature is first activated // Lazy plugin loader: loads plugin scripts when their tab/feature is first activated
@@ -1643,48 +1632,23 @@ SPDX-License-Identifier: GPL-2.0-or-later
var pluginScripts = { var pluginScripts = {
'digital-modes': ['/ft8.js', '/ft4.js', '/ft2.js', '/wspr.js', '/cw.js', '/background-decode.js', '/sat.js', '/wefax.js'], '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-data': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js'],
'map': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'], 'map': ['/map-core.js', '/leaflet-ais-tracksymbol.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'],
'statistics': ['/map-core.js'], 'statistics': ['/map-core.js'],
'bookmarks': ['/bookmarks.js'], 'bookmarks': ['/bookmarks.js'],
'recorder': [], 'recorder': [],
'settings': ['/vchan.js', '/scheduler.js'] 'settings': ['/vchan.js', '/scheduler.js']
}; };
var loaded = new Set(); var loaded = new Set();
var loading = new Map();
function loadScript(src) {
if (loaded.has(src)) return Promise.resolve();
if (loading.has(src)) return loading.get(src);
var request = new Promise(function(resolve, reject) {
var s = document.createElement('script');
s.src = src;
s.onload = function() {
loaded.add(src);
loading.delete(src);
resolve();
};
s.onerror = function() {
loading.delete(src);
reject(new Error('Failed to load plugin script: ' + src));
};
document.body.appendChild(s);
});
loading.set(src, request);
return request;
}
function loadPlugins(tab) { function loadPlugins(tab) {
var scripts = pluginScripts[tab]; var scripts = pluginScripts[tab];
if (!scripts) return Promise.resolve(); if (!scripts) return;
return scripts.reduce(function(sequence, src) { scripts.forEach(function(src) {
return sequence.then(function() { return loadScript(src); }); if (loaded.has(src)) return;
}, Promise.resolve()); loaded.add(src);
} var s = document.createElement('script');
s.src = src;
function requestPlugins(tab) { s.defer = true;
return loadPlugins(tab).catch(function(err) { document.body.appendChild(s);
console.error(err);
}); });
} }
// Eager plugin loading is triggered by app.js (after window.trx is set up) // Eager plugin loading is triggered by app.js (after window.trx is set up)
@@ -1692,18 +1656,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
// loading them before app.js would cause map-core.js to crash when // loading them before app.js would cause map-core.js to crash when
// window.trx is not yet defined. // window.trx is not yet defined.
window.loadEagerPlugins = function() { window.loadEagerPlugins = function() {
return Promise.all( ['digital-modes', 'map-data', 'bookmarks', 'settings'].forEach(loadPlugins);
['digital-modes', 'map-data', 'bookmarks', 'settings'].map(requestPlugins)
);
}; };
// Load others on tab switch // Load others on tab switch
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var tab = e.target.closest('[data-tab]'); var tab = e.target.closest('[data-tab]');
if (tab) requestPlugins(tab.dataset.tab); if (tab) loadPlugins(tab.dataset.tab);
}); });
window.loadPluginsForTab = loadPlugins; window.loadPluginsForTab = loadPlugins;
})(); })();
</script> </script>
<!-- Template cloning is handled by navigateToTab() in app.js --> <!-- Template cloning is handled by navigateToTab() in app.js -->
<script defer src="/vendor/leaflet.js"></script>
</body> </body>
</html> </html>
@@ -3,10 +3,10 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
// Map, statistics, and geolocation module (lazy-loaded on map tab activation). // Map, statistics, and geolocation module (lazy-loaded on map tab activation).
// Communicates with app.js through explicit state/core/module services. // Communicates with app.js core via window.trx namespace.
(function () { (function () {
"use strict"; "use strict";
const { state: T, core: C, modules } = window.trx; const T = window.trx;
// Destructure shared utility functions for convenience // Destructure shared utility functions for convenience
const { saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans, const { saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans,
@@ -14,7 +14,7 @@
formatUptime, latLonToMaidenhead, locatorToLatLon, haversineKm, formatUptime, latLonToMaidenhead, locatorToLatLon, haversineKm,
formatDistanceKm, formatTimeAgo, currentDecodeHistoryRetentionMs, formatDistanceKm, formatTimeAgo, currentDecodeHistoryRetentionMs,
formatWavelength, bookmarkDistanceText, buildBookmarkTooltipText, formatWavelength, bookmarkDistanceText, buildBookmarkTooltipText,
nearestBookmarkForHz } = C; nearestBookmarkForHz } = T;
function updateMapRigFilter() { function updateMapRigFilter() {
const el = document.getElementById("map-rig-filter"); const el = document.getElementById("map-rig-filter");
@@ -261,7 +261,7 @@
if (canRenderMap) { if (canRenderMap) {
refreshAprsTrack(call, entry); refreshAprsTrack(call, entry);
} else { } else {
C.markDecodeMapSyncPending(); T.markDecodeMapSyncPending();
} }
if (!visible) { if (!visible) {
if (canRenderMap && selectedAprsTrackCall && String(selectedAprsTrackCall) === String(call)) { if (canRenderMap && selectedAprsTrackCall && String(selectedAprsTrackCall) === String(call)) {
@@ -294,7 +294,7 @@
if (canRenderMap) { if (canRenderMap) {
refreshAisTrack(key, entry); refreshAisTrack(key, entry);
} else { } else {
C.markDecodeMapSyncPending(); T.markDecodeMapSyncPending();
} }
if (!visible) { if (!visible) {
if (canRenderMap && selectedAisTrackMmsi && String(selectedAisTrackMmsi) === String(key)) { if (canRenderMap && selectedAisTrackMmsi && String(selectedAisTrackMmsi) === String(key)) {
@@ -337,7 +337,7 @@
entry.stations = new Set(); entry.stations = new Set();
entry.bandMeta = new Map(); entry.bandMeta = new Map();
if (canRenderMap) setRetainedMapMarkerVisible(entry.marker, false); if (canRenderMap) setRetainedMapMarkerVisible(entry.marker, false);
else C.markDecodeMapSyncPending(); else T.markDecodeMapSyncPending();
return false; return false;
} }
const nextStations = new Set(); const nextStations = new Set();
@@ -352,7 +352,7 @@
); );
const count = Math.max(nextDetails.size, nextStations.size || 0, 1); const count = Math.max(nextDetails.size, nextStations.size || 0, 1);
if (!canRenderMap) { if (!canRenderMap) {
C.markDecodeMapSyncPending(); T.markDecodeMapSyncPending();
return true; return true;
} }
ensureDecodeLocatorMarker(entry); ensureDecodeLocatorMarker(entry);
@@ -392,7 +392,7 @@
pruneLocatorEntry(key, entry, cutoffMs); pruneLocatorEntry(key, entry, cutoffMs);
} }
if (!aprsMap || T.decodeHistoryReplayActive) { if (!aprsMap || T.decodeHistoryReplayActive) {
C.markDecodeMapSyncPending(); T.markDecodeMapSyncPending();
return; return;
} }
rebuildDecodeContactPaths(); rebuildDecodeContactPaths();
@@ -415,7 +415,7 @@
function locatorFilterColor(type) { function locatorFilterColor(type) {
const hues = locatorThemeHues(); const hues = locatorThemeHues();
const lightTheme = C.currentTheme() === "light"; const lightTheme = T.currentTheme() === "light";
const sat = lightTheme ? 66 : 76; const sat = lightTheme ? 66 : 76;
const light = lightTheme ? 42 : 56; const light = lightTheme ? 42 : 56;
const hue = type === "bookmark" const hue = type === "bookmark"
@@ -539,7 +539,7 @@
} }
function locatorThemeHues() { function locatorThemeHues() {
const pal = C.canvasPalette(); const pal = T.canvasPalette();
const baseHue = paletteHue(pal?.spectrumLine, 145); const baseHue = paletteHue(pal?.spectrumLine, 145);
const waveHue = paletteHue(pal?.waveformLine, baseHue + 34); const waveHue = paletteHue(pal?.waveformLine, baseHue + 34);
const peakHue = paletteHue(pal?.waveformPeak, baseHue - 42); const peakHue = paletteHue(pal?.waveformPeak, baseHue - 42);
@@ -560,7 +560,7 @@
function locatorBandChipColor(label) { function locatorBandChipColor(label) {
const hues = locatorThemeHues(); const hues = locatorThemeHues();
const lightTheme = C.currentTheme() === "light"; const lightTheme = T.currentTheme() === "light";
const hue = wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508); const hue = wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508);
const sat = lightTheme ? 68 : 78; const sat = lightTheme ? 68 : 78;
const light = lightTheme ? 44 : 58; const light = lightTheme ? 44 : 58;
@@ -606,7 +606,7 @@
const safeCount = Math.max(1, Number.isFinite(count) ? count : 1); const safeCount = Math.max(1, Number.isFinite(count) ? count : 1);
const intensity = Math.min(1, Math.log2(safeCount + 1) / 5); const intensity = Math.min(1, Math.log2(safeCount + 1) / 5);
const hue = locatorHueForEntry(entry); const hue = locatorHueForEntry(entry);
const lightTheme = C.currentTheme() === "light"; const lightTheme = T.currentTheme() === "light";
const strokeSat = lightTheme ? 62 : 74; const strokeSat = lightTheme ? 62 : 74;
const fillSat = lightTheme ? 68 : 78; const fillSat = lightTheme ? 68 : 78;
const strokeLight = lightTheme ? 40 : 56; const strokeLight = lightTheme ? 40 : 56;
@@ -1573,7 +1573,7 @@
stageResizeObserver = new ResizeObserver(() => sizeAprsMapToViewport()); stageResizeObserver = new ResizeObserver(() => sizeAprsMapToViewport());
stageResizeObserver.observe(stage); stageResizeObserver.observe(stage);
} }
updateMapBaseLayerForTheme(C.currentTheme()); updateMapBaseLayerForTheme(T.currentTheme());
syncAprsReceiverMarker(); syncAprsReceiverMarker();
// Rebuild popup content on open (keeps age/distance/rig list fresh) // Rebuild popup content on open (keeps age/distance/rig list fresh)
@@ -2307,18 +2307,18 @@
existing.rigIds.add(msgRigId); existing.rigIds.add(msgRigId);
} }
if (!visible) { if (!visible) {
if (!C.decodeHistoryMapRenderingDeferred()) { if (!T.decodeHistoryMapRenderingDeferred()) {
setRetainedMapMarkerVisible(existing.marker, false); setRetainedMapMarkerVisible(existing.marker, false);
} else { } else {
C.markDecodeMapSyncPending(); T.markDecodeMapSyncPending();
} }
return; return;
} }
if (!C.decodeHistoryMapRenderingDeferred()) { if (!T.decodeHistoryMapRenderingDeferred()) {
ensureVdesMarker(key, existing); ensureVdesMarker(key, existing);
setRetainedMapMarkerVisible(existing.marker, true); setRetainedMapMarkerVisible(existing.marker, true);
} else { } else {
C.markDecodeMapSyncPending(); T.markDecodeMapSyncPending();
} }
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([msg.lat, msg.lon]); existing.marker.setLatLng([msg.lat, msg.lon]);
@@ -2334,11 +2334,11 @@
}; };
vdesMarkers.set(key, entry); vdesMarkers.set(key, entry);
if (!visible) return; if (!visible) return;
if (!C.decodeHistoryMapRenderingDeferred()) { if (!T.decodeHistoryMapRenderingDeferred()) {
ensureVdesMarker(key, entry); ensureVdesMarker(key, entry);
setRetainedMapMarkerVisible(entry.marker, true); setRetainedMapMarkerVisible(entry.marker, true);
} else { } else {
C.markDecodeMapSyncPending(); T.markDecodeMapSyncPending();
} }
if (aprsMap && entry.marker && !T.decodeHistoryReplayActive) { if (aprsMap && entry.marker && !T.decodeHistoryReplayActive) {
entry.marker.setPopupContent(popupHtml); entry.marker.setPopupContent(popupHtml);
@@ -2365,7 +2365,7 @@
if (T.locationSubtitle) { if (T.locationSubtitle) {
T.locationSubtitle.textContent = `Location: ${grid} · ${label}`; T.locationSubtitle.textContent = `Location: ${grid} · ${label}`;
} }
C.updateDocumentTitle(C.activeChannelRds()); T.updateDocumentTitle(T.activeChannelRds());
}) })
.catch(() => {}); .catch(() => {});
} }
@@ -2443,8 +2443,8 @@
} }
function scheduleDecodeMapMaintenance() { function scheduleDecodeMapMaintenance() {
if (C.decodeHistoryMapRenderingDeferred()) { if (T.decodeHistoryMapRenderingDeferred()) {
C.markDecodeMapSyncPending(); T.markDecodeMapSyncPending();
return; return;
} }
scheduleUiFrameJob("decode-map-maintenance", () => { scheduleUiFrameJob("decode-map-maintenance", () => {
@@ -3461,7 +3461,7 @@
} }
// Register module API for core to call // Register module API for core to call
modules.map = { window.trx.map = {
initAprsMap, initAprsMap,
sizeAprsMapToViewport, sizeAprsMapToViewport,
syncAprsReceiverMarker, syncAprsReceiverMarker,
@@ -382,7 +382,7 @@ window.pruneAisHistoryView = function() {
}; };
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all AIS decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_ais_decode"); await postPath("/clear_ais_decode");
window.resetAisHistoryView(); window.resetAisHistoryView();
@@ -442,7 +442,7 @@ window.restoreAprsHistory = function(packets) {
}; };
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all APRS decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_aprs_decode"); await postPath("/clear_aprs_decode");
window.resetAprsHistoryView(); window.resetAprsHistoryView();
@@ -215,10 +215,10 @@
}); });
} }
async function resetBackgroundDecode() { function resetBackgroundDecode() {
const rigId = currentRigId; const rigId = currentRigId;
if (!rigId) return; if (!rigId) return;
if (!await window.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return; if (!confirm("Reset background decode configuration? This cannot be undone.")) return;
apiResetConfig(rigId) apiResetConfig(rigId)
.then(function (saved) { .then(function (saved) {
currentConfig = saved; currentConfig = saved;
@@ -331,13 +331,8 @@ async function bmSave(e) {
const comment = document.getElementById("bm-comment").value.trim(); const comment = document.getElementById("bm-comment").value.trim();
const decoders = bmReadDecoders(); const decoders = bmReadDecoders();
const formError = document.getElementById("bm-form-error");
if (formError) formError.textContent = "";
if (!name || !Number.isFinite(freq_hz) || !mode) { if (!name || !Number.isFinite(freq_hz) || !mode) {
if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode."; alert("Name, Frequency, and Mode are required.");
const invalid = !name ? document.getElementById("bm-name")
: !Number.isFinite(freq_hz) ? document.getElementById("bm-freq") : document.getElementById("bm-mode");
invalid?.focus();
return; return;
} }
@@ -378,13 +373,12 @@ async function bmSave(e) {
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to save bookmark:", err); console.error("Failed to save bookmark:", err);
if (formError) formError.textContent = "Failed to save bookmark: " + err.message; alert("Failed to save bookmark: " + err.message);
window.trxUi?.notify("Bookmark could not be saved", { kind: "error" });
} }
} }
async function bmDelete(id) { async function bmDelete(id) {
if (!await window.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return; if (!confirm("Delete this bookmark?")) 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 {
@@ -395,7 +389,7 @@ async function bmDelete(id) {
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to delete bookmark:", err); console.error("Failed to delete bookmark:", err);
window.trxUi?.notify("Failed to delete bookmark: " + err.message, { kind: "error" }); alert("Failed to delete bookmark: " + err.message);
} }
} }
@@ -561,12 +555,7 @@ async function bmMoveSelected() {
const target = document.getElementById("bm-move-target")?.value; const target = document.getElementById("bm-move-target")?.value;
if (!target) return; if (!target) return;
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target; const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
if (!await window.trxUi.confirm({ if (!confirm(`Move ${ids.length} bookmark${ids.length > 1 ? "s" : ""} to "${targetLabel}"?`)) return;
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 = {};
@@ -588,7 +577,7 @@ async function bmMoveSelected() {
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to move bookmarks:", err); console.error("Failed to move bookmarks:", err);
window.trxUi?.notify("Failed to move bookmarks: " + err.message, { kind: "error" }); alert("Failed to move bookmarks: " + err.message);
} }
} }
@@ -609,11 +598,7 @@ 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 (!await window.trxUi.confirm({ if (!confirm(`Delete ${ids.length} selected bookmark${ids.length > 1 ? "s" : ""}?`)) return;
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 = {};
@@ -634,7 +619,7 @@ async function bmDeleteSelected() {
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to delete bookmarks:", err); console.error("Failed to delete bookmarks:", err);
window.trxUi?.notify("Failed to delete bookmarks: " + err.message, { kind: "error" }); alert("Failed to delete bookmarks: " + err.message);
} }
} }
@@ -364,7 +364,7 @@ window.resetCwHistoryView = function() {
}; };
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all CW decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_cw_decode"); await postPath("/clear_cw_decode");
window.resetCwHistoryView(); window.resetCwHistoryView();
@@ -190,7 +190,7 @@ ft2DecodeToggleBtn?.addEventListener("click", async () => {
}); });
document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear FT2 history?", message: "All stored FT2 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all FT2 decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_ft2_decode"); await postPath("/clear_ft2_decode");
window.resetFt2HistoryView(); window.resetFt2HistoryView();
@@ -190,7 +190,7 @@ ft4DecodeToggleBtn?.addEventListener("click", async () => {
}); });
document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear FT4 history?", message: "All stored FT4 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all FT4 decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_ft4_decode"); await postPath("/clear_ft4_decode");
window.resetFt4HistoryView(); window.resetFt4HistoryView();
@@ -462,7 +462,7 @@ ft8DecodeToggleBtn?.addEventListener("click", async () => {
}); });
document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear FT8 history?", message: "All stored FT8 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all FT8 decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_ft8_decode"); await postPath("/clear_ft8_decode");
window.resetFt8HistoryView(); window.resetFt8HistoryView();
@@ -388,7 +388,7 @@ hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
}); });
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all HF APRS decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_hf_aprs_decode"); await postPath("/clear_hf_aprs_decode");
window.resetHfAprsHistoryView(); window.resetHfAprsHistoryView();
@@ -250,9 +250,9 @@
var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN; var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
var bmId = dom.bookmark ? dom.bookmark.value : ""; var bmId = dom.bookmark ? dom.bookmark.value : "";
if (!satellite) { window.trxUi?.notify("Enter a satellite name.", { kind: "error" }); document.getElementById("scheduler-sat-name")?.focus(); return; } if (!satellite) { alert("Please enter a satellite name."); return; }
if (isNaN(noradId) || noradId <= 0) { window.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" }); document.getElementById("scheduler-sat-norad")?.focus(); return; } if (isNaN(noradId) || noradId <= 0) { alert("Please enter a valid NORAD catalog number."); return; }
if (!bmId) { window.trxUi?.notify("Select a bookmark.", { kind: "error" }); document.getElementById("scheduler-sat-bookmark")?.focus(); return; } if (!bmId) { alert("Please select a bookmark."); return; }
var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5; var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0; var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
@@ -295,7 +295,7 @@ satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
document document
.getElementById("settings-clear-sat-history") .getElementById("settings-clear-sat-history")
?.addEventListener("click", async () => { ?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all satellite decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_lrpt_decode"); await postPath("/clear_lrpt_decode");
window.resetSatHistoryView(); window.resetSatHistoryView();
@@ -599,7 +599,7 @@
const bmId = bmEl.value; const bmId = bmEl.value;
if (!bmId) { if (!bmId) {
window.trxUi?.notify("Select a primary bookmark before saving.", { kind: "error" }); alert("Please select a primary bookmark.");
return; return;
} }
@@ -887,7 +887,7 @@
var recEl = tr.querySelector('[data-field="record"]'); var recEl = tr.querySelector('[data-field="record"]');
var exEl = tr.querySelector('[data-field="exclusive"]'); var exEl = tr.querySelector('[data-field="exclusive"]');
if (bmEl && !bmEl.value) { window.trxUi?.notify("Select a bookmark before saving.", { kind: "error" }); bmEl.focus(); return; } if (bmEl && !bmEl.value) { alert('Please select a bookmark.'); return; }
entry.start_min = hhmmToMin(startEl.value); entry.start_min = hhmmToMin(startEl.value);
entry.end_min = hhmmToMin(endEl.value); entry.end_min = hhmmToMin(endEl.value);
@@ -1236,10 +1236,10 @@
return el ? el.value : ""; return el ? el.value : "";
} }
async function resetScheduler() { function resetScheduler() {
const rig = currentRigId; const rig = currentRigId;
if (!rig) return; if (!rig) return;
if (!await window.trxUi.confirm({ title: "Reset scheduler?", message: "This rig's scheduler configuration will be reset to Disabled.", confirmLabel: "Reset" })) return; if (!confirm("Reset scheduler for this rig to Disabled?")) return;
apiDeleteScheduler(rig) apiDeleteScheduler(rig)
.then(function () { .then(function () {
@@ -317,7 +317,7 @@ window.restoreVdesHistory = function(messages) {
}; };
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all VDES decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_vdes_decode"); await postPath("/clear_vdes_decode");
window.resetVdesHistoryView(); window.resetVdesHistoryView();
@@ -270,7 +270,7 @@ wsprDecodeToggleBtn?.addEventListener("click", async () => {
}); });
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all WSPR decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_wspr_decode"); await postPath("/clear_wspr_decode");
window.resetWsprHistoryView(); window.resetWsprHistoryView();
@@ -257,7 +257,7 @@
} }
// Register module API // Register module API
window.trx.modules.screenshot = { window.trx.screenshot = {
captureSpectrumScreenshot, captureSpectrumScreenshot,
buildSpectrumSnapshotCanvas, buildSpectrumSnapshotCanvas,
saveCanvasAsPng, saveCanvasAsPng,
@@ -1231,7 +1231,7 @@ small { color: var(--text-muted); }
} }
.top-bar-actions { .top-bar-actions {
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 0.45rem 0.6rem; gap: 0.45rem 0.6rem;
min-width: 0; min-width: 0;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1404,21 +1404,10 @@ small { color: var(--text-muted); }
padding: 0.9rem 0.2rem 0; padding: 0.9rem 0.2rem 0;
} }
.header-rig-switch { .header-rig-switch {
display: grid; display: flex;
grid-template-columns: minmax(0, 1fr); align-items: center;
align-items: start;
gap: 0.35rem; gap: 0.35rem;
} }
.header-rig-summary {
display: block;
max-width: 20rem;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-rig-switch select { .header-rig-switch select {
min-width: 8rem; min-width: 8rem;
height: 2rem; height: 2rem;
@@ -1428,7 +1417,6 @@ small { color: var(--text-muted); }
background: var(--input-bg); background: var(--input-bg);
color: var(--text); color: var(--text);
font-size: 0.85rem; font-size: 0.85rem;
align-self: start;
} }
.header-rig-switch button { .header-rig-switch button {
height: 2rem; height: 2rem;
@@ -2851,256 +2839,6 @@ canvas:focus-visible, [tabindex]:focus-visible {
outline-offset: 2px; outline-offset: 2px;
} }
/* ── Shared interaction primitives ───────────────────────────────────── */
.visually-hidden {
position: absolute !important;
width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.toast-region {
position: fixed;
z-index: 120;
right: var(--space-4);
bottom: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
width: min(28rem, calc(100vw - 2rem));
pointer-events: none;
}
.toast {
--toast-accent: var(--accent-yellow);
position: relative;
display: grid;
grid-template-columns: 1.25rem minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-3);
min-height: 3rem;
padding: 0.7rem 0.75rem;
border: 1px solid color-mix(in srgb, var(--toast-accent) 35%, var(--border-light));
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--card-bg) 96%, var(--toast-accent) 4%);
color: var(--text);
box-shadow: 0 14px 38px color-mix(in srgb, #000 38%, transparent), inset 3px 0 0 var(--toast-accent);
opacity: 0;
transform: translateY(0.65rem) scale(0.98);
transition: opacity var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out);
pointer-events: auto;
}
.toast::before {
display: grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--toast-accent) 18%, transparent);
color: var(--toast-accent);
content: "i";
font-size: var(--fs-xs);
font-weight: 800;
}
.toast > span { min-width: 0; line-height: 1.35; }
.toast-visible { opacity: 1; transform: translateY(0); }
.toast-success { --toast-accent: var(--accent-green); }
.toast-success::before { content: "✓"; }
.toast-error { --toast-accent: var(--accent-red); }
.toast-error::before { content: "!"; }
.toast button {
width: auto;
min-height: 2rem;
height: 2rem;
padding: 0 var(--space-3);
border-color: color-mix(in srgb, var(--toast-accent) 55%, var(--btn-border));
color: var(--toast-accent);
font-size: var(--fs-sm);
font-weight: 700;
}
.ui-dialog {
max-width: none;
border: 0;
padding: 0;
border-radius: var(--radius-lg);
color: var(--text);
background: transparent;
box-shadow: 0 24px 70px color-mix(in srgb, #000 55%, transparent);
}
.ui-dialog::backdrop { background: rgba(3, 8, 18, 0.72); backdrop-filter: blur(5px); }
.ui-dialog-card {
width: min(29rem, calc(100vw - 2rem));
margin: 0;
padding: var(--space-6);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
background: linear-gradient(145deg, color-mix(in srgb, var(--card-bg) 94%, var(--text) 6%), var(--card-bg));
box-sizing: border-box;
}
.ui-dialog-card h2 { margin: 0 0 var(--space-2); color: var(--text-heading); font-size: var(--fs-md); line-height: 1.3; }
.ui-dialog-card p { margin: 0; color: var(--text-muted); line-height: 1.55; }
.ui-dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-6); }
.ui-dialog-actions button { min-width: 6rem; }
.ui-dialog-actions .danger { background: var(--accent-red); border-color: var(--accent-red); color: #fff; font-weight: 700; }
.ui-dialog-actions .danger:hover:not(:disabled) { background: color-mix(in srgb, var(--accent-red) 82%, #fff); border-color: color-mix(in srgb, var(--accent-red) 82%, #fff); }
.form-error { flex: 1 1 100%; min-height: 1.2em; color: var(--accent-red); font-size: var(--fs-sm); font-weight: 600; }
button.is-active {
border-color: var(--accent-green);
background: color-mix(in srgb, var(--btn-bg) 82%, var(--accent-green) 18%);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-green) 32%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent-green) 12%, transparent);
}
#ptt-btn.is-active { background: var(--accent-red) !important; border-color: var(--accent-red) !important; color: white !important; }
.is-busy { cursor: progress !important; opacity: 0.68; }
.operator-layout-picker {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding-left: 0.6rem;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--input-bg) 88%, transparent);
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
overflow: visible;
}
.operator-layout-picker::before { content: "Layout"; }
.operator-layout-picker select {
width: auto;
min-height: 2rem;
max-width: 9rem;
padding: 0 1.75rem 0 0.45rem;
border: 0;
background-color: transparent;
color: var(--text);
font-size: var(--fs-sm);
font-weight: 600;
text-transform: none;
cursor: pointer;
}
.operator-layout-picker:focus-within { border-color: var(--accent-green); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-green) 20%, transparent); }
.operator-layout-picker select:focus-visible { outline: 0; }
.advanced-radio-controls {
margin-top: var(--space-3);
border: 1px solid color-mix(in srgb, var(--border-light) 75%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--input-bg) 68%, transparent);
overflow: clip;
transition: border-color var(--dur-fast) var(--ease-standard), background-color var(--dur-fast) var(--ease-standard);
}
.advanced-radio-controls:hover { border-color: var(--border-light); }
.advanced-radio-controls[open] { background: color-mix(in srgb, var(--surface) 86%, transparent); }
.advanced-radio-controls summary {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0.65rem 0.75rem;
color: var(--text-heading);
font-size: var(--fs-sm);
font-weight: 700;
cursor: pointer;
list-style: none;
user-select: none;
}
.advanced-radio-controls summary::-webkit-details-marker { display: none; }
.advanced-radio-controls summary::before {
content: "";
color: var(--accent-text);
font-size: 1.2rem;
line-height: 1;
transform: rotate(0deg);
transition: transform var(--dur-base) var(--ease-standard);
}
.advanced-radio-controls[open] summary::before { transform: rotate(90deg); }
.advanced-radio-controls[open] summary { border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); }
.advanced-radio-body { display: grid; gap: var(--space-3); padding: var(--space-3); }
.advanced-radio-body > * { margin: 0; }
body[data-operator-layout="digital"] .controls-col-wfm { display: none !important; }
body[data-operator-layout="broadcast"] #tx-power-col,
body[data-operator-layout="broadcast"] #tx-meters,
body[data-operator-layout="broadcast"] #tx-limit-row,
body[data-operator-layout="broadcast"] #vfo-row,
body[data-operator-layout="broadcast"] #sam-controls-col,
body[data-operator-layout="broadcast"] #advanced-radio-controls {
display: none !important;
}
body[data-operator-layout="broadcast"] .controls-row {
grid-template-columns: minmax(8rem, 0.65fr) auto minmax(20rem, 2fr);
}
body[data-operator-layout="broadcast"] #wfm-controls-col,
body[data-operator-layout="broadcast"] #audio-row,
body[data-operator-layout="broadcast"] #spectrum-bw-row {
border-color: color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
background: color-mix(in srgb, var(--accent-green) 7%, transparent);
}
body[data-operator-layout="broadcast"] #wfm-controls-col {
padding: var(--space-2) var(--space-3);
border: 1px solid color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
border-radius: var(--radius-md);
}
body[data-operator-layout="broadcast"] #audio-row {
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
border-radius: var(--radius-md);
}
body[data-operator-layout="broadcast"] #spectrum-bw-row {
padding: var(--space-2);
border: 1px solid color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
border-radius: var(--radius-sm);
}
body[data-operator-layout="broadcast"] #ais-bar-overlay,
body[data-operator-layout="broadcast"] #vdes-bar-overlay,
body[data-operator-layout="broadcast"] #ft8-bar-overlay,
body[data-operator-layout="broadcast"] #aprs-bar-overlay,
body[data-operator-layout="broadcast"] #hf-aprs-bar-overlay,
body[data-operator-layout="broadcast"] #cw-bar-overlay {
display: none !important;
}
@media (max-width: 760px) {
body[data-operator-layout="broadcast"] .controls-row { grid-template-columns: 1fr auto; }
body[data-operator-layout="broadcast"] #wfm-controls-col { grid-column: 1 / -1; }
}
.mobile-more-btn, .mobile-more-menu, .decoder-tab-select { display: none; }
.mobile-more-menu {
position: fixed;
right: max(0.75rem, env(safe-area-inset-right));
bottom: calc(5.4rem + env(safe-area-inset-bottom));
z-index: 80;
min-width: 12rem;
padding: var(--space-2);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--card-bg) 97%, transparent);
box-shadow: 0 18px 45px color-mix(in srgb, #000 42%, transparent);
}
.mobile-more-menu.is-open { display: grid; gap: 0.2rem; animation: trx-menu-in var(--dur-base) var(--ease-out); }
.mobile-more-menu button {
justify-content: flex-start;
width: 100%;
height: 2.65rem;
padding: 0 var(--space-3);
border-color: transparent;
background: transparent;
text-align: left;
}
.mobile-more-menu button:hover { background: var(--btn-hover-bg); border-color: transparent; }
.decoder-state-dot {
display: inline-block;
width: 0.45rem;
height: 0.45rem;
margin-left: 0.4rem;
border: 1px solid color-mix(in srgb, var(--text-muted) 70%, transparent);
border-radius: 50%;
background: color-mix(in srgb, var(--text-muted) 60%, transparent);
vertical-align: middle;
}
.decoder-state-dot[data-state="active"] { background: var(--accent-green); box-shadow: 0 0 0.35rem color-mix(in srgb, var(--accent-green) 65%, transparent); }
.decoder-state-dot[data-state="error"] { background: var(--accent-red); }
@keyframes trx-menu-in { from { opacity: 0; transform: translateY(0.4rem) scale(0.98); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
}
/* ── Scrollbars ───────────────────────────────────────────────────────── */ /* ── Scrollbars ───────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
@@ -3215,7 +2953,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
bottom: calc(0.55rem + env(safe-area-inset-bottom)); bottom: calc(0.55rem + env(safe-area-inset-bottom));
z-index: 30; z-index: 30;
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.25rem; gap: 0.25rem;
padding: 0.38rem; padding: 0.38rem;
border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent); border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);
@@ -3255,38 +2993,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; } .tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; } .tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="statistics"] .tab-label { font-size: 0.6rem; } .tab[data-tab="statistics"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
.mobile-more-btn { display: flex; }
.mobile-more-btn[aria-expanded="true"] {
color: var(--accent-text);
background: color-mix(in srgb, var(--accent-green) 10%, transparent);
}
.tab .tab-label, .tab[data-tab] .tab-label { font-size: 0.75rem; }
.decoder-tab-select {
display: block;
width: 100%;
min-height: 2.8rem;
margin-bottom: var(--space-3);
padding: 0 var(--space-3);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--input-bg) 90%, var(--card-bg));
color: var(--text);
font-size: 1rem;
font-weight: 600;
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 5%, transparent);
}
#tab-digital-modes > .sub-tab-bar { display: none; }
.toast-region { bottom: calc(5.7rem + env(safe-area-inset-bottom)); }
.top-bar-actions { .top-bar-actions {
width: 100%; width: 100%;
justify-content: flex-start; justify-content: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.45rem; gap: 0.45rem;
} }
.operator-layout-picker { max-width: 100%; }
.operator-layout-picker select { max-width: 10rem; }
.header-rig-switch, .header-rig-switch,
.header-style-pick { .header-style-pick {
flex: 1 1 12rem; flex: 1 1 12rem;
@@ -4198,27 +3910,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
} }
} }
@media (max-width: 480px) {
.toast-region {
right: max(0.65rem, env(safe-area-inset-right));
width: calc(100vw - max(1.3rem, env(safe-area-inset-left) + env(safe-area-inset-right)));
}
.toast { grid-template-columns: 1.25rem minmax(0, 1fr); }
.toast button { grid-column: 2; justify-self: start; }
.ui-dialog-card { padding: var(--space-5); }
.ui-dialog-actions { flex-direction: column-reverse; }
.ui-dialog-actions button { width: 100%; }
}
/* Preserve legibility after the component-specific compact-mobile rules. */
@media (max-width: 640px) {
button, input, select, textarea, .tab-label, .sub-tab,
.label, .hint, .subtitle, .wfm-control-label, .wfm-intf-val,
.ft8-header, .ft8-message, .aprs-packet, .ais-message, .vdes-message {
font-size: max(0.75rem, 12px);
}
}
/* Theme styles have been moved to themes.css */ /* Theme styles have been moved to themes.css */
@@ -5370,11 +5061,3 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }
@media (max-width: 640px) {
button, input, select, textarea, .tab-label, .sub-tab,
.label, .hint, .subtitle, .wfm-control-label, .wfm-intf-val,
.ft8-header, .ft8-message, .aprs-packet, .ais-message, .vdes-message {
font-size: max(0.75rem, 12px);
}
}
@@ -1,144 +0,0 @@
// 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");
@@ -1,351 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
"use strict";
// Shared UI primitives. Keeping these outside app.js prevents navigation,
// feedback, dialogs, and layout preferences from growing separate state models.
(function initUiCore() {
const api = window.trxUi = window.trxUi || {};
function ensureLiveRegions() {
if (!document.getElementById("toast-region")) {
const region = document.createElement("div");
region.id = "toast-region";
region.className = "toast-region";
region.setAttribute("aria-live", "polite");
region.setAttribute("aria-atomic", "false");
document.body.appendChild(region);
}
if (!document.getElementById("ui-confirm-dialog")) {
const dialog = document.createElement("dialog");
dialog.id = "ui-confirm-dialog";
dialog.className = "ui-dialog";
dialog.innerHTML = `
<form method="dialog" class="ui-dialog-card">
<h2 id="ui-confirm-title">Confirm action</h2>
<p id="ui-confirm-message"></p>
<div class="ui-dialog-actions">
<button value="cancel" type="submit">Cancel</button>
<button value="confirm" type="submit" class="danger">Confirm</button>
</div>
</form>`;
document.body.appendChild(dialog);
}
}
api.notify = function notify(message, options = {}) {
ensureLiveRegions();
const { kind = "info", duration = kind === "error" ? 7000 : 3200, action = null } = options;
const toast = document.createElement("div");
toast.className = `toast toast-${kind}`;
toast.setAttribute("role", kind === "error" ? "alert" : "status");
const text = document.createElement("span");
text.textContent = message;
toast.appendChild(text);
if (action && typeof action.run === "function") {
const button = document.createElement("button");
button.type = "button";
button.textContent = action.label || "Retry";
button.addEventListener("click", () => { action.run(); toast.remove(); });
toast.appendChild(button);
}
document.getElementById("toast-region").appendChild(toast);
requestAnimationFrame(() => toast.classList.add("toast-visible"));
if (duration > 0) setTimeout(() => toast.remove(), duration);
return toast;
};
api.confirm = function confirmAction(options = {}) {
ensureLiveRegions();
const dialog = document.getElementById("ui-confirm-dialog");
document.getElementById("ui-confirm-title").textContent = options.title || "Confirm action";
document.getElementById("ui-confirm-message").textContent = options.message || "Continue?";
const confirmButton = dialog.querySelector('[value="confirm"]');
confirmButton.textContent = options.confirmLabel || "Confirm";
confirmButton.classList.toggle("danger", options.danger !== false);
return new Promise((resolve) => {
const finish = () => resolve(dialog.returnValue === "confirm");
dialog.addEventListener("close", finish, { once: true });
dialog.showModal();
});
};
api.setButtonState = function setButtonState(button, options = {}) {
if (!button) return;
const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options;
button.classList.toggle("is-active", active);
button.classList.toggle("is-busy", busy);
button.setAttribute("aria-pressed", String(active));
button.setAttribute("aria-busy", String(busy));
button.disabled = disabled || busy;
const label = active ? activeLabel : inactiveLabel;
if (label) button.textContent = label;
};
api.prepareTabList = function prepareTabList(bar, kind = "primary") {
if (!bar) return;
if (bar._accessibleTabsPrepared) return;
bar._accessibleTabsPrepared = true;
const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]";
const buttons = Array.from(bar.querySelectorAll(selector));
bar.setAttribute("role", "tablist");
buttons.forEach((button, index) => {
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(button.classList.contains("active")));
button.tabIndex = button.classList.contains("active") || (!buttons.some(b => b.classList.contains("active")) && index === 0) ? 0 : -1;
const key = button.dataset.tab || button.dataset.subtab;
button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`);
const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`);
if (panel) {
if (!button.id) button.id = `${kind}-tab-${key}`;
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", button.id);
}
});
bar.addEventListener("keydown", (event) => {
if (!buttons.includes(event.target)) return;
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1
: event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
if (!direction) return;
event.preventDefault();
const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length];
next.focus();
next.click();
});
};
api.syncSelectedTab = function syncSelectedTab(bar, selected) {
if (!bar) return;
bar.querySelectorAll('[role="tab"]').forEach((tab) => {
const active = tab === selected;
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
});
};
const layouts = {
compact: { label: "Compact", advanced: false, preferredTab: "main" },
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, preferredTab: "main", capability: "broadcast" },
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, preferredTab: "digital-modes", capability: "digital" },
full: { label: "Full controls", advanced: true, preferredTab: "main" },
};
const layoutCapabilities = { broadcast: false, digital: false };
let activeRigId = null;
function layoutStorageKey() {
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
}
function savedLayoutName() {
return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact";
}
function layoutAvailable(layout) {
return !layout.capability || layoutCapabilities[layout.capability] === true;
}
function unavailableLayoutMessage() {
const unavailable = Object.values(layouts).filter(layout => !layoutAvailable(layout) && layout.unavailable);
return unavailable.length ? `Unavailable: ${unavailable.map(layout => layout.unavailable).join("; ")}.` : "";
}
function refreshLayoutOptions() {
const select = document.getElementById("operator-layout-select");
if (!select) return;
const previous = select.value || document.body.dataset.operatorLayout || "compact";
select.replaceChildren();
Object.entries(layouts).forEach(([value, layout]) => {
if (!layoutAvailable(layout)) return;
select.add(new Option(layout.label, value));
});
const available = Array.from(select.options).some(option => option.value === previous);
select.value = available ? previous : "compact";
if (!available && previous !== "compact") api.applyLayout("compact", { persist: false });
select.title = unavailableLayoutMessage();
}
api.setLayoutCapabilities = function setLayoutCapabilities(capabilities = {}) {
Object.keys(layoutCapabilities).forEach((name) => {
if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]);
});
refreshLayoutOptions();
const select = document.getElementById("operator-layout-select");
const saved = savedLayoutName();
if (select && Array.from(select.options).some(option => option.value === saved)) {
select.value = saved;
api.applyLayout(saved, { persist: false });
}
};
api.setActiveRig = function setActiveRig(rigId) {
activeRigId = typeof rigId === "string" && rigId ? rigId : null;
const saved = savedLayoutName();
const select = document.getElementById("operator-layout-select");
if (select) select.value = Array.from(select.options).some(option => option.value === saved) ? saved : "compact";
api.applyLayout(select?.value || saved, { persist: false });
};
api.applyLayout = function applyLayout(name, options = {}) {
const requestedLayout = layouts[name];
const permittedName = requestedLayout && layoutAvailable(requestedLayout) ? name : "compact";
const layout = layouts[permittedName];
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
if (options.persist !== false) localStorage.setItem(layoutStorageKey(), document.body.dataset.operatorLayout);
const details = document.getElementById("advanced-radio-controls");
if (details) details.open = layout.advanced;
if (options.navigate && typeof window.navigateToTab === "function") {
window.navigateToTab(layout.preferredTab);
}
};
function installLayoutControls() {
const actions = document.querySelector(".top-bar-actions");
if (actions && !document.getElementById("operator-layout-select")) {
const label = document.createElement("label");
label.className = "operator-layout-picker";
label.innerHTML = '<span class="visually-hidden">Operator layout</span><select id="operator-layout-select" aria-label="Operator layout"></select>';
const select = label.querySelector("select");
const savedLayout = savedLayoutName();
actions.insertBefore(label, actions.firstChild);
select.value = savedLayout;
refreshLayoutOptions();
if (savedLayout !== "broadcast" && layouts[savedLayout]) select.value = savedLayout;
select.addEventListener("change", () => api.applyLayout(select.value, { navigate: true }));
api.applyLayout(select.value);
}
const tray = document.querySelector(".controls-tray");
if (tray && !document.getElementById("advanced-radio-controls")) {
const details = document.createElement("details");
details.id = "advanced-radio-controls";
details.className = "advanced-radio-controls";
details.innerHTML = '<summary>Advanced radio controls</summary><div class="advanced-radio-body"></div>';
const body = details.querySelector(".advanced-radio-body");
["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => {
const element = document.getElementById(id);
if (element) body.appendChild(element);
});
tray.appendChild(details);
api.applyLayout(savedLayoutName(), { persist: false });
}
}
function installMobileMore() {
const nav = document.querySelector(".tab-bar-nav");
if (!nav || document.getElementById("mobile-more-btn")) return;
const more = document.createElement("button");
more.id = "mobile-more-btn";
more.className = "tab mobile-more-btn";
more.type = "button";
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
more.setAttribute("aria-haspopup", "menu");
more.setAttribute("aria-expanded", "false");
const menu = document.createElement("div");
menu.id = "mobile-more-menu";
menu.className = "mobile-more-menu";
menu.setAttribute("role", "menu");
more.setAttribute("aria-controls", menu.id);
const closeMore = (restoreFocus = false) => {
if (!menu.classList.contains("is-open")) return;
menu.classList.remove("is-open");
more.setAttribute("aria-expanded", "false");
if (restoreFocus) more.focus();
};
api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return;
const item = document.createElement("button");
item.type = "button";
item.setAttribute("role", "menuitem");
item.dataset.navigateTab = tabName;
item.textContent = source.textContent.trim();
item.addEventListener("click", () => {
if (typeof window.navigateToTab === "function") window.navigateToTab(tabName);
closeMore();
});
menu.appendChild(item);
});
more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(open));
if (open) menu.querySelector('[role="menuitem"]')?.focus();
});
document.addEventListener("click", (event) => {
if (!menu.contains(event.target) && !more.contains(event.target)) closeMore();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMore(true);
});
window.addEventListener("resize", () => closeMore());
window.addEventListener("popstate", () => closeMore());
nav.append(more, menu);
}
function installDecoderPicker() {
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
if (!bar || document.getElementById("decoder-tab-select")) return;
const select = document.createElement("select");
select.id = "decoder-tab-select";
select.className = "decoder-tab-select";
select.setAttribute("aria-label", "Decoder view");
const groups = [
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
];
groups.forEach(([label, ids]) => {
const group = document.createElement("optgroup");
group.label = label;
ids.forEach((id) => {
const button = bar.querySelector(`[data-subtab="${id}"]`);
if (button) group.appendChild(new Option(button.textContent.trim(), id));
});
select.appendChild(group);
});
select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click());
bar.insertAdjacentElement("afterend", select);
}
function installDecoderBadges() {
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
if (!bar) return;
bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => {
const id = button.dataset.subtab;
if (id === "overview" || button.querySelector(".decoder-state-dot")) return;
const dot = document.createElement("span");
dot.className = "decoder-state-dot";
dot.setAttribute("aria-hidden", "true");
button.appendChild(dot);
const status = document.getElementById(`${id}-status`);
if (!status) return;
const sync = () => {
const value = status.textContent.toLowerCase();
const state = /receiv|decod|connected|listening/.test(value) ? "active"
: /error|fail|disconnected/.test(value) ? "error" : "idle";
dot.dataset.state = state;
button.title = `${button.childNodes[0]?.textContent?.trim() || id}: ${status.textContent.trim()}`;
};
new MutationObserver(sync).observe(status, { childList: true, characterData: true, subtree: true });
sync();
});
}
api.init = function init() {
ensureLiveRegions();
installLayoutControls();
installMobileMore();
installDecoderPicker();
installDecoderBadges();
api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary");
document.querySelectorAll(".sub-tab-bar").forEach(bar => api.prepareTabList(bar, "secondary"));
window.addEventListener("unhandledrejection", (event) => {
const message = event.reason?.message || "An operation failed unexpectedly";
api.notify(message, { kind: "error" });
});
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", api.init, { once: true });
else api.init();
})();
File diff suppressed because one or more lines are too long
@@ -29,7 +29,6 @@ define_gz_cache!(gz_index_html, status::index_html(), "index.html");
define_gz_cache!(gz_style_css, status::STYLE_CSS, "style.css"); define_gz_cache!(gz_style_css, status::STYLE_CSS, "style.css");
define_gz_cache!(gz_themes_css, status::THEMES_CSS, "themes.css"); define_gz_cache!(gz_themes_css, status::THEMES_CSS, "themes.css");
define_gz_cache!(gz_app_js, status::APP_JS, "app.js"); define_gz_cache!(gz_app_js, status::APP_JS, "app.js");
define_gz_cache!(gz_ui_core_js, status::UI_CORE_JS, "ui-core.js");
define_gz_cache!(gz_map_core_js, status::MAP_CORE_JS, "map-core.js"); define_gz_cache!(gz_map_core_js, status::MAP_CORE_JS, "map-core.js");
define_gz_cache!(gz_screenshot_js, status::SCREENSHOT_JS, "screenshot.js"); define_gz_cache!(gz_screenshot_js, status::SCREENSHOT_JS, "screenshot.js");
define_gz_cache!( define_gz_cache!(
@@ -76,13 +75,6 @@ define_gz_cache!(gz_bandplan_json, status::BANDPLAN_JSON, "bandplan.json");
// Vendored DSEG14 Classic font // Vendored DSEG14 Classic font
// (binary woff2 — served directly, not through gz_cache) // (binary woff2 — served directly, not through gz_cache)
// Vendored opus-decoder 0.7.11
define_gz_cache!(
gz_opus_decoder_js,
status::OPUS_DECODER_JS,
"opus-decoder-0.7.11.min.js"
);
// Vendored Leaflet 1.9.4 // Vendored Leaflet 1.9.4
define_gz_cache!(gz_leaflet_js, status::LEAFLET_JS, "leaflet.js"); define_gz_cache!(gz_leaflet_js, status::LEAFLET_JS, "leaflet.js");
define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css"); define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css");
@@ -181,12 +173,6 @@ pub(crate) async fn app_js(req: HttpRequest) -> impl Responder {
static_asset_response(&req, "application/javascript; charset=utf-8", c) static_asset_response(&req, "application/javascript; charset=utf-8", c)
} }
#[get("/ui-core.js")]
pub(crate) async fn ui_core_js(req: HttpRequest) -> impl Responder {
let c = gz_ui_core_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/map-core.js")] #[get("/map-core.js")]
pub(crate) async fn map_core_js(req: HttpRequest) -> impl Responder { pub(crate) async fn map_core_js(req: HttpRequest) -> impl Responder {
let c = gz_map_core_js(); let c = gz_map_core_js();
@@ -355,12 +341,6 @@ pub(crate) async fn dseg14_classic_woff2() -> impl Responder {
.body(status::DSEG14_CLASSIC_WOFF2) .body(status::DSEG14_CLASSIC_WOFF2)
} }
#[get("/vendor/opus-decoder-0.7.11.min.js")]
pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
let c = gz_opus_decoder_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Vendored Leaflet 1.9.4 // Vendored Leaflet 1.9.4
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -643,7 +643,6 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::style_css) .service(assets::style_css)
.service(assets::themes_css) .service(assets::themes_css)
.service(assets::app_js) .service(assets::app_js)
.service(assets::ui_core_js)
.service(assets::map_core_js) .service(assets::map_core_js)
.service(assets::screenshot_js) .service(assets::screenshot_js)
.service(assets::decode_history_worker_js) .service(assets::decode_history_worker_js)
@@ -669,8 +668,6 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::bandplan_json) .service(assets::bandplan_json)
// Vendored DSEG14 Classic font // Vendored DSEG14 Classic font
.service(assets::dseg14_classic_woff2) .service(assets::dseg14_classic_woff2)
// Vendored opus-decoder 0.7.11
.service(assets::opus_decoder_js)
// Vendored Leaflet 1.9.4 // Vendored Leaflet 1.9.4
.service(assets::leaflet_js) .service(assets::leaflet_js)
.service(assets::leaflet_css) .service(assets::leaflet_css)
@@ -388,9 +388,6 @@ struct RigListItem {
display_name: Option<String>, display_name: Option<String>,
manufacturer: String, manufacturer: String,
model: String, model: String,
supported_modes: Vec<trx_core::RigMode>,
tx: bool,
filter_controls: bool,
initialized: bool, initialized: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
latitude: Option<f64>, latitude: Option<f64>,
@@ -425,9 +422,6 @@ fn map_rig_entry(entry: &RemoteRigEntry) -> RigListItem {
display_name: entry.display_name.clone(), display_name: entry.display_name.clone(),
manufacturer: entry.state.info.manufacturer.clone(), manufacturer: entry.state.info.manufacturer.clone(),
model: entry.state.info.model.clone(), model: entry.state.info.model.clone(),
supported_modes: entry.state.info.capabilities.supported_modes.clone(),
tx: entry.state.info.capabilities.tx,
filter_controls: entry.state.info.capabilities.filter_controls,
initialized: entry.state.initialized, initialized: entry.state.initialized,
latitude: entry.state.server_latitude, latitude: entry.state.server_latitude,
longitude: entry.state.server_longitude, longitude: entry.state.server_longitude,
@@ -12,7 +12,6 @@ const INDEX_HTML: &str = include_str!("../assets/web/index.html");
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css"); pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
pub const THEMES_CSS: &str = include_str!("../assets/web/themes.css"); pub const THEMES_CSS: &str = include_str!("../assets/web/themes.css");
pub const APP_JS: &str = include_str!("../assets/web/app.js"); pub const APP_JS: &str = include_str!("../assets/web/app.js");
pub const UI_CORE_JS: &str = include_str!("../assets/web/ui-core.js");
pub const MAP_CORE_JS: &str = include_str!("../assets/web/map-core.js"); pub const MAP_CORE_JS: &str = include_str!("../assets/web/map-core.js");
pub const SCREENSHOT_JS: &str = include_str!("../assets/web/screenshot.js"); pub const SCREENSHOT_JS: &str = include_str!("../assets/web/screenshot.js");
pub const DECODE_HISTORY_WORKER_JS: &str = include_str!("../assets/web/decode-history-worker.js"); pub const DECODE_HISTORY_WORKER_JS: &str = include_str!("../assets/web/decode-history-worker.js");
@@ -41,9 +40,6 @@ pub const BANDPLAN_JSON: &str = include_str!("../assets/web/bandplan.json");
pub const DSEG14_CLASSIC_WOFF2: &[u8] = pub const DSEG14_CLASSIC_WOFF2: &[u8] =
include_bytes!("../assets/web/vendor/dseg14-classic-latin-400-normal.woff2"); include_bytes!("../assets/web/vendor/dseg14-classic-latin-400-normal.woff2");
// Vendored opus-decoder 0.7.11 browser build (WebAssembly embedded in JS)
pub const OPUS_DECODER_JS: &str = include_str!("../assets/web/vendor/opus-decoder-0.7.11.min.js");
// Vendored Leaflet 1.9.4 // Vendored Leaflet 1.9.4
pub const LEAFLET_JS: &str = include_str!("../assets/web/vendor/leaflet.js"); pub const LEAFLET_JS: &str = include_str!("../assets/web/vendor/leaflet.js");
pub const LEAFLET_CSS: &str = include_str!("../assets/web/vendor/leaflet.css"); pub const LEAFLET_CSS: &str = include_str!("../assets/web/vendor/leaflet.css");
@@ -72,22 +68,3 @@ pub fn index_html() -> &'static str {
.replace("{client_build_date}", CLIENT_BUILD_DATE) .replace("{client_build_date}", CLIENT_BUILD_DATE)
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shared_ui_core_is_embedded_and_loaded_before_app() {
let html = index_html();
let ui_core = html.find("/ui-core.js").expect("ui-core script is loaded");
let app = html.find("/app.js").expect("application script is loaded");
assert!(
ui_core < app,
"UI primitives must load before application code"
);
assert!(UI_CORE_JS.contains("window.trxUi"));
assert!(html.contains("<summary>Scheduler controls</summary>"));
}
}
+513 -11
View File
@@ -6,14 +6,13 @@
#[cfg(feature = "ft2")] #[cfg(feature = "ft2")]
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet; use std::collections::{HashSet, VecDeque};
#[cfg(test)]
use std::collections::VecDeque;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use base64::Engine as _;
use bytes::Bytes; use bytes::Bytes;
use flate2::write::GzEncoder; use flate2::write::GzEncoder;
use flate2::Compression; use flate2::Compression;
@@ -35,9 +34,10 @@ use trx_core::audio::{
AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB,
AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
}; };
#[cfg(test)] use trx_core::decode::{
use trx_core::decode::{AisMessage, AprsPacket, CwEvent}; AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, LrptImage, LrptProgress,
use trx_core::decode::{DecodedMessage, Ft8Message, LrptImage, LrptProgress, WsprMessage}; VdesMessage, WefaxMessage, WsprMessage,
};
use trx_core::rig::state::{RigMode, RigState}; use trx_core::rig::state::{RigMode, RigState};
use trx_core::vchan::SharedVChanManager; use trx_core::vchan::SharedVChanManager;
use trx_cw::CwDecoder; use trx_cw::CwDecoder;
@@ -47,11 +47,21 @@ use trx_wspr::WsprDecoder;
use uuid::Uuid; use uuid::Uuid;
use crate::config::AudioConfig; use crate::config::AudioConfig;
use crate::history_policy::{current_timestamp_ms, lock_or_recover};
#[cfg(test)]
use crate::history_policy::{enforce_capacity, prune_by_age, MAX_HISTORY_ENTRIES};
use trx_decode_log::DecoderLoggers; use trx_decode_log::DecoderLoggers;
const APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const HF_APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const AIS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const VDES_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const CW_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const WSPR_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const LRPT_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const WEFAX_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
/// Maximum entries per decoder history queue. Prevents unbounded memory growth
/// on busy channels (e.g. AIS near a port). Oldest entries are evicted when
/// the limit is reached, independent of the time-based pruning.
const MAX_HISTORY_ENTRIES: usize = 10_000;
/// Silence timeout before auto-finalising an LRPT pass (30 s without new MCUs). /// Silence timeout before auto-finalising an LRPT pass (30 s without new MCUs).
const LRPT_PASS_SILENCE_TIMEOUT: Duration = Duration::from_secs(30); const LRPT_PASS_SILENCE_TIMEOUT: Duration = Duration::from_secs(30);
const FT8_SAMPLE_RATE: u32 = 12_000; const FT8_SAMPLE_RATE: u32 = 12_000;
@@ -65,6 +75,13 @@ const DECODE_AUDIO_GATE_RMS: f32 = 2.5e-4;
const AUDIO_STREAM_ERROR_LOG_INTERVAL: Duration = Duration::from_secs(60); const AUDIO_STREAM_ERROR_LOG_INTERVAL: Duration = Duration::from_secs(60);
const AUDIO_STREAM_RECOVERY_DELAY: Duration = Duration::from_secs(1); const AUDIO_STREAM_RECOVERY_DELAY: Duration = Duration::from_secs(1);
fn current_timestamp_ms() -> i64 {
match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
Ok(dur) => dur.as_millis() as i64,
Err(_) => 0,
}
}
#[cfg(feature = "ft2")] #[cfg(feature = "ft2")]
fn retain_ft2_window(buf: &mut Vec<f32>) { fn retain_ft2_window(buf: &mut Vec<f32>) {
if buf.len() > FT2_ASYNC_BUFFER_SAMPLES { if buf.len() > FT2_ASYNC_BUFFER_SAMPLES {
@@ -343,7 +360,492 @@ fn classify_stream_error(err: &str) -> &'static str {
} }
} }
pub use crate::decoder_history::DecoderHistories; /// Per-rig decoder history store.
///
/// Replaces the previous process-wide `OnceLock` statics so that each rig
/// instance can maintain its own independent history. Pass an
/// `Arc<DecoderHistories>` into every decoder task and into the audio listener.
pub struct DecoderHistories {
pub ais: Mutex<VecDeque<(Instant, AisMessage)>>,
pub vdes: Mutex<VecDeque<(Instant, VdesMessage)>>,
pub aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub hf_aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub cw: Mutex<VecDeque<(Instant, CwEvent)>>,
pub ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub ft4: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub ft2: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
/// Approximate total entry count across all decoders, maintained
/// atomically so `estimated_total_count()` avoids 9 lock acquisitions.
total_count: AtomicUsize,
}
/// Acquire a mutex, recovering from poisoning with a warning log.
fn lock_or_recover<'a, T>(mutex: &'a Mutex<T>, label: &str) -> std::sync::MutexGuard<'a, T> {
mutex.lock().unwrap_or_else(|e| {
tracing::warn!(
"Mutex for {} was poisoned (prior panic); recovering with potentially inconsistent data",
label
);
e.into_inner()
})
}
/// Enforce capacity limit on a history deque by evicting oldest entries.
fn enforce_capacity<T>(deque: &mut VecDeque<T>, max: usize) {
while deque.len() > max {
deque.pop_front();
}
}
/// Drop entries older than `retention` from the front of a time-tagged deque.
/// Uses `checked_sub` so an early `now` (before `retention` elapses since the
/// monotonic clock origin) is treated as "no entries are old enough to prune"
/// rather than panicking.
fn prune_by_age<T>(deque: &mut VecDeque<(Instant, T)>, retention: Duration, now: Instant) {
let Some(cutoff) = now.checked_sub(retention) else {
return;
};
while let Some((ts, _)) = deque.front() {
if *ts < cutoff {
deque.pop_front();
} else {
break;
}
}
}
impl DecoderHistories {
pub fn new() -> Arc<Self> {
Arc::new(Self {
ais: Mutex::new(VecDeque::new()),
vdes: Mutex::new(VecDeque::new()),
aprs: Mutex::new(VecDeque::new()),
hf_aprs: Mutex::new(VecDeque::new()),
cw: Mutex::new(VecDeque::new()),
ft8: Mutex::new(VecDeque::new()),
ft4: Mutex::new(VecDeque::new()),
ft2: Mutex::new(VecDeque::new()),
wspr: Mutex::new(VecDeque::new()),
lrpt: Mutex::new(VecDeque::new()),
wefax: Mutex::new(VecDeque::new()),
total_count: AtomicUsize::new(0),
})
}
/// Adjust the atomic total count after a record/prune/clear operation.
///
/// Uses a CAS loop for decrements to prevent underflow wrapping the
/// counter to `usize::MAX` (which would cause a capacity-overflow panic
/// when pre-allocating the history replay blob).
fn adjust_total_count(&self, old_len: usize, new_len: usize) {
if new_len > old_len {
self.total_count
.fetch_add(new_len - old_len, Ordering::Relaxed);
} else if old_len > new_len {
let delta = old_len - new_len;
let mut current = self.total_count.load(Ordering::Relaxed);
loop {
let next = current.saturating_sub(delta);
match self.total_count.compare_exchange_weak(
current,
next,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => break,
Err(actual) => current = actual,
}
}
}
}
// --- AIS ---
fn prune_ais(history: &mut VecDeque<(Instant, AisMessage)>) {
prune_by_age(history, AIS_HISTORY_RETENTION, Instant::now());
}
pub fn record_ais_message(&self, mut msg: AisMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.ais, "ais_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ais(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ais_history(&self) -> Vec<AisMessage> {
let mut h = lock_or_recover(&self.ais, "ais_history");
let before = h.len();
Self::prune_ais(&mut h);
self.adjust_total_count(before, h.len());
h.iter().map(|(_, msg)| msg.clone()).collect()
}
// --- VDES ---
fn prune_vdes(history: &mut VecDeque<(Instant, VdesMessage)>) {
prune_by_age(history, VDES_HISTORY_RETENTION, Instant::now());
}
pub fn record_vdes_message(&self, mut msg: VdesMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.vdes, "vdes_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_vdes(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_vdes_history(&self) -> Vec<VdesMessage> {
let mut h = lock_or_recover(&self.vdes, "vdes_history");
let before = h.len();
Self::prune_vdes(&mut h);
self.adjust_total_count(before, h.len());
h.iter().map(|(_, msg)| msg.clone()).collect()
}
// --- APRS ---
fn prune_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
prune_by_age(history, APRS_HISTORY_RETENTION, Instant::now());
}
pub fn record_aprs_packet(&self, mut pkt: AprsPacket) {
if !pkt.crc_ok {
return;
}
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
h.push_back((Instant::now(), pkt));
Self::prune_aprs(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_aprs_history(&self) -> Vec<AprsPacket> {
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
Self::prune_aprs(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
.collect()
}
pub fn clear_aprs_history(&self) {
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- HF APRS ---
fn prune_hf_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
prune_by_age(history, HF_APRS_HISTORY_RETENTION, Instant::now());
}
pub fn record_hf_aprs_packet(&self, mut pkt: AprsPacket) {
if !pkt.crc_ok {
return;
}
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
h.push_back((Instant::now(), pkt));
Self::prune_hf_aprs(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_hf_aprs_history(&self) -> Vec<AprsPacket> {
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
Self::prune_hf_aprs(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
.collect()
}
pub fn clear_hf_aprs_history(&self) {
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- CW ---
fn prune_cw(history: &mut VecDeque<(Instant, CwEvent)>) {
prune_by_age(history, CW_HISTORY_RETENTION, Instant::now());
}
pub fn record_cw_event(&self, evt: CwEvent) {
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
h.push_back((Instant::now(), evt));
Self::prune_cw(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_cw_history(&self) -> Vec<CwEvent> {
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
Self::prune_cw(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, evt): &(Instant, CwEvent)| evt.clone())
.collect()
}
pub fn clear_cw_history(&self) {
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- FT8 ---
fn prune_ft8(history: &mut VecDeque<(Instant, Ft8Message)>) {
prune_by_age(history, FT8_HISTORY_RETENTION, Instant::now());
}
pub fn record_ft8_message(&self, msg: Ft8Message) {
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft8(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ft8_history(&self) -> Vec<Ft8Message> {
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
Self::prune_ft8(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
.collect()
}
pub fn clear_ft8_history(&self) {
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- FT4 ---
fn prune_ft4(history: &mut VecDeque<(Instant, Ft8Message)>) {
prune_by_age(history, FT8_HISTORY_RETENTION, Instant::now());
}
pub fn record_ft4_message(&self, msg: Ft8Message) {
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft4(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ft4_history(&self) -> Vec<Ft8Message> {
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
Self::prune_ft4(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
.collect()
}
pub fn clear_ft4_history(&self) {
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- FT2 ---
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
fn prune_ft2(history: &mut VecDeque<(Instant, Ft8Message)>) {
prune_by_age(history, FT8_HISTORY_RETENTION, Instant::now());
}
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
pub fn record_ft2_message(&self, msg: Ft8Message) {
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft2(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
pub fn snapshot_ft2_history(&self) -> Vec<Ft8Message> {
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
Self::prune_ft2(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
.collect()
}
pub fn clear_ft2_history(&self) {
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- WSPR ---
fn prune_wspr(history: &mut VecDeque<(Instant, WsprMessage)>) {
prune_by_age(history, WSPR_HISTORY_RETENTION, Instant::now());
}
pub fn record_wspr_message(&self, msg: WsprMessage) {
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_wspr(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_wspr_history(&self) -> Vec<WsprMessage> {
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
Self::prune_wspr(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, WsprMessage)| msg.clone())
.collect()
}
pub fn clear_wspr_history(&self) {
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- LRPT ---
fn prune_lrpt(history: &mut VecDeque<(Instant, LrptImage)>) {
prune_by_age(history, LRPT_HISTORY_RETENTION, Instant::now());
}
pub fn record_lrpt_image(&self, mut img: LrptImage) {
if img.ts_ms.is_none() {
img.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
h.push_back((Instant::now(), img));
Self::prune_lrpt(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_lrpt_history(&self) -> Vec<LrptImage> {
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
Self::prune_lrpt(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, img): &(Instant, LrptImage)| img.clone())
.collect()
}
pub fn clear_lrpt_history(&self) {
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- WEFAX ---
fn prune_wefax(history: &mut VecDeque<(Instant, WefaxMessage)>) {
prune_by_age(history, WEFAX_HISTORY_RETENTION, Instant::now());
}
pub fn record_wefax_message(&self, mut msg: WefaxMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
// Strip bulk PNG data before storing in memory/persistence.
msg.png_data = None;
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_wefax(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_wefax_history(&self) -> Vec<WefaxMessage> {
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
Self::prune_wefax(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg)| {
let mut m = msg.clone();
// Re-read PNG from disk so remote clients can save a local copy.
if m.png_data.is_none() {
if let Some(ref path) = m.path {
if let Ok(bytes) = std::fs::read(path) {
m.png_data =
Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
}
}
}
m
})
.collect()
}
pub fn clear_wefax_history(&self) {
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
/// Returns a quick (non-pruning) estimate of the total number of history
/// entries across all decoders, used for pre-allocating the replay blob.
///
/// Uses an `AtomicUsize` counter maintained by record/prune/clear methods,
/// avoiding 9 separate mutex acquisitions.
pub fn estimated_total_count(&self) -> usize {
self.total_count.load(Ordering::Relaxed)
}
}
/// Spawn the audio capture thread. /// Spawn the audio capture thread.
/// ///
-489
View File
@@ -1,489 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Per-rig storage and lifecycle operations for decoded-message histories.
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use base64::Engine as _;
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, VdesMessage, WefaxMessage, WsprMessage,
};
use crate::history_policy::{
current_timestamp_ms, enforce_capacity, lock_or_recover, prune_by_age, HISTORY_RETENTION,
MAX_HISTORY_ENTRIES,
};
/// Per-rig decoder history store.
///
/// Replaces the previous process-wide `OnceLock` statics so that each rig
/// instance can maintain its own independent history. Pass an
/// `Arc<DecoderHistories>` into every decoder task and into the audio listener.
pub struct DecoderHistories {
pub ais: Mutex<VecDeque<(Instant, AisMessage)>>,
pub vdes: Mutex<VecDeque<(Instant, VdesMessage)>>,
pub aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub hf_aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub cw: Mutex<VecDeque<(Instant, CwEvent)>>,
pub ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub ft4: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub ft2: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
/// Approximate total entry count across all decoders, maintained
/// atomically so `estimated_total_count()` avoids 11 lock acquisitions.
total_count: AtomicUsize,
}
impl DecoderHistories {
pub fn new() -> Arc<Self> {
Arc::new(Self {
ais: Mutex::new(VecDeque::new()),
vdes: Mutex::new(VecDeque::new()),
aprs: Mutex::new(VecDeque::new()),
hf_aprs: Mutex::new(VecDeque::new()),
cw: Mutex::new(VecDeque::new()),
ft8: Mutex::new(VecDeque::new()),
ft4: Mutex::new(VecDeque::new()),
ft2: Mutex::new(VecDeque::new()),
wspr: Mutex::new(VecDeque::new()),
lrpt: Mutex::new(VecDeque::new()),
wefax: Mutex::new(VecDeque::new()),
total_count: AtomicUsize::new(0),
})
}
/// Adjust the atomic total count after a record/prune/clear operation.
///
/// Uses a CAS loop for decrements to prevent underflow wrapping the
/// counter to `usize::MAX` (which would cause a capacity-overflow panic
/// when pre-allocating the history replay blob).
pub(crate) fn adjust_total_count(&self, old_len: usize, new_len: usize) {
if new_len > old_len {
self.total_count
.fetch_add(new_len - old_len, Ordering::Relaxed);
} else if old_len > new_len {
let delta = old_len - new_len;
let mut current = self.total_count.load(Ordering::Relaxed);
loop {
let next = current.saturating_sub(delta);
match self.total_count.compare_exchange_weak(
current,
next,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => break,
Err(actual) => current = actual,
}
}
}
}
// --- AIS ---
fn prune_ais(history: &mut VecDeque<(Instant, AisMessage)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_ais_message(&self, mut msg: AisMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.ais, "ais_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ais(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ais_history(&self) -> Vec<AisMessage> {
let mut h = lock_or_recover(&self.ais, "ais_history");
let before = h.len();
Self::prune_ais(&mut h);
self.adjust_total_count(before, h.len());
h.iter().map(|(_, msg)| msg.clone()).collect()
}
// --- VDES ---
fn prune_vdes(history: &mut VecDeque<(Instant, VdesMessage)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_vdes_message(&self, mut msg: VdesMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.vdes, "vdes_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_vdes(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_vdes_history(&self) -> Vec<VdesMessage> {
let mut h = lock_or_recover(&self.vdes, "vdes_history");
let before = h.len();
Self::prune_vdes(&mut h);
self.adjust_total_count(before, h.len());
h.iter().map(|(_, msg)| msg.clone()).collect()
}
// --- APRS ---
fn prune_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_aprs_packet(&self, mut pkt: AprsPacket) {
if !pkt.crc_ok {
return;
}
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
h.push_back((Instant::now(), pkt));
Self::prune_aprs(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_aprs_history(&self) -> Vec<AprsPacket> {
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
Self::prune_aprs(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
.collect()
}
pub fn clear_aprs_history(&self) {
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- HF APRS ---
fn prune_hf_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_hf_aprs_packet(&self, mut pkt: AprsPacket) {
if !pkt.crc_ok {
return;
}
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
h.push_back((Instant::now(), pkt));
Self::prune_hf_aprs(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_hf_aprs_history(&self) -> Vec<AprsPacket> {
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
Self::prune_hf_aprs(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
.collect()
}
pub fn clear_hf_aprs_history(&self) {
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- CW ---
fn prune_cw(history: &mut VecDeque<(Instant, CwEvent)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_cw_event(&self, evt: CwEvent) {
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
h.push_back((Instant::now(), evt));
Self::prune_cw(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_cw_history(&self) -> Vec<CwEvent> {
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
Self::prune_cw(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, evt): &(Instant, CwEvent)| evt.clone())
.collect()
}
pub fn clear_cw_history(&self) {
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- FT8 ---
fn prune_ft8(history: &mut VecDeque<(Instant, Ft8Message)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_ft8_message(&self, msg: Ft8Message) {
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft8(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ft8_history(&self) -> Vec<Ft8Message> {
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
Self::prune_ft8(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
.collect()
}
pub fn clear_ft8_history(&self) {
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- FT4 ---
fn prune_ft4(history: &mut VecDeque<(Instant, Ft8Message)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_ft4_message(&self, msg: Ft8Message) {
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft4(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ft4_history(&self) -> Vec<Ft8Message> {
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
Self::prune_ft4(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
.collect()
}
pub fn clear_ft4_history(&self) {
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- FT2 ---
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
fn prune_ft2(history: &mut VecDeque<(Instant, Ft8Message)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
pub fn record_ft2_message(&self, msg: Ft8Message) {
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft2(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
pub fn snapshot_ft2_history(&self) -> Vec<Ft8Message> {
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
Self::prune_ft2(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
.collect()
}
pub fn clear_ft2_history(&self) {
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- WSPR ---
fn prune_wspr(history: &mut VecDeque<(Instant, WsprMessage)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_wspr_message(&self, msg: WsprMessage) {
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_wspr(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_wspr_history(&self) -> Vec<WsprMessage> {
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
Self::prune_wspr(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, WsprMessage)| msg.clone())
.collect()
}
pub fn clear_wspr_history(&self) {
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- LRPT ---
fn prune_lrpt(history: &mut VecDeque<(Instant, LrptImage)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_lrpt_image(&self, mut img: LrptImage) {
if img.ts_ms.is_none() {
img.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
h.push_back((Instant::now(), img));
Self::prune_lrpt(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_lrpt_history(&self) -> Vec<LrptImage> {
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
Self::prune_lrpt(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, img): &(Instant, LrptImage)| img.clone())
.collect()
}
pub fn clear_lrpt_history(&self) {
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- WEFAX ---
fn prune_wefax(history: &mut VecDeque<(Instant, WefaxMessage)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_wefax_message(&self, mut msg: WefaxMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
// Strip bulk PNG data before storing in memory/persistence.
msg.png_data = None;
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_wefax(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_wefax_history(&self) -> Vec<WefaxMessage> {
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
Self::prune_wefax(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg)| {
let mut m = msg.clone();
// Re-read PNG from disk so remote clients can save a local copy.
if m.png_data.is_none() {
if let Some(ref path) = m.path {
if let Ok(bytes) = std::fs::read(path) {
m.png_data =
Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
}
}
}
m
})
.collect()
}
pub fn clear_wefax_history(&self) {
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
/// Returns a quick (non-pruning) estimate of the total number of history
/// entries across all decoders, used for pre-allocating the replay blob.
///
/// Uses an `AtomicUsize` counter maintained by record/prune/clear methods,
/// avoiding 11 separate mutex acquisitions.
pub fn estimated_total_count(&self) -> usize {
self.total_count.load(Ordering::Relaxed)
}
/// Rebuild the aggregate count after bulk restoration bypasses the normal
/// record methods.
pub(crate) fn recalculate_total_count(&self) {
let total = lock_or_recover(&self.ais, "ais_history").len()
+ lock_or_recover(&self.vdes, "vdes_history").len()
+ lock_or_recover(&self.aprs, "aprs_history").len()
+ lock_or_recover(&self.hf_aprs, "hf_aprs_history").len()
+ lock_or_recover(&self.cw, "cw_history").len()
+ lock_or_recover(&self.ft8, "ft8_history").len()
+ lock_or_recover(&self.ft4, "ft4_history").len()
+ lock_or_recover(&self.ft2, "ft2_history").len()
+ lock_or_recover(&self.wspr, "wspr_history").len()
+ lock_or_recover(&self.lrpt, "lrpt_history").len()
+ lock_or_recover(&self.wefax, "wefax_history").len();
self.total_count.store(total, Ordering::Relaxed);
}
}
-56
View File
@@ -1,56 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Shared retention and synchronization policy for decoder histories.
use std::collections::VecDeque;
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
pub(crate) const HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
/// Maximum entries per decoder history queue. Oldest entries are evicted on
/// busy channels independently of time-based pruning.
pub(crate) const MAX_HISTORY_ENTRIES: usize = 10_000;
pub(crate) fn current_timestamp_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or(0)
}
pub(crate) fn lock_or_recover<'a, T>(mutex: &'a Mutex<T>, label: &str) -> MutexGuard<'a, T> {
mutex.lock().unwrap_or_else(|error| {
tracing::warn!(
"Mutex for {} was poisoned (prior panic); recovering with potentially inconsistent data",
label
);
error.into_inner()
})
}
pub(crate) fn enforce_capacity<T>(deque: &mut VecDeque<T>, max: usize) {
while deque.len() > max {
deque.pop_front();
}
}
/// Drop entries older than `retention` from an ordered history queue.
pub(crate) fn prune_by_age<T>(
deque: &mut VecDeque<(Instant, T)>,
retention: Duration,
now: Instant,
) {
let Some(cutoff) = now.checked_sub(retention) else {
return;
};
while let Some((timestamp, _)) = deque.front() {
if *timestamp < cutoff {
deque.pop_front();
} else {
break;
}
}
}
+5 -139
View File
@@ -19,7 +19,7 @@ use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde::{de::DeserializeOwned, Deserialize, Serialize};
use trx_core::decode::{ use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, VdesMessage, WefaxMessage, WsprMessage, AisMessage, AprsPacket, CwEvent, Ft8Message, VdesMessage, WefaxMessage, WsprMessage,
}; };
use crate::audio::DecoderHistories; use crate::audio::DecoderHistories;
@@ -118,11 +118,6 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
h.push_back(e); h.push_back(e);
} }
} }
if let Ok(mut h) = histories.hf_aprs.lock() {
for e in load_key::<AprsPacket>(db, &k("hf_aprs")) {
h.push_back(e);
}
}
if let Ok(mut h) = histories.cw.lock() { if let Ok(mut h) = histories.cw.lock() {
for e in load_key::<CwEvent>(db, &k("cw")) { for e in load_key::<CwEvent>(db, &k("cw")) {
h.push_back(e); h.push_back(e);
@@ -133,32 +128,16 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
h.push_back(e); h.push_back(e);
} }
} }
if let Ok(mut h) = histories.ft4.lock() {
for e in load_key::<Ft8Message>(db, &k("ft4")) {
h.push_back(e);
}
}
if let Ok(mut h) = histories.ft2.lock() {
for e in load_key::<Ft8Message>(db, &k("ft2")) {
h.push_back(e);
}
}
if let Ok(mut h) = histories.wspr.lock() { if let Ok(mut h) = histories.wspr.lock() {
for e in load_key::<WsprMessage>(db, &k("wspr")) { for e in load_key::<WsprMessage>(db, &k("wspr")) {
h.push_back(e); h.push_back(e);
} }
} }
if let Ok(mut h) = histories.lrpt.lock() {
for e in load_key::<LrptImage>(db, &k("lrpt")) {
h.push_back(e);
}
}
if let Ok(mut h) = histories.wefax.lock() { if let Ok(mut h) = histories.wefax.lock() {
for e in load_key::<WefaxMessage>(db, &k("wefax")) { for e in load_key::<WefaxMessage>(db, &k("wefax")) {
h.push_back(e); h.push_back(e);
} }
} }
histories.recalculate_total_count();
} }
/// Flush `histories` to the database under `rig_id`-prefixed keys and sync. /// Flush `histories` to the database under `rig_id`-prefixed keys and sync.
@@ -183,11 +162,6 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
drop(h); drop(h);
save_key(db, &k("aprs"), &snapshot); save_key(db, &k("aprs"), &snapshot);
} }
if let Ok(h) = histories.hf_aprs.lock() {
let snapshot = h.clone();
drop(h);
save_key(db, &k("hf_aprs"), &snapshot);
}
if let Ok(h) = histories.cw.lock() { if let Ok(h) = histories.cw.lock() {
let snapshot = h.clone(); let snapshot = h.clone();
drop(h); drop(h);
@@ -198,26 +172,11 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
drop(h); drop(h);
save_key(db, &k("ft8"), &snapshot); save_key(db, &k("ft8"), &snapshot);
} }
if let Ok(h) = histories.ft4.lock() {
let snapshot = h.clone();
drop(h);
save_key(db, &k("ft4"), &snapshot);
}
if let Ok(h) = histories.ft2.lock() {
let snapshot = h.clone();
drop(h);
save_key(db, &k("ft2"), &snapshot);
}
if let Ok(h) = histories.wspr.lock() { if let Ok(h) = histories.wspr.lock() {
let snapshot = h.clone(); let snapshot = h.clone();
drop(h); drop(h);
save_key(db, &k("wspr"), &snapshot); save_key(db, &k("wspr"), &snapshot);
} }
if let Ok(h) = histories.lrpt.lock() {
let snapshot = h.clone();
drop(h);
save_key(db, &k("lrpt"), &snapshot);
}
if let Ok(h) = histories.wefax.lock() { if let Ok(h) = histories.wefax.lock() {
let snapshot = h.clone(); let snapshot = h.clone();
drop(h); drop(h);
@@ -226,38 +185,20 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
let _ = db.dump(); let _ = db.dump();
} }
fn flush_all_rigs(db: &Mutex<PickleDb>, rig_histories: &[(String, Arc<DecoderHistories>)]) {
let Ok(mut guard) = db.lock() else {
tracing::warn!("history database mutex poisoned; skipping periodic flush");
return;
};
for (rig_id, histories) in rig_histories {
flush_all(&mut guard, rig_id, histories);
}
}
/// Spawn a Tokio task that flushes all rigs' histories to disk every 60 seconds. /// Spawn a Tokio task that flushes all rigs' histories to disk every 60 seconds.
///
/// Snapshot cloning, JSON serialization, and disk I/O run on Tokio's blocking
/// pool so a large history database cannot stall an async runtime worker.
pub fn spawn_flush_task( pub fn spawn_flush_task(
db: Arc<Mutex<PickleDb>>, db: Arc<Mutex<PickleDb>>,
rig_histories: Vec<(String, Arc<DecoderHistories>)>, rig_histories: Vec<(String, Arc<DecoderHistories>)>,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
let rig_histories = Arc::new(rig_histories);
let mut interval = tokio::time::interval(Duration::from_secs(60)); let mut interval = tokio::time::interval(Duration::from_secs(60));
interval.tick().await; // consume the immediate first tick interval.tick().await; // consume the immediate first tick
loop { loop {
interval.tick().await; interval.tick().await;
let db = Arc::clone(&db); if let Ok(mut guard) = db.lock() {
let rig_histories = Arc::clone(&rig_histories); for (rig_id, histories) in &rig_histories {
if let Err(err) = tokio::task::spawn_blocking(move || { flush_all(&mut guard, rig_id, histories);
flush_all_rigs(&db, rig_histories.as_slice()); }
})
.await
{
tracing::warn!(error = %err, "history flush worker failed");
} }
} }
}); });
@@ -361,79 +302,4 @@ mod tests {
let _ = std::fs::remove_file(&db_file); let _ = std::fs::remove_file(&db_file);
let _ = std::fs::remove_dir(&dir); let _ = std::fs::remove_dir(&dir);
} }
#[test]
fn flush_and_load_all_restores_previously_omitted_histories() {
let db_file = std::env::temp_dir().join(format!(
"trx_history_all_{}_{}.db",
std::process::id(),
now_unix_ms()
));
let mut db = PickleDb::new(
&db_file,
PickleDbDumpPolicy::DumpUponRequest,
SerializationMethod::Json,
);
let source = DecoderHistories::new();
let now = Instant::now();
source.hf_aprs.lock().unwrap().push_back((
now,
AprsPacket {
rig_id: Some("rig-a".into()),
ts_ms: Some(now_unix_ms()),
src_call: "TEST".into(),
dest_call: "APRS".into(),
path: String::new(),
info: "history".into(),
info_bytes: Vec::new(),
packet_type: "position".into(),
crc_ok: true,
lat: None,
lon: None,
symbol_table: None,
symbol_code: None,
},
));
for (queue, mode) in [(&source.ft4, "FT4"), (&source.ft2, "FT2")] {
queue.lock().unwrap().push_back((
now,
Ft8Message {
rig_id: Some("rig-a".into()),
ts_ms: now_unix_ms(),
snr_db: -10.0,
dt_s: 0.1,
freq_hz: 1_000.0,
message: mode.into(),
},
));
}
source.lrpt.lock().unwrap().push_back((
now,
LrptImage {
rig_id: Some("rig-a".into()),
pass_start_ms: now_unix_ms(),
pass_end_ms: now_unix_ms(),
mcu_count: 1,
path: "/tmp/lrpt.png".into(),
ts_ms: Some(now_unix_ms()),
satellite: None,
channels: None,
geo_bounds: None,
ground_track: None,
},
));
flush_all(&mut db, "rig-a", &source);
let restored = DecoderHistories::new();
load_all(&db, "rig-a", &restored);
assert_eq!(restored.hf_aprs.lock().unwrap().len(), 1);
assert_eq!(restored.ft4.lock().unwrap().len(), 1);
assert_eq!(restored.ft2.lock().unwrap().len(), 1);
assert_eq!(restored.lrpt.lock().unwrap().len(), 1);
assert_eq!(restored.estimated_total_count(), 4);
let _ = std::fs::remove_file(db_file);
}
} }
-2
View File
@@ -4,9 +4,7 @@
mod audio; mod audio;
mod config; mod config;
mod decoder_history;
mod error; mod error;
mod history_policy;
mod history_store; mod history_store;
mod listener; mod listener;
mod rig_handle; mod rig_handle;
@@ -251,9 +251,9 @@ fn mul_freq_domain(buf: &mut [FftComplex<f32>], h_freq: &[FftComplex<f32>], scal
unsafe { unsafe {
mul_freq_domain_neon(buf, h_freq, scale); mul_freq_domain_neon(buf, h_freq, scale);
} }
return;
} }
#[cfg(not(target_arch = "aarch64"))]
mul_freq_domain_scalar(buf, h_freq, scale); mul_freq_domain_scalar(buf, h_freq, scale);
} }