Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
241ea6c00e |
@@ -1,47 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
# CI for the self-hosted, host-executor Podman runners (see container/).
|
|
||||||
# The runner image bakes in the Rust toolchain and all build dependencies,
|
|
||||||
# so jobs go straight to cargo — no apt/rustup setup steps (which also
|
|
||||||
# collided on the dpkg lock when jobs ran concurrently in the same runner).
|
|
||||||
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: rustfmt
|
|
||||||
run: cargo fmt --all -- --check
|
|
||||||
- name: clippy
|
|
||||||
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Build
|
|
||||||
run: cargo build --workspace --all-targets --locked
|
|
||||||
- name: Test
|
|
||||||
run: cargo test --workspace --locked
|
|
||||||
|
|
||||||
reuse:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: REUSE compliance
|
|
||||||
# `reuse` CLI instead of fsfe/reuse-action: the latter is a Docker
|
|
||||||
# action, which the host-executor runners cannot run. `reuse` is baked
|
|
||||||
# into the runner image (see container/Containerfile).
|
|
||||||
run: reuse lint
|
|
||||||
+1
-21
@@ -18,7 +18,7 @@ When contributing to the project, please follow these guidelines:
|
|||||||
- Use a maximum of 80 characters per line.
|
- Use a maximum of 80 characters per line.
|
||||||
- Use a blank line between the commit message and the body.
|
- Use a blank line between the commit message and the body.
|
||||||
- Sign your commits with `git commit -s`.
|
- Sign your commits with `git commit -s`.
|
||||||
- Disclose AI/LLM assistance with an `Assisted-By:` trailer (see below).
|
- Explicitly mark LLM usage in commit messages with 'Co-authored-by:'.
|
||||||
|
|
||||||
Use the format below for commit titles:
|
Use the format below for commit titles:
|
||||||
[<type>](<crate>): <description>
|
[<type>](<crate>): <description>
|
||||||
@@ -39,23 +39,3 @@ Allowed types:
|
|||||||
- chore: build or maintenance changes
|
- chore: build or maintenance changes
|
||||||
|
|
||||||
Write isolated commits for each crate.
|
Write isolated commits for each crate.
|
||||||
|
|
||||||
## Attribution trailers
|
|
||||||
|
|
||||||
This project follows the Linux kernel convention for crediting work.
|
|
||||||
|
|
||||||
The `Co-Authored-By:` and `Co-Developed-By:` trailers name **people** who
|
|
||||||
authored the change. They are reserved for humans, and every person named
|
|
||||||
this way must also add their own `Signed-off-by:` line. Never use these
|
|
||||||
trailers for tools, assistants, or bots.
|
|
||||||
|
|
||||||
When a commit was produced with help from an AI assistant or LLM,
|
|
||||||
disclose it with an `Assisted-By:` trailer naming the tool (and model,
|
|
||||||
where relevant). The human committer remains the author of record and
|
|
||||||
takes responsibility for the change through `Signed-off-by:`.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
Assisted-By: Claude Code (claude-opus-4)
|
|
||||||
Co-Authored-By: Jane Developer <jane@example.com>
|
|
||||||
Signed-off-by: Your Name <you@example.com>
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ path = [
|
|||||||
"README.md",
|
"README.md",
|
||||||
"trx-rs.toml.example",
|
"trx-rs.toml.example",
|
||||||
"docs/**",
|
"docs/**",
|
||||||
"aidocs/**",
|
|
||||||
"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 +41,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"
|
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
# Work In Progress — Project Improvement Areas
|
|
||||||
|
|
||||||
Living tracker for engineering-infrastructure and hardening work identified
|
|
||||||
during a July 2026 repo scan. The architecture-level backlog
|
|
||||||
(`docs/Improvement-Areas.md`, P0–P3) is closed; these items focus on the
|
|
||||||
tooling, robustness, and product concerns *around* the code.
|
|
||||||
|
|
||||||
Status legend: **Done** · **In progress** · **Not started**
|
|
||||||
|
|
||||||
| # | Tier | Area | Status |
|
|
||||||
|----|------|------|--------|
|
|
||||||
| 1 | 1 — Infrastructure | Gitea Actions CI (fmt, clippy, test, REUSE) | In progress |
|
|
||||||
| 2 | 1 — Infrastructure | Supply-chain & lint governance (cargo-deny/audit, MSRV, `[workspace.lints]`) | Not started |
|
|
||||||
| 3 | 1 — Infrastructure | Release & deployment (container image, systemd units, binary releases) | Not started |
|
|
||||||
| 4 | 2 — Robustness | Panic-resilience audit (harden ~495 unwrap/expect/panic sites) | Not started |
|
|
||||||
| 5 | 2 — Robustness | `unsafe` SIMD safety scaffolding (`# Safety` docs, scalar↔SIMD equivalence tests) | Not started |
|
|
||||||
| 6 | 2 — Robustness | DSP performance benchmarks (criterion, guard optimization gains) | Not started |
|
|
||||||
| 7 | 2 — Robustness | Test-coverage measurement & gap-filling (llvm-cov; CAT backends, server tasks) | Not started |
|
|
||||||
| 8 | 3 — Product | Frontend modularization & tooling (split `app.js` into ES modules, ESLint, JS tests) | Not started |
|
|
||||||
| 9 | 3 — Product | Runtime observability (`/health`, Prometheus metrics) | Not started |
|
|
||||||
| 10 | 3 — Product | Documentation freshness & consolidation (reconcile `docs/` with code) | Not started |
|
|
||||||
|
|
||||||
## Tier 1 — Infrastructure gaps
|
|
||||||
|
|
||||||
### 1. Gitea Actions CI
|
|
||||||
No `.gitea/` / `.github/` / Woodpecker workflows exist, despite 768 tests.
|
|
||||||
Add a workflow running `cargo fmt --check`, `cargo clippy -D warnings`,
|
|
||||||
`cargo build`/`cargo test`, and REUSE lint on push + pull_request.
|
|
||||||
System deps: `pkg-config cmake libopus-dev libasound2-dev libsoapysdr-dev`
|
|
||||||
(the `soapysdr` backend is a default feature).
|
|
||||||
|
|
||||||
Notes for the runner: assumes an `act_runner` registered with an
|
|
||||||
`ubuntu-latest` label and GitHub-action proxying enabled (used by
|
|
||||||
`actions/checkout`, `actions/cache`, `fsfe/reuse-action`). `clippy` runs
|
|
||||||
with `-D warnings`; core crates are already clean, but if the first full
|
|
||||||
`--all-features` run surfaces warnings in a less-travelled crate, fix them
|
|
||||||
(preferred) or temporarily soften that step.
|
|
||||||
|
|
||||||
### 2. Supply-chain & lint governance
|
|
||||||
No `deny.toml`, `cargo audit`, `rustfmt.toml`/`clippy.toml`, declared MSRV,
|
|
||||||
or `[workspace.lints]`. Add dependency auditing to CI, pin an MSRV
|
|
||||||
(`rust-version`), and centralize lint policy in the workspace manifest.
|
|
||||||
|
|
||||||
### 3. Release & deployment
|
|
||||||
No `Dockerfile`, systemd units, packaging, or release automation (only
|
|
||||||
`script/dummy-server.sh`). Add a container image, example systemd units for
|
|
||||||
`trx-server`/`trx-client`, and a tag-triggered static-binary release job.
|
|
||||||
|
|
||||||
## Tier 2 — Robustness & correctness
|
|
||||||
|
|
||||||
### 4. Panic-resilience audit
|
|
||||||
495 `unwrap()`/`expect()`/`panic!` sites, 11 in the hottest server files
|
|
||||||
(`audio.rs`, `rig_task.rs`). A panic there can drop a rig task or the
|
|
||||||
process. Convert hot-path panics to error propagation / graceful
|
|
||||||
degradation; reserve `expect` for documented invariants.
|
|
||||||
|
|
||||||
### 5. `unsafe` SIMD safety scaffolding
|
|
||||||
13 `unsafe` blocks (AVX2 DSP). Add `# Safety` docs stating invariants,
|
|
||||||
confirm runtime feature detection is tested, and add property tests
|
|
||||||
asserting SIMD output matches the scalar fallback across random inputs.
|
|
||||||
|
|
||||||
### 6. DSP performance benchmarks
|
|
||||||
`docs/Optimization-Guidelines.md` documents NCO/polyphase/AVX2 gains, but no
|
|
||||||
`criterion` benches guard them. Add benches for the demod/resample/FFT hot
|
|
||||||
paths so regressions surface as numbers.
|
|
||||||
|
|
||||||
### 7. Test-coverage measurement & gap-filling
|
|
||||||
67 of 146 Rust files have no test module. Protocol is well covered; backends
|
|
||||||
(CAT BCD/ASCII encoding), `listener.rs`, and `config.rs` look thin. Wire up
|
|
||||||
`cargo-llvm-cov` and target the CAT backends and server tasks first.
|
|
||||||
|
|
||||||
## Tier 3 — Product & maintainability
|
|
||||||
|
|
||||||
### 8. Frontend modularization & tooling
|
|
||||||
`app.js` is 8,760 lines and `map-core.js` 3,515, with no modules, linter, or
|
|
||||||
tests. Keeping vanilla HTML+JS (no framework), split into native ES modules
|
|
||||||
by concern, add ESLint + Prettier, and add `node:test` unit tests for pure
|
|
||||||
logic (frequency formatting, unit math, decode parsing).
|
|
||||||
|
|
||||||
### 9. Runtime observability
|
|
||||||
`tracing` is set up, but there is no `/health` endpoint or metrics
|
|
||||||
instrumentation. Add health/readiness endpoints and Prometheus-format
|
|
||||||
metrics (decode rates, reconnects, audio underruns, per-rig state).
|
|
||||||
|
|
||||||
### 10. Documentation freshness & consolidation
|
|
||||||
`docs/` (13 files) is mostly dated 2026-03-29 while code moved into July, and
|
|
||||||
mixes planning artifacts with reference docs. Reconcile against current code,
|
|
||||||
separate "plans" from "reference," and fold still-true content into the
|
|
||||||
canonical docs.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
# Gitea Actions runner image for trx-rs CI (host-executor / "Pattern B").
|
|
||||||
#
|
|
||||||
# All build dependencies, the Rust toolchain, Node.js (for JS actions such as
|
|
||||||
# actions/checkout and actions/cache) and the `reuse` tool are baked in, so CI
|
|
||||||
# runs skip the per-run apt/rustup install cost. `sudo` is present so the
|
|
||||||
# existing workflow's `sudo apt-get ...` / rustup steps remain valid — they
|
|
||||||
# just become fast no-ops because everything is already installed.
|
|
||||||
FROM docker.io/library/debian:bookworm-slim
|
|
||||||
|
|
||||||
ARG ACT_RUNNER_VERSION=0.2.11
|
|
||||||
ARG NODE_MAJOR=20
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
|
||||||
RUSTUP_HOME=/opt/rustup \
|
|
||||||
CARGO_HOME=/opt/cargo \
|
|
||||||
PATH=/opt/cargo/bin:/usr/local/bin:/usr/bin:/bin
|
|
||||||
|
|
||||||
# Base tooling + trx-rs build dependencies (mirrors .gitea/workflows/ci.yml).
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates curl xz-utils git sudo pipx \
|
|
||||||
build-essential pkg-config cmake clang libclang-dev \
|
|
||||||
libopus-dev libasound2-dev libsoapysdr-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Node.js (JS-based actions need node in PATH under the host executor).
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# REUSE >= 3 (Debian's packaged reuse is too old for REUSE.toml).
|
|
||||||
# The [charset-normalizer] extra provides an encoding-detection backend;
|
|
||||||
# without it (and without libmagic) reuse fails to import at runtime.
|
|
||||||
RUN PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install 'reuse[charset-normalizer]'
|
|
||||||
|
|
||||||
# Rust stable with rustfmt + clippy, installed system-wide.
|
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
|
||||||
| sh -s -- -y --no-modify-path --profile minimal \
|
|
||||||
--component rustfmt --component clippy \
|
|
||||||
&& chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME"
|
|
||||||
|
|
||||||
# act_runner binary.
|
|
||||||
RUN arch="$(dpkg --print-architecture)"; \
|
|
||||||
case "$arch" in amd64) rarch=amd64;; arm64) rarch=arm64;; *) echo "unsupported arch $arch" >&2; exit 1;; esac; \
|
|
||||||
curl -fsSL -o /usr/local/bin/act_runner \
|
|
||||||
"https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-${rarch}" \
|
|
||||||
&& chmod +x /usr/local/bin/act_runner
|
|
||||||
|
|
||||||
# Default config template (seeded into the /data volume on first boot).
|
|
||||||
COPY config.yaml /etc/act_runner/config.yaml
|
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
|
||||||
|
|
||||||
# /data holds the .runner registration, cache and workflow workspaces.
|
|
||||||
VOLUME /data
|
|
||||||
WORKDIR /data
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
<!--
|
|
||||||
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
-->
|
|
||||||
|
|
||||||
# Podman-based Gitea Actions runners
|
|
||||||
|
|
||||||
Run two independent Gitea Actions runners on one host as rootless Podman
|
|
||||||
containers managed by systemd (Quadlet) — one per project — instead of two
|
|
||||||
VMs. Uses the **host executor**: workflow steps run directly inside a
|
|
||||||
purpose-built runner image that already has the Rust toolchain and all build
|
|
||||||
dependencies baked in, so CI runs skip the per-run install cost and no
|
|
||||||
Docker/Podman socket is needed.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `Containerfile` | Runner image: Debian + build deps + clang + Rust + Node + `reuse` + `act_runner`. |
|
|
||||||
| `entrypoint.sh` | Registers on first boot (if needed), then runs the daemon. |
|
|
||||||
| `config.yaml` | act_runner config template (seeded into each runner's volume). |
|
|
||||||
| `trx-rs-runner.container` | Quadlet unit for the trx-rs runner. |
|
|
||||||
| `project2-runner.container` | Quadlet unit for the second project's runner. |
|
|
||||||
|
|
||||||
## Prerequisites (once per host)
|
|
||||||
|
|
||||||
Rootless Podman with cgroups v2 (default on modern distros). As the unprivileged
|
|
||||||
user that will own the runners:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Survive logout / start on boot without an interactive session.
|
|
||||||
loginctl enable-linger "$USER"
|
|
||||||
```
|
|
||||||
|
|
||||||
No `podman.socket` is required for the host executor.
|
|
||||||
|
|
||||||
## 1. Build the image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd container
|
|
||||||
podman build -t trx-rs-ci:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Get a registration token
|
|
||||||
|
|
||||||
For **each** repo: *Settings → Actions → Runners → Create new Runner* and copy
|
|
||||||
the token. (Org- or instance-level tokens work too if you prefer wider scope.)
|
|
||||||
|
|
||||||
## 3. Install and start the runners
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/.config/containers/systemd
|
|
||||||
cp trx-rs-runner.container project2-runner.container ~/.config/containers/systemd/
|
|
||||||
|
|
||||||
# Paste each repo's token for the FIRST boot only:
|
|
||||||
# Environment=GITEA_RUNNER_REGISTRATION_TOKEN=xxxx…
|
|
||||||
$EDITOR ~/.config/containers/systemd/trx-rs-runner.container
|
|
||||||
$EDITOR ~/.config/containers/systemd/project2-runner.container
|
|
||||||
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
systemctl --user start trx-rs-runner
|
|
||||||
systemctl --user start project2-runner
|
|
||||||
|
|
||||||
systemctl --user status trx-rs-runner
|
|
||||||
podman logs -f gitea-runner-trx-rs
|
|
||||||
```
|
|
||||||
|
|
||||||
Once each runner shows **online** in the repo's runner list, blank out the
|
|
||||||
`GITEA_RUNNER_REGISTRATION_TOKEN` line again (the registration is persisted in
|
|
||||||
the `…-data` volume) and `systemctl --user daemon-reload`.
|
|
||||||
|
|
||||||
## 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; 1–2 is sensible when two runners share a host.
|
|
||||||
- **`PodmanArgs=--cpus/--memory`** (in each `.container`) — hard resource caps
|
|
||||||
so one project cannot starve the other.
|
|
||||||
- **SELinux** — the `:Z` volume flag is already set; keep it if SELinux is
|
|
||||||
enforcing.
|
|
||||||
|
|
||||||
## 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"
|
|
||||||
```
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# act_runner configuration template. Seeded into /data/config.yaml on first
|
|
||||||
# boot; edit the copy inside the volume to change settings per runner.
|
|
||||||
|
|
||||||
log:
|
|
||||||
level: info
|
|
||||||
|
|
||||||
runner:
|
|
||||||
# Registration state. Relative to the daemon's working directory (/data).
|
|
||||||
file: .runner
|
|
||||||
# Concurrent jobs this runner will pick up. Rust builds are heavy — keep this
|
|
||||||
# modest, especially if two runners share one host. The trx-rs workflow has
|
|
||||||
# three parallel jobs (lint, test, reuse); capacity 2 lets two overlap.
|
|
||||||
capacity: 2
|
|
||||||
timeout: 3h
|
|
||||||
# Map the workflow's `runs-on: ubuntu-latest` to the HOST executor, i.e. run
|
|
||||||
# steps directly inside THIS container (which already has all the toolchain).
|
|
||||||
# No Docker/Podman socket is required in this mode.
|
|
||||||
labels:
|
|
||||||
- "ubuntu-latest:host"
|
|
||||||
|
|
||||||
cache:
|
|
||||||
# Built-in actions cache server (used by actions/cache). Stored in the volume.
|
|
||||||
enabled: true
|
|
||||||
dir: "/data/cache"
|
|
||||||
|
|
||||||
host:
|
|
||||||
# Where per-job workspaces are created.
|
|
||||||
workdir_parent: /data/workflows
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# Registers the runner on first boot (if no .runner state exists in /data),
|
|
||||||
# then runs the act_runner daemon. Idempotent: on subsequent boots it reuses
|
|
||||||
# the stored registration and ignores the token.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
CONFIG_FILE="${CONFIG_FILE:-/data/config.yaml}"
|
|
||||||
|
|
||||||
cd /data
|
|
||||||
|
|
||||||
# Seed the config from the image's template on first boot so it lives in the
|
|
||||||
# persistent volume and can be edited there.
|
|
||||||
if [ ! -f "$CONFIG_FILE" ]; then
|
|
||||||
cp /etc/act_runner/config.yaml "$CONFIG_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# runner.file in config.yaml is ".runner" (relative to this CWD => /data/.runner).
|
|
||||||
if [ ! -f /data/.runner ]; then
|
|
||||||
if [ -z "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" ]; then
|
|
||||||
echo "ERROR: no /data/.runner registration and GITEA_RUNNER_REGISTRATION_TOKEN is empty." >&2
|
|
||||||
echo " Grab a token from the repo's Settings -> Actions -> Runners and set it" >&2
|
|
||||||
echo " in the Quadlet unit for the first boot only." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Registering runner '${GITEA_RUNNER_NAME:-podman}' with ${GITEA_INSTANCE_URL} ..."
|
|
||||||
act_runner register --no-interactive \
|
|
||||||
--config "$CONFIG_FILE" \
|
|
||||||
--instance "${GITEA_INSTANCE_URL:?set GITEA_INSTANCE_URL}" \
|
|
||||||
--token "$GITEA_RUNNER_REGISTRATION_TOKEN" \
|
|
||||||
--name "${GITEA_RUNNER_NAME:-podman}" \
|
|
||||||
--labels "${GITEA_RUNNER_LABELS:-ubuntu-latest:host}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec act_runner daemon --config "$CONFIG_FILE"
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# Rootless Podman Quadlet for the SECOND project's Gitea Actions runner,
|
|
||||||
# co-located on the same host as the trx-rs runner.
|
|
||||||
#
|
|
||||||
# It has its own name, its own data volume and its own registration token, so
|
|
||||||
# the two runners are fully independent. They share the `ubuntu-latest` label,
|
|
||||||
# but registration SCOPE (which repo each token came from) keeps their jobs
|
|
||||||
# separate — neither will pick up the other's work.
|
|
||||||
#
|
|
||||||
# If project 2 needs different build dependencies, build it its own image from
|
|
||||||
# an adjusted Containerfile and point Image= at that instead of reusing the
|
|
||||||
# trx-rs image below.
|
|
||||||
|
|
||||||
[Unit]
|
|
||||||
Description=Gitea Actions runner — project 2
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
Image=localhost/gitea-act-runner:latest
|
|
||||||
ContainerName=gitea-runner-project2
|
|
||||||
Volume=gitea-runner-project2-data:/data:Z
|
|
||||||
|
|
||||||
Environment=CONFIG_FILE=/data/config.yaml
|
|
||||||
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
|
|
||||||
Environment=GITEA_RUNNER_NAME=project2-podman
|
|
||||||
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
|
|
||||||
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
|
|
||||||
|
|
||||||
PodmanArgs=--cpus=4.0 --memory=6g
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
TimeoutStartSec=0
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@@ -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
|
|
||||||
@@ -243,7 +243,7 @@ fn parse_frame(frame: RawFrame, channel: &str) -> Option<AisMessage> {
|
|||||||
|
|
||||||
let message_type = get_uint(&bits, 0, 6)? as u8;
|
let message_type = get_uint(&bits, 0, 6)? as u8;
|
||||||
let repeat = get_uint(&bits, 6, 2)? as u8;
|
let repeat = get_uint(&bits, 6, 2)? as u8;
|
||||||
let mmsi = get_uint(&bits, 8, 30)?;
|
let mmsi = get_uint(&bits, 8, 30)? as u32;
|
||||||
|
|
||||||
let mut msg = AisMessage {
|
let mut msg = AisMessage {
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
|
|||||||
@@ -638,7 +638,7 @@ mod tests {
|
|||||||
for (i, &ch) in b"N0CALL".iter().enumerate() {
|
for (i, &ch) in b"N0CALL".iter().enumerate() {
|
||||||
addr[i] = ch << 1;
|
addr[i] = ch << 1;
|
||||||
}
|
}
|
||||||
addr[6] = 1; // SSID=0, last=true
|
addr[6] = (0 << 1) | 1; // SSID=0, last=true
|
||||||
|
|
||||||
let decoded = decode_ax25_address(&addr, 0);
|
let decoded = decode_ax25_address(&addr, 0);
|
||||||
assert_eq!(decoded.call, "N0CALL");
|
assert_eq!(decoded.call, "N0CALL");
|
||||||
@@ -652,7 +652,7 @@ mod tests {
|
|||||||
for (i, &ch) in b"SP2SJG".iter().enumerate() {
|
for (i, &ch) in b"SP2SJG".iter().enumerate() {
|
||||||
addr[i] = ch << 1;
|
addr[i] = ch << 1;
|
||||||
}
|
}
|
||||||
addr[6] = 5 << 1; // SSID=5, last=false
|
addr[6] = (5 << 1) | 0; // SSID=5, last=false
|
||||||
|
|
||||||
let decoded = decode_ax25_address(&addr, 0);
|
let decoded = decode_ax25_address(&addr, 0);
|
||||||
assert_eq!(decoded.call, "SP2SJG");
|
assert_eq!(decoded.call, "SP2SJG");
|
||||||
@@ -667,7 +667,7 @@ mod tests {
|
|||||||
for (i, &ch) in b"W1AW ".iter().enumerate() {
|
for (i, &ch) in b"W1AW ".iter().enumerate() {
|
||||||
addr[i] = ch << 1;
|
addr[i] = ch << 1;
|
||||||
}
|
}
|
||||||
addr[6] = 1;
|
addr[6] = (0 << 1) | 1;
|
||||||
|
|
||||||
let decoded = decode_ax25_address(&addr, 0);
|
let decoded = decode_ax25_address(&addr, 0);
|
||||||
assert_eq!(decoded.call, "W1AW");
|
assert_eq!(decoded.call, "W1AW");
|
||||||
@@ -691,7 +691,7 @@ mod tests {
|
|||||||
for &ch in src_bytes.as_bytes().iter().take(6) {
|
for &ch in src_bytes.as_bytes().iter().take(6) {
|
||||||
frame.push(ch << 1);
|
frame.push(ch << 1);
|
||||||
}
|
}
|
||||||
frame.push(1); // SSID=0, last=true
|
frame.push((0 << 1) | 1); // SSID=0, last=true
|
||||||
// Control + PID
|
// Control + PID
|
||||||
frame.push(0x03); // UI frame
|
frame.push(0x03); // UI frame
|
||||||
frame.push(0xF0); // No layer-3 protocol
|
frame.push(0xF0); // No layer-3 protocol
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ impl CallsignHashTable {
|
|||||||
let mut idx = start_idx;
|
let mut idx = start_idx;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let entry = self.entries[idx].as_ref()?;
|
match &self.entries[idx] {
|
||||||
|
Some(entry) => {
|
||||||
let stored = (entry.hash & HASH22_MASK) >> shift;
|
let stored = (entry.hash & HASH22_MASK) >> shift;
|
||||||
if stored == target {
|
if stored == target {
|
||||||
return Some(entry.callsign.clone());
|
return Some(entry.callsign.clone());
|
||||||
@@ -170,6 +171,9 @@ impl CallsignHashTable {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
None => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Age all entries and remove those older than `max_age`.
|
/// Age all entries and remove those older than `max_age`.
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ pub(crate) fn ldpc_check(codeword: &[u8; FTX_LDPC_N]) -> i32 {
|
|||||||
for m in 0..FTX_LDPC_M {
|
for m in 0..FTX_LDPC_M {
|
||||||
let mut x: u8 = 0;
|
let mut x: u8 = 0;
|
||||||
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||||
for &nm in FTX_LDPC_NM[m].iter().take(num_rows) {
|
for i in 0..num_rows {
|
||||||
x ^= codeword[nm as usize - 1];
|
x ^= codeword[FTX_LDPC_NM[m][i] as usize - 1];
|
||||||
}
|
}
|
||||||
if x != 0 {
|
if x != 0 {
|
||||||
errors += 1;
|
errors += 1;
|
||||||
@@ -81,11 +81,11 @@ pub fn ldpc_decode(
|
|||||||
for j in 0..FTX_LDPC_M {
|
for j in 0..FTX_LDPC_M {
|
||||||
let num_rows = FTX_LDPC_NUM_ROWS[j] as usize;
|
let num_rows = FTX_LDPC_NUM_ROWS[j] as usize;
|
||||||
let m_row = j * FTX_LDPC_N;
|
let m_row = j * FTX_LDPC_N;
|
||||||
for &nm1 in FTX_LDPC_NM[j].iter().take(num_rows) {
|
for ii1 in 0..num_rows {
|
||||||
let i1 = nm1 as usize - 1;
|
let i1 = FTX_LDPC_NM[j][ii1] as usize - 1;
|
||||||
let mut a = 1.0f32;
|
let mut a = 1.0f32;
|
||||||
for &nm2 in FTX_LDPC_NM[j].iter().take(num_rows) {
|
for ii2 in 0..num_rows {
|
||||||
let i2 = nm2 as usize - 1;
|
let i2 = FTX_LDPC_NM[j][ii2] as usize - 1;
|
||||||
if i2 != i1 {
|
if i2 != i1 {
|
||||||
a *= fast_tanh(-m_matrix[m_row + i2] / 2.0f32);
|
a *= fast_tanh(-m_matrix[m_row + i2] / 2.0f32);
|
||||||
}
|
}
|
||||||
@@ -97,8 +97,8 @@ pub fn ldpc_decode(
|
|||||||
// Hard decisions
|
// Hard decisions
|
||||||
for i in 0..FTX_LDPC_N {
|
for i in 0..FTX_LDPC_N {
|
||||||
let mut l = codeword[i];
|
let mut l = codeword[i];
|
||||||
for &mn in FTX_LDPC_MN[i].iter().take(3) {
|
for j in 0..3 {
|
||||||
l += e_matrix[(mn as usize - 1) * FTX_LDPC_N + i];
|
l += e_matrix[(FTX_LDPC_MN[i][j] as usize - 1) * FTX_LDPC_N + i];
|
||||||
}
|
}
|
||||||
plain[i] = if l > 0.0 { 1 } else { 0 };
|
plain[i] = if l > 0.0 { 1 } else { 0 };
|
||||||
}
|
}
|
||||||
@@ -113,12 +113,12 @@ pub fn ldpc_decode(
|
|||||||
|
|
||||||
// Update m[][] from e[][]
|
// Update m[][] from e[][]
|
||||||
for i in 0..FTX_LDPC_N {
|
for i in 0..FTX_LDPC_N {
|
||||||
for (ji1, &mn1) in FTX_LDPC_MN[i].iter().enumerate().take(3) {
|
for ji1 in 0..3 {
|
||||||
let j1 = mn1 as usize - 1;
|
let j1 = FTX_LDPC_MN[i][ji1] as usize - 1;
|
||||||
let mut l = codeword[i];
|
let mut l = codeword[i];
|
||||||
for (ji2, &mn2) in FTX_LDPC_MN[i].iter().enumerate().take(3) {
|
for ji2 in 0..3 {
|
||||||
if ji1 != ji2 {
|
if ji1 != ji2 {
|
||||||
let j2 = mn2 as usize - 1;
|
let j2 = FTX_LDPC_MN[i][ji2] as usize - 1;
|
||||||
l += e_matrix[j2 * FTX_LDPC_N + i];
|
l += e_matrix[j2 * FTX_LDPC_N + i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ pub fn charn(mut c: i32, table: CharTable) -> char {
|
|||||||
return EXTRAS[c as usize];
|
return EXTRAS[c as usize];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CharTable::AlphanumSpaceSlash if c == 0 => {
|
CharTable::AlphanumSpaceSlash => {
|
||||||
|
if c == 0 {
|
||||||
return '/';
|
return '/';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,9 +116,11 @@ pub fn nchar(c: char, table: CharTable) -> Option<i32> {
|
|||||||
'?' => return Some(n + 4),
|
'?' => return Some(n + 4),
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
CharTable::AlphanumSpaceSlash if c == '/' => {
|
CharTable::AlphanumSpaceSlash => {
|
||||||
|
if c == '/' {
|
||||||
return Some(n);
|
return Some(n);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -632,18 +632,33 @@ impl Candidate {
|
|||||||
}
|
}
|
||||||
let segment = usize::from((block_b & 0x0003) as u8);
|
let segment = usize::from((block_b & 0x0003) as u8);
|
||||||
let di = ((block_b >> 2) & 0x1) != 0;
|
let di = ((block_b >> 2) & 0x1) != 0;
|
||||||
let di_flag = Some(di);
|
match segment {
|
||||||
let slot = match segment {
|
0 => {
|
||||||
0 => &mut self.state.dynamic_pty,
|
if self.state.dynamic_pty != Some(di) {
|
||||||
1 => &mut self.state.compressed,
|
self.state.dynamic_pty = Some(di);
|
||||||
2 => &mut self.state.artificial_head,
|
|
||||||
3 => &mut self.state.stereo,
|
|
||||||
_ => unreachable!("segment is masked to two bits"),
|
|
||||||
};
|
|
||||||
if *slot != di_flag {
|
|
||||||
*slot = di_flag;
|
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
if self.state.compressed != Some(di) {
|
||||||
|
self.state.compressed = Some(di);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
if self.state.artificial_head != Some(di) {
|
||||||
|
self.state.artificial_head = Some(di);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
if self.state.stereo != Some(di) {
|
||||||
|
self.state.stereo = Some(di);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
let [b0, b1] = block_d.to_be_bytes();
|
let [b0, b1] = block_d.to_be_bytes();
|
||||||
self.ps_bytes[segment * 2] = sanitize_text_byte(b0);
|
self.ps_bytes[segment * 2] = sanitize_text_byte(b0);
|
||||||
self.ps_bytes[segment * 2 + 1] = sanitize_text_byte(b1);
|
self.ps_bytes[segment * 2 + 1] = sanitize_text_byte(b1);
|
||||||
@@ -1443,9 +1458,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BPSK modulate onto the 57 kHz subcarrier.
|
// BPSK modulate onto the 57 kHz subcarrier.
|
||||||
for (t, sample) in shaped.iter_mut().enumerate().take(n) {
|
for t in 0..n {
|
||||||
let phase = TAU * RDS_SUBCARRIER_HZ * t as f32 / sample_rate;
|
let phase = TAU * RDS_SUBCARRIER_HZ * t as f32 / sample_rate;
|
||||||
*sample *= phase.cos();
|
shaped[t] *= phase.cos();
|
||||||
}
|
}
|
||||||
shaped
|
shaped
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ mod tests {
|
|||||||
.flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
|
.flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
|
||||||
.collect();
|
.collect();
|
||||||
// Append wrong CRC
|
// Append wrong CRC
|
||||||
bits.resize(bits.len() + 16, 0);
|
for _ in 0..16 {
|
||||||
|
bits.push(0);
|
||||||
|
}
|
||||||
assert!(!check_crc16(&bits));
|
assert!(!check_crc16(&bits));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -346,8 +346,8 @@ mod tests {
|
|||||||
write_bits(&mut bits, 12, 32, 123456); // source_id
|
write_bits(&mut bits, 12, 32, 123456); // source_id
|
||||||
write_bits(&mut bits, 44, 11, 20); // data_count = 20
|
write_bits(&mut bits, 44, 11, 20); // data_count = 20
|
||||||
// Fill some payload
|
// Fill some payload
|
||||||
for (i, bit) in bits.iter_mut().enumerate().take(75).skip(55) {
|
for i in 55..75 {
|
||||||
*bit = (i % 2) as u8;
|
bits[i] = (i % 2) as u8;
|
||||||
}
|
}
|
||||||
append_crc(&mut bits);
|
append_crc(&mut bits);
|
||||||
|
|
||||||
|
|||||||
@@ -374,8 +374,8 @@ mod tests {
|
|||||||
let (y, m, d, h, mi, _) = unix_to_utc(1775055000);
|
let (y, m, d, h, mi, _) = unix_to_utc(1775055000);
|
||||||
assert_eq!(y, 2026);
|
assert_eq!(y, 2026);
|
||||||
// Just verify reasonable values without asserting exact date.
|
// Just verify reasonable values without asserting exact date.
|
||||||
assert!((1..=12).contains(&m));
|
assert!(m >= 1 && m <= 12);
|
||||||
assert!((1..=31).contains(&d));
|
assert!(d >= 1 && d <= 31);
|
||||||
assert!(h < 24);
|
assert!(h < 24);
|
||||||
assert!(mi < 60);
|
assert!(mi < 60);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,8 +161,10 @@ mod tests {
|
|||||||
|
|
||||||
for line_idx in 0..20 {
|
for line_idx in 0..20 {
|
||||||
let mut line = vec![1.0f32; spl];
|
let mut line = vec![1.0f32; spl];
|
||||||
for slot in line.iter_mut().skip(pulse_start).take(pw) {
|
for j in pulse_start..pulse_start + pw {
|
||||||
*slot = 0.0;
|
if j < spl {
|
||||||
|
line[j] = 0.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let result = det.process(&line);
|
let result = det.process(&line);
|
||||||
if let Some(offset) = result {
|
if let Some(offset) = result {
|
||||||
|
|||||||
@@ -483,7 +483,7 @@ mod tests {
|
|||||||
let c4 = idx27(b'T');
|
let c4 = idx27(b'T');
|
||||||
let c5 = idx27(b' ');
|
let c5 = idx27(b' ');
|
||||||
let n1 = ((c0 * 36 + c1) * 10 + c2) * 27u32.pow(3) + c3 * 27u32.pow(2) + c4 * 27 + c5;
|
let n1 = ((c0 * 36 + c1) * 10 + c2) * 27u32.pow(3) + c3 * 27u32.pow(2) + c4 * 27 + c5;
|
||||||
let m1 = (179 - 10 * 5 - 2) * 180 + 10 * 13; // FN20 (final term is 0)
|
let m1 = (179 - 10 * 5 - 2) * 180 + 10 * 13 + 0; // FN20
|
||||||
let power_code = 37u32;
|
let power_code = 37u32;
|
||||||
|
|
||||||
let mut input_bits = [0u8; NBITS];
|
let mut input_bits = [0u8; NBITS];
|
||||||
@@ -530,8 +530,8 @@ mod tests {
|
|||||||
fn interleave_deinterleave_roundtrip() {
|
fn interleave_deinterleave_roundtrip() {
|
||||||
// Create a sequence of distinguishable values
|
// Create a sequence of distinguishable values
|
||||||
let mut original = [0u8; NSYMS];
|
let mut original = [0u8; NSYMS];
|
||||||
for (i, slot) in original.iter_mut().enumerate() {
|
for i in 0..NSYMS {
|
||||||
*slot = (i % 256) as u8;
|
original[i] = (i % 256) as u8;
|
||||||
}
|
}
|
||||||
|
|
||||||
let interleaved = interleave(&original);
|
let interleaved = interleave(&original);
|
||||||
|
|||||||
@@ -356,6 +356,59 @@ async fn run_single_rig_audio_client(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{resolve_audio_addr, AudioConnectConfig};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_audio_addr_prefers_fixed_url() {
|
||||||
|
let mut rig_connect = HashMap::new();
|
||||||
|
rig_connect.insert(
|
||||||
|
"home-hf".to_string(),
|
||||||
|
AudioConnectConfig::fixed("audio.example.com:4700".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let addr = resolve_audio_addr(
|
||||||
|
"home-hf",
|
||||||
|
Some(4531),
|
||||||
|
&rig_connect,
|
||||||
|
&AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
||||||
|
);
|
||||||
|
assert_eq!(addr, "audio.example.com:4700");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_audio_addr_uses_advertised_port_with_remote_host() {
|
||||||
|
let mut rig_connect = HashMap::new();
|
||||||
|
rig_connect.insert(
|
||||||
|
"home-hf".to_string(),
|
||||||
|
AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
||||||
|
);
|
||||||
|
|
||||||
|
let addr = resolve_audio_addr(
|
||||||
|
"home-hf",
|
||||||
|
Some(4600),
|
||||||
|
&rig_connect,
|
||||||
|
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
||||||
|
);
|
||||||
|
assert_eq!(addr, "control.example.com:4600");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_audio_addr_falls_back_to_default_port() {
|
||||||
|
let rig_connect = HashMap::new();
|
||||||
|
|
||||||
|
let addr = resolve_audio_addr(
|
||||||
|
"home-hf",
|
||||||
|
None,
|
||||||
|
&rig_connect,
|
||||||
|
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
||||||
|
);
|
||||||
|
assert_eq!(addr, "fallback.example.com:4531");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle a single TCP connection for one rig. Similar to `handle_audio_connection`
|
/// Handle a single TCP connection for one rig. Similar to `handle_audio_connection`
|
||||||
/// but publishes to per-rig channels directly and mirrors to global when selected.
|
/// but publishes to per-rig channels directly and mirrors to global when selected.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -714,56 +767,3 @@ async fn handle_single_rig_connection(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{resolve_audio_addr, AudioConnectConfig};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_audio_addr_prefers_fixed_url() {
|
|
||||||
let mut rig_connect = HashMap::new();
|
|
||||||
rig_connect.insert(
|
|
||||||
"home-hf".to_string(),
|
|
||||||
AudioConnectConfig::fixed("audio.example.com:4700".to_string()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let addr = resolve_audio_addr(
|
|
||||||
"home-hf",
|
|
||||||
Some(4531),
|
|
||||||
&rig_connect,
|
|
||||||
&AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
|
||||||
);
|
|
||||||
assert_eq!(addr, "audio.example.com:4700");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_audio_addr_uses_advertised_port_with_remote_host() {
|
|
||||||
let mut rig_connect = HashMap::new();
|
|
||||||
rig_connect.insert(
|
|
||||||
"home-hf".to_string(),
|
|
||||||
AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
|
||||||
);
|
|
||||||
|
|
||||||
let addr = resolve_audio_addr(
|
|
||||||
"home-hf",
|
|
||||||
Some(4600),
|
|
||||||
&rig_connect,
|
|
||||||
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
|
||||||
);
|
|
||||||
assert_eq!(addr, "control.example.com:4600");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_audio_addr_falls_back_to_default_port() {
|
|
||||||
let rig_connect = HashMap::new();
|
|
||||||
|
|
||||||
let addr = resolve_audio_addr(
|
|
||||||
"home-hf",
|
|
||||||
None,
|
|
||||||
&rig_connect,
|
|
||||||
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
|
||||||
);
|
|
||||||
assert_eq!(addr, "fallback.example.com:4531");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1110,8 +1110,8 @@ url = "remote.example.com:4530"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_rejects_duplicate_remote_names() {
|
fn test_validate_rejects_duplicate_remote_names() {
|
||||||
let config = ClientConfig {
|
let mut config = ClientConfig::default();
|
||||||
remotes: vec![
|
config.remotes = vec![
|
||||||
RemoteEntry {
|
RemoteEntry {
|
||||||
name: "dup".to_string(),
|
name: "dup".to_string(),
|
||||||
url: "a:4530".to_string(),
|
url: "a:4530".to_string(),
|
||||||
@@ -1126,24 +1126,20 @@ url = "remote.example.com:4530"
|
|||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(config.validate().unwrap_err().contains("duplicate name"));
|
assert!(config.validate().unwrap_err().contains("duplicate name"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_rejects_empty_remote_name() {
|
fn test_validate_rejects_empty_remote_name() {
|
||||||
let config = ClientConfig {
|
let mut config = ClientConfig::default();
|
||||||
remotes: vec![RemoteEntry {
|
config.remotes = vec![RemoteEntry {
|
||||||
name: "".to_string(),
|
name: "".to_string(),
|
||||||
url: "a:4530".to_string(),
|
url: "a:4530".to_string(),
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
}],
|
}];
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(config
|
assert!(config
|
||||||
.validate()
|
.validate()
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
@@ -1152,16 +1148,14 @@ url = "remote.example.com:4530"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_rejects_empty_remote_url() {
|
fn test_validate_rejects_empty_remote_url() {
|
||||||
let config = ClientConfig {
|
let mut config = ClientConfig::default();
|
||||||
remotes: vec![RemoteEntry {
|
config.remotes = vec![RemoteEntry {
|
||||||
name: "hf".to_string(),
|
name: "hf".to_string(),
|
||||||
url: " ".to_string(),
|
url: " ".to_string(),
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
}],
|
}];
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(config
|
assert!(config
|
||||||
.validate()
|
.validate()
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
@@ -1170,16 +1164,14 @@ url = "remote.example.com:4530"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_rejects_zero_remote_poll_interval() {
|
fn test_validate_rejects_zero_remote_poll_interval() {
|
||||||
let config = ClientConfig {
|
let mut config = ClientConfig::default();
|
||||||
remotes: vec![RemoteEntry {
|
config.remotes = vec![RemoteEntry {
|
||||||
name: "hf".to_string(),
|
name: "hf".to_string(),
|
||||||
url: "a:4530".to_string(),
|
url: "a:4530".to_string(),
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 0,
|
poll_interval_ms: 0,
|
||||||
}],
|
}];
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(config
|
assert!(config
|
||||||
.validate()
|
.validate()
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
|
|||||||
@@ -1678,7 +1678,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn global_target_for_snapshot_skips_other_server_selection() {
|
fn global_target_for_snapshot_skips_other_server_selection() {
|
||||||
let snapshot = sample_snapshot();
|
let snapshot = sample_snapshot();
|
||||||
let rigs = [RigEntry {
|
let rigs = vec![RigEntry {
|
||||||
rig_id: "hf".to_string(),
|
rig_id: "hf".to_string(),
|
||||||
display_name: Some("Gdansk HF".to_string()),
|
display_name: Some("Gdansk HF".to_string()),
|
||||||
state: snapshot,
|
state: snapshot,
|
||||||
|
|||||||
@@ -645,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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1279,7 +1279,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
|
|||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1305,7 +1305,7 @@ async function refreshRigList() {
|
|||||||
serverRigs = rigs;
|
serverRigs = rigs;
|
||||||
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.
|
||||||
}
|
}
|
||||||
@@ -3143,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));
|
||||||
}
|
}
|
||||||
@@ -3894,7 +3894,7 @@ async function switchRigFromSelect(selectEl) {
|
|||||||
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 bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || "");
|
if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || "");
|
||||||
window.trx.modules.map?.syncAprsReceiverMarker();
|
window.trx.map?.syncAprsReceiverMarker();
|
||||||
// Switch this session's rig and reconnect SSE to the new rig's
|
// Switch this session's rig and reconnect SSE to the new rig's
|
||||||
// state channel.
|
// state channel.
|
||||||
try {
|
try {
|
||||||
@@ -4480,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;
|
||||||
@@ -4544,7 +4544,7 @@ 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();
|
||||||
@@ -4721,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; } },
|
||||||
@@ -4763,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,
|
||||||
@@ -4773,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.
|
||||||
@@ -4799,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) {
|
||||||
@@ -4934,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 ---
|
||||||
@@ -6104,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6114,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) {
|
||||||
@@ -6202,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); }
|
||||||
@@ -8002,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;
|
||||||
|
|||||||
@@ -104,24 +104,24 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Auth gate (hidden by default, shown if auth is required) -->
|
<!-- Auth gate (hidden by default, shown if auth is required) -->
|
||||||
<div id="auth-gate" class="auth-gate" style="display:none;">
|
<div id="auth-gate" style="display:none; max-width: 30rem; margin: 0 auto 0.9rem; padding: 1.25rem 0 1.5rem; text-align: center;">
|
||||||
<div class="auth-gate-head">
|
<div style="margin-bottom: 1.5rem;">
|
||||||
<div class="auth-gate-title">Access Required</div>
|
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;">Access Required</div>
|
||||||
<div class="auth-gate-sub">Enter passphrase to continue</div>
|
<div style="color: var(--text-muted);">Enter passphrase to continue</div>
|
||||||
</div>
|
</div>
|
||||||
<form id="auth-form" class="auth-form">
|
<form id="auth-form" style="margin: 1rem 0 0.35rem;">
|
||||||
<input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" />
|
<input type="password" id="auth-passphrase" placeholder="Passphrase" autocomplete="off" style="width: 100%; padding: 0.65rem 0.75rem; margin-bottom: 1rem; border: 1px solid var(--border-light); border-radius: 0.45rem; background: var(--input-bg); color: var(--text); font-size: 1rem; box-sizing: border-box;" />
|
||||||
<button type="submit" class="auth-submit">Login</button>
|
<button type="submit" style="width: 100%; padding: 0.65rem 0.75rem; background: var(--accent-green); color: #fff; border: none; border-radius: 0.45rem; font-weight: 700; cursor: pointer; font-size: 1rem; box-sizing: border-box;">Login</button>
|
||||||
</form>
|
</form>
|
||||||
<button id="auth-guest-btn" type="button" class="auth-guest" style="display: none;">Continue as Guest</button>
|
<button id="auth-guest-btn" type="button" style="width: 100%; padding: 0.65rem 0.75rem; background: var(--btn-bg); color: var(--text); border: 1px solid var(--border-light); border-radius: 0.45rem; font-weight: 600; cursor: pointer; font-size: 1rem; box-sizing: border-box; margin-top: 0; display: none;">Continue as Guest</button>
|
||||||
<div id="auth-error" class="auth-error" style="display: none;"></div>
|
<div id="auth-error" style="color: #ff6b6b; font-size: 0.9rem; margin-top: 1rem; display: none;"></div>
|
||||||
<div id="auth-role" class="auth-role" style="display: none;"></div>
|
<div id="auth-role" style="margin-top: 1rem; color: var(--text-muted); font-size: 0.85rem; display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-main" class="tab-panel">
|
<div id="tab-main" class="tab-panel">
|
||||||
<div id="server-lost-banner" aria-live="assertive"><span class="banner-dot"></span>trx-server connection lost — waiting for reconnect</div>
|
<div id="server-lost-banner" aria-live="assertive"><span class="banner-dot"></span>trx-server connection lost — waiting for reconnect</div>
|
||||||
<div id="loading" role="status" aria-live="polite" style="text-align:center; padding:2rem 0;">
|
<div id="loading" role="status" aria-live="polite" style="text-align:center; padding:2rem 0;">
|
||||||
<div id="loading-title" style="margin-bottom:0.4rem; font-size:1.1rem; font-weight:600;">Initializing (rig)…</div>
|
<div id="loading-title" style="margin-bottom:0.4rem; font-size:1.1rem; font-weight:600;">Initializing (rig)…</div>
|
||||||
<div id="loading-sub" style="color:var(--text-muted);"></div>
|
<div id="loading-sub" style="color:#9aa4b5;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="content" style="display:none;">
|
<div id="content" style="display:none;">
|
||||||
<div class="signal-visual-block">
|
<div class="signal-visual-block">
|
||||||
@@ -1623,9 +1623,7 @@ 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="/app.js"></script>
|
<script defer src="/app.js"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -1634,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)
|
||||||
@@ -1683,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,
|
||||||
|
|||||||
@@ -257,7 +257,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register module API
|
// Register module API
|
||||||
window.trx.modules.screenshot = {
|
window.trx.screenshot = {
|
||||||
captureSpectrumScreenshot,
|
captureSpectrumScreenshot,
|
||||||
buildSpectrumSnapshotCanvas,
|
buildSpectrumSnapshotCanvas,
|
||||||
saveCanvasAsPng,
|
saveCanvasAsPng,
|
||||||
|
|||||||
@@ -47,50 +47,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
--border-hover: color-mix(in srgb, var(--border-light) 80%, var(--text) 20%);
|
--border-hover: color-mix(in srgb, var(--border-light) 80%, var(--text) 20%);
|
||||||
--accent-green-hover: color-mix(in srgb, var(--accent-green) 85%, #fff 15%);
|
--accent-green-hover: color-mix(in srgb, var(--accent-green) 85%, #fff 15%);
|
||||||
--accent-green-active: color-mix(in srgb, var(--accent-green) 75%, #fff 25%);
|
--accent-green-active: color-mix(in srgb, var(--accent-green) 75%, #fff 25%);
|
||||||
/* Accent tuned for text/links: mixes toward the theme's text colour,
|
|
||||||
which is always the higher-contrast direction (lighter on dark
|
|
||||||
themes, darker on light). Use for accent-coloured text; keep
|
|
||||||
--accent-green for fills, indicators and meters. */
|
|
||||||
--accent-text: color-mix(in srgb, var(--accent-green) 82%, var(--text) 18%);
|
|
||||||
|
|
||||||
/* ── Design tokens ──────────────────────────────────────────────────
|
|
||||||
Derived tokens use color-mix over themed variables, so they
|
|
||||||
re-resolve per active [data-style]/[data-theme] automatically. */
|
|
||||||
|
|
||||||
/* Elevated surface, one step lighter than --card-bg. Consumed by
|
|
||||||
.controls-tray and other raised panels. */
|
|
||||||
--surface: color-mix(in srgb, var(--card-bg) 90%, var(--text) 7%);
|
|
||||||
|
|
||||||
/* Type scale */
|
|
||||||
--fs-xs: 0.75rem;
|
|
||||||
--fs-sm: 0.85rem;
|
|
||||||
--fs-base: 1rem;
|
|
||||||
--fs-md: 1.125rem;
|
|
||||||
--fs-lg: 1.25rem;
|
|
||||||
--fs-xl: 1.5rem;
|
|
||||||
--fs-2xl: 2rem;
|
|
||||||
|
|
||||||
/* Spacing scale (4px grid) */
|
|
||||||
--space-1: 0.25rem;
|
|
||||||
--space-2: 0.5rem;
|
|
||||||
--space-3: 0.75rem;
|
|
||||||
--space-4: 1rem;
|
|
||||||
--space-5: 1.25rem;
|
|
||||||
--space-6: 1.5rem;
|
|
||||||
--space-8: 2rem;
|
|
||||||
|
|
||||||
/* Corner radii */
|
|
||||||
--radius-sm: 6px;
|
|
||||||
--radius-md: 0.5rem;
|
|
||||||
--radius-lg: 1rem;
|
|
||||||
--radius-pill: 999px;
|
|
||||||
|
|
||||||
/* Motion */
|
|
||||||
--dur-fast: 100ms;
|
|
||||||
--dur-base: 180ms;
|
|
||||||
--dur-slow: 300ms;
|
|
||||||
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
@@ -133,10 +89,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
font-size: var(--fs-base);
|
|
||||||
line-height: 1.5;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
@@ -168,57 +120,6 @@ body {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* ── Auth gate ────────────────────────────────────────────────────────
|
|
||||||
Class-based; #display toggling stays inline (driven by app.js). */
|
|
||||||
.auth-gate {
|
|
||||||
max-width: 30rem;
|
|
||||||
margin: 0 auto 0.9rem;
|
|
||||||
padding: var(--space-5) 0 var(--space-6);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.auth-gate-head { margin-bottom: var(--space-6); }
|
|
||||||
.auth-gate-title { font-size: var(--fs-md); font-weight: 600; margin-bottom: var(--space-2); }
|
|
||||||
.auth-gate-sub { color: var(--text-muted); }
|
|
||||||
.auth-form { margin: var(--space-4) 0 0.35rem; }
|
|
||||||
.auth-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--input-bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: var(--fs-base);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.auth-submit,
|
|
||||||
.auth-guest {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--fs-base);
|
|
||||||
cursor: pointer;
|
|
||||||
box-sizing: border-box;
|
|
||||||
transition: background-color var(--dur-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
.auth-submit {
|
|
||||||
background: var(--accent-green);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.auth-submit:hover:not(:disabled) { background: var(--accent-green-hover); }
|
|
||||||
.auth-guest {
|
|
||||||
background: var(--btn-bg);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
}
|
|
||||||
.auth-guest:hover:not(:disabled) { background: var(--btn-hover-bg); }
|
|
||||||
.auth-error { color: var(--accent-red); font-size: var(--fs-sm); margin-top: var(--space-4); }
|
|
||||||
.auth-role { margin-top: var(--space-4); color: var(--text-muted); font-size: var(--fs-sm); }
|
|
||||||
|
|
||||||
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
|
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
|
||||||
#tab-main .label > span {
|
#tab-main .label > span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -319,7 +220,7 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0.22rem;
|
gap: 0.22rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -507,7 +408,7 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0 0.65rem;
|
padding: 0 0.65rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.92rem;
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -585,7 +486,7 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
|
|||||||
}
|
}
|
||||||
.scheduler-cycle-status {
|
.scheduler-cycle-status {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.74rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
opacity: 0.88;
|
opacity: 0.88;
|
||||||
}
|
}
|
||||||
@@ -1456,7 +1357,7 @@ small { color: var(--text-muted); }
|
|||||||
}
|
}
|
||||||
.header-logo { height: 4.6em; width: auto; flex-shrink: 0; filter: drop-shadow(0 4px 12px rgba(0,0,0,0.35)); }
|
.header-logo { height: 4.6em; width: auto; flex-shrink: 0; filter: drop-shadow(0 4px 12px rgba(0,0,0,0.35)); }
|
||||||
.subtitle { color: var(--text-muted); font-size: 0.95rem; }
|
.subtitle { color: var(--text-muted); font-size: 0.95rem; }
|
||||||
.subtitle a { color: var(--accent-text); text-decoration: none; }
|
.subtitle a { color: var(--accent-green); text-decoration: none; }
|
||||||
.subtitle a:hover { text-decoration: underline; }
|
.subtitle a:hover { text-decoration: underline; }
|
||||||
.band-tag { display: inline-block; padding: 2px 6px; border-radius: 6px; background: var(--btn-bg); color: var(--text); font-size: 0.82rem; border: 1px solid var(--border-light); margin-left: 6px; }
|
.band-tag { display: inline-block; padding: 2px 6px; border-radius: 6px; background: var(--btn-bg); color: var(--text); font-size: 0.82rem; border: 1px solid var(--border-light); margin-left: 6px; }
|
||||||
.signal { display: flex; gap: 0.6rem; align-items: center; }
|
.signal { display: flex; gap: 0.6rem; align-items: center; }
|
||||||
@@ -1532,7 +1433,7 @@ small { color: var(--text-muted); }
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
.tab.active { border-bottom-color: var(--accent-green); color: var(--accent-text); font-weight: 600; }
|
.tab.active { border-bottom-color: var(--accent-green); color: var(--accent-green); font-weight: 600; }
|
||||||
.tab:hover:not(.active) { color: var(--text); }
|
.tab:hover:not(.active) { color: var(--text); }
|
||||||
/* Tab icons — hidden on desktop, shown on mobile bottom nav */
|
/* Tab icons — hidden on desktop, shown on mobile bottom nav */
|
||||||
.tab-icon {
|
.tab-icon {
|
||||||
@@ -1557,7 +1458,7 @@ small { color: var(--text-muted); }
|
|||||||
.footer { display: flex; justify-content: space-between; align-items: baseline; margin-top: auto; padding-top: 1rem; flex-shrink: 0; }
|
.footer { display: flex; justify-content: space-between; align-items: baseline; margin-top: auto; padding-top: 1rem; flex-shrink: 0; }
|
||||||
.full-row { grid-column: 1 / -1; }
|
.full-row { grid-column: 1 / -1; }
|
||||||
.copyright { color: var(--text-muted); font-size: 0.75rem; opacity: 0.7; }
|
.copyright { color: var(--text-muted); font-size: 0.75rem; opacity: 0.7; }
|
||||||
.copyright a { color: var(--accent-text); text-decoration: none; }
|
.copyright a { color: var(--accent-green); text-decoration: none; }
|
||||||
.copyright a:hover { text-decoration: underline; }
|
.copyright a:hover { text-decoration: underline; }
|
||||||
.gh-link-wrap {
|
.gh-link-wrap {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1598,7 +1499,7 @@ small { color: var(--text-muted); }
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.vol-pct {
|
.vol-pct {
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -1701,7 +1602,7 @@ small { color: var(--text-muted); }
|
|||||||
}
|
}
|
||||||
.shortcut-table td {
|
.shortcut-table td {
|
||||||
padding: 0.32rem 0.4rem;
|
padding: 0.32rem 0.4rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.88rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--border-light) 40%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--border-light) 40%, transparent);
|
||||||
}
|
}
|
||||||
@@ -1731,7 +1632,7 @@ small { color: var(--text-muted); }
|
|||||||
.shortcut-overlay-hint {
|
.shortcut-overlay-hint {
|
||||||
margin-top: 0.7rem;
|
margin-top: 0.7rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.75rem;
|
font-size: 0.76rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1805,7 +1706,7 @@ small { color: var(--text-muted); }
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.decode-history-overlay-title {
|
.decode-history-overlay-title {
|
||||||
font-size: 1rem;
|
font-size: 0.98rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--text-heading);
|
color: var(--text-heading);
|
||||||
}
|
}
|
||||||
@@ -1855,7 +1756,7 @@ small { color: var(--text-muted); }
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.map-qso-summary-title {
|
.map-qso-summary-title {
|
||||||
font-size: 0.85rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1940,7 +1841,7 @@ button.map-qso-card:focus-visible {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: color-mix(in srgb, var(--accent-green) 16%, transparent);
|
background: color-mix(in srgb, var(--accent-green) 16%, transparent);
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
font-size: 0.75rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
.map-qso-card-distance {
|
.map-qso-card-distance {
|
||||||
@@ -1957,7 +1858,7 @@ button.map-qso-card:focus-visible {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.map-qso-card-pair {
|
.map-qso-card-pair {
|
||||||
font-size: 0.9rem;
|
font-size: 0.92rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -2047,7 +1948,7 @@ button.map-qso-card:focus-visible {
|
|||||||
border: 1px solid color-mix(in srgb, var(--border-light) 74%, transparent);
|
border: 1px solid color-mix(in srgb, var(--border-light) 74%, transparent);
|
||||||
background: color-mix(in srgb, var(--card-bg) 82%, transparent);
|
background: color-mix(in srgb, var(--card-bg) 82%, transparent);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.75rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -2066,7 +1967,7 @@ button.map-qso-card:focus-visible {
|
|||||||
border: 1px solid color-mix(in srgb, var(--border-light) 74%, transparent);
|
border: 1px solid color-mix(in srgb, var(--border-light) 74%, transparent);
|
||||||
background: color-mix(in srgb, var(--card-bg) 82%, transparent);
|
background: color-mix(in srgb, var(--card-bg) 82%, transparent);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.75rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -2099,7 +2000,7 @@ button.map-qso-card:focus-visible {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.map-band-legend-title {
|
.map-band-legend-title {
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -2126,7 +2027,7 @@ button.map-qso-card:focus-visible {
|
|||||||
}
|
}
|
||||||
.map-band-legend-text {
|
.map-band-legend-text {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -2183,7 +2084,7 @@ body.map-fake-fullscreen-active {
|
|||||||
}
|
}
|
||||||
.aprs-summary-label {
|
.aprs-summary-label {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -2241,7 +2142,7 @@ body.map-fake-fullscreen-active {
|
|||||||
}
|
}
|
||||||
.ais-summary-label {
|
.ais-summary-label {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -2359,7 +2260,7 @@ body.map-fake-fullscreen-active {
|
|||||||
}
|
}
|
||||||
.aprs-meta-text {
|
.aprs-meta-text {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
.aprs-inline-btn {
|
.aprs-inline-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -2372,7 +2273,7 @@ body.map-fake-fullscreen-active {
|
|||||||
background: var(--filter-bg);
|
background: var(--filter-bg);
|
||||||
color: var(--filter-fg);
|
color: var(--filter-fg);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.75rem;
|
font-size: 0.74rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.aprs-inline-btn:hover {
|
.aprs-inline-btn:hover {
|
||||||
@@ -2385,7 +2286,7 @@ body.map-fake-fullscreen-active {
|
|||||||
.aprs-details summary {
|
.aprs-details summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
font-size: 0.75rem;
|
font-size: 0.76rem;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.aprs-details[open] summary {
|
.aprs-details[open] summary {
|
||||||
@@ -2463,7 +2364,7 @@ body.map-fake-fullscreen-active {
|
|||||||
}
|
}
|
||||||
.ais-meta-text {
|
.ais-meta-text {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
.ais-pos-link {
|
.ais-pos-link {
|
||||||
color: var(--accent-red);
|
color: var(--accent-red);
|
||||||
@@ -2505,7 +2406,7 @@ body.map-fake-fullscreen-active {
|
|||||||
}
|
}
|
||||||
.vdes-raw {
|
.vdes-raw {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.76rem;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; }
|
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; }
|
||||||
@@ -2564,18 +2465,18 @@ body.map-fake-fullscreen-active {
|
|||||||
}
|
}
|
||||||
.decode-locator-tip-meta {
|
.decode-locator-tip-meta {
|
||||||
margin-top: 0.12rem;
|
margin-top: 0.12rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.74rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.decode-locator-tip-rx {
|
.decode-locator-tip-rx {
|
||||||
margin-top: 0.12rem;
|
margin-top: 0.12rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
color: var(--accent-yellow);
|
color: var(--accent-yellow);
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
.decode-locator-tip-note {
|
.decode-locator-tip-note {
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.76rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -2602,7 +2503,7 @@ body.map-fake-fullscreen-active {
|
|||||||
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||||
color: color-mix(in srgb, var(--accent-green) 72%, var(--accent-yellow));
|
color: color-mix(in srgb, var(--accent-green) 72%, var(--accent-yellow));
|
||||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -2675,7 +2576,7 @@ body.map-fake-fullscreen-active {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-width: 4.4rem;
|
min-width: 4.4rem;
|
||||||
padding-top: 0.35rem;
|
padding-top: 0.35rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -2703,7 +2604,7 @@ body.map-fake-fullscreen-active {
|
|||||||
border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);
|
border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);
|
||||||
background: color-mix(in srgb, var(--input-bg) 92%, transparent);
|
background: color-mix(in srgb, var(--input-bg) 92%, transparent);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -2720,7 +2621,7 @@ body.map-fake-fullscreen-active {
|
|||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
}
|
}
|
||||||
.map-locator-empty {
|
.map-locator-empty {
|
||||||
font-size: 0.75rem;
|
font-size: 0.74rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
@@ -2754,7 +2655,7 @@ body.map-fake-fullscreen-active {
|
|||||||
background: color-mix(in srgb, var(--input-bg) 96%, transparent);
|
background: color-mix(in srgb, var(--input-bg) 96%, transparent);
|
||||||
}
|
}
|
||||||
.map-locator-chip-text {
|
.map-locator-chip-text {
|
||||||
font-size: 0.78rem;
|
font-size: 0.77rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.map-search-input {
|
.map-search-input {
|
||||||
@@ -2811,7 +2712,7 @@ body.map-fake-fullscreen-active {
|
|||||||
.rds-raw-header { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; margin-bottom: 0.3rem; }
|
.rds-raw-header { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; margin-bottom: 0.3rem; }
|
||||||
.rds-raw-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 0.3rem; }
|
.rds-raw-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 0.3rem; }
|
||||||
.rds-raw-header .rds-raw-label { margin-bottom: 0; }
|
.rds-raw-header .rds-raw-label { margin-bottom: 0; }
|
||||||
#rds-raw-copy-btn { height: 1.65rem; padding: 0 0.55rem; font-size: 0.75rem; }
|
#rds-raw-copy-btn { height: 1.65rem; padding: 0 0.55rem; font-size: 0.72rem; }
|
||||||
.rds-raw { background: var(--input-bg); border: 1px solid var(--border-light); border-radius: 6px; padding: 0.5rem 0.6rem; font-size: 0.8rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; color: var(--text); white-space: pre; overflow-x: auto; margin: 0; }
|
.rds-raw { background: var(--input-bg); border: 1px solid var(--border-light); border-radius: 6px; padding: 0.5rem 0.6rem; font-size: 0.8rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; color: var(--text); white-space: pre; overflow-x: auto; margin: 0; }
|
||||||
|
|
||||||
.cw-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
|
.cw-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
|
||||||
@@ -2832,9 +2733,7 @@ body.map-fake-fullscreen-active {
|
|||||||
.cw-config input[type="number"][readonly] { opacity: 0.6; }
|
.cw-config input[type="number"][readonly] { opacity: 0.6; }
|
||||||
.cw-config input[type="number"]:disabled { opacity: 0.58; }
|
.cw-config input[type="number"]:disabled { opacity: 0.58; }
|
||||||
|
|
||||||
button:focus-visible, input:focus-visible, select:focus-visible,
|
button:focus-visible, input:focus-visible, select:focus-visible {
|
||||||
textarea:focus-visible, a:focus-visible,
|
|
||||||
canvas:focus-visible, [tabindex]:focus-visible {
|
|
||||||
outline: 2px solid var(--accent-green);
|
outline: 2px solid var(--accent-green);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
@@ -2945,7 +2844,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
}
|
}
|
||||||
/* Collapse verbose subtitles — the About tab has this info */
|
/* Collapse verbose subtitles — the About tab has this info */
|
||||||
.tab-bar .subtitle { display: none; }
|
.tab-bar .subtitle { display: none; }
|
||||||
.tab-bar .title { font-size: 0.9rem; }
|
.tab-bar .title { font-size: 0.92rem; }
|
||||||
.tab-bar-nav {
|
.tab-bar-nav {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: calc(0.6rem + env(safe-area-inset-left));
|
left: calc(0.6rem + env(safe-area-inset-left));
|
||||||
@@ -3607,7 +3506,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
will-change: transform;
|
will-change: transform;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.65rem;
|
font-size: 0.66rem;
|
||||||
background: var(--bm-cat-bg, var(--accent-yellow));
|
background: var(--bm-cat-bg, var(--accent-yellow));
|
||||||
color: var(--bm-cat-fg, #1a202c);
|
color: var(--bm-cat-fg, #1a202c);
|
||||||
border: 1px solid var(--bm-cat-bg, var(--accent-yellow));
|
border: 1px solid var(--bm-cat-bg, var(--accent-yellow));
|
||||||
@@ -3699,7 +3598,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
}
|
}
|
||||||
.spectrum-bookmark-chip-side .spectrum-bookmark-name {
|
.spectrum-bookmark-chip-side .spectrum-bookmark-name {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 0.65rem;
|
font-size: 0.64rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -3763,7 +3662,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.73rem;
|
||||||
}
|
}
|
||||||
#spectrum-level-row {
|
#spectrum-level-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3810,7 +3709,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.73rem;
|
||||||
}
|
}
|
||||||
#spectrum-gamma-label {
|
#spectrum-gamma-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3825,7 +3724,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
#spectrum-gamma-value {
|
#spectrum-gamma-value {
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
min-width: 1.6rem;
|
min-width: 1.6rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -3983,7 +3882,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
|
|
||||||
.bm-label input,
|
.bm-label input,
|
||||||
.bm-label select {
|
.bm-label select {
|
||||||
font-size: 0.85rem;
|
font-size: 0.88rem;
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4039,7 +3938,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.85rem;
|
font-size: 0.84rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bm-decoder-check input {
|
.bm-decoder-check input {
|
||||||
@@ -4149,7 +4048,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
.bm-page-summary,
|
.bm-page-summary,
|
||||||
.bm-page-indicator {
|
.bm-page-indicator {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
.bm-page-controls {
|
.bm-page-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4367,7 +4266,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 0.55rem 1rem;
|
padding: 0.55rem 1rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
#sch-sat-form-cancel:hover {
|
#sch-sat-form-cancel:hover {
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
@@ -4579,7 +4478,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
.sch-log-action {
|
.sch-log-action {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
.sch-log-bm {
|
.sch-log-bm {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -4788,7 +4687,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
}
|
}
|
||||||
.bgd-checklist-meta {
|
.bgd-checklist-meta {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* ── SAT panel ──────────────────────────────────────────────────────── */
|
/* ── SAT panel ──────────────────────────────────────────────────────── */
|
||||||
@@ -4857,7 +4756,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
.stats-control-label {
|
.stats-control-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
@@ -4897,7 +4796,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
.stats-counter-label {
|
.stats-counter-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.74rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
@@ -4920,7 +4819,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
gap: 0.18rem;
|
gap: 0.18rem;
|
||||||
}
|
}
|
||||||
.stats-section-title {
|
.stats-section-title {
|
||||||
font-size: 0.85rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -4985,7 +4884,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
#tab-statistics { padding: 0.75rem; }
|
#tab-statistics { padding: 0.75rem; }
|
||||||
.stats-counters { grid-template-columns: repeat(2, 1fr); }
|
.stats-counters { grid-template-columns: repeat(2, 1fr); }
|
||||||
.stats-counter-value { font-size: 1.3rem; }
|
.stats-counter-value { font-size: 1.3rem; }
|
||||||
.stats-bar-label { min-width: 4rem; font-size: 0.75rem; }
|
.stats-bar-label { min-width: 4rem; font-size: 0.72rem; }
|
||||||
.stats-section { padding: 0.8rem 0.85rem 0.9rem; }
|
.stats-section { padding: 0.8rem 0.85rem 0.9rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5046,18 +4945,3 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Panel-switch fade ────────────────────────────────────────────────
|
|
||||||
Panels are shown by clearing inline display (app.js), so this fires on
|
|
||||||
every tab/sub-tab change. Opacity-only to avoid transforming canvases
|
|
||||||
and the Leaflet map. Gated so reduced-motion users get instant switches. */
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
.tab-panel,
|
|
||||||
.sub-tab-panel {
|
|
||||||
animation: trx-fade-in var(--dur-base) var(--ease-out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes trx-fade-in {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|||||||
-232
File diff suppressed because one or more lines are too long
@@ -75,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");
|
||||||
@@ -348,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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -668,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)
|
||||||
|
|||||||
@@ -40,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");
|
||||||
|
|||||||
+509
-6
@@ -8,10 +8,11 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::{HashSet, VecDeque};
|
use std::collections::{HashSet, 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,7 +36,7 @@ use trx_core::audio::{
|
|||||||
};
|
};
|
||||||
use trx_core::decode::{
|
use trx_core::decode::{
|
||||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, LrptImage, LrptProgress,
|
AisMessage, AprsPacket, CwEvent, 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;
|
||||||
@@ -46,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, enforce_capacity, lock_or_recover, 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;
|
||||||
@@ -64,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 {
|
||||||
@@ -342,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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1596,7 +1596,7 @@ mod tests {
|
|||||||
let t = i as f32 / fs;
|
let t = i as f32 / fs;
|
||||||
adj_phase += adj_mod_index * adj_composite[i];
|
adj_phase += adj_mod_index * adj_composite[i];
|
||||||
let adj = Complex::from_polar(0.5, adj_phase + TAU * adj_freq_offset * t);
|
let adj = Complex::from_polar(0.5, adj_phase + TAU * adj_freq_offset * t);
|
||||||
*s += adj;
|
*s = *s + adj;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut decoder = WfmStereoDecoder::new(
|
let mut decoder = WfmStereoDecoder::new(
|
||||||
@@ -1673,7 +1673,7 @@ mod tests {
|
|||||||
// Mix at 70 % of the main signal's amplitude — strong enough to
|
// Mix at 70 % of the main signal's amplitude — strong enough to
|
||||||
// overcome the FM capture effect and visibly degrade pilot coherence.
|
// overcome the FM capture effect and visibly degrade pilot coherence.
|
||||||
for (s, intf) in iq.iter_mut().zip(intf_iq.iter()) {
|
for (s, intf) in iq.iter_mut().zip(intf_iq.iter()) {
|
||||||
*s += intf * 0.7;
|
*s = *s + intf * 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut decoder = WfmStereoDecoder::new(
|
let mut decoder = WfmStereoDecoder::new(
|
||||||
|
|||||||
Reference in New Issue
Block a user