Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3080a58c4a | ||
|
|
b12c83e8b5 | ||
|
|
ff4e2a5c5d | ||
|
|
2ef9f80fc1 | ||
|
|
6fe549e34e | ||
|
|
406a8f86b1 | ||
|
|
4b17b4ac1d | ||
|
|
977f7b709e | ||
|
|
a2838b06a2 | ||
|
|
c6cd661676 | ||
|
|
bf08c7ebc0 | ||
|
|
c7470acc1c | ||
|
|
bcde03de38 | ||
|
|
63d46562c7 |
@@ -0,0 +1,47 @@
|
|||||||
|
# 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
|
||||||
@@ -11,6 +11,7 @@ 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",
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Work In Progress — Project Improvement Areas
|
||||||
|
|
||||||
|
Living tracker for engineering-infrastructure and hardening work identified
|
||||||
|
during a July 2026 repo scan. The architecture-level backlog
|
||||||
|
(`docs/Improvement-Areas.md`, P0–P3) is closed; these items focus on the
|
||||||
|
tooling, robustness, and product concerns *around* the code.
|
||||||
|
|
||||||
|
Status legend: **Done** · **In progress** · **Not started**
|
||||||
|
|
||||||
|
| # | Tier | Area | Status |
|
||||||
|
|----|------|------|--------|
|
||||||
|
| 1 | 1 — Infrastructure | Gitea Actions CI (fmt, clippy, test, REUSE) | In progress |
|
||||||
|
| 2 | 1 — Infrastructure | Supply-chain & lint governance (cargo-deny/audit, MSRV, `[workspace.lints]`) | Not started |
|
||||||
|
| 3 | 1 — Infrastructure | Release & deployment (container image, systemd units, binary releases) | Not started |
|
||||||
|
| 4 | 2 — Robustness | Panic-resilience audit (harden ~495 unwrap/expect/panic sites) | Not started |
|
||||||
|
| 5 | 2 — Robustness | `unsafe` SIMD safety scaffolding (`# Safety` docs, scalar↔SIMD equivalence tests) | Not started |
|
||||||
|
| 6 | 2 — Robustness | DSP performance benchmarks (criterion, guard optimization gains) | Not started |
|
||||||
|
| 7 | 2 — Robustness | Test-coverage measurement & gap-filling (llvm-cov; CAT backends, server tasks) | Not started |
|
||||||
|
| 8 | 3 — Product | Frontend modularization & tooling (split `app.js` into ES modules, ESLint, JS tests) | Not started |
|
||||||
|
| 9 | 3 — Product | Runtime observability (`/health`, Prometheus metrics) | Not started |
|
||||||
|
| 10 | 3 — Product | Documentation freshness & consolidation (reconcile `docs/` with code) | Not started |
|
||||||
|
|
||||||
|
## Tier 1 — Infrastructure gaps
|
||||||
|
|
||||||
|
### 1. Gitea Actions CI
|
||||||
|
No `.gitea/` / `.github/` / Woodpecker workflows exist, despite 768 tests.
|
||||||
|
Add a workflow running `cargo fmt --check`, `cargo clippy -D warnings`,
|
||||||
|
`cargo build`/`cargo test`, and REUSE lint on push + pull_request.
|
||||||
|
System deps: `pkg-config cmake libopus-dev libasound2-dev libsoapysdr-dev`
|
||||||
|
(the `soapysdr` backend is a default feature).
|
||||||
|
|
||||||
|
Notes for the runner: assumes an `act_runner` registered with an
|
||||||
|
`ubuntu-latest` label and GitHub-action proxying enabled (used by
|
||||||
|
`actions/checkout`, `actions/cache`, `fsfe/reuse-action`). `clippy` runs
|
||||||
|
with `-D warnings`; core crates are already clean, but if the first full
|
||||||
|
`--all-features` run surfaces warnings in a less-travelled crate, fix them
|
||||||
|
(preferred) or temporarily soften that step.
|
||||||
|
|
||||||
|
### 2. Supply-chain & lint governance
|
||||||
|
No `deny.toml`, `cargo audit`, `rustfmt.toml`/`clippy.toml`, declared MSRV,
|
||||||
|
or `[workspace.lints]`. Add dependency auditing to CI, pin an MSRV
|
||||||
|
(`rust-version`), and centralize lint policy in the workspace manifest.
|
||||||
|
|
||||||
|
### 3. Release & deployment
|
||||||
|
No `Dockerfile`, systemd units, packaging, or release automation (only
|
||||||
|
`script/dummy-server.sh`). Add a container image, example systemd units for
|
||||||
|
`trx-server`/`trx-client`, and a tag-triggered static-binary release job.
|
||||||
|
|
||||||
|
## Tier 2 — Robustness & correctness
|
||||||
|
|
||||||
|
### 4. Panic-resilience audit
|
||||||
|
495 `unwrap()`/`expect()`/`panic!` sites, 11 in the hottest server files
|
||||||
|
(`audio.rs`, `rig_task.rs`). A panic there can drop a rig task or the
|
||||||
|
process. Convert hot-path panics to error propagation / graceful
|
||||||
|
degradation; reserve `expect` for documented invariants.
|
||||||
|
|
||||||
|
### 5. `unsafe` SIMD safety scaffolding
|
||||||
|
13 `unsafe` blocks (AVX2 DSP). Add `# Safety` docs stating invariants,
|
||||||
|
confirm runtime feature detection is tested, and add property tests
|
||||||
|
asserting SIMD output matches the scalar fallback across random inputs.
|
||||||
|
|
||||||
|
### 6. DSP performance benchmarks
|
||||||
|
`docs/Optimization-Guidelines.md` documents NCO/polyphase/AVX2 gains, but no
|
||||||
|
`criterion` benches guard them. Add benches for the demod/resample/FFT hot
|
||||||
|
paths so regressions surface as numbers.
|
||||||
|
|
||||||
|
### 7. Test-coverage measurement & gap-filling
|
||||||
|
67 of 146 Rust files have no test module. Protocol is well covered; backends
|
||||||
|
(CAT BCD/ASCII encoding), `listener.rs`, and `config.rs` look thin. Wire up
|
||||||
|
`cargo-llvm-cov` and target the CAT backends and server tasks first.
|
||||||
|
|
||||||
|
## Tier 3 — Product & maintainability
|
||||||
|
|
||||||
|
### 8. Frontend modularization & tooling
|
||||||
|
`app.js` is 8,760 lines and `map-core.js` 3,515, with no modules, linter, or
|
||||||
|
tests. Keeping vanilla HTML+JS (no framework), split into native ES modules
|
||||||
|
by concern, add ESLint + Prettier, and add `node:test` unit tests for pure
|
||||||
|
logic (frequency formatting, unit math, decode parsing).
|
||||||
|
|
||||||
|
### 9. Runtime observability
|
||||||
|
`tracing` is set up, but there is no `/health` endpoint or metrics
|
||||||
|
instrumentation. Add health/readiness endpoints and Prometheus-format
|
||||||
|
metrics (decode rates, reconnects, audio underruns, per-rig state).
|
||||||
|
|
||||||
|
### 10. Documentation freshness & consolidation
|
||||||
|
`docs/` (13 files) is mostly dated 2026-03-29 while code moved into July, and
|
||||||
|
mixes planning artifacts with reference docs. Reconcile against current code,
|
||||||
|
separate "plans" from "reference," and fold still-true content into the
|
||||||
|
canonical docs.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 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"]
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<!--
|
||||||
|
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"
|
||||||
|
```
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/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"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Rootless Podman Quadlet for the SECOND project's Gitea Actions runner,
|
||||||
|
# co-located on the same host as the trx-rs runner.
|
||||||
|
#
|
||||||
|
# It has its own name, its own data volume and its own registration token, so
|
||||||
|
# the two runners are fully independent. They share the `ubuntu-latest` label,
|
||||||
|
# but registration SCOPE (which repo each token came from) keeps their jobs
|
||||||
|
# separate — neither will pick up the other's work.
|
||||||
|
#
|
||||||
|
# If project 2 needs different build dependencies, build it its own image from
|
||||||
|
# an adjusted Containerfile and point Image= at that instead of reusing the
|
||||||
|
# trx-rs image below.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Gitea Actions runner — project 2
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
Image=localhost/gitea-act-runner:latest
|
||||||
|
ContainerName=gitea-runner-project2
|
||||||
|
Volume=gitea-runner-project2-data:/data:Z
|
||||||
|
|
||||||
|
Environment=CONFIG_FILE=/data/config.yaml
|
||||||
|
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
|
||||||
|
Environment=GITEA_RUNNER_NAME=project2-podman
|
||||||
|
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
|
||||||
|
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
|
||||||
|
|
||||||
|
PodmanArgs=--cpus=4.0 --memory=6g
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
TimeoutStartSec=0
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 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)? as u32;
|
let mmsi = get_uint(&bits, 8, 30)?;
|
||||||
|
|
||||||
let mut msg = AisMessage {
|
let mut msg = AisMessage {
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
|
|||||||
@@ -638,7 +638,7 @@ mod tests {
|
|||||||
for (i, &ch) in b"N0CALL".iter().enumerate() {
|
for (i, &ch) in b"N0CALL".iter().enumerate() {
|
||||||
addr[i] = ch << 1;
|
addr[i] = ch << 1;
|
||||||
}
|
}
|
||||||
addr[6] = (0 << 1) | 1; // SSID=0, last=true
|
addr[6] = 1; // SSID=0, last=true
|
||||||
|
|
||||||
let decoded = decode_ax25_address(&addr, 0);
|
let decoded = decode_ax25_address(&addr, 0);
|
||||||
assert_eq!(decoded.call, "N0CALL");
|
assert_eq!(decoded.call, "N0CALL");
|
||||||
@@ -652,7 +652,7 @@ mod tests {
|
|||||||
for (i, &ch) in b"SP2SJG".iter().enumerate() {
|
for (i, &ch) in b"SP2SJG".iter().enumerate() {
|
||||||
addr[i] = ch << 1;
|
addr[i] = ch << 1;
|
||||||
}
|
}
|
||||||
addr[6] = (5 << 1) | 0; // SSID=5, last=false
|
addr[6] = 5 << 1; // SSID=5, last=false
|
||||||
|
|
||||||
let decoded = decode_ax25_address(&addr, 0);
|
let decoded = decode_ax25_address(&addr, 0);
|
||||||
assert_eq!(decoded.call, "SP2SJG");
|
assert_eq!(decoded.call, "SP2SJG");
|
||||||
@@ -667,7 +667,7 @@ mod tests {
|
|||||||
for (i, &ch) in b"W1AW ".iter().enumerate() {
|
for (i, &ch) in b"W1AW ".iter().enumerate() {
|
||||||
addr[i] = ch << 1;
|
addr[i] = ch << 1;
|
||||||
}
|
}
|
||||||
addr[6] = (0 << 1) | 1;
|
addr[6] = 1;
|
||||||
|
|
||||||
let decoded = decode_ax25_address(&addr, 0);
|
let decoded = decode_ax25_address(&addr, 0);
|
||||||
assert_eq!(decoded.call, "W1AW");
|
assert_eq!(decoded.call, "W1AW");
|
||||||
@@ -691,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((0 << 1) | 1); // SSID=0, last=true
|
frame.push(1); // SSID=0, last=true
|
||||||
// Control + PID
|
// Control + PID
|
||||||
frame.push(0x03); // UI frame
|
frame.push(0x03); // UI frame
|
||||||
frame.push(0xF0); // No layer-3 protocol
|
frame.push(0xF0); // No layer-3 protocol
|
||||||
|
|||||||
@@ -160,8 +160,7 @@ impl CallsignHashTable {
|
|||||||
let mut idx = start_idx;
|
let mut idx = start_idx;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match &self.entries[idx] {
|
let entry = self.entries[idx].as_ref()?;
|
||||||
Some(entry) => {
|
|
||||||
let stored = (entry.hash & HASH22_MASK) >> shift;
|
let stored = (entry.hash & HASH22_MASK) >> shift;
|
||||||
if stored == target {
|
if stored == target {
|
||||||
return Some(entry.callsign.clone());
|
return Some(entry.callsign.clone());
|
||||||
@@ -171,9 +170,6 @@ 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 i in 0..num_rows {
|
for &nm in FTX_LDPC_NM[m].iter().take(num_rows) {
|
||||||
x ^= codeword[FTX_LDPC_NM[m][i] as usize - 1];
|
x ^= codeword[nm as usize - 1];
|
||||||
}
|
}
|
||||||
if x != 0 {
|
if x != 0 {
|
||||||
errors += 1;
|
errors += 1;
|
||||||
@@ -81,11 +81,11 @@ pub fn ldpc_decode(
|
|||||||
for j in 0..FTX_LDPC_M {
|
for j in 0..FTX_LDPC_M {
|
||||||
let num_rows = FTX_LDPC_NUM_ROWS[j] as usize;
|
let num_rows = FTX_LDPC_NUM_ROWS[j] as usize;
|
||||||
let m_row = j * FTX_LDPC_N;
|
let m_row = j * FTX_LDPC_N;
|
||||||
for ii1 in 0..num_rows {
|
for &nm1 in FTX_LDPC_NM[j].iter().take(num_rows) {
|
||||||
let i1 = FTX_LDPC_NM[j][ii1] as usize - 1;
|
let i1 = nm1 as usize - 1;
|
||||||
let mut a = 1.0f32;
|
let mut a = 1.0f32;
|
||||||
for ii2 in 0..num_rows {
|
for &nm2 in FTX_LDPC_NM[j].iter().take(num_rows) {
|
||||||
let i2 = FTX_LDPC_NM[j][ii2] as usize - 1;
|
let i2 = nm2 as usize - 1;
|
||||||
if i2 != i1 {
|
if i2 != i1 {
|
||||||
a *= fast_tanh(-m_matrix[m_row + i2] / 2.0f32);
|
a *= fast_tanh(-m_matrix[m_row + i2] / 2.0f32);
|
||||||
}
|
}
|
||||||
@@ -97,8 +97,8 @@ pub fn ldpc_decode(
|
|||||||
// Hard decisions
|
// Hard decisions
|
||||||
for i in 0..FTX_LDPC_N {
|
for i in 0..FTX_LDPC_N {
|
||||||
let mut l = codeword[i];
|
let mut l = codeword[i];
|
||||||
for j in 0..3 {
|
for &mn in FTX_LDPC_MN[i].iter().take(3) {
|
||||||
l += e_matrix[(FTX_LDPC_MN[i][j] as usize - 1) * FTX_LDPC_N + i];
|
l += e_matrix[(mn as usize - 1) * FTX_LDPC_N + i];
|
||||||
}
|
}
|
||||||
plain[i] = if l > 0.0 { 1 } else { 0 };
|
plain[i] = if l > 0.0 { 1 } else { 0 };
|
||||||
}
|
}
|
||||||
@@ -113,12 +113,12 @@ pub fn ldpc_decode(
|
|||||||
|
|
||||||
// Update m[][] from e[][]
|
// Update m[][] from e[][]
|
||||||
for i in 0..FTX_LDPC_N {
|
for i in 0..FTX_LDPC_N {
|
||||||
for ji1 in 0..3 {
|
for (ji1, &mn1) in FTX_LDPC_MN[i].iter().enumerate().take(3) {
|
||||||
let j1 = FTX_LDPC_MN[i][ji1] as usize - 1;
|
let j1 = mn1 as usize - 1;
|
||||||
let mut l = codeword[i];
|
let mut l = codeword[i];
|
||||||
for ji2 in 0..3 {
|
for (ji2, &mn2) in FTX_LDPC_MN[i].iter().enumerate().take(3) {
|
||||||
if ji1 != ji2 {
|
if ji1 != ji2 {
|
||||||
let j2 = FTX_LDPC_MN[i][ji2] as usize - 1;
|
let j2 = mn2 as usize - 1;
|
||||||
l += e_matrix[j2 * FTX_LDPC_N + i];
|
l += e_matrix[j2 * FTX_LDPC_N + i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,11 +64,9 @@ pub fn charn(mut c: i32, table: CharTable) -> char {
|
|||||||
return EXTRAS[c as usize];
|
return EXTRAS[c as usize];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CharTable::AlphanumSpaceSlash => {
|
CharTable::AlphanumSpaceSlash if c == 0 => {
|
||||||
if c == 0 {
|
|
||||||
return '/';
|
return '/';
|
||||||
}
|
}
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,11 +114,9 @@ pub fn nchar(c: char, table: CharTable) -> Option<i32> {
|
|||||||
'?' => return Some(n + 4),
|
'?' => return Some(n + 4),
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
CharTable::AlphanumSpaceSlash => {
|
CharTable::AlphanumSpaceSlash if c == '/' => {
|
||||||
if c == '/' {
|
|
||||||
return Some(n);
|
return Some(n);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -632,33 +632,18 @@ impl Candidate {
|
|||||||
}
|
}
|
||||||
let segment = usize::from((block_b & 0x0003) as u8);
|
let segment = usize::from((block_b & 0x0003) as u8);
|
||||||
let di = ((block_b >> 2) & 0x1) != 0;
|
let di = ((block_b >> 2) & 0x1) != 0;
|
||||||
match segment {
|
let di_flag = Some(di);
|
||||||
0 => {
|
let slot = match segment {
|
||||||
if self.state.dynamic_pty != Some(di) {
|
0 => &mut self.state.dynamic_pty,
|
||||||
self.state.dynamic_pty = Some(di);
|
1 => &mut self.state.compressed,
|
||||||
|
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);
|
||||||
@@ -1458,9 +1443,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BPSK modulate onto the 57 kHz subcarrier.
|
// BPSK modulate onto the 57 kHz subcarrier.
|
||||||
for t in 0..n {
|
for (t, sample) in shaped.iter_mut().enumerate().take(n) {
|
||||||
let phase = TAU * RDS_SUBCARRIER_HZ * t as f32 / sample_rate;
|
let phase = TAU * RDS_SUBCARRIER_HZ * t as f32 / sample_rate;
|
||||||
shaped[t] *= phase.cos();
|
*sample *= phase.cos();
|
||||||
}
|
}
|
||||||
shaped
|
shaped
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,9 +134,7 @@ mod tests {
|
|||||||
.flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
|
.flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
|
||||||
.collect();
|
.collect();
|
||||||
// Append wrong CRC
|
// Append wrong CRC
|
||||||
for _ in 0..16 {
|
bits.resize(bits.len() + 16, 0);
|
||||||
bits.push(0);
|
|
||||||
}
|
|
||||||
assert!(!check_crc16(&bits));
|
assert!(!check_crc16(&bits));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -346,8 +346,8 @@ mod tests {
|
|||||||
write_bits(&mut bits, 12, 32, 123456); // source_id
|
write_bits(&mut bits, 12, 32, 123456); // source_id
|
||||||
write_bits(&mut bits, 44, 11, 20); // data_count = 20
|
write_bits(&mut bits, 44, 11, 20); // data_count = 20
|
||||||
// Fill some payload
|
// Fill some payload
|
||||||
for i in 55..75 {
|
for (i, bit) in bits.iter_mut().enumerate().take(75).skip(55) {
|
||||||
bits[i] = (i % 2) as u8;
|
*bit = (i % 2) as u8;
|
||||||
}
|
}
|
||||||
append_crc(&mut bits);
|
append_crc(&mut bits);
|
||||||
|
|
||||||
|
|||||||
@@ -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!(m >= 1 && m <= 12);
|
assert!((1..=12).contains(&m));
|
||||||
assert!(d >= 1 && d <= 31);
|
assert!((1..=31).contains(&d));
|
||||||
assert!(h < 24);
|
assert!(h < 24);
|
||||||
assert!(mi < 60);
|
assert!(mi < 60);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,10 +161,8 @@ mod tests {
|
|||||||
|
|
||||||
for line_idx in 0..20 {
|
for line_idx in 0..20 {
|
||||||
let mut line = vec![1.0f32; spl];
|
let mut line = vec![1.0f32; spl];
|
||||||
for j in pulse_start..pulse_start + pw {
|
for slot in line.iter_mut().skip(pulse_start).take(pw) {
|
||||||
if j < spl {
|
*slot = 0.0;
|
||||||
line[j] = 0.0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let result = det.process(&line);
|
let result = det.process(&line);
|
||||||
if let Some(offset) = result {
|
if let Some(offset) = result {
|
||||||
|
|||||||
@@ -483,7 +483,7 @@ mod tests {
|
|||||||
let c4 = idx27(b'T');
|
let c4 = idx27(b'T');
|
||||||
let c5 = idx27(b' ');
|
let c5 = idx27(b' ');
|
||||||
let n1 = ((c0 * 36 + c1) * 10 + c2) * 27u32.pow(3) + c3 * 27u32.pow(2) + c4 * 27 + c5;
|
let n1 = ((c0 * 36 + c1) * 10 + c2) * 27u32.pow(3) + c3 * 27u32.pow(2) + c4 * 27 + c5;
|
||||||
let m1 = (179 - 10 * 5 - 2) * 180 + 10 * 13 + 0; // FN20
|
let m1 = (179 - 10 * 5 - 2) * 180 + 10 * 13; // FN20 (final term is 0)
|
||||||
let power_code = 37u32;
|
let power_code = 37u32;
|
||||||
|
|
||||||
let mut input_bits = [0u8; NBITS];
|
let mut input_bits = [0u8; NBITS];
|
||||||
@@ -530,8 +530,8 @@ mod tests {
|
|||||||
fn interleave_deinterleave_roundtrip() {
|
fn interleave_deinterleave_roundtrip() {
|
||||||
// Create a sequence of distinguishable values
|
// Create a sequence of distinguishable values
|
||||||
let mut original = [0u8; NSYMS];
|
let mut original = [0u8; NSYMS];
|
||||||
for i in 0..NSYMS {
|
for (i, slot) in original.iter_mut().enumerate() {
|
||||||
original[i] = (i % 256) as u8;
|
*slot = (i % 256) as u8;
|
||||||
}
|
}
|
||||||
|
|
||||||
let interleaved = interleave(&original);
|
let interleaved = interleave(&original);
|
||||||
|
|||||||
@@ -356,59 +356,6 @@ async fn run_single_rig_audio_client(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{resolve_audio_addr, AudioConnectConfig};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_audio_addr_prefers_fixed_url() {
|
|
||||||
let mut rig_connect = HashMap::new();
|
|
||||||
rig_connect.insert(
|
|
||||||
"home-hf".to_string(),
|
|
||||||
AudioConnectConfig::fixed("audio.example.com:4700".to_string()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let addr = resolve_audio_addr(
|
|
||||||
"home-hf",
|
|
||||||
Some(4531),
|
|
||||||
&rig_connect,
|
|
||||||
&AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
|
||||||
);
|
|
||||||
assert_eq!(addr, "audio.example.com:4700");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_audio_addr_uses_advertised_port_with_remote_host() {
|
|
||||||
let mut rig_connect = HashMap::new();
|
|
||||||
rig_connect.insert(
|
|
||||||
"home-hf".to_string(),
|
|
||||||
AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
|
||||||
);
|
|
||||||
|
|
||||||
let addr = resolve_audio_addr(
|
|
||||||
"home-hf",
|
|
||||||
Some(4600),
|
|
||||||
&rig_connect,
|
|
||||||
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
|
||||||
);
|
|
||||||
assert_eq!(addr, "control.example.com:4600");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_audio_addr_falls_back_to_default_port() {
|
|
||||||
let rig_connect = HashMap::new();
|
|
||||||
|
|
||||||
let addr = resolve_audio_addr(
|
|
||||||
"home-hf",
|
|
||||||
None,
|
|
||||||
&rig_connect,
|
|
||||||
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
|
||||||
);
|
|
||||||
assert_eq!(addr, "fallback.example.com:4531");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a single TCP connection for one rig. Similar to `handle_audio_connection`
|
/// Handle a single TCP connection for one rig. Similar to `handle_audio_connection`
|
||||||
/// but publishes to per-rig channels directly and mirrors to global when selected.
|
/// but publishes to per-rig channels directly and mirrors to global when selected.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -767,3 +714,56 @@ async fn handle_single_rig_connection(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{resolve_audio_addr, AudioConnectConfig};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_audio_addr_prefers_fixed_url() {
|
||||||
|
let mut rig_connect = HashMap::new();
|
||||||
|
rig_connect.insert(
|
||||||
|
"home-hf".to_string(),
|
||||||
|
AudioConnectConfig::fixed("audio.example.com:4700".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let addr = resolve_audio_addr(
|
||||||
|
"home-hf",
|
||||||
|
Some(4531),
|
||||||
|
&rig_connect,
|
||||||
|
&AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
||||||
|
);
|
||||||
|
assert_eq!(addr, "audio.example.com:4700");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_audio_addr_uses_advertised_port_with_remote_host() {
|
||||||
|
let mut rig_connect = HashMap::new();
|
||||||
|
rig_connect.insert(
|
||||||
|
"home-hf".to_string(),
|
||||||
|
AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
||||||
|
);
|
||||||
|
|
||||||
|
let addr = resolve_audio_addr(
|
||||||
|
"home-hf",
|
||||||
|
Some(4600),
|
||||||
|
&rig_connect,
|
||||||
|
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
||||||
|
);
|
||||||
|
assert_eq!(addr, "control.example.com:4600");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_audio_addr_falls_back_to_default_port() {
|
||||||
|
let rig_connect = HashMap::new();
|
||||||
|
|
||||||
|
let addr = resolve_audio_addr(
|
||||||
|
"home-hf",
|
||||||
|
None,
|
||||||
|
&rig_connect,
|
||||||
|
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
||||||
|
);
|
||||||
|
assert_eq!(addr, "fallback.example.com:4531");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1110,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 mut config = ClientConfig::default();
|
let config = ClientConfig {
|
||||||
config.remotes = vec![
|
remotes: vec![
|
||||||
RemoteEntry {
|
RemoteEntry {
|
||||||
name: "dup".to_string(),
|
name: "dup".to_string(),
|
||||||
url: "a:4530".to_string(),
|
url: "a:4530".to_string(),
|
||||||
@@ -1126,20 +1126,24 @@ 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 mut config = ClientConfig::default();
|
let config = ClientConfig {
|
||||||
config.remotes = vec![RemoteEntry {
|
remotes: vec![RemoteEntry {
|
||||||
name: "".to_string(),
|
name: "".to_string(),
|
||||||
url: "a:4530".to_string(),
|
url: "a:4530".to_string(),
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
}];
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
assert!(config
|
assert!(config
|
||||||
.validate()
|
.validate()
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
@@ -1148,14 +1152,16 @@ url = "remote.example.com:4530"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_rejects_empty_remote_url() {
|
fn test_validate_rejects_empty_remote_url() {
|
||||||
let mut config = ClientConfig::default();
|
let config = ClientConfig {
|
||||||
config.remotes = vec![RemoteEntry {
|
remotes: vec![RemoteEntry {
|
||||||
name: "hf".to_string(),
|
name: "hf".to_string(),
|
||||||
url: " ".to_string(),
|
url: " ".to_string(),
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
}];
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
assert!(config
|
assert!(config
|
||||||
.validate()
|
.validate()
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
@@ -1164,14 +1170,16 @@ url = "remote.example.com:4530"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_rejects_zero_remote_poll_interval() {
|
fn test_validate_rejects_zero_remote_poll_interval() {
|
||||||
let mut config = ClientConfig::default();
|
let config = ClientConfig {
|
||||||
config.remotes = vec![RemoteEntry {
|
remotes: vec![RemoteEntry {
|
||||||
name: "hf".to_string(),
|
name: "hf".to_string(),
|
||||||
url: "a:4530".to_string(),
|
url: "a:4530".to_string(),
|
||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 0,
|
poll_interval_ms: 0,
|
||||||
}];
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
assert!(config
|
assert!(config
|
||||||
.validate()
|
.validate()
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
|
|||||||
@@ -1678,7 +1678,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn global_target_for_snapshot_skips_other_server_selection() {
|
fn global_target_for_snapshot_skips_other_server_selection() {
|
||||||
let snapshot = sample_snapshot();
|
let snapshot = sample_snapshot();
|
||||||
let rigs = vec![RigEntry {
|
let rigs = [RigEntry {
|
||||||
rig_id: "hf".to_string(),
|
rig_id: "hf".to_string(),
|
||||||
display_name: Some("Gdansk HF".to_string()),
|
display_name: Some("Gdansk HF".to_string()),
|
||||||
state: snapshot,
|
state: snapshot,
|
||||||
|
|||||||
@@ -1624,6 +1624,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.11/dist/opus-decoder.min.js" charset="UTF-8"></script>
|
<script defer src="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>
|
||||||
@@ -1632,41 +1634,67 @@ 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', '/leaflet-ais-tracksymbol.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'],
|
'map': ['/map-core.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();
|
||||||
function loadPlugins(tab) {
|
var loading = new Map();
|
||||||
var scripts = pluginScripts[tab];
|
|
||||||
if (!scripts) return;
|
function loadScript(src) {
|
||||||
scripts.forEach(function(src) {
|
if (loaded.has(src)) return Promise.resolve();
|
||||||
if (loaded.has(src)) return;
|
if (loading.has(src)) return loading.get(src);
|
||||||
loaded.add(src);
|
|
||||||
|
var request = new Promise(function(resolve, reject) {
|
||||||
var s = document.createElement('script');
|
var s = document.createElement('script');
|
||||||
s.src = src;
|
s.src = src;
|
||||||
s.defer = true;
|
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);
|
document.body.appendChild(s);
|
||||||
});
|
});
|
||||||
|
loading.set(src, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPlugins(tab) {
|
||||||
|
var scripts = pluginScripts[tab];
|
||||||
|
if (!scripts) return Promise.resolve();
|
||||||
|
return scripts.reduce(function(sequence, src) {
|
||||||
|
return sequence.then(function() { return loadScript(src); });
|
||||||
|
}, Promise.resolve());
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestPlugins(tab) {
|
||||||
|
return loadPlugins(tab).catch(function(err) {
|
||||||
|
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)
|
||||||
// via window.loadEagerPlugins(). Dynamic scripts are effectively async, so
|
// via window.loadEagerPlugins(). Dynamic scripts are effectively async, so
|
||||||
// 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() {
|
||||||
['digital-modes', 'map-data', 'bookmarks', 'settings'].forEach(loadPlugins);
|
return Promise.all(
|
||||||
|
['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) loadPlugins(tab.dataset.tab);
|
if (tab) requestPlugins(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>
|
||||||
|
|||||||
@@ -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 = *s + adj;
|
*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 = *s + intf * 0.7;
|
*s += intf * 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut decoder = WfmStereoDecoder::new(
|
let mut decoder = WfmStereoDecoder::new(
|
||||||
|
|||||||
Reference in New Issue
Block a user