Compare commits

..
Author SHA1 Message Date
sjgandClaude Opus 5 e52c4839c9 [fix](trx-frontend-http): give satellite passes their own page
CI / lint (pull_request) Successful in 6m30s
CI / test (pull_request) Successful in 8m31s
CI / frontend (pull_request) Successful in 4m30s
CI / reuse (pull_request) Successful in 5s
Pass predictions were a third view inside the Weather Satellite Decoder card,
under Digital modes — a planning tool filed behind a decoder toggle, beside the
FT8 and WEFAX panels it has nothing to do with.  Nothing about knowing when a
bird comes over belongs there.

Move them to /satellites, reached from Tools alongside Statistics, Recorder,
Settings and About: occasional destinations that live behind that menu rather
than taking a slot in the operating strip.  Adding a sixth strip button wrapped
the phone nav onto two rows and cost the desktop strip its labels at 1280px, so
the tab is hidden from the strip exactly the way its four peers already are —
the nav is byte-for-byte what it was.

The prediction code moves out of sat.ts into its own plugin that loads with the
page, so the decoder card no longer carries it.  Countdowns stop when the page
is hidden and each visit reloads, since passes go stale while it is closed.
The server grows a /satellites index route so a deep link or a refresh serves
the SPA shell rather than a 404.

Closes #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 23:23:48 +02:00
88 changed files with 3941 additions and 9325 deletions
+1 -8
View File
@@ -24,12 +24,6 @@ cargo test -p trx-core
./target/release/trx-server --print-config > trx-server.toml
./target/release/trx-client --print-config > trx-client.toml
# Validate a config without starting anything (reports every problem)
./target/release/trx-server --check-config --config trx-rs.toml
# Regenerate trx-rs.toml.example after changing a config struct
cargo run -p trx-config --example generate_example
# Run server
./target/release/trx-server --config trx-server.toml
# or via CLI args:
@@ -47,8 +41,7 @@ This is a Cargo workspace. All crates live under `src/`:
src/
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
trx-app/ # Shared application helpers (logging init, name normalization)
trx-config/ # Client + server config structs, loader, validators (~2,500 LOC)
trx-app/ # Shared application helpers (config paths, logging init)
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
trx-backend/ # Backend abstraction trait + factory + dummy
Generated
+4 -32
View File
@@ -2412,16 +2412,6 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.149"
@@ -3041,6 +3031,10 @@ dependencies = [
name = "trx-app"
version = "0.1.0"
dependencies = [
"dirs",
"serde",
"thiserror 2.0.18",
"toml",
"tracing",
"tracing-subscriber",
]
@@ -3121,7 +3115,6 @@ dependencies = [
"toml",
"tracing",
"trx-app",
"trx-config",
"trx-core",
"trx-frontend",
"trx-frontend-http",
@@ -3131,23 +3124,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "trx-config"
version = "0.1.0"
dependencies = [
"dirs",
"serde",
"serde_ignored",
"tempfile",
"thiserror 2.0.18",
"toml",
"toml_edit 0.22.27",
"tracing",
"trx-core",
"trx-decode-log",
"trx-reporting",
]
[[package]]
name = "trx-configurator"
version = "0.1.0"
@@ -3156,16 +3132,13 @@ dependencies = [
"dialoguer",
"tempfile",
"tokio-serial",
"toml",
"toml_edit 0.22.27",
"trx-config",
]
[[package]]
name = "trx-core"
version = "0.1.0"
dependencies = [
"base64",
"flate2",
"reqwest",
"serde",
@@ -3321,7 +3294,6 @@ dependencies = [
"trx-app",
"trx-aprs",
"trx-backend",
"trx-config",
"trx-core",
"trx-cw",
"trx-decode-log",
-1
View File
@@ -18,7 +18,6 @@ members = [
"src/trx-core",
"src/trx-protocol",
"src/trx-app",
"src/trx-config",
"src/trx-reporting",
"src/trx-server",
"src/trx-server/trx-backend",
+3 -10
View File
@@ -93,17 +93,13 @@ The wizard walks you through rig selection, serial port detection, audio
settings, and frontend options, then writes `trx-server.toml` and
`trx-client.toml`.
Alternatively, copy `trx-rs.toml.example` — a commented example covering every
setting — and edit it by hand:
Alternatively, generate example configs and edit them by hand:
```bash
cp trx-rs.toml.example trx-rs.toml
./target/release/trx-server --check-config --config trx-rs.toml
./target/release/trx-server --print-config > trx-server.toml
./target/release/trx-client --print-config > trx-client.toml
```
`--check-config` reports everything wrong with a config without starting
anything. `--print-config` prints the same settings without comments.
### 4. Run
```bash
@@ -111,9 +107,6 @@ anything. `--print-config` prints the same settings without comments.
./target/release/trx-client --config trx-client.toml
```
A single `trx-rs.toml` can configure both: the server reads its `[trx-server]`
section and the client reads `[trx-client]`.
Open the configured HTTP frontend address in a browser (default `http://localhost:8080`).
## How It Works
-252
View File
@@ -322,255 +322,3 @@ trx-configurator
| `trx-app` | Config types and validation | Yes |
| `serialport` | Serial port enumeration | Yes (transitive) |
| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) |
---
## Logbook and Ham Radio Layout
Two halves of one feature ([#54](https://git.haxx.space/sjg/trx-rs/issues/54)): a station
logbook in a panel of its own, and an operator layout that puts a transceiver's controls
around it.
Nothing in the application records a QSO today. The map's "QSO summary" cards describe
contacts *between other stations*, reconstructed from decoded traffic; bookmarks are
frequencies, not contacts. Neither is the operator's own log.
### Requirements
| ID | Description |
|----|-------------|
| REQ-LOG-001 | The system shall record QSOs the operator makes, each holding at minimum callsign, date, time, band, frequency, mode and both signal reports. |
| REQ-LOG-002 | When starting a log entry, the system shall pre-fill exactly six fields: frequency, mode, rig name, time, callsign and locator. |
| REQ-LOG-003 | The system shall leave every other field of a log entry empty for the operator to fill. |
| REQ-LOG-004 | The system shall allow a logged QSO to be edited and deleted. |
| REQ-LOG-005 | The system shall survive a crash without losing a QSO that was recorded before it. |
| REQ-LOG-006 | The system shall list, search and filter the log by callsign, band, mode and date range. |
| REQ-LOG-007 | Where a decoded station is on screen, the system shall offer to start a log entry from it, pre-filled, without logging it unattended. |
| REQ-LOG-008 | The system shall show whether a callsign has been worked before, and on which bands. |
| REQ-FMT-001 | The system shall export the log as an ADIF 3.1.x `.adi` file. |
| REQ-FMT-002 | The system shall import ADIF `.adi` files produced by other logging software, preserving fields it does not itself use. |
| REQ-FMT-003 | When importing, the system shall identify QSOs already held and shall not duplicate them. |
| REQ-FMT-004 | The system shall export a filtered selection of the log as a Cabrillo 3.0 file for contest submission. |
| REQ-LOG-009 | The system shall stamp QSO times from the server's clock in UTC, and shall tell the operator when the browser's clock disagrees with it by more than one second. |
| REQ-LAY-001 | The system shall present the logbook in a panel of its own, reachable whatever layout is selected. |
| REQ-LAY-002 | The system shall offer a "Ham radio" operator layout presenting the transceiver controls and that panel together. |
| REQ-LAY-003 | Where the selected rig cannot transmit, the system shall not offer the ham layout. |
### A decode is not a QSO
The decoders are receive-only: FT8, CW, APRS and the rest report what was *heard*. A heard
callsign is the beginning of a log entry, not a contact, and the logbook must never write one
by itself — REQ-LOG-007 says offer and pre-fill, never auto-log. Digital QSOs made in
WSJT-X or similar arrive the way every other logger takes them: through ADIF import.
### What is pre-filled, and what is not
Six fields, and no more (REQ-LOG-002, REQ-LOG-003):
| Field | From | ADIF |
|-------|------|------|
| Frequency | the selected rig's dial | `FREQ`, with `BAND` derived from it |
| Mode | the selected rig | `MODE`, and `SUBMODE` where the mode implies one |
| Rig name | the rig's display name | `MY_RIG` |
| Time | the server's clock, UTC, when the entry opens | `QSO_DATE`, `TIME_ON` |
| Callsign | the decode row or map station the entry was started from, else empty | `CALL` |
| Locator | that station's grid, where the decode carried one, else empty | `GRIDSQUARE` |
Signal reports, power, name, QTH and the rest stay empty. A report in particular is the
operator's to give: an FT8 SNR is not what was sent, and pre-filling one would put a number in
the log that nobody exchanged.
The station's own callsign and locator are not pre-filled per entry either — they are station
identity, taken from configuration when the QSO is written (`STATION_CALLSIGN`, `OPERATOR`,
`MY_GRIDSQUARE`), and shown once at the top of the panel rather than typed into every row.
### Architecture
#### New crate: `trx-logbook`
```
src/trx-client/
trx-logbook/
src/
lib.rs # LogbookHandle: open, add, edit, delete, query, import, export
qso.rs # Qso: the record, its ADIF field mapping, band/mode helpers
adif.rs # ADI reader and writer (pure Rust, no new dependencies)
store.rs # Append-only JSON Lines file, in-memory index, compaction
dedupe.rs # Worked-before and import-collision rules
```
A library crate under `src/trx-client/`, beside `trx-frontend`, consumed by
`trx-frontend-http`. The log belongs to the station rather than to a rig or a frontend, so it
sits where any frontend can reach it.
#### Storage: append-only, not a rewritten blob
Bookmarks use `PickleDb` with `AutoDump`, which rewrites the whole file on every write. That
is right for a few dozen bookmarks and wrong for a log: a station with 40 000 QSOs would
rewrite several megabytes to log one contact, and lose the lot if the power went during the
dump.
The log is instead a JSON Lines file — the shape `trx-decode-log` already uses — appended one
record per write and read into an in-memory index at startup. An edit or a delete appends a new revision of that record's id; the load
keeps the last one, and a compaction pass rewrites the file when superseded records exceed a
threshold. Appending is O(1) and atomic per line, so a crash costs at most the line being
written.
#### Two formats, for the two things a log is asked for
The file formats were left open ("pick a well-known ham format"), so: **ADIF for interchange,
Cabrillo for contest submission.** Both are implemented in-repo, in the way this project
already implements its decoders, and neither adds a dependency.
**ADIF has to stay.** It is not one option among several — it is the only thing the ecosystem
reads. LoTW, eQSL, Club Log, QRZ.com and every other logger take ADIF and nothing else, so a
log that cannot write `.adi` cannot be uploaded, confirmed, or moved to another program. That
is a one-way door, and the interoperability is most of the point of keeping a log at all.
Nothing on disk is ADI regardless: the store is JSON Lines, and ADIF is what comes out of an
export.
ADI is a tagged text format — `<FIELD:length>value`, records ended by `<EOR>`, a header ended
by `<EOH>`, everything outside a tag ignored — small enough to implement exactly. The reader
must be lenient in the ways real files are irregular (lowercase tags, CRLF, missing header,
unknown fields, type indicators) and the writer strict. Unknown fields are carried through
import to export unchanged, so a round trip through trx-rs does not quietly strip what another
logger wrote. ADX, the XML serialisation of the same data model, is out of scope: it is part
of the standard but almost nothing reads it.
**Cabrillo is the second format, because ADIF cannot do its job.** Contest logs are submitted
to sponsors in Cabrillo 3.0 and are rejected in anything else — a header of `CALLSIGN:`,
`CONTEST:`, `CATEGORY-*` and `CLAIMED-SCORE:` lines, then one fixed-column `QSO:` line per
contact carrying frequency in kHz, a mode code (`CW`, `PH`, `FM`, `RY`, `DG`), the UTC date and
time, and both stations' calls, reports and exchanges. It is export-only and drops everything
outside the contest's exchange, which is why it complements ADIF rather than replacing it.
It arrives with the contest exchange fields in phase 5, since without a serial or a zone to
put in the exchange there is nothing for it to write.
#### Integration points
| Source | What it gives the log | How |
|--------|----------------------|-----|
| `RigState` | `FREQ`, `BAND`, `MODE`/`SUBMODE`, and the rig id a QSO was made on | watch channel already in the frontend context |
| Client config `general.callsign` | `STATION_CALLSIGN`, and the default `OPERATOR` | already surfaced as `owner_callsign` in frontend meta |
| The QSO's own rig, and its position | `MY_GRIDSQUARE` | per-rig latitude and longitude already carried in the rig list |
| Server clock | `QSO_DATE`, `TIME_ON` in UTC | new `GET /logbook/now`, which also feeds the browser-clock check |
| Decoder panels and map | a pre-filled entry: callsign, grid, and the report to offer | existing decode history; no new plumbing |
| `bandForHz` | `BAND` from a frequency | exists in `map-core.ts`; move to a shared module |
#### HTTP API
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/logbook` | Query: filters, paging |
| `POST` | `/logbook` | Add a QSO |
| `PUT` | `/logbook/{id}` | Edit |
| `DELETE` | `/logbook/{id}` | Delete |
| `GET` | `/logbook/export.adi` | ADIF export, honouring the current filter |
| `GET` | `/logbook/export.cbr` | Cabrillo export of a contest selection |
| `POST` | `/logbook/import` | Import, answering with counts: added, duplicate, rejected |
| `GET` | `/logbook/worked/{call}` | Worked-before: bands and modes |
| `GET` | `/logbook/now` | The server's UTC clock, for stamping entries and checking the browser's |
Writes require the control role, as the rig endpoints do.
### Frontend
The logbook is **its own panel**, not a strip bolted to the radio page: a `logbook` entry in
the tab order beside Bookmarks, holding the entry form, the table with the filters of
REQ-LOG-006, and import and export. It stands on its own in every layout, so a log can be kept
without adopting the ham layout, and read while another layout is selected (REQ-LAY-001).
The panel is three parts: the station line at the top (own callsign, locator, the rig a QSO
would be logged against), the entry form beneath it opening with the six pre-filled fields,
and the log itself under that, filtered as REQ-LOG-006 asks. Worked-before shows against
the callsign as it is typed.
The **ham layout** is a fifth entry in the operator layouts (`compact`, `broadcast`, `digital`,
`full`), which already gate on capability, seed the disclosure sections and persist per rig:
```ts
ham: {
label: "Ham radio",
unavailable: "Ham radio needs a rig that can transmit",
advanced: true, audio: true, scheduler: false,
preferredTab: "logbook", capability: "ham",
}
```
with the `ham` capability set from `RigCapabilities.tx`. It keeps frequency, VFO, mode, filter,
PTT, power and the meters, and hides the broadcast furniture. What it adds over `full` is where
it starts: the logbook panel, with the radio controls a keystroke away rather than the other
way round — the layout an operator working the bands wants, where logging the contact is the
task and the rig is the instrument.
### Phases
| Phase | Lands |
|-------|-------|
| 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers |
| 2 | Store, dedupe, and the HTTP API behind the control role |
| 3 | Logbook tab: entry, table, filters, import, export |
| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before |
| 5 | Contest exchange fields and Cabrillo export; QSL and LoTW/eQSL fields; per-band worked/confirmed statistics |
### Decisions
**One station log, not one per rig.** Awards and uploads are per station callsign — DXCC, WAS
and LoTW all count the callsign, not the radio — and a station worked on the second rig is
still worked. The rig is recorded on the QSO (`MY_RIG`) rather than dividing the log by it.
The station *location* fields do follow the rig, though: trx-rs rigs can be in different
places, so `MY_GRIDSQUARE` is taken from the rig that made the QSO rather than from one global
setting, which is also what LoTW's station locations expect.
**The operator is a per-QSO field, set once per session.** ADIF separates `STATION_CALLSIGN`
(the call used on the air) from `OPERATOR` (the person at the key); multi-operator stations
rotate operators through one station callsign, which is why contest loggers record it per QSO.
It is stored per QSO, defaulted from the configured callsign so a single operator never touches
it, and changed on the station line at the top of the panel where it sticks for the session.
It cannot be taken from the session's identity: the auth roles are `control` and `rx`, with no
notion of who is logged in.
**Server clock, and the log says so.** The server is the machine at the radio; the browser may
be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the
server, and when a browser's clock disagrees by more than a second the panel says so rather
than silently logging a time the operator did not expect.
**The log file is configurable, and defaults to the user's data directory.** Bookmarks live in
the config directory because they are settings; decode logs live in the cache directory because
they are disposable. A QSO log is neither — it is irreplaceable, and cache directories are
swept by cleaners. `[logbook].path` in the client config, defaulting to
`dirs::data_dir()/trx-rs/logbook.jsonl`, so a station that keeps its log on a synced or
backed-up volume can say so.
**Import collisions: callsign, band, mode and a two-minute window.** Two loggers rarely agree
to the second on the same QSO — one records the time the contact started, another the time it
was entered — so an exact-minute key duplicates half of what it is asked to merge. Two minutes
absorbs that. It does not swallow legitimate re-works: contest rules forbid a second contact
with the same station on the same band and mode, so a repeat inside two minutes is the same
QSO. Times are compared as instants rather than date and time strings, so a QSO either side of
midnight matches. Modes are normalised before comparison, or a log that stored `SSB` would fail
to match ours that stored `USB`.
### Rig modes to ADIF modes
The rig reports what it is demodulating; ADIF wants what the contact was made on, which is not
always the same word:
| Rig mode | ADIF `MODE` | ADIF `SUBMODE` |
|----------|-------------|----------------|
| `USB`, `LSB` | `SSB` | `USB` / `LSB` |
| `CW`, `CWR` | `CW` | — |
| `AM`, `SAM` | `AM` | — |
| `FM`, `WFM` | `FM` | — |
| `PKT` | `PKT` | — |
| `DIG` | decided by the decoder in use, not by the rig | |
| `AIS`, `VDES` | none — not amateur modes, and these rigs do not log | |
| `Other(..)` | passed through when it names an ADIF mode, else left for the operator | |
`DIG` is the one that cannot come from the rig: a rig in `DIG` is in FT8, FT4 or something else
depending on which decoder is running, and an entry started from an FT8 row logs `FT8` rather
than the rig's word for it. WSPR never opens an entry at all — it is a beacon mode, and hearing
a beacon is not a contact.
The table is data in `qso.rs`, checked against the ADIF enumeration when it is written, with
anything unrecognised left to the operator rather than guessed into the log.
+23 -200
View File
@@ -17,61 +17,30 @@ frontends.
## Configuration
Both `trx-server` and `trx-client` read TOML. The server takes its settings
from the `[trx-server]` section and the client from `[trx-client]`, so one
`trx-rs.toml` can configure both — or each may live in its own file with the
section header left off.
`trx-rs.toml.example` in the repository root is a complete, commented example
generated from the config definitions themselves. `--print-config` prints the
same settings without the comments.
Both `trx-server` and `trx-client` use TOML configuration files. Use
`--print-config` to generate a fully commented example.
### File Locations
Both binaries use the same lookup order:
**trx-server** lookup order:
1. `--config <FILE>`
2. `./trx-rs.toml`
3. `~/.config/trx-rs/trx-rs.toml`
4. `/etc/trx-rs/trx-rs.toml`
2. `./trx-server.toml`
3. `~/.trx-server.toml`
4. `~/.config/trx-rs/server.toml`
5. `/etc/trx-rs/server.toml`
**trx-client** lookup order:
1. `--config <FILE>`
2. `./trx-client.toml`
3. `~/.config/trx-rs/client.toml`
4. `/etc/trx-rs/client.toml`
CLI arguments override config file values.
### Checking a Config
### Environment Variables
`--check-config` loads the file, reports every problem it finds — unknown keys,
invalid values, listeners fighting over a port — and exits without starting
anything:
```bash
trx-server --check-config --config trx-rs.toml
trx-client --check-config --config trx-rs.toml
```
Unknown keys are warnings by default, so a config written for a newer version
still runs on an older binary. `--strict-config` makes them fatal.
`trx-configurator --check <FILE>` runs the same checks.
### Environment Variables and Secrets
Any string in the config may reference an environment variable as `${VAR}`;
an unset variable is an error rather than an empty value.
Credentials can be kept out of the config entirely by pointing at a file
instead. Every secret has a `*_file` sibling — set one or the other, never
both:
| Inline key | File key | Contents |
|------------|----------|----------|
| `[listen.auth].tokens` | `tokens_file` | one token per line |
| `[[remotes]].auth.token` | `token_file` | the token |
| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase |
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
Blank lines and `#` comments are ignored in the list files. A config that holds
credentials inline and is readable by group or others is flagged at startup.
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by
both server and client.
### Server Options
@@ -127,7 +96,6 @@ credentials inline and is readable by group or others is flagged at startup.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
| `tokens_file` | string | — | Read tokens from this file, one per line |
#### `[audio]`
@@ -153,13 +121,6 @@ When audio is enabled, at least one of `rx_enabled` or `tx_enabled` must be true
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
| `spectrum_fft_size` | usize | `1024` | Spectrum FFT bins; power of two, 1288192 |
| `spectrum_interval_ms` | u64 | `50` | How often a spectrum frame is pushed to subscribed clients |
Spectrum is the largest thing on the client connection. On a slow or
high-latency link, halving `spectrum_fft_size` halves the bytes per frame (at
half the frequency resolution) and raising `spectrum_interval_ms` sends fewer of
them; see [Spectrum over a slow link](#spectrum-over-a-slow-link).
#### `[sdr.gain]`
@@ -236,29 +197,6 @@ Notes:
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
`%MM%`, `%DD%` (UTC).
#### `[decoders]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | string[] | all decoders | Decoders to run for this rig |
| `output_dir` | string | `"$XDG_CACHE_HOME/trx-rs"` | Base directory for decoders that write images |
Valid decoder names: `aprs`, `aprs_hf`, `ais`, `cw`, `ft2`, `ft4`, `ft8`,
`lrpt`, `sstv`, `vdes`, `wefax`, `wspr` — the same names `[[sdr.channels]]`
uses. An unrecognised name is a config error.
Every decoder runs by default, which costs real CPU on a small machine. On a
station that only works digital modes, listing just what you use is worth it:
```toml
[decoders]
enabled = ["ft8", "ft4", "wspr"]
```
`sstv`, `wefax` and `lrpt` write images into a subdirectory of `output_dir`
named after the decoder. `ais` and `vdes` additionally require an SDR channel
configured to feed them.
#### Multi-Rig Configuration
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
@@ -302,32 +240,12 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|-------|------|---------|-------------|
| `url` | string | — | Server address (e.g. `localhost:4530`) |
| `poll_interval_ms` | u64 | `750` | State poll interval |
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
#### `[remote.auth]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `token` | string | — | Auth token (must not be empty if set) |
| `token_file` | string | — | Read the token from this file instead |
#### `[[remotes]]`
Preferred over the single `[remote]` section: one entry per rig, each mapping a
short name to a server and an optional server-side rig id.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `name` | string | — | Short name used everywhere in the client |
| `url` | string | — | Server address (`host:port`) |
| `rig_id` | string | — | Rig id on a multi-rig server |
| `auth.token` | string | — | Auth token |
| `auth.token_file` | string | — | Read the token from this file instead |
| `poll_interval_ms` | u64 | `750` | State poll interval |
The `name` is the key used by `default_rig_name`, `rigctl.rig_ports`,
`audio.rig_urls`, `audio.rig_ports` and `decode_history_retention_min_by_rig`.
A name in any of those maps that no remote answers to is a config error.
#### `[frontends.http]`
@@ -336,31 +254,6 @@ A name in any of those maps that no remote answers to is a config error.
| `enabled` | bool | `true` | Enable web UI |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `8080` | Bind port |
| `default_rig_name` | string | — | Remote selected on startup |
| `initial_map_zoom` | u8 | `10` | Starting zoom for the APRS map |
| `show_sdr_gain_control` | bool | `true` | Expose the RF gain control |
| `bandplan_enabled` | bool | `true` | Show the bandplan strip |
| `bandplan_region` | string | `"iaru_r1"` | `iaru_r1`, `iaru_r2`, or `iaru_r3` |
| `decode_history_retention_min` | u64 | `1440` | Decode history retention |
| `decode_history_retention_min_by_rig` | table | `{}` | Per-remote retention override |
| `spectrum_coverage_margin_hz` | u32 | `50000` | Centre-retune guard margin |
| `spectrum_usable_span_ratio` | f32 | `0.92` | Usable fraction of the sampled span |
#### `[frontends.http.auth]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Require a passphrase |
| `rx_passphrase` | string | — | Passphrase granting receive-only access |
| `rx_passphrase_file` | string | — | Read it from this file instead |
| `control_passphrase` | string | — | Passphrase granting full control |
| `control_passphrase_file` | string | — | Read it from this file instead |
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users |
| `session_ttl_min` | u64 | `480` | Session lifetime |
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
With `enabled = true`, at least one passphrase must be set.
#### `[frontends.rigctl]`
@@ -368,11 +261,7 @@ With `enabled = true`, at least one passphrase must be set.
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable Hamlib rigctl |
| `listen` | ip | `127.0.0.1` | Bind address |
| `rig_ports` | table | `{}` | Remote name → local port; one listener each |
One listener is started per `rig_ports` entry, each routing to its rig, so
`rig_ports` must name at least one remote when the frontend is enabled. The
older single `port` key and `--rigctl-port` are ignored.
| `port` | u16 | `4532` | Bind port |
#### `[frontends.http_json]`
@@ -382,17 +271,13 @@ older single `port` key and `--rigctl-port` are ignored.
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
| `auth.tokens_file` | string | — | Read tokens from this file, one per line |
#### `[frontends.audio]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable audio client |
| `server_url` | string | — | Audio endpoint for every remote |
| `rig_urls` | table | `{}` | Remote name → audio URL (wins over `server_url`) |
| `server_port` | u16 | `4531` | Fallback port when no URL is configured |
| `rig_ports` | table | `{}` | Remote name → port; superseded by `rig_urls` |
| `server_port` | u16 | `4531` | Server audio port |
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
| `bridge.rx_output_device` | string | — | Local playback device |
| `bridge.tx_input_device` | string | — | Local capture device |
@@ -402,78 +287,16 @@ older single `port` key and `--rigctl-port` are ignored.
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
loopback on Linux, BlackHole on macOS).
### Spectrum over a slow link
Spectrum dominates the server↔client connection: everything else is a few
hundred bytes, a frame is a few kilobytes. Three things govern what it costs.
**Frames are pushed, not polled.** The client subscribes and the server sends
frames at `[sdr].spectrum_interval_ms`. Polling cost a round trip per frame, so
the rate was capped at 1/RTT — on a 200 ms link you could not exceed 5 frames a
second however often the client asked. Clients fall back to polling
automatically against a server too old to stream.
**Bins travel as whole dBFS.** They are base64-encoded `i8` on the wire, about
an eighth of the JSON array of floats they used to be, at the resolution the
display draws anyway.
**Both ends have a rate, and the slower one wins.** The server pushes no faster
than `[sdr].spectrum_interval_ms`; the client asks for no more than
`[[remotes]].spectrum_interval_ms`.
For a link that struggles, start here:
```toml
[trx-server.sdr]
spectrum_fft_size = 512 # half the bins, half the bytes
spectrum_interval_ms = 200 # 5 frames/s instead of 20
[[trx-client.remotes]]
name = "remote-site"
url = "radio.example.com:4530"
spectrum_interval_ms = 200
```
That is roughly 0.7 KB per frame at 5 frames/s — about 3.5 KB/s, against
roughly 200 KB/s for 1024 float bins at 20 frames/s.
### CLI Override Summary
**trx-server:**
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`,
`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only.
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
`--port`. SDR options are file-only.
**trx-client:**
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`,
`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`,
`--http-port`, `--rigctl-listen`, `--http-json-listen`, `--http-json-port`,
`--callsign`.
`--listen` on the server overrides the bind address of both the control
listener and every rig's audio listener.
---
## Multiple Rigs in the Web UI
A client connected to several rigs decodes all of them at once, whichever one
is on screen. The rig picker in the header decides which rig the UI is about,
and each page answers that differently:
| Page | Shows |
|------|-------|
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. HF APRS is a source of its own there, filtered apart from VHF APRS. |
| Statistics | The whole station, including the per-rig comparison. |
Switching rigs repaints the radio and digital modes pages for the rig now
selected. Nothing is lost by switching: the traffic other rigs heard is still
held, and switching back brings it up again. The one exception is the CW pane,
which is a single running stream of copied text rather than a list of frames,
so it starts empty on the rig you switch to.
Each browser tab keeps its own selection, so two tabs can watch two rigs.
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
---
+4
View File
@@ -9,5 +9,9 @@ edition = "2021"
license = "GPL-2.0-or-later"
[dependencies]
serde = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dirs = "6"
thiserror = "2"
+88
View File
@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use serde::de::DeserializeOwned;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file {0}: {1}")]
ReadError(PathBuf, String),
#[error("Failed to parse config file {0}: {1}")]
ParseError(PathBuf, String),
}
/// Returns the default search paths for `trx-rs.toml`
/// (current directory → XDG config → /etc).
fn config_search_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("trx-rs.toml")];
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("trx-rs").join("trx-rs.toml"));
}
paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml"));
paths
}
/// Extract and deserialize a named section from a TOML file.
///
/// Returns `Ok(Some(cfg))` when the section is present and parses cleanly,
/// `Ok(None)` when the section is absent, or `Err` on I/O / parse failure.
fn load_section_from_file<T: DeserializeOwned>(
path: &Path,
key: &str,
) -> Result<Option<T>, ConfigError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
let table: toml::Table = toml::from_str(&content)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
let Some(section) = table.get(key) else {
return Ok(None);
};
// Re-serialize the section then parse as T so all serde defaults apply.
let section_toml = toml::to_string(section)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
let cfg = toml::from_str::<T>(&section_toml)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
Ok(Some(cfg))
}
/// Trait for loading configuration from a `trx-rs.toml` section.
pub trait ConfigFile: Sized + Default + DeserializeOwned {
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
fn section_key() -> &'static str;
/// Load the section from a specific file path.
///
/// Returns an error if the file cannot be read, is not valid TOML, or
/// does not contain the expected `[<section_key>]` header.
fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
ConfigError::ParseError(
path.to_path_buf(),
format!("missing [{}] section", Self::section_key()),
)
})
}
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
/// the first file that contains the expected section.
///
/// Returns `(config, path_where_found)` or `(Default::default(), None)`
/// when no config file is found.
fn load_from_default_paths() -> Result<(Self, Option<PathBuf>), ConfigError> {
for path in config_search_paths() {
if path.exists() {
if let Some(cfg) = load_section_from_file::<Self>(&path, Self::section_key())? {
return Ok((cfg, Some(path)));
}
}
}
Ok((Self::default(), None))
}
}
+4 -4
View File
@@ -2,12 +2,12 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Shared application helpers.
//!
//! Configuration types and their loader live in the `trx-config` crate.
pub mod config;
pub mod logging;
pub mod shared_config;
pub mod util;
pub use config::{ConfigError, ConfigFile};
pub use logging::init_logging;
pub use shared_config::{validate_log_level, validate_tokens};
pub use util::normalize_name;
@@ -19,52 +19,6 @@
//! would either bloat both binaries with unused fields or require a trait
//! abstraction that adds complexity without clear benefit.
use std::net::IpAddr;
/// A socket a component intends to bind, and what it is for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoundSocket {
pub addr: IpAddr,
pub port: u16,
/// Human-readable owner, e.g. `[listen]` or `[frontends.http]`.
pub label: String,
}
impl BoundSocket {
pub fn new(addr: IpAddr, port: u16, label: impl Into<String>) -> Self {
Self {
addr,
port,
label: label.into(),
}
}
}
/// Reject two components trying to bind the same socket.
///
/// A wildcard address (`0.0.0.0` / `::`) conflicts with any other address on
/// the same port, since binding it claims every interface. Port 0 means "pick
/// an ephemeral port" and never conflicts.
pub fn check_socket_conflicts(sockets: &[BoundSocket]) -> Result<(), String> {
for (i, a) in sockets.iter().enumerate() {
if a.port == 0 {
continue;
}
for b in &sockets[i + 1..] {
if b.port != a.port {
continue;
}
if a.addr == b.addr || a.addr.is_unspecified() || b.addr.is_unspecified() {
return Err(format!(
"{} and {} would both bind {}:{}",
a.label, b.label, a.addr, a.port
));
}
}
}
Ok(())
}
/// Validate that a log level string is one of the accepted values.
///
/// Returns `Ok(())` when `level` is `None` (defaulting is handled elsewhere)
@@ -99,47 +53,6 @@ pub fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> {
mod tests {
use super::*;
fn sock(addr: &str, port: u16, label: &str) -> BoundSocket {
BoundSocket::new(addr.parse().unwrap(), port, label)
}
#[test]
fn test_socket_conflicts_detects_exact_duplicate() {
let err = check_socket_conflicts(&[
sock("127.0.0.1", 8080, "[frontends.http]"),
sock("127.0.0.1", 8080, "[frontends.http_json]"),
])
.unwrap_err();
assert!(err.contains("127.0.0.1:8080"), "unexpected error: {err}");
}
#[test]
fn test_socket_conflicts_detects_wildcard_overlap() {
assert!(check_socket_conflicts(&[
sock("0.0.0.0", 4530, "[listen]"),
sock("127.0.0.1", 4530, "[audio]"),
])
.is_err());
}
#[test]
fn test_socket_conflicts_allows_distinct_addresses() {
assert!(check_socket_conflicts(&[
sock("127.0.0.1", 4530, "[listen]"),
sock("192.168.1.5", 4530, "[audio]"),
])
.is_ok());
}
#[test]
fn test_socket_conflicts_ignores_ephemeral_ports() {
assert!(check_socket_conflicts(&[
sock("127.0.0.1", 0, "[frontends.http_json]"),
sock("127.0.0.1", 0, "[frontends.http]"),
])
.is_ok());
}
#[test]
fn test_validate_log_level_none() {
assert!(validate_log_level(None).is_ok());
-1
View File
@@ -21,7 +21,6 @@ uuid = { workspace = true }
cpal = "0.15"
opus = "0.3"
trx-app = { path = "../trx-app" }
trx-config = { path = "../trx-config" }
trx-core = { path = "../trx-core" }
trx-protocol = { path = "../trx-protocol" }
trx-frontend = { path = "trx-frontend" }
File diff suppressed because it is too large Load Diff
+8 -135
View File
@@ -20,7 +20,6 @@ use tokio::task::JoinHandle;
use tracing::{error, info};
use trx_app::{init_logging, normalize_name};
use trx_config::ConfigFile;
use trx_core::audio::AudioStreamInfo;
use trx_core::decode::DecodedMessage;
@@ -51,12 +50,6 @@ struct Cli {
/// Print example configuration and exit
#[arg(long = "print-config")]
print_config: bool,
/// Treat unknown configuration keys as a fatal error
#[arg(long = "strict-config")]
strict_config: bool,
/// Validate the configuration and exit without starting anything
#[arg(long = "check-config")]
check_config: bool,
/// Remote server URL (host:port)
#[arg(short = 'u', long = "url")]
url: Option<String>,
@@ -81,7 +74,7 @@ struct Cli {
/// rigctl frontend listen address
#[arg(long = "rigctl-listen")]
rigctl_listen: Option<IpAddr>,
/// Deprecated: ignored, use [frontends.rigctl].rig_ports
/// rigctl frontend listen port
#[arg(long = "rigctl-port")]
rigctl_port: Option<u16>,
/// JSON TCP frontend listen address
@@ -116,65 +109,6 @@ async fn main() -> DynResult<()> {
Ok(())
}
/// `--check-config`: report everything wrong with the configuration and exit.
///
/// Unlike startup, this reports every problem it finds rather than stopping at
/// the first, so a config can be fixed in one pass. The file is checked as
/// written, without CLI overrides.
fn check_config(loaded: &trx_config::ConfigLoad<ClientConfig>) -> DynResult<()> {
match &loaded.path {
Some(path) => println!("{}", path.display()),
None => println!("(no config file found; checking built-in defaults)"),
}
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
warnings.extend(ClientConfig::deprecations(&loaded.present_keys));
let mut cfg = loaded.config.clone();
let mut errors = Vec::new();
if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) {
errors.push(e);
}
let cfg = &cfg;
let remotes = cfg.resolved_remotes();
if remotes.is_empty() {
warnings.push(
"no remotes configured; --url will be required at startup (add [[remotes]] entries)"
.to_string(),
);
}
errors.extend(cfg.validate_all());
if !remotes.is_empty() {
errors.extend(cfg.validate_resolved_all(&remotes));
}
for w in &warnings {
println!(" warning: {}", w);
}
for e in &errors {
println!(" error: {}", e);
}
if errors.is_empty() {
println!(
" OK: {} remote(s) configured: {}",
remotes.len(),
remotes
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if !warnings.is_empty() {
println!(" {} warning(s)", warnings.len());
}
Ok(())
} else {
Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
}
}
/// Holds the state needed after async initialization completes.
struct AppState {
shutdown_tx: watch::Sender<bool>,
@@ -201,44 +135,20 @@ async fn async_init() -> DynResult<AppState> {
std::process::exit(0);
}
let loaded = if let Some(ref path) = cli.config {
ClientConfig::load_from_file(path)?
let (cfg, config_path) = if let Some(ref path) = cli.config {
let cfg = ClientConfig::load_from_file(path)?;
(cfg, Some(path.clone()))
} else {
ClientConfig::load_from_default_paths()?
};
let config_path = loaded.path.clone();
cfg.validate()
.map_err(|e| format!("Invalid client configuration: {}", e))?;
if cli.check_config {
match check_config(&loaded) {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
}
}
// Logging comes up before any config complaint so the warnings are visible.
init_logging(loaded.config.general.log_level.as_deref());
init_logging(cfg.general.log_level.as_deref());
if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display());
}
loaded.report_unknown_keys(cli.strict_config)?;
loaded.report_deprecations();
if cli.rigctl_port.is_some() {
tracing::warn!(
"--rigctl-port is ignored; give each rig its own listener via \
[frontends.rigctl].rig_ports"
);
}
let mut cfg = loaded.config;
// Secrets configured as *_file are read before validation, so everything
// downstream sees resolved values.
cfg.resolve_secrets(config_path.as_deref())?;
cfg.validate()
.map_err(|e| format!("Invalid client configuration: {}", e))?;
frontend_runtime.http_auth.tokens = cfg
.frontends
@@ -288,14 +198,10 @@ async fn async_init() -> DynResult<AppState> {
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
vec![RemoteEntry {
spectrum_interval_ms: cfg.remote.spectrum_interval_ms,
name,
url: url.clone(),
rig_id,
auth: config::RemoteAuthConfig {
token,
token_file: None,
},
auth: config::RemoteAuthConfig { token },
poll_interval_ms,
}]
} else {
@@ -356,30 +262,6 @@ async fn async_init() -> DynResult<AppState> {
.http_json_listen
.unwrap_or(cfg.frontends.http_json.listen);
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
// Fold the CLI overrides back into the config so validation and the
// frontends agree on what is about to be bound.
cfg.frontends.http.enabled = frontends.iter().any(|f| f == "http");
cfg.frontends.rigctl.enabled = frontends.iter().any(|f| f == "rigctl");
cfg.frontends.http_json.enabled = frontends.iter().any(|f| f == "httpjson");
cfg.frontends.http.listen = http_listen;
cfg.frontends.http.port = http_port;
cfg.frontends.rigctl.listen = rigctl_listen;
cfg.frontends.http_json.listen = http_json_listen;
cfg.frontends.http_json.port = http_json_port;
// Second validation phase: the per-rig maps are keyed by remote short name,
// so they can only be checked once the remote list is final.
if cli.url.is_none() {
cfg.validate_resolved(&resolved_remotes)
.map_err(|e| format!("Invalid client configuration: {}", e))?;
} else {
// --url replaces the configured remotes outright, so only the socket
// checks still apply.
trx_config::shared::check_socket_conflicts(&cfg.bound_sockets())
.map_err(|e| format!("Invalid client configuration: {}", e))?;
}
let callsign = cli
.callsign
.clone()
@@ -484,13 +366,6 @@ async fn async_init() -> DynResult<AppState> {
.map(|e| e.poll_interval_ms)
.min()
.unwrap_or(750);
// Entries sharing a server share its connections, so the most frequent
// request wins: whoever wants spectrum fastest sets the rate.
let spectrum_interval = entries
.iter()
.map(|e| e.spectrum_interval_ms)
.min()
.unwrap_or_else(|| remote_client::DEFAULT_SPECTRUM_INTERVAL.as_millis() as u64);
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
for entry in entries {
@@ -504,8 +379,6 @@ async fn async_init() -> DynResult<AppState> {
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
rig_states: frontend_runtime.routing.rig_states.clone(),
poll_interval: Duration::from_millis(poll_interval),
spectrum_interval: Duration::from_millis(spectrum_interval),
spectrum_stream_unsupported: Arc::new(std::sync::atomic::AtomicBool::new(false)),
spectrum: frontend_runtime.spectrum.sender.clone(),
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
server_connected: frontend_runtime.routing.server_connected.clone(),
+103 -275
View File
@@ -20,22 +20,35 @@ use trx_core::{RigError, RigResult};
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
use trx_protocol::rig_command_to_client;
use trx_protocol::types::RigEntry;
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, SpectrumFrame};
// Endpoint parsing lives in `trx-config` so config validation and the
// connection code agree on what a URL means.
pub use trx_config::url::{parse_audio_url, parse_remote_url, RemoteEndpoint};
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate};
const DEFAULT_REMOTE_PORT: u16 = 4530;
const DEFAULT_AUDIO_PORT: u16 = 4531;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const IO_TIMEOUT: Duration = Duration::from_secs(15);
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
// Default spectrum cadence when a config does not specify one. Both the push
// stream and the poll fallback run at the configured rate; see
// `[[remotes]].spectrum_interval_ms`.
pub const DEFAULT_SPECTRUM_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteEndpoint {
pub host: String,
pub port: u16,
}
impl RemoteEndpoint {
pub fn connect_addr(&self) -> String {
if self.host.contains(':') && !self.host.starts_with('[') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
}
// Keep remote spectrum reasonably responsive without returning to the old
// timeout churn caused by a much tighter request cadence.
const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Clone)]
pub struct RemoteClientConfig {
@@ -44,12 +57,6 @@ pub struct RemoteClientConfig {
pub selected_rig_id: Arc<Mutex<Option<String>>>,
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
pub poll_interval: Duration,
/// How often spectrum frames are wanted. Drives the poll fallback and is
/// the rate the client asks the server to push at.
pub spectrum_interval: Duration,
/// Set once a server has rejected `SubscribeSpectrum`, so later
/// connections to it go straight to polling instead of asking again.
pub spectrum_stream_unsupported: Arc<AtomicBool>,
/// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe.
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
/// Shared flag: `true` while a TCP connection to trx-server is active.
@@ -498,100 +505,6 @@ async fn send_get_sat_passes_on(
))
}
/// What ended a spectrum stream attempt.
enum SpectrumStreamOutcome {
/// The stream ran and is over; the connection is spent.
Finished,
/// The server rejected the subscription. The connection is still usable,
/// so the caller can poll on it.
Unsupported,
}
/// Subscribe to the server's spectrum push for one rig and publish frames as
/// they arrive.
///
/// The server answers either with frames or, when it is too old to know the
/// command, with an error response — which leaves the connection usable for
/// polling, so falling back costs no reconnect.
async fn run_spectrum_stream(
config: &RemoteClientConfig,
writer: &mut (impl AsyncWriteExt + Unpin),
reader: &mut (impl AsyncBufRead + Unpin),
short_name: &str,
shutdown_rx: &mut watch::Receiver<bool>,
) -> RigResult<SpectrumStreamOutcome> {
let envelope = build_envelope(
config,
ClientCommand::SubscribeSpectrum,
Some(short_name.to_string()),
);
let mut payload = serde_json::to_string(&envelope)
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
payload.push('\n');
time::timeout(SPECTRUM_IO_TIMEOUT, writer.write_all(payload.as_bytes()))
.await
.map_err(|_| RigError::communication("spectrum subscribe write timed out".to_string()))?
.map_err(|e| RigError::communication(format!("spectrum subscribe write failed: {e}")))?;
time::timeout(SPECTRUM_IO_TIMEOUT, writer.flush())
.await
.map_err(|_| RigError::communication("spectrum subscribe flush timed out".to_string()))?
.map_err(|e| RigError::communication(format!("spectrum subscribe flush failed: {e}")))?;
// Re-check what the UI wants: switching rigs has to end this stream so the
// connection can be rebuilt for the new one.
let mut supervisor = time::interval(Duration::from_millis(500));
supervisor.tick().await;
loop {
tokio::select! {
changed = shutdown_rx.changed() => {
match changed {
Ok(()) if *shutdown_rx.borrow() => return Ok(SpectrumStreamOutcome::Finished),
Ok(()) => {}
Err(_) => return Ok(SpectrumStreamOutcome::Finished),
}
}
_ = supervisor.tick() => {
let wanted = active_spectrum_rig_ids(config);
if wanted.len() != 1 || wanted[0] != short_name {
return Ok(SpectrumStreamOutcome::Finished);
}
}
line = read_limited_line(reader, MAX_JSON_LINE_BYTES) => {
let line = line
.map_err(|e| RigError::communication(format!("spectrum read failed: {e}")))?
.ok_or_else(|| {
RigError::communication("spectrum connection closed".to_string())
})?;
let trimmed = line.trim_end();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<SpectrumFrame>(trimmed) {
Ok(frame) => publish_spectrum_frame(config, short_name, frame),
// Anything that is not a frame means the server would rather
// answer than stream: an older build rejecting the command.
Err(_) => return Ok(SpectrumStreamOutcome::Unsupported),
}
}
}
}
}
/// Publish one pushed frame to the per-rig and selected-rig watch channels.
fn publish_spectrum_frame(config: &RemoteClientConfig, short_name: &str, frame: SpectrumFrame) {
if let Ok(map) = config.rig_spectrums.read() {
if let Some(tx) = map.get(short_name) {
tx.send_modify(|s| s.set(Some(frame.spectrum.clone()), frame.vchan_rds.clone()));
}
}
if selected_rig_id(config).as_deref() == Some(short_name) {
config
.spectrum
.send_modify(|s| s.set(Some(frame.spectrum), frame.vchan_rds));
}
}
async fn handle_spectrum_connection(
config: &RemoteClientConfig,
stream: TcpStream,
@@ -599,33 +512,7 @@ async fn handle_spectrum_connection(
) -> RigResult<()> {
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
// Prefer the push stream: polling costs a round trip per frame, so on a
// high-latency link the frame rate is 1/RTT no matter what interval is
// configured. It only works for one rig per connection, and only against
// a server new enough to understand the command.
let streamable = active_spectrum_rig_ids(config);
if streamable.len() == 1 && !config.spectrum_stream_unsupported.load(Ordering::Relaxed) {
match run_spectrum_stream(
config,
&mut writer,
&mut reader,
&streamable[0],
shutdown_rx,
)
.await?
{
SpectrumStreamOutcome::Finished => return Ok(()),
SpectrumStreamOutcome::Unsupported => {
info!("Server does not support spectrum streaming; falling back to polling");
config
.spectrum_stream_unsupported
.store(true, Ordering::Relaxed);
}
}
}
let mut interval = time::interval(config.spectrum_interval);
let mut interval = time::interval(SPECTRUM_POLL_INTERVAL);
// Cache the token outside the poll loop to avoid cloning it every 50ms.
let cached_token = config.token.clone();
@@ -1308,13 +1195,92 @@ async fn read_limited_line<R: AsyncBufRead + Unpin>(
}
}
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
}
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
}
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(format!("{kind} url is empty"));
}
let addr = trimmed
.strip_prefix("tcp://")
.or_else(|| trimmed.strip_prefix("http-json://"))
.or_else(|| trimmed.strip_prefix("audio://"))
.unwrap_or(trimmed);
parse_host_port(addr, default_port, kind)
}
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
if let Some(rest) = input.strip_prefix('[') {
let closing = rest
.find(']')
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
let host = &rest[..closing];
let remainder = &rest[closing + 1..];
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
let port = if remainder.is_empty() {
default_port
} else if let Some(port_str) = remainder.strip_prefix(':') {
parse_port(port_str, kind)?
} else {
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
};
return Ok(RemoteEndpoint {
host: host.to_string(),
port,
});
}
if input.contains(':') {
if input.matches(':').count() > 1 {
return Err(format!(
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
));
}
let (host, port_str) = input
.rsplit_once(':')
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
return Ok(RemoteEndpoint {
host: host.to_string(),
port: parse_port(port_str, kind)?,
});
}
Ok(RemoteEndpoint {
host: input.to_string(),
port: default_port,
})
}
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
let port: u16 = port_str
.parse()
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
if port == 0 {
return Err(format!("invalid {kind} port: 0"));
}
Ok(port)
}
#[cfg(test)]
mod tests {
#[allow(unused_imports)]
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
use super::{
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
DEFAULT_SPECTRUM_INTERVAL,
};
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
@@ -1509,8 +1475,6 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(100),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1556,8 +1520,6 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1573,130 +1535,6 @@ mod tests {
assert_eq!(envelope.rig_id.as_deref(), Some("sdr"));
}
fn stream_test_config(spectrum_tx: watch::Sender<SharedSpectrum>) -> super::RemoteClientConfig {
super::RemoteClientConfig {
addr: "127.0.0.1:4530".to_string(),
token: None,
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
rig_states: Arc::new(RwLock::new(HashMap::new())),
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
rig_id_to_short_name: HashMap::new(),
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
sat_passes: Arc::new(RwLock::new(None)),
rig_meters: Arc::new(RwLock::new(HashMap::new())),
}
}
/// A pushed frame reaches the watch channel the UI reads, with no request
/// from the client beyond the initial subscribe.
#[tokio::test]
async fn spectrum_stream_publishes_pushed_frames() {
use tokio::io::{AsyncBufReadExt, BufReader};
let (spectrum_tx, mut spectrum_rx) = watch::channel(SharedSpectrum::default());
let config = stream_test_config(spectrum_tx);
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
let (client_read, mut client_write) = tokio::io::split(client_io);
let mut client_read = BufReader::new(client_read);
let task = tokio::spawn(async move {
super::run_spectrum_stream(
&config,
&mut client_write,
&mut client_read,
"sdr",
&mut shutdown_rx,
)
.await
});
// The server sees the subscribe, then pushes without being asked.
let mut server = BufReader::new(&mut server_io);
let mut subscribe = String::new();
server.read_line(&mut subscribe).await.expect("subscribe");
assert!(
subscribe.contains("subscribe_spectrum"),
"unexpected command: {subscribe}"
);
let frame = trx_protocol::SpectrumFrame {
rig_id: "sdr".to_string(),
spectrum: trx_core::rig::state::SpectrumData {
bins: vec![-70.0, -30.0],
center_hz: 14_200_000,
sample_rate: 1_920_000,
rds: None,
},
vchan_rds: None,
};
let mut line = serde_json::to_string(&frame).unwrap();
line.push('\n');
server_io.write_all(line.as_bytes()).await.expect("push");
server_io.flush().await.expect("flush");
spectrum_rx.changed().await.expect("spectrum published");
let published = spectrum_rx.borrow().clone();
let spectrum = published.frame.expect("spectrum present");
assert_eq!(spectrum.center_hz, 14_200_000);
assert_eq!(spectrum.bins, vec![-70.0, -30.0]);
let _ = shutdown_tx.send(true);
let _ = task.await;
}
/// An older server answers the unknown command with an error instead of
/// frames. That has to read as "poll instead", not as a dead connection.
#[tokio::test]
async fn spectrum_stream_falls_back_when_unsupported() {
use tokio::io::{AsyncBufReadExt, BufReader};
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
let config = stream_test_config(spectrum_tx);
let (_shutdown_tx, mut shutdown_rx) = watch::channel(false);
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
let (client_read, mut client_write) = tokio::io::split(client_io);
let mut client_read = BufReader::new(client_read);
let task = tokio::spawn(async move {
super::run_spectrum_stream(
&config,
&mut client_write,
&mut client_read,
"sdr",
&mut shutdown_rx,
)
.await
});
let mut server = BufReader::new(&mut server_io);
let mut subscribe = String::new();
server.read_line(&mut subscribe).await.expect("subscribe");
server_io
.write_all(
b"{\"success\":false,\"state\":null,\"error\":\"Invalid JSON: unknown variant\"}\n",
)
.await
.expect("error response");
server_io.flush().await.expect("flush");
let outcome = task.await.expect("join").expect("stream result");
assert!(
matches!(outcome, super::SpectrumStreamOutcome::Unsupported),
"an error response should fall back to polling"
);
}
#[test]
fn build_envelope_translates_short_name_to_server_rig_id() {
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
@@ -1710,8 +1548,6 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1744,8 +1580,6 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1770,8 +1604,6 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1812,8 +1644,6 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: known_rigs.clone(),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1887,8 +1717,6 @@ mod tests {
selected_rig_id,
known_rigs,
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1,7 +1,3 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore,
hostState
@@ -144,22 +140,18 @@ function aisLatestByVessel(messages) {
}
return Array.from(byMmsi.values());
}
function aisRigMessages() {
return forActiveRig(aisMessageHistory);
}
function updateAisSummary() {
const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
}
const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
const vessels = aisLatestByVessel(aisMessageHistory);
if (aisVesselCountEl) {
const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
}
if (aisLatestSeenEl) {
const latest = rigMessages[0];
const latest = aisMessageHistory[0];
if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet";
} else {
@@ -230,9 +222,7 @@ function updateAisBar() {
updateAisSummary();
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const recent = aisMessageHistory.filter(
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)
);
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
const messages = aisLatestByVessel(recent).slice(0, 8);
if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none";
@@ -277,7 +267,7 @@ function renderAisHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of aisRigMessages()) {
for (const message of aisMessageHistory) {
fragment.appendChild(renderAisRow(message));
}
aisMessagesEl.replaceChildren(fragment);
@@ -309,10 +299,10 @@ function normalizeServerAisMessage(msg) {
}
function onServerAisBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -352,9 +342,8 @@ if (aisFilterInput) {
});
}
function onServerAis(msg) {
const message = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg));
}
updateAisSummary();
window.trxPluginRuntime.registerDecoder({
@@ -364,10 +353,6 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
rerender: () => {
updateAisBar();
renderAisHistory();
},
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
@@ -1065,9 +1065,6 @@ var runtime = {
plugin.prune();
return true;
},
rerenderAll() {
for (const plugin of decoders.values()) plugin.rerender?.();
},
syncMapAll() {
for (const plugin of decoders.values()) plugin.syncMap?.();
},
@@ -2905,9 +2902,9 @@ function applyRigList(activeRigId, rigIds, displayNames) {
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
if (rigListChanged) {
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.populateScopePicker();
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
}
@@ -3558,13 +3555,6 @@ function positionRdsPsOverlay() {
function resetRdsDisplay() {
updateRdsPsOverlay(primaryRds);
}
function refreshDecodeBars() {
window.updateAisBar?.();
window.updateVdesBar?.();
window.updateAprsBar?.();
window.updateFt8Bar?.();
window.updateCwBar?.();
}
function resetDecoderStateOnRigSwitch() {
primaryRds = null;
vchanRdsById = /* @__PURE__ */ new Map();
@@ -3579,8 +3569,6 @@ function resetDecoderStateOnRigSwitch() {
const el = document.getElementById(id);
if (el) el.textContent = "--";
});
refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
}
function resetWfmStereoIndicator() {
if (!wfmStFlagEl) return;
@@ -4800,7 +4788,10 @@ function render(update) {
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
}
refreshDecodeBars();
if (window.updateAisBar) window.updateAisBar();
if (window.updateVdesBar) window.updateVdesBar();
if (window.updateAprsBar) window.updateAprsBar();
if (window.updateFt8Bar) window.updateFt8Bar();
for (const d of decoderRegistry) {
if (d.activation !== "toggle") continue;
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
@@ -5889,7 +5880,6 @@ function navigateToTab(name, options = {}) {
if (leavingSatellites) window.clearSatPredictionDom?.();
void loadPluginsForTab(name).then(() => {
if (name === "satellites") window.refreshSatPredictions?.();
flushPendingDecodeStats();
}).catch((error) => {
console.error(error);
});
@@ -6001,9 +5991,7 @@ async function initializeApp() {
showAuthGate(allowGuest);
}
}
var settingsUiReady = false;
function initSettingsUI() {
settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
@@ -6274,9 +6262,7 @@ Object.defineProperties(trxState, {
} }
});
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
void loadEagerPlugins().then(() => {
if (settingsUiReady) initSettingsUI();
}).catch((error) => {
void loadEagerPlugins().catch((error) => {
console.error(error);
});
void initializeApp();
@@ -7624,35 +7610,11 @@ var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
"sstv",
"sstv_progress"
]);
var pendingDecodeStats = [];
var PENDING_DECODE_STATS_MAX = 5e4;
function recordDecodeStat(kind, rig, tsMs) {
const stats = window.trx.modules.map;
if (stats) {
stats.statsRecordDecode(kind, rig, tsMs);
return;
}
pendingDecodeStats.push({ kind, rig, tsMs });
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
}
}
function flushPendingDecodeStats() {
const stats = window.trx.modules.map;
if (!stats || pendingDecodeStats.length === 0) return;
for (const entry of pendingDecodeStats.splice(0)) {
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
}
stats.scheduleStatsRender();
}
function scheduleStatsRenderIfLoaded() {
window.trx.modules.map?.scheduleStatsRender();
}
function dispatchDecodeMessage(msg, skipStats = false) {
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
scheduleStatsRenderIfLoaded();
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender();
}
}
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
@@ -7698,9 +7660,9 @@ function restoreDecodeHistoryGroup(kind, messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (!IMAGE_DECODE_KINDS.has(kind)) {
for (const msg of messages) {
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
}
scheduleStatsRenderIfLoaded();
window.trx.modules.map?.scheduleStatsRender();
}
window.trxPluginRuntime.restore(kind, messages);
}
@@ -5,10 +5,6 @@ import {
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore,
hostState
@@ -86,25 +82,20 @@ function aprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(aprsFilterText);
}
function aprsRigPackets() {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets() {
const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
return packets.filter(aprsFilterMatch);
}
function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets();
if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
}
if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (aprsLatestSeenEl) {
const latest = rigPackets[0];
const latest = aprsPacketHistory[0];
if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -162,9 +153,7 @@ function updateAprsBar() {
if (!aprsBarOverlay) return;
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter(
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id)
);
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
@@ -226,11 +215,11 @@ function normalizeServerAprsPacket(pkt) {
}
function onServerAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -288,9 +277,8 @@ if (aprsFilterInput) {
});
}
function onServerAprs(pkt) {
const packet = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
}
renderAprsHistory();
window.trxPluginRuntime.registerDecoder({
@@ -300,10 +288,6 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
rerender: () => {
updateAprsBar();
renderAprsHistory();
},
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
@@ -19,11 +19,9 @@ var bgdWindow = window;
let bookmarkList = [];
let statusInterval = null;
let bgdDirty = false;
let statusByBookmark = /* @__PURE__ */ new Map();
let lastStatus = null;
function initBackgroundDecode(rigId, role) {
backgroundDecodeRole = role;
currentRigId = rigId || hostState.lastActiveRigId || null;
currentRigId = rigId || null;
if (currentRigId) loadBackgroundDecode();
startStatusPolling();
}
@@ -110,7 +108,7 @@ var bgdWindow = window;
}
setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist();
const isControl = isControlRole();
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
const panel = document.getElementById("background-decode-panel");
if (panel) {
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
@@ -121,10 +119,6 @@ var bgdWindow = window;
const resetBtn = document.getElementById("background-decode-reset-btn");
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
syncSaveButton();
}
function currentFilterText() {
return document.getElementById("bgd-bookmark-filter")?.value ?? "";
}
function renderBookmarkChecklist(filterText = "") {
const container = document.getElementById("bgd-bookmark-checklist");
@@ -140,49 +134,20 @@ var bgdWindow = window;
return text.indexOf(filter) >= 0;
}) : all;
if (filtered.length === 0) {
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
renderSelectionSummary();
container.innerHTML = '<div class="bgd-checklist-empty">' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "</div>";
return;
}
filtered.forEach(function(bookmark) {
const row = document.createElement("label");
row.className = "bgd-checklist-row";
row.dataset.bmId = bookmark.id;
const decoders = bookmarkDecoderKinds(bookmark);
const selected = selectedIds.has(bookmark.id);
if (selected) row.classList.add("is-selected");
row.innerHTML = '<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz)) + '<span class="bgd-checklist-mode">' + escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span></span>" + stateBadgeHtml(bookmark.id, selected);
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
row.innerHTML = '<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span>";
row.querySelector("input")?.addEventListener("change", function(e) {
onChecklistToggle(bookmark.id, e.currentTarget.checked);
});
container.appendChild(row);
});
renderSelectionSummary();
}
function emptyListText(supportedCount) {
if (supportedCount > 0) return "No bookmark matches that filter.";
if (bookmarkList.length > 0) {
return "None of your bookmarks name a decoder that can run in the background. Give one a decoder on the Bookmarks tab to list it here.";
}
return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
}
function stateBadgeHtml(bookmarkId, selected) {
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
const entry = statusByBookmark.get(bookmarkId);
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '"><span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
}
function renderSelectionSummary() {
const el = document.getElementById("bgd-selection-summary");
if (!el) return;
const selected = currentConfig?.bookmark_ids.length ?? 0;
if (selected === 0) {
el.textContent = "Nothing selected — background decoding is idle.";
return;
}
const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
el.textContent = currentConfig?.enabled ? `${noun}, ${String(active)} decoding now.` : `${noun}. Switch Enabled on to start decoding them.`;
}
function onChecklistToggle(bookmarkId, checked) {
if (!currentConfig) {
@@ -217,7 +182,7 @@ var bgdWindow = window;
}).catch(function(err) {
showToast(`Save failed: ${errorMessage(err)}`, true);
}).finally(function() {
syncSaveButton();
if (btn) btn.disabled = false;
});
}
async function resetBackgroundDecode() {
@@ -245,83 +210,59 @@ var bgdWindow = window;
});
}
function renderStatus(status) {
lastStatus = status;
statusByBookmark = new Map(
(status.entries ?? []).filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0).map((entry) => [entry.bookmark_id, entry])
);
renderSpanSummary();
renderBookmarkChecklist(currentFilterText());
}
function renderSpanSummary() {
const el = document.getElementById("bgd-span-summary");
if (!el) return;
const status = lastStatus;
if (!status) {
el.textContent = "";
const card = document.getElementById("background-decode-status-card");
if (!card) return;
const entries = status.entries ?? [];
if (!entries.length) {
card.textContent = "No background decode bookmarks configured.";
return;
}
if (!status.active_rig) {
el.textContent = "This rig is not the one playing audio.";
el.dataset.tone = "warn";
return;
}
const centre = typeof status.center_hz === "number" && Number.isFinite(status.center_hz) ? formatFreq(status.center_hz) : null;
const half = typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0 ? formatFreq(status.sample_rate / 2) : null;
el.dataset.tone = "";
el.textContent = centre && half ? `Span ${centre} ±${half}` : centre ? `Centre ${centre}` : "";
}
function stateHelp(state) {
switch (state) {
case "active":
return "Decoding on a hidden channel.";
case "out_of_span":
return "Outside the span the rig is tuned across, so it cannot be heard from here.";
case "waiting_for_spectrum":
return "Waiting for the first spectrum frame from the rig.";
case "waiting_for_user":
return "Nobody is listening to this rig, so no audio is being pulled.";
case "missing_bookmark":
return "The bookmark this was selected from is gone.";
case "no_supported_decoders":
return "No decoder that runs in the background can decode this bookmark.";
case "disabled":
return "Background decoding is switched off.";
case "handled_by_scheduler":
case "scheduler_has_control":
return "The scheduler is running this bookmark instead.";
case "handled_by_virtual_channel":
return "A virtual channel is already on this frequency.";
case "pending":
return "Selected, and not started yet — save to apply.";
default:
return "Selected, but not decoding.";
const summary = [];
if (status.active_rig) {
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
} else {
summary.push("This rig is not currently selected for audio.");
}
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
html += '<div class="bgd-status-list">';
entries.forEach(function(entry) {
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
const parts = [];
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
if (entry.mode) parts.push(entry.mode);
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
parts.push(entry.decoder_kinds.join("/").toUpperCase());
}
html += '<div class="bgd-status-row"><div><div class="bgd-status-name">' + escHtml(name) + '</div><div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div></div><div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '"><svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' + escHtml(prettyState(entry.state)) + "</div></div>";
});
html += "</div>";
card.innerHTML = html;
}
function prettyState(state) {
switch (state) {
case "active":
return "Decoding";
return "✓ Active";
case "out_of_span":
return "Out of span";
return "Out of span";
case "waiting_for_spectrum":
return "Waiting for spectrum";
return "Waiting";
case "waiting_for_user":
return "Nobody listening";
return "△ No user";
case "missing_bookmark":
return "Bookmark gone";
return "✗ Missing";
case "no_supported_decoders":
return "No decoder";
return "✗ Unsupported";
case "disabled":
return "Off";
return "△ Disabled";
case "handled_by_scheduler":
return "△ Scheduler";
case "scheduler_has_control":
return "Scheduler has it";
return "Scheduler";
case "handled_by_virtual_channel":
return "On a channel";
case "pending":
return "Not saved";
return "△ VChan";
default:
return "Idle";
return "△ Inactive";
}
}
function setCheckbox(id, value) {
@@ -344,21 +285,13 @@ var bgdWindow = window;
function markBgdDirty() {
if (bgdDirty) return;
bgdDirty = true;
syncSaveButton();
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.add("sch-dirty");
}
function clearBgdDirty() {
bgdDirty = false;
syncSaveButton();
}
function syncSaveButton() {
const btn = document.getElementById("background-decode-save-btn");
if (!btn) return;
btn.classList.toggle("sch-dirty", bgdDirty);
btn.disabled = !bgdDirty || !isControlRole();
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
}
function isControlRole() {
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
if (btn) btn.classList.remove("sch-dirty");
}
function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast");
@@ -378,7 +311,7 @@ var bgdWindow = window;
return bm.id;
});
currentConfig.bookmark_ids = ids;
renderBookmarkChecklist(currentFilterText());
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
markBgdDirty();
}
function deselectAllBookmarks() {
@@ -386,7 +319,7 @@ var bgdWindow = window;
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
currentConfig.bookmark_ids = [];
renderBookmarkChecklist(currentFilterText());
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
markBgdDirty();
}
function wireBackgroundDecodeEvents() {
@@ -401,9 +334,7 @@ var bgdWindow = window;
if (enabledCb && !enabledCb._wired) {
enabledCb._wired = true;
enabledCb.addEventListener("change", function() {
if (currentConfig) currentConfig.enabled = enabledCb.checked;
markBgdDirty();
renderBookmarkChecklist(currentFilterText());
});
}
const selectAllBtn = document.getElementById("bgd-select-all-btn");
@@ -1,7 +1,3 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -157,10 +153,9 @@ function initializeFtxDecoder(config) {
const render = () => {
prune();
if (!messagesElement) return;
const rigMessages = forActiveRig(history);
const fragment = document.createDocumentFragment();
let count = 0;
for (const message of rigMessages) {
for (const message of history) {
if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message));
@@ -168,26 +163,20 @@ function initializeFtxDecoder(config) {
}
messagesElement.replaceChildren(fragment);
};
const plotLocator = (message) => {
const normalize = (message) => {
const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
if (grids.length === 0) return;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
const frequency = displayFrequency(message.freq_hz);
bridge.mapAddLocator?.(raw, grids, id, station, {
...message,
freq_hz: frequency ?? message.freq_hz,
locator_details: locatorDetails
});
};
const normalize = (message) => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
if (grids.length > 0) {
bridge.mapAddLocator?.(raw, grids, id, station, {
...message,
freq_hz: frequency ?? message.freq_hz,
locator_details: locatorDetails
});
}
return {
// The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band.
rig_id: message.rig_id ?? null,
receiver: bridge.getDecodeRigMeta?.() ?? null,
ts_ms: message.ts_ms,
snr_db: message.snr_db,
@@ -199,9 +188,7 @@ function initializeFtxDecoder(config) {
};
const receiveBatch = (messages) => {
if (messages.length === 0) return;
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
status.textContent = "Receiving";
}
if (status) status.textContent = "Receiving";
history = messages.map(normalize).reverse().concat(history);
prune();
bridge.setFt8FamilyBarDecoder?.(id);
@@ -215,7 +202,7 @@ function initializeFtxDecoder(config) {
bridge.clearMapMarkersByType?.(id);
};
const barFrames = () => {
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5 && isActiveRigDecode(message.rig_id)).slice(0, 8);
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
let html = "";
for (const message of recent) {
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
@@ -239,15 +226,7 @@ function initializeFtxDecoder(config) {
prune();
render();
},
reset,
rerender: () => {
bridge.updateFt8Bar?.();
render();
},
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => {
for (const message of [...history].reverse()) plotLocator(message);
}
reset
});
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
const updatePeriod = () => {
@@ -1,18 +0,0 @@
import {
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/active-rig.ts
function isActiveRigDecode(rigId) {
const activeRigId = hostState.lastActiveRigId;
if (!activeRigId || !rigId) return true;
return rigId === activeRigId;
}
function forActiveRig(items) {
return items.filter((item) => isActiveRigDecode(item.rig_id));
}
export {
isActiveRigDecode,
forActiveRig
};
@@ -1,6 +1,3 @@
import {
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -28,7 +25,7 @@ var CW_BAR_LINE_GAP_MS = 5e3;
var cwLastAppendTime = 0;
var cwTonePickerRaf = null;
var cwBarHistory = [];
var cwBarCurrentLines = /* @__PURE__ */ new Map();
var cwBarCurrentLine = null;
var cwBarDismissedAtMs = 0;
var cwAutoLocalOverride = null;
function escapeCwHtml(input) {
@@ -53,22 +50,20 @@ cwWindow.applyCwAutoUiFromServer = function(enabled) {
if (cwAutoLocalOverride !== null) return;
applyCwAutoUi(enabled);
};
function cwBarFlushCurrentLine(key) {
const line = cwBarCurrentLines.get(key);
cwBarCurrentLines.delete(key);
if (line?.text.trim()) {
cwBarHistory.unshift(line);
function cwBarFlushCurrentLine() {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine);
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
}
cwBarCurrentLine = null;
}
function updateCwBar() {
if (!cwBarOverlay) return;
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
const isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
const inProgress = [...cwBarCurrentLines.values()].filter((l) => l.text && isActiveRigDecode(l.rigId)).sort((a, b) => b.tsMs - a.tsMs);
const liveLines = [...inProgress, ...recent];
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none";
@@ -313,7 +308,7 @@ function resetCwHistoryView() {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBarHistory = [];
cwBarCurrentLines.clear();
cwBarCurrentLine = null;
updateCwBar();
drawCwTonePicker();
}
@@ -329,9 +324,8 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
})();
});
function onServerCw(evt) {
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl && forSelectedRig) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
const line = document.createElement("div");
@@ -352,29 +346,25 @@ function onServerCw(evt) {
}
if (evt.text) {
const now = Date.now();
const rigId = evt.rig_id ?? null;
const key = rigId ?? "";
if (evt.text === "\n") {
cwBarFlushCurrentLine(key);
cwBarFlushCurrentLine();
} else {
let line = cwBarCurrentLines.get(key);
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine(key);
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine();
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
cwBarCurrentLines.set(key, line);
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
}
line.text += evt.text;
line.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
cwBarCurrentLine.text += evt.text;
cwBarCurrentLine.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
}
updateCwBar();
}
if (cwSignalIndicator && forSelectedRig) {
if (cwSignalIndicator) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
}
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
if (!cwAutoInput || cwAutoInput.checked) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm));
}
@@ -399,14 +389,7 @@ cwWindow.trxPluginRuntime.registerDecoder({
id: "cw",
onMessage: onServerCw,
restore: restoreCwHistory,
reset: resetCwHistoryView,
// The copied text of a rig that is no longer on screen cannot be unpicked
// from the pane, so the switch starts the new rig's stream from empty.
rerender: () => {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
updateCwBar();
}
reset: resetCwHistoryView
});
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution();
@@ -1,7 +1,6 @@
import {
initializeFtxDecoder
} from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js";
} from "./chunk-O2Y7YEVQ.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft2.ts
@@ -1,7 +1,6 @@
import {
initializeFtxDecoder
} from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js";
} from "./chunk-O2Y7YEVQ.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft4.ts
@@ -2,8 +2,7 @@ import {
initializeFt8FamilyBar,
initializeFtxDecoder,
installFtxCompatibilityHelpers
} from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js";
} from "./chunk-O2Y7YEVQ.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft8.ts
@@ -5,10 +5,6 @@ import {
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore,
hostState
@@ -71,26 +67,21 @@ function hfAprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(hfAprsFilterText);
}
function hfAprsRigPackets() {
return forActiveRig(hfAprsPacketHistory);
}
function hfAprsVisiblePackets() {
const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
return packets.filter(hfAprsFilterMatch);
}
var collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
}
if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (hfAprsLatestSeenEl) {
const latest = rigPackets[0];
const latest = hfAprsPacketHistory[0];
if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -149,32 +140,17 @@ function resetHfAprsHistoryView() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
}
function pruneHfAprsHistoryView() {
pruneHfAprsPacketHistory();
renderHfAprsHistory();
}
function plotHfAprsPacket(pkt) {
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
hfAprsWindow.aprsMapAddStation(
pkt.srcCall ?? "",
pkt.lat,
pkt.lon,
pkt.info ?? "",
pkt.symbolTable,
pkt.symbolCode,
pkt,
"hf_aprs"
);
}
function addHfAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender();
}
function normalizeServerHfAprsPacket(pkt) {
@@ -182,14 +158,13 @@ function normalizeServerHfAprsPacket(pkt) {
}
function onServerHfAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized = [];
for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotHfAprsPacket(next);
normalized.push(next);
}
normalized.reverse();
@@ -252,9 +227,8 @@ if (hfAprsFilterInput) {
});
}
function onServerHfAprs(pkt) {
const packet = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
}
renderHfAprsHistory();
window.trxPluginRuntime.registerDecoder({
@@ -263,10 +237,5 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerHfAprsBatch,
restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView,
rerender: renderHfAprsHistory,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry);
}
prune: pruneHfAprsHistoryView
});
@@ -60,7 +60,7 @@ var mapWindow = window;
const decodeContactPaths = /* @__PURE__ */ new Map();
let selectedMapQsoKey = null;
const mapMarkers = /* @__PURE__ */ new Set();
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
const MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
@@ -128,12 +128,6 @@ var mapWindow = window;
}
return trimmed;
}
function aprsEntrySource(entry) {
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
}
function aprsStationKey(call, source) {
return source === "aprs" ? call : `${source}:${call}`;
}
function refreshAprsTrack(call, entry) {
if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
@@ -155,7 +149,7 @@ var mapWindow = window;
lineJoin: "round",
interactive: false
});
track.__trxType = aprsEntrySource(entry);
track.__trxType = "aprs";
track._aprsCall = call;
entry.track = track;
}
@@ -387,7 +381,6 @@ var mapWindow = window;
}
function mapSourceLabel(type) {
if (type === "bookmark") return "Bookmarks";
if (type === "hf_aprs") return "HF APRS";
return String(type || "").toUpperCase();
}
function locatorFilterColor(type) {
@@ -403,7 +396,6 @@ var mapWindow = window;
if (type === "vdes") return "#a78bfa";
if (type === "sat") return "#f59e0b";
if (type === "aprs") return "#00d17f";
if (type === "hf_aprs") return "#fb7185";
return locatorFilterColor(type);
}
function bandForHz(hz) {
@@ -939,8 +931,10 @@ var mapWindow = window;
}
}
for (const entry of stationMarkers.values()) {
if (!entry?.visibleInHistoryWindow) continue;
availableSources.add(aprsEntrySource(entry));
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
availableSources.add("aprs");
break;
}
}
const bandMap = /* @__PURE__ */ new Map();
for (const entry of locatorMarkers.values()) {
@@ -972,7 +966,7 @@ var mapWindow = window;
for (const key of Array.from(mapLocatorFilter.bands)) {
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
}
const sourceItems = ["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
key,
label: mapSourceLabel(key),
color: mapSourceColor(key),
@@ -1034,10 +1028,9 @@ var mapWindow = window;
}
return parts.join(" ").toLowerCase();
}
if (type === "aprs" || type === "hf_aprs") {
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(key);
const call = entry?.call ?? key;
if (type === "aprs") {
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(call);
const info = entry?.info ? String(entry.info) : "";
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
return `${call} ${info} ${pktRaw}`.toLowerCase();
@@ -1207,10 +1200,9 @@ var mapWindow = window;
}
};
mapWindow.clearMapMarkersByType = function(type) {
if (type === "aprs" || type === "hf_aprs") {
if (type === "aprs") {
selectedAprsTrackCall = null;
stationMarkers.forEach((entry, key) => {
if (aprsEntrySource(entry) !== type) return;
stationMarkers.forEach((entry) => {
if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker);
@@ -1219,8 +1211,8 @@ var mapWindow = window;
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
mapMarkers.delete(entry.track);
}
stationMarkers.delete(key);
});
stationMarkers.clear();
return;
}
if (type === "ais") {
@@ -1456,15 +1448,13 @@ var mapWindow = window;
if (!ll) return;
const entry = stationMarkers.get(marker._aprsCall);
if (!entry) return;
const source = aprsEntrySource(entry);
const call = entry.call ?? String(marker._aprsCall);
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
refreshAprsTrack(String(marker._aprsCall), entry);
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
entry.track.addTo(aprsMap);
}
selectedAprsTrackCall = String(marker._aprsCall);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
return;
}
if (marker._aisMmsi) {
@@ -1858,33 +1848,28 @@ var mapWindow = window;
}
return null;
}
function _aprsAddMarkerToMap(key, entry) {
function _aprsAddMarkerToMap(call, entry) {
if (!aprsMap || entry.lat == null || entry.lon == null) return;
refreshAprsTrack(key, entry);
const source = aprsEntrySource(entry);
const call = entry.call ?? key;
refreshAprsTrack(call, entry);
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
const color = mapSourceColor(source);
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
radius: 6,
color,
fillColor: color,
color: "#00d17f",
fillColor: "#00d17f",
fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(popupContent);
marker.__trxType = source;
marker.__trxType = "aprs";
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
marker._aprsCall = key;
marker._aprsCall = call;
entry.marker = marker;
mapMarkers.add(marker);
}
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
const nextPoint = [lat, lon];
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
const stationSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
const key = aprsStationKey(call, stationSource);
const existing = stationMarkers.get(key);
const existing = stationMarkers.get(call);
if (existing) {
existing.pkt = pkt;
existing.lat = lat;
@@ -1903,8 +1888,7 @@ var mapWindow = window;
} else if (prevPoint) {
prevPoint.tsMs = tsMs;
}
existing.call = call;
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -1915,8 +1899,7 @@ var mapWindow = window;
track: null,
trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint],
type: stationSource,
call,
type: "aprs",
pkt,
lat,
lon,
@@ -1925,9 +1908,9 @@ var mapWindow = window;
symbolCode,
rigIds: new Set(msgRigId ? [msgRigId] : [])
};
stationMarkers.set(key, entry);
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
stationMarkers.set(call, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
if (aprsMap) scheduleDecodeMapMaintenance();
}
};
@@ -1,7 +1,3 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -115,7 +111,6 @@ function paintRow(line, rgb) {
liveRows = Math.max(liveRows, line + 1);
}
function onProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state;
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
@@ -136,20 +131,15 @@ function onImage(msg) {
sstvHistory.push(image);
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory();
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) {
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"}${image.lines ?? 0} lines`;
}
scheduleUi("sstv-latest", renderLatestCard);
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
}
function sstvRigImages() {
return forActiveRig(sstvHistory);
}
function renderLatestCard() {
if (!sstvDom.liveLatest) return;
const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
const latest = sstvHistory[sstvHistory.length - 1];
if (!latest) {
sstvDom.liveLatest.innerHTML = "";
return;
@@ -170,9 +160,8 @@ function renderLatestCard() {
</div>`;
}
function filteredHistory() {
const rigImages = sstvRigImages();
const text = filterText.trim().toLowerCase();
const matching = text ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : rigImages;
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice();
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching;
@@ -252,11 +241,7 @@ sstvWindow.trxPluginRuntime.registerDecoder({
onMessage: onImage,
restore: restoreHistory,
prune: renderHistoryTable,
reset: resetHistoryView,
rerender: () => {
renderLatestCard();
renderHistoryTable();
}
reset: resetHistoryView
});
sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress",
@@ -1,7 +1,3 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -64,21 +60,17 @@ function vdesHexPreview(rawBytes) {
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
}
function vdesRigMessages() {
return forActiveRig(vdesMessageHistory);
}
function updateVdesSummary() {
pruneVdesMessageHistory();
if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText();
}
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) {
const count = rigMessages.length;
const count = vdesMessageHistory.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
}
if (vdesLatestSeenEl) {
const latest = rigMessages[0];
const latest = vdesMessageHistory[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
}
}
@@ -145,7 +137,7 @@ function updateVdesBar() {
updateVdesSummary();
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)).slice(0, 6);
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = "";
@@ -190,7 +182,7 @@ function renderVdesHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of vdesRigMessages()) {
for (const message of vdesMessageHistory) {
fragment.appendChild(renderVdesRow(message));
}
vdesMessagesEl.replaceChildren(fragment);
@@ -217,10 +209,10 @@ function normalizeServerVdesMessage(msg) {
}
function onServerVdesBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -259,8 +251,8 @@ function plotVdesMessage(msg) {
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next);
plotVdesMessage(next);
}
@@ -277,10 +269,6 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
rerender: () => {
updateVdesBar();
renderVdesHistory();
},
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
@@ -1,7 +1,3 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -106,17 +102,13 @@ function paintLine(lineBytes) {
wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++;
}
function wefaxRigImages() {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return;
const rigImages = wefaxRigImages();
if (rigImages.length === 0) {
if (wefaxImageHistory.length === 0) {
wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
return;
}
const img = rigImages[0];
const img = wefaxImageHistory[0];
if (!img) return;
const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
@@ -137,7 +129,7 @@ function renderWefaxLatestCard() {
wefaxDom.liveLatest.innerHTML = html;
}
function getWefaxFilteredHistory() {
let items = wefaxRigImages();
let items = wefaxImageHistory;
if (wefaxFilterText) {
items = items.filter(function(i) {
const haystack = [
@@ -181,7 +173,7 @@ function renderWefaxHistoryTable() {
}
wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) {
const total = wefaxRigImages().length;
const total = wefaxImageHistory.length;
const shown = items.length;
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
}
@@ -216,7 +208,6 @@ function addWefaxImage(msg) {
}
}
function onServerWefaxProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state && !msg.line_data) {
if (wefaxDom.status) {
wefaxDom.status.textContent = msg.state;
@@ -243,7 +234,6 @@ function onServerWefaxProgress(msg) {
}
function onServerWefax(msg) {
addWefaxImage(msg);
if (!isActiveRigDecode(msg.rig_id)) return;
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
if (wefaxDom.status) {
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
@@ -338,11 +328,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerWefax,
restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView,
rerender: () => {
renderWefaxLatestCard();
renderWefaxHistoryTable();
}
reset: resetWefaxHistoryView
});
wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress",
@@ -1,7 +1,3 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -68,10 +64,10 @@ function renderWsprRow(msg) {
function renderWsprHistory() {
pruneWsprMessageHistory();
if (!wsprMessagesEl) return;
const rigMessages = forActiveRig(wsprMessageHistory);
const fragment = document.createDocumentFragment();
for (const message of rigMessages) {
fragment.appendChild(renderWsprRow(message));
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
const message = wsprMessageHistory[i];
if (message) fragment.appendChild(renderWsprRow(message));
}
wsprMessagesEl.replaceChildren(fragment);
}
@@ -94,8 +90,6 @@ function normalizeServerWsprMessage(msg) {
station,
rfHz,
history: {
rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms,
snr_db: msg.snr_db,
@@ -107,11 +101,16 @@ function normalizeServerWsprMessage(msg) {
}
function onServerWsprBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerWsprMessage(msg);
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
plotWsprLocator(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
});
}
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
@@ -251,19 +250,15 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
}
})();
});
function plotWsprLocator(msg) {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...rfHz === null ? {} : { freq_hz: rfHz }
});
}
function onServerWspr(msg) {
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
if (wsprStatus) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg);
plotWsprLocator(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
});
}
addWsprMessage(next.history);
}
wsprWindow.trxPluginRuntime.registerDecoder({
@@ -272,10 +267,5 @@ wsprWindow.trxPluginRuntime.registerDecoder({
onBatch: onServerWsprBatch,
restore: onServerWsprBatch,
prune: pruneWsprHistoryView,
reset: resetWsprHistoryView,
rerender: renderWsprHistory,
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => {
for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message);
}
reset: resetWsprHistoryView
});
@@ -1467,31 +1467,30 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
<div id="background-decode-panel" class="sch-panel">
<div class="sch-toast" id="background-decode-toast" role="alert" aria-live="polite" style="display:none;"></div>
<!-- Now Playing status card (moved to top) -->
<div class="now-playing-card">
<div id="background-decode-status-card" class="sch-status-card">No background decode bookmarks configured.</div>
</div>
<div class="sch-section">
<div class="sch-section-title">Background Decode</div>
<p class="bgd-intro">
Decodes saved bookmarks on hidden channels while you work another band.
Needs an SDR rig, and a bookmark is only decoded while it falls inside
the span the rig is tuned across.
</p>
<div class="bgd-controls">
<label class="bgd-enable">
<input type="checkbox" id="background-decode-enabled" />
<span>Enabled</span>
<div class="sch-section-title">Configuration</div>
<div class="sch-row">
<label class="sch-label bgd-toggle-wrap">Background decode
<span class="bgd-toggle-row">
<input type="checkbox" id="background-decode-enabled" />
<span>Enable hidden background decoder channels</span>
</span>
</label>
<span id="bgd-span-summary" class="bgd-span" aria-live="polite"></span>
</div>
<div class="bgd-list-toolbar">
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter"
placeholder="Filter bookmarks&hellip;" aria-label="Filter bookmarks" />
<div class="sch-row" style="flex-direction:column;gap:0.5rem;">
<label class="sch-label" style="min-width:100%;">Bookmarks
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter" placeholder="Filter bookmarks..." />
</label>
<div class="bgd-select-actions">
<button type="button" id="bgd-select-all-btn" class="bgd-select-btn" aria-label="Select every bookmark">All</button>
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Select no bookmarks">None</button>
<button type="button" id="bgd-select-all-btn" class="bgd-select-btn" aria-label="Select all bookmarks">Select All</button>
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Deselect all bookmarks">Deselect All</button>
</div>
<div id="bgd-bookmark-checklist" class="bgd-checklist"></div>
</div>
<div id="bgd-bookmark-checklist" class="bgd-checklist" role="group"
aria-label="Bookmarks decoded in the background"></div>
<div id="bgd-selection-summary" class="bgd-summary" aria-live="polite"></div>
</div>
<div class="sch-actions">
@@ -5589,6 +5589,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
#sch-sat-form-cancel:hover {
background: var(--border);
}
.bgd-toggle-wrap {
min-width: 18rem;
flex: 1 1 20rem;
}
.bgd-toggle-row {
display: inline-flex;
align-items: center;
gap: 0.55rem;
min-height: var(--control-height);
color: var(--text);
font-weight: 500;
}
.bgd-add-row {
display: flex;
gap: 0.55rem;
@@ -5612,6 +5624,48 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--text-muted);
font-size: 0.78rem;
}
.bgd-status-list {
display: grid;
gap: 0.65rem;
}
.bgd-status-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 0.75rem;
border: 1px solid var(--border-light);
border-radius: 0.55rem;
background: color-mix(in srgb, var(--card-bg) 74%, transparent);
}
.bgd-status-name {
font-weight: 600;
}
.bgd-status-meta {
color: var(--text-muted);
font-size: 0.82rem;
}
.bgd-status-state {
align-self: center;
white-space: nowrap;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--accent-green);
}
.bgd-status-state[data-state="out_of_span"],
.bgd-status-state[data-state="waiting_for_spectrum"],
.bgd-status-state[data-state="waiting_for_user"],
.bgd-status-state[data-state="scheduler_has_control"],
.bgd-status-state[data-state="inactive"],
.bgd-status-state[data-state="handled_by_scheduler"],
.bgd-status-state[data-state="handled_by_virtual_channel"] {
color: var(--accent-yellow);
}
.bgd-status-state[data-state="missing_bookmark"],
.bgd-status-state[data-state="no_supported_decoders"] {
color: var(--accent-red);
}
/* ── "Now Playing" status card (top of scheduler & bgd panels) ──── */
.now-playing-card {
border-left: 3px solid var(--accent-green);
@@ -5827,65 +5881,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--text-muted);
opacity: 0.7;
}
.bgd-intro {
margin: 0 0 0.9rem;
max-width: 62ch;
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.45;
}
/* The switch and what the rig can hear, on one line: the second is the reason
a selected bookmark may not be decoding, so it belongs beside the first. */
.bgd-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.9rem;
}
.bgd-enable {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
cursor: pointer;
}
.bgd-span {
color: var(--text-muted);
font-size: 0.82rem;
}
.bgd-span[data-tone="warn"] {
color: var(--text-warn, #b7791f);
}
/* The filter and the bulk buttons sit on the list they act on, and stay on it
when the panel narrows. */
.bgd-list-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.bgd-list-toolbar .bgd-checklist-filter {
flex: 1 1 auto;
min-width: 0;
}
.bgd-checklist {
max-height: 22rem;
max-height: 16rem;
overflow-y: auto;
border: 1px solid var(--border-light);
border-radius: 0.4rem;
background: var(--bg-secondary);
}
/* One row per bookmark: pick it here, and read what it is doing here too. */
.bgd-checklist-row {
display: grid;
grid-template-columns: auto minmax(6rem, 1fr) auto auto;
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.5rem 0.65rem;
padding: 0.45rem 0.65rem;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
font-size: 0.85rem;
@@ -5897,83 +5904,28 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.bgd-checklist-row:hover {
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
}
.bgd-checklist-row.is-selected {
background: color-mix(in srgb, var(--accent-green) 8%, transparent);
}
.bgd-checklist-row input[type="checkbox"] {
flex-shrink: 0;
}
.bgd-checklist-name {
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bgd-checklist-meta {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
color: var(--text-muted);
font-size: 0.78rem;
margin-left: auto;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.bgd-checklist-mode {
color: var(--text-muted);
opacity: 0.85;
}
/* A dot and a word. The dot carries the state, so the word can stay a word. */
.bgd-state {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
min-width: 9.5rem;
color: var(--text-muted);
font-size: 0.76rem;
white-space: nowrap;
}
.bgd-state-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: currentcolor;
opacity: 0.55;
flex-shrink: 0;
}
.bgd-state[data-state="active"] {
color: var(--accent-green);
font-weight: 600;
}
.bgd-state[data-state="active"] .bgd-state-dot {
opacity: 1;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-green) 22%, transparent);
}
.bgd-state[data-state="missing_bookmark"],
.bgd-state[data-state="no_supported_decoders"] {
color: var(--color-error, #c0392b);
}
.bgd-state[data-state="unselected"] {
min-width: 0;
}
.bgd-checklist-empty {
padding: 1rem 0.75rem;
padding: 0.75rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.5;
}
/* What the selection adds up to, under the list it describes. */
.bgd-summary {
margin-top: 0.5rem;
color: var(--text-muted);
font-size: 0.8rem;
}
/* ── Select all / none buttons ─────────────────────────────────────── */
/* ── Select All / Deselect All buttons ────────────────────────────── */
.bgd-select-actions {
display: flex;
flex-shrink: 0;
gap: 0.5rem;
}
.bgd-select-btn {
@@ -5991,6 +5943,15 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
background: var(--card-bg);
color: var(--text);
}
/* ── SVG State Dot Badges ─────────────────────────────────────────── */
.bgd-state-dot {
width: 8px;
height: 8px;
display: inline-block;
vertical-align: middle;
margin-right: 4px;
fill: currentColor;
}
@media (max-width: 600px) {
.channel-scheduler-controls {
flex-direction: column;
@@ -6031,7 +5992,8 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.bm-page-summary {
text-align: center;
}
.bgd-add-row {
.bgd-add-row,
.bgd-status-row {
flex-direction: column;
align-items: stretch;
}
@@ -6041,17 +6003,8 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.interleave-ring-label {
max-width: 8rem;
}
/* Two lines rather than four columns: the name and its state stay legible
when there is no room for the frequency beside them. */
.bgd-checklist-row {
grid-template-columns: auto minmax(0, 1fr);
row-gap: 0.2rem;
}
.bgd-checklist-meta,
.bgd-state {
grid-column: 2;
justify-content: flex-start;
min-width: 0;
.bgd-checklist-meta {
margin-left: 0;
font-size: 0.75rem;
}
}
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -78,10 +78,6 @@ export type RdsData = { pi?: number | null, program_service?: string | null, rad
export type SpectrumData = {
/**
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
*
* On the wire these are base64-encoded `i8` whole dBFS (see
* `spectrum_wire`), which is what the display draws anyway; the TypeScript
* type describes the decoded array the browser receives over SSE.
*/
bins: Array<number>,
/**
@@ -392,7 +392,6 @@ declare global {
updateVdesBar?(value?: number): void;
updateAprsBar?(value?: number): void;
updateFt8Bar?(value?: number): void;
updateCwBar?(value?: number): void;
updateSatLiveState?(value: unknown): void;
applyCwAutoUi?(enabled: boolean): void;
applyCwAutoUiFromServer?(enabled: boolean): void;
@@ -1468,13 +1467,9 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
// Told every time, not only when the list changes: these modules load lazily
// and can miss the one telling there was, which left them holding no rig and
// showing an empty panel until the operator switched rigs. Both ignore a rig
// they already have.
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
if (rigListChanged) {
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.populateScopePicker();
void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
}
@@ -2212,17 +2207,6 @@ function resetRdsDisplay() {
updateRdsPsOverlay(primaryRds);
}
// The mini views over the waterfall show only what the rig on screen heard, so
// they have to be repainted whenever that rig — or its mode — changes, not just
// when the next decode happens to arrive.
function refreshDecodeBars() {
window.updateAisBar?.();
window.updateVdesBar?.();
window.updateAprsBar?.();
window.updateFt8Bar?.();
window.updateCwBar?.();
}
function resetDecoderStateOnRigSwitch() {
// RDS
primaryRds = null;
@@ -2242,12 +2226,6 @@ function resetDecoderStateOnRigSwitch() {
const el = document.getElementById(id);
if (el) el.textContent = "--";
});
// The decode stream is not rig-scoped, so the histories behind the mini views
// and the decoder panels survive the switch: repaint both for the rig now
// selected instead of leaving the outgoing rig's traffic on screen.
refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
}
function resetWfmStereoIndicator() {
@@ -3698,7 +3676,10 @@ function render(update: AppUpdate) {
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
}
refreshDecodeBars();
if (window.updateAisBar) window.updateAisBar();
if (window.updateVdesBar) window.updateVdesBar();
if (window.updateAprsBar) window.updateAprsBar();
if (window.updateFt8Bar) window.updateFt8Bar();
// Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong.
for (const d of decoderRegistry) {
if (d.activation !== "toggle") continue;
@@ -4916,8 +4897,6 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
// Passes go stale while the page is closed, so each visit reloads them.
// The first visit is what imports the module, which renders on its own.
if (name === "satellites") window.refreshSatPredictions?.();
// The map module owns the decode log; this is the moment it can exist.
flushPendingDecodeStats();
}).catch((error: unknown) => { console.error(error); });
if (name === "map") {
_initMapWhenReady();
@@ -5040,12 +5019,7 @@ async function initializeApp() {
}
}
// Whether the session got far enough to wire the settings panels up, so the
// plugins can be wired again if they turn up after it did.
let settingsUiReady = false;
function initSettingsUI() {
settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
@@ -5198,15 +5172,7 @@ window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules
// 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.
//
// Wire the settings panels again once they are here: the session can be up
// before these modules finish importing, and the wiring at that point silently
// skipped whichever had not arrived. A panel that missed it had no rig, no
// data, and dead buttons — for the rest of the session, since nothing came
// back to it. Both entry points are safe to run twice.
void loadEagerPlugins()
.then(() => { if (settingsUiReady) initSettingsUI(); })
.catch((error: unknown) => { console.error(error); });
void loadEagerPlugins().catch((error: unknown) => { console.error(error); });
// Start the app
void initializeApp();
@@ -6616,49 +6582,11 @@ const IMAGE_DECODE_KINDS = new Set([
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
]);
// The decode log the Statistics page counts lives in the map module, which is
// lazy: it arrives when the Map or Statistics tab is first opened, long after
// the history replayed and the live decodes started coming. Recording into a
// module that is not there yet dropped every one of them, and nothing replayed
// them afterwards -- so the page opened empty and only filled in from decodes
// heard after it, which is why it took a reload (landing on the tab, so the
// module loads at startup) to show the whole picture. Hold them here until
// the module arrives, then hand them over.
interface PendingDecodeStat { kind: string; rig: string | null; tsMs: number | undefined }
const pendingDecodeStats: PendingDecodeStat[] = [];
// The module's own log keeps 50k entries; there is no point holding more here.
const PENDING_DECODE_STATS_MAX = 50_000;
function recordDecodeStat(kind: string, rig: string | null, tsMs?: number) {
const stats = window.trx.modules.map;
if (stats) {
stats.statsRecordDecode(kind, rig, tsMs);
return;
}
pendingDecodeStats.push({ kind, rig, tsMs });
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
}
}
function flushPendingDecodeStats() {
const stats = window.trx.modules.map;
if (!stats || pendingDecodeStats.length === 0) return;
for (const entry of pendingDecodeStats.splice(0)) {
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
}
stats.scheduleStatsRender();
}
function scheduleStatsRenderIfLoaded() {
window.trx.modules.map?.scheduleStatsRender();
}
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
scheduleStatsRenderIfLoaded();
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender();
}
}
@@ -6708,9 +6636,9 @@ function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
// Record statistics for restored history messages.
if (!IMAGE_DECODE_KINDS.has(kind)) {
for (const msg of messages) {
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
}
scheduleStatsRenderIfLoaded();
window.trx.modules.map?.scheduleStatsRender();
}
window.trxPluginRuntime.restore(kind, messages);
}
@@ -31,7 +31,7 @@ function mapEl(id: string): MapElement {
return element as MapElement;
}
type DecoderSource = "ais" | "vdes" | "aprs" | "hf_aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
type DecoderSource = "ais" | "vdes" | "aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
interface TrackPoint { lat: number; lon: number; tsMs: number }
interface DecodeDetail {
station?: string | null; source?: string | null; target?: string | null;
@@ -83,9 +83,6 @@ interface MapEntry {
bookmarks?: Array<Record<string, unknown>>;
bounds?: Leaflet.LatLngBoundsExpression;
symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
/** Callsign as heard. The map key qualifies it with the source, so that a
* station worked on both 144 MHz and 30 m keeps one marker per band. */
call?: string;
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
pathKey?: string; sourceGrid?: string; targetGrid?: string;
from?: LatLon; to?: LatLon;
@@ -174,7 +171,7 @@ interface MapWindow {
clearMapMarkersByType?(type: DecoderSource): void;
navigateToAprsMap?(lat: number, lon: number): void;
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage, source?: "aprs" | "hf_aprs"): void;
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage): void;
aisMapAddVessel?(message: MapMessage): void;
vdesMapAddPoint?(message: MapMessage): void;
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
@@ -231,7 +228,7 @@ const mapWindow = window as unknown as MapWindow;
const decodeContactPaths = new Map<string, MapEntry>();
let selectedMapQsoKey: string | null = null;
const mapMarkers = new Set<TrxLayer>();
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
/** Chip key that clears a selection rather than naming a band or a source. */
const MAP_FILTER_ALL_KEY = "__all";
@@ -305,18 +302,6 @@ const mapWindow = window as unknown as MapWindow;
return trimmed;
}
/** The source a station marker belongs to, defaulting to VHF APRS for
* entries stored before HF APRS had a source of its own. */
function aprsEntrySource(entry: MapEntry | null | undefined): DecoderSource {
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
}
/** Map key for a station: the callsign alone on VHF, source-qualified on HF,
* so one station heard on both bands keeps a marker for each. */
function aprsStationKey(call: string, source: DecoderSource): string {
return source === "aprs" ? call : `${source}:${call}`;
}
function refreshAprsTrack(call: string, entry: MapEntry): void {
if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
@@ -338,7 +323,7 @@ const mapWindow = window as unknown as MapWindow;
lineJoin: "round",
interactive: false,
}) as unknown as TrxLayer;
track.__trxType = aprsEntrySource(entry);
track.__trxType = "aprs";
track._aprsCall = call;
entry.track = track;
}
@@ -590,7 +575,6 @@ const mapWindow = window as unknown as MapWindow;
function mapSourceLabel(type: DecoderSource): string {
if (type === "bookmark") return "Bookmarks";
if (type === "hf_aprs") return "HF APRS";
return String(type || "").toUpperCase();
}
@@ -610,8 +594,6 @@ const mapWindow = window as unknown as MapWindow;
if (type === "vdes") return "#a78bfa";
if (type === "sat") return "#f59e0b";
if (type === "aprs") return "#00d17f";
// Far enough from the VHF green to tell the two apart at a glance.
if (type === "hf_aprs") return "#fb7185";
return locatorFilterColor(type);
}
@@ -1206,8 +1188,10 @@ const mapWindow = window as unknown as MapWindow;
}
}
for (const entry of stationMarkers.values()) {
if (!entry?.visibleInHistoryWindow) continue;
availableSources.add(aprsEntrySource(entry));
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
availableSources.add("aprs");
break;
}
}
const bandMap = new Map<string, FilterChip>();
for (const entry of locatorMarkers.values()) {
@@ -1241,7 +1225,7 @@ const mapWindow = window as unknown as MapWindow;
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
}
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
.filter((key) => availableSources.has(key))
.map((key) => ({
key,
@@ -1312,10 +1296,9 @@ const mapWindow = window as unknown as MapWindow;
}
return parts.join(" ").toLowerCase();
}
if (type === "aprs" || type === "hf_aprs") {
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(key);
const call = entry?.call ?? key;
if (type === "aprs") {
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(call);
const info = entry?.info ? String(entry.info) : "";
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
return `${call} ${info} ${pktRaw}`.toLowerCase();
@@ -1513,10 +1496,9 @@ const mapWindow = window as unknown as MapWindow;
};
mapWindow.clearMapMarkersByType = function(type) {
if (type === "aprs" || type === "hf_aprs") {
if (type === "aprs") {
selectedAprsTrackCall = null;
stationMarkers.forEach((entry, key) => {
if (aprsEntrySource(entry) !== type) return;
stationMarkers.forEach((entry) => {
if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker);
@@ -1525,8 +1507,8 @@ const mapWindow = window as unknown as MapWindow;
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
mapMarkers.delete(entry.track);
}
stationMarkers.delete(key);
});
stationMarkers.clear();
return;
}
@@ -1799,15 +1781,13 @@ const mapWindow = window as unknown as MapWindow;
if (!ll) return;
const entry = stationMarkers.get(marker._aprsCall);
if (!entry) return;
const source = aprsEntrySource(entry);
const call = entry.call ?? String(marker._aprsCall);
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
refreshAprsTrack(String(marker._aprsCall), entry);
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
entry.track.addTo(aprsMap);
}
selectedAprsTrackCall = String(marker._aprsCall);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
return;
}
@@ -2290,33 +2270,28 @@ const mapWindow = window as unknown as MapWindow;
return null;
}
function _aprsAddMarkerToMap(key: string, entry: MapEntry): void {
function _aprsAddMarkerToMap(call: string, entry: MapEntry): void {
if (!aprsMap || entry.lat == null || entry.lon == null) return;
refreshAprsTrack(key, entry);
const source = aprsEntrySource(entry);
const call = entry.call ?? key;
refreshAprsTrack(call, entry);
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
const color = mapSourceColor(source);
const marker = (icon
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
: L.circleMarker([entry.lat, entry.lon], {
radius: 6, color, fillColor: color, fillOpacity: 0.8
radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
marker.__trxType = source;
marker.__trxType = "aprs";
marker.__trxRigIds = entry.rigIds || new Set();
marker._aprsCall = key;
marker._aprsCall = call;
entry.marker = marker;
mapMarkers.add(marker);
}
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
const nextPoint: Leaflet.LatLngTuple = [lat, lon];
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
const stationSource: DecoderSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
const key = aprsStationKey(call, stationSource);
const existing = stationMarkers.get(key);
const existing = stationMarkers.get(call);
if (existing) {
existing.pkt = pkt;
existing.lat = lat;
@@ -2335,8 +2310,7 @@ const mapWindow = window as unknown as MapWindow;
} else if (prevPoint) {
prevPoint.tsMs = tsMs;
}
existing.call = call;
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -2347,8 +2321,7 @@ const mapWindow = window as unknown as MapWindow;
track: null,
trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint],
type: stationSource,
call,
type: "aprs",
pkt,
lat,
lon,
@@ -2357,9 +2330,9 @@ const mapWindow = window as unknown as MapWindow;
symbolCode,
rigIds: new Set(msgRigId ? [msgRigId] : []),
};
stationMarkers.set(key, entry);
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
stationMarkers.set(call, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
if (aprsMap) scheduleDecodeMapMaintenance();
}
};
@@ -81,7 +81,6 @@ const runtime: TrxPluginRuntime = {
plugin.prune();
return true;
},
rerenderAll() { for (const plugin of decoders.values()) plugin.rerender?.(); },
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
clearQueued() { queued.clear(); },
hasDecoder: (id) => decoders.has(id),
@@ -1,26 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
// The decode stream is not rig-scoped: every rig a client is connected to sends
// its decodes to every browser, each frame naming the rig that heard it (the
// client stamps `rig_id` as it leaves the audio connection). The map wants all
// of them — it has its own filter, and plotting only one rig would empty it —
// but the radio page and the decoder panels describe one rig at a time: the one
// the operator selected, whose spectrum is on screen and whose audio is
// playing. A frame a background rig copied on another band belongs to neither
// picture, so both filter on this.
export function isActiveRigDecode(rigId: string | null | undefined): boolean {
const activeRigId = hostState.lastActiveRigId;
// Before the rig list has arrived, and for a decode that reached the browser
// without a rig of its own, there is nothing to disagree with.
if (!activeRigId || !rigId) return true;
return rigId === activeRigId;
}
/** The decodes of `items` that the selected rig heard, in their original order. */
export function forActiveRig<T extends { rig_id?: string | null | undefined }>(items: T[]): T[] {
return items.filter((item) => isActiveRigDecode(item.rig_id));
}
@@ -6,7 +6,6 @@ import { hostCore, hostState } from "./host.js";
export {};
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface AisMessage {
@@ -191,27 +190,20 @@ function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
return Array.from(byMmsi.values());
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function aisRigMessages(): AisMessage[] {
return forActiveRig(aisMessageHistory);
}
function updateAisSummary() {
const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
}
const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
const vessels = aisLatestByVessel(aisMessageHistory);
if (aisVesselCountEl) {
const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
}
if (aisLatestSeenEl) {
const latest = rigMessages[0];
const latest = aisMessageHistory[0];
if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet";
} else {
@@ -322,9 +314,7 @@ function updateAisBar() {
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const recent = aisMessageHistory.filter(
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id),
);
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
const messages = aisLatestByVessel(recent).slice(0, 8);
if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none";
@@ -378,7 +368,7 @@ function renderAisHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of aisRigMessages()) {
for (const message of aisMessageHistory) {
fragment.appendChild(renderAisRow(message));
}
aisMessagesEl.replaceChildren(fragment);
@@ -417,11 +407,10 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage {
function onServerAisBatch(messages: AisMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized: AisMessage[] = [];
for (const msg of messages) {
const next = normalizeServerAisMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -463,9 +452,8 @@ if (aisFilterInput) {
}
function onServerAis(msg: AisMessage): void {
const message = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg));
}
updateAisSummary();
@@ -476,7 +464,6 @@ updateAisSummary();
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
rerender: () => { updateAisBar(); renderAisHistory(); },
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
});
@@ -4,7 +4,6 @@
import { hostCore, hostState } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import {
aprsAgeText,
aprsPacketCategory,
@@ -107,29 +106,21 @@ function aprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(aprsFilterText);
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function aprsRigPackets(): AprsPacket[] {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets(): AprsPacket[] {
const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
return packets.filter(aprsFilterMatch);
}
function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets();
if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
}
if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (aprsLatestSeenEl) {
const latest = rigPackets[0];
const latest = aprsPacketHistory[0];
if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -188,9 +179,7 @@ function updateAprsBar() {
if (!aprsBarOverlay) return;
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter(
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id),
);
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
@@ -267,12 +256,11 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -336,9 +324,8 @@ if (aprsFilterInput) {
// --- Server-side APRS decode handler ---
function onServerAprs(pkt: AprsPacket): void {
const packet = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
}
renderAprsHistory();
@@ -349,7 +336,6 @@ renderAprsHistory();
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
rerender: () => { updateAprsBar(); renderAprsHistory(); },
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
});
@@ -66,17 +66,10 @@ const bgdWindow = window as unknown as BackgroundBridge;
let bookmarkList: Bookmark[] = [];
let statusInterval: ReturnType<typeof setInterval> | null = null;
let bgdDirty = false;
/** Last polled state per bookmark, so a row can say what it is doing. */
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
let lastStatus: BackgroundDecodeStatus | null = null;
function initBackgroundDecode(rigId: string | null, role: string | null): void {
backgroundDecodeRole = role;
// The panel used to take whatever rig it was handed at load and wait to be
// told again. Loading before the rig list arrives handed it null, and the
// next telling only came when the operator switched rigs, so the panel sat
// empty and silent. The host knows the rig; ask it.
currentRigId = rigId || hostState.lastActiveRigId || null;
currentRigId = rigId || null;
if (currentRigId) loadBackgroundDecode();
startStatusPolling();
}
@@ -178,7 +171,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist();
const isControl = isControlRole();
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
const panel = document.getElementById("background-decode-panel");
if (panel) {
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
@@ -189,11 +182,6 @@ const bgdWindow = window as unknown as BackgroundBridge;
const resetBtn = document.getElementById("background-decode-reset-btn");
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
syncSaveButton();
}
function currentFilterText(): string {
return (document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value ?? "";
}
function renderBookmarkChecklist(filterText = ""): void {
@@ -215,65 +203,26 @@ const bgdWindow = window as unknown as BackgroundBridge;
: all;
if (filtered.length === 0) {
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
renderSelectionSummary();
container.innerHTML = '<div class="bgd-checklist-empty">' +
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
'</div>';
return;
}
filtered.forEach(function (bookmark) {
const row = document.createElement("label");
row.className = "bgd-checklist-row";
row.dataset.bmId = bookmark.id;
const decoders = bookmarkDecoderKinds(bookmark);
const selected = selectedIds.has(bookmark.id);
if (selected) row.classList.add("is-selected");
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
row.innerHTML =
'<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
'<span class="bgd-checklist-meta">'
+ escHtml(formatFreq(bookmark.freq_hz)) + '<span class="bgd-checklist-mode">'
+ escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span></span>'
+ stateBadgeHtml(bookmark.id, selected);
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) {
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
});
container.appendChild(row);
});
renderSelectionSummary();
}
/** Why the list is empty, in the terms the operator can act on. */
function emptyListText(supportedCount: number): string {
if (supportedCount > 0) return "No bookmark matches that filter.";
if (bookmarkList.length > 0) {
return "None of your bookmarks name a decoder that can run in the background."
+ " Give one a decoder on the Bookmarks tab to list it here.";
}
return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
}
/** The live state of a selected bookmark, as the row's own badge. */
function stateBadgeHtml(bookmarkId: string, selected: boolean): string {
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
const entry = statusByBookmark.get(bookmarkId);
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '">'
+ '<span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
}
function renderSelectionSummary(): void {
const el = document.getElementById("bgd-selection-summary");
if (!el) return;
const selected = currentConfig?.bookmark_ids.length ?? 0;
if (selected === 0) {
el.textContent = "Nothing selected — background decoding is idle.";
return;
}
const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
el.textContent = currentConfig?.enabled
? `${noun}, ${String(active)} decoding now.`
: `${noun}. Switch Enabled on to start decoding them.`;
}
function onChecklistToggle(bookmarkId: string, checked: boolean): void {
@@ -311,7 +260,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
showToast(`Save failed: ${errorMessage(err)}`, true);
})
.finally(function () {
syncSaveButton();
if (btn) btn.disabled = false;
});
}
@@ -346,77 +295,58 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
function renderStatus(status: BackgroundDecodeStatus): void {
lastStatus = status;
statusByBookmark = new Map(
(status.entries ?? [])
.filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0)
.map((entry) => [entry.bookmark_id as string, entry]),
);
renderSpanSummary();
// Rows carry the state, so the list repaints — keeping the filter the
// operator typed and the selection they have not saved yet.
renderBookmarkChecklist(currentFilterText());
}
/** What the rig is listening across, which is what decides whether a
* selected bookmark can be decoded at all. */
function renderSpanSummary(): void {
const el = document.getElementById("bgd-span-summary");
if (!el) return;
const status = lastStatus;
if (!status) {
el.textContent = "";
const card = document.getElementById("background-decode-status-card");
if (!card) return;
const entries = status.entries ?? [];
if (!entries.length) {
card.textContent = "No background decode bookmarks configured.";
return;
}
if (!status.active_rig) {
el.textContent = "This rig is not the one playing audio.";
el.dataset.tone = "warn";
return;
const summary = [];
if (status.active_rig) {
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
} else {
summary.push("This rig is not currently selected for audio.");
}
const centre = typeof status.center_hz === "number" && Number.isFinite(status.center_hz)
? formatFreq(status.center_hz)
: null;
const half = typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0
? formatFreq(status.sample_rate / 2)
: null;
el.dataset.tone = "";
el.textContent = centre && half ? `Span ${centre} ±${half}` : centre ? `Centre ${centre}` : "";
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
html += '<div class="bgd-status-list">';
entries.forEach(function (entry) {
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
const parts = [];
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
if (entry.mode) parts.push(entry.mode);
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
parts.push(entry.decoder_kinds.join("/").toUpperCase());
}
html +=
'<div class="bgd-status-row">' +
'<div>' +
'<div class="bgd-status-name">' + escHtml(name) + '</div>' +
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
'</div>' +
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' +
'<svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' +
escHtml(prettyState(entry.state)) + '</div>' +
'</div>';
});
html += "</div>";
card.innerHTML = html;
}
/** The one-line reason behind a state, for the row's tooltip. */
function stateHelp(state: string | undefined): string {
switch (state) {
case "active": return "Decoding on a hidden channel.";
case "out_of_span": return "Outside the span the rig is tuned across, so it cannot be heard from here.";
case "waiting_for_spectrum": return "Waiting for the first spectrum frame from the rig.";
case "waiting_for_user": return "Nobody is listening to this rig, so no audio is being pulled.";
case "missing_bookmark": return "The bookmark this was selected from is gone.";
case "no_supported_decoders": return "No decoder that runs in the background can decode this bookmark.";
case "disabled": return "Background decoding is switched off.";
case "handled_by_scheduler":
case "scheduler_has_control": return "The scheduler is running this bookmark instead.";
case "handled_by_virtual_channel": return "A virtual channel is already on this frequency.";
case "pending": return "Selected, and not started yet — save to apply.";
default: return "Selected, but not decoding.";
}
}
// The dot beside the word already says whether this is running, so the
// words no longer carry a tick or a triangle of their own.
function prettyState(state: string | undefined): string {
switch (state) {
case "active": return "Decoding";
case "out_of_span": return "Out of span";
case "waiting_for_spectrum": return "Waiting for spectrum";
case "waiting_for_user": return "Nobody listening";
case "missing_bookmark": return "Bookmark gone";
case "no_supported_decoders": return "No decoder";
case "disabled": return "Off";
case "handled_by_scheduler":
case "scheduler_has_control": return "Scheduler has it";
case "handled_by_virtual_channel": return "On a channel";
case "pending": return "Not saved";
default: return "Idle";
case "active": return "\u2713 Active";
case "out_of_span": return "\u25B3 Out of span";
case "waiting_for_spectrum": return "\u25B3 Waiting";
case "waiting_for_user": return "\u25B3 No user";
case "missing_bookmark": return "\u2717 Missing";
case "no_supported_decoders": return "\u2717 Unsupported";
case "disabled": return "\u25B3 Disabled";
case "handled_by_scheduler": return "\u25B3 Scheduler";
case "scheduler_has_control": return "\u25B3 Scheduler";
case "handled_by_virtual_channel": return "\u25B3 VChan";
default: return "\u25B3 Inactive";
}
}
@@ -450,25 +380,14 @@ const bgdWindow = window as unknown as BackgroundBridge;
function markBgdDirty() {
if (bgdDirty) return;
bgdDirty = true;
syncSaveButton();
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.add("sch-dirty");
}
function clearBgdDirty() {
bgdDirty = false;
syncSaveButton();
}
/** Save offers itself only when there is something to save. */
function syncSaveButton() {
const btn = document.getElementById("background-decode-save-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.classList.toggle("sch-dirty", bgdDirty);
btn.disabled = !bgdDirty || !isControlRole();
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
}
function isControlRole(): boolean {
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.remove("sch-dirty");
}
function showToast(msg: string, isError: boolean): void {
@@ -488,7 +407,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
const ids = supportedBookmarks().map(function (bm) { return bm.id; });
currentConfig.bookmark_ids = ids;
renderBookmarkChecklist(currentFilterText());
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
markBgdDirty();
}
@@ -497,7 +416,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
currentConfig.bookmark_ids = [];
renderBookmarkChecklist(currentFilterText());
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
markBgdDirty();
}
@@ -513,11 +432,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
if (enabledCb && !enabledCb._wired) {
enabledCb._wired = true;
enabledCb.addEventListener("change", function () {
if (currentConfig) currentConfig.enabled = enabledCb.checked;
markBgdDirty();
renderBookmarkChecklist(currentFilterText());
});
enabledCb.addEventListener("change", function () { markBgdDirty(); });
}
const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
@@ -4,7 +4,6 @@
import { hostCore } from "./host.js";
import { isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
@@ -21,11 +20,8 @@ interface CwRenderer {
drawPoints(points: number[], size: number, color: Rgba): void;
}
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
interface CwEvent { rig_id?: string | null; text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
interface CwLine {
rigId: string | null; tsMs: number; ts: string; text: string;
wpm: number | null; tone_hz: number | null; lastMs: number;
}
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number }
interface CwToneRange {
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
toneSpanHz: number; lowerSideband: boolean; mode: string;
@@ -70,10 +66,7 @@ const CW_BAR_LINE_GAP_MS = 5000;
let cwLastAppendTime = 0;
let cwTonePickerRaf: number | null = null;
let cwBarHistory: CwLine[] = [];
// One line in progress per rig, keyed by rig id ("" for a decode that names no
// rig). Two rigs copying at the same time each fill their own line instead of
// braiding their characters into one.
const cwBarCurrentLines = new Map<string, CwLine>();
let cwBarCurrentLine: CwLine | null = null;
let cwBarDismissedAtMs = 0;
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
// acknowledged). While set, server-state updates must not override the
@@ -108,13 +101,12 @@ cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
applyCwAutoUi(enabled);
};
function cwBarFlushCurrentLine(key: string): void {
const line = cwBarCurrentLines.get(key);
cwBarCurrentLines.delete(key);
if (line?.text.trim()) {
cwBarHistory.unshift(line);
function cwBarFlushCurrentLine(): void {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine);
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
}
cwBarCurrentLine = null;
}
function updateCwBar(): void {
@@ -122,12 +114,9 @@ function updateCwBar(): void {
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
const isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
// Prepend the in-progress lines so characters appear immediately
const inProgress = [...cwBarCurrentLines.values()]
.filter((l) => l.text && isActiveRigDecode(l.rigId))
.sort((a, b) => b.tsMs - a.tsMs);
const liveLines = [...inProgress, ...recent];
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
// Prepend the in-progress line so characters appear immediately
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none";
@@ -413,7 +402,7 @@ function resetCwHistoryView(): void {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBarHistory = [];
cwBarCurrentLines.clear();
cwBarCurrentLine = null;
updateCwBar();
drawCwTonePicker();
}
@@ -432,13 +421,8 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
// --- Server-side CW decode handler ---
function onServerCw(evt: CwEvent): void {
// The panel is a single stream of copied text: characters from a background
// rig do not belong in it at all, and interleaving them would make both
// rigs unreadable. The bar histories below keep every rig, each on its own
// line, and pick out the selected rig when they paint.
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl && forSelectedRig) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
// Append decoded text to output
const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
@@ -461,29 +445,25 @@ function onServerCw(evt: CwEvent): void {
// Bar history accumulation (regardless of pause state)
if (evt.text) {
const now = Date.now();
const rigId = evt.rig_id ?? null;
const key = rigId ?? "";
if (evt.text === "\n") {
cwBarFlushCurrentLine(key);
cwBarFlushCurrentLine();
} else {
let line = cwBarCurrentLines.get(key);
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine(key);
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine();
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
cwBarCurrentLines.set(key, line);
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
}
line.text += evt.text;
line.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
cwBarCurrentLine.text += evt.text;
cwBarCurrentLine.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
}
updateCwBar();
}
if (cwSignalIndicator && forSelectedRig) {
if (cwSignalIndicator) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
}
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
if (!cwAutoInput || cwAutoInput.checked) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm));
}
@@ -511,13 +491,6 @@ cwWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerCw,
restore: restoreCwHistory,
reset: resetCwHistoryView,
// The copied text of a rig that is no longer on screen cannot be unpicked
// from the pane, so the switch starts the new rig's stream from empty.
rerender: () => {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
updateCwBar();
},
});
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
@@ -4,7 +4,6 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
@@ -16,7 +15,6 @@ export interface FtxMessage {
snr_db?: number | undefined;
dt_s?: number | undefined;
freq_hz?: number | undefined;
rig_id?: string | null | undefined;
receiver?: unknown;
[key: string]: unknown;
}
@@ -229,12 +227,9 @@ export function initializeFtxDecoder(config: FtxConfig): void {
const render = (): void => {
prune();
if (!messagesElement) return;
// The history holds every rig's decodes, for the map; the panel is about
// the rig the operator is working.
const rigMessages = forActiveRig(history);
const fragment = document.createDocumentFragment();
let count = 0;
for (const message of rigMessages) {
for (const message of history) {
if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message));
@@ -242,32 +237,20 @@ export function initializeFtxDecoder(config: FtxConfig): void {
}
messagesElement.replaceChildren(fragment);
};
/** Hands a decode's grid squares to the map, if the map module is loaded yet.
* Split out of normalize so a decode can be replayed onto a map that
* arrived later: the module is lazy, and everything decoded before it
* loaded had nowhere to go. */
const plotLocator = (message: FtxMessage): void => {
const normalize = (message: FtxMessage): FtxMessage => {
const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0
? locatorDetails.map(({ grid }) => grid)
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
if (grids.length === 0) return;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
// Already an RF frequency on a replay, an audio offset on arrival; the
// conversion only fires below 100 kHz, so it is right either way.
const frequency = displayFrequency(message.freq_hz);
bridge.mapAddLocator?.(raw, grids, id, station, {
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
});
};
const normalize = (message: FtxMessage): FtxMessage => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
if (grids.length > 0) {
bridge.mapAddLocator?.(raw, grids, id, station, {
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
});
}
return {
// The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band.
rig_id: message.rig_id ?? null,
receiver: bridge.getDecodeRigMeta?.() ?? null,
ts_ms: message.ts_ms,
snr_db: message.snr_db,
@@ -279,10 +262,7 @@ export function initializeFtxDecoder(config: FtxConfig): void {
};
const receiveBatch = (messages: FtxMessage[]): void => {
if (messages.length === 0) return;
// "Receiving" is the panel's own status, and the panel is the rig's.
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
status.textContent = "Receiving";
}
if (status) status.textContent = "Receiving";
history = messages.map(normalize).reverse().concat(history);
prune();
bridge.setFt8FamilyBarDecoder?.(id);
@@ -297,8 +277,7 @@ export function initializeFtxDecoder(config: FtxConfig): void {
};
const barFrames = (): BarFrames => {
const recent = history
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000
&& isActiveRigDecode(message.rig_id))
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000)
.slice(0, 8);
let html = "";
for (const message of recent) {
@@ -320,9 +299,6 @@ export function initializeFtxDecoder(config: FtxConfig): void {
restore: receiveBatch,
prune: () => { prune(); render(); },
reset,
rerender: () => { bridge.updateFt8Bar?.(); render(); },
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => { for (const message of [...history].reverse()) plotLocator(message); },
});
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
@@ -4,7 +4,6 @@
import { hostCore, hostState } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import {
aprsAgeText,
aprsPacketCategory,
@@ -20,12 +19,6 @@ interface HfAprsBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
navigateToAprsMap?: (lat: number, lon: number) => void;
aprsMapAddStation?: (
call: string, lat: number, lon: number, info: string,
symbolTable: string | null | undefined, symbolCode: string | null | undefined,
packet: AprsPacket, source: "aprs" | "hf_aprs",
) => void;
clearMapMarkersByType?: (type: string) => void;
getDecodeRigMeta?: () => unknown;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
@@ -97,31 +90,23 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(hfAprsFilterText);
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function hfAprsRigPackets(): AprsPacket[] {
return forActiveRig(hfAprsPacketHistory);
}
function hfAprsVisiblePackets(): AprsPacket[] {
const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
return packets.filter(hfAprsFilterMatch);
}
const collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
}
if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (hfAprsLatestSeenEl) {
const latest = rigPackets[0];
const latest = hfAprsPacketHistory[0];
if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -183,7 +168,6 @@ function resetHfAprsHistoryView(): void {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
}
function pruneHfAprsHistoryView(): void {
@@ -191,17 +175,6 @@ function pruneHfAprsHistoryView(): void {
renderHfAprsHistory();
}
/** Hands a positioned packet to the map, if the map module is loaded yet.
* HF traffic goes on as its own source: it is a different band and a
* different path, and the map filter offers it separately. */
function plotHfAprsPacket(pkt: AprsPacket): void {
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
hfAprsWindow.aprsMapAddStation(
pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "",
pkt.symbolTable, pkt.symbolCode, pkt, "hf_aprs",
);
}
function addHfAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
@@ -210,8 +183,6 @@ function addHfAprsPacket(pkt: AprsPacket): void {
hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender();
}
@@ -221,15 +192,13 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerHfAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = [];
for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotHfAprsPacket(next);
normalized.push(next);
}
normalized.reverse();
@@ -297,9 +266,8 @@ if (hfAprsFilterInput) {
// --- Server-side HF APRS decode handler ---
function onServerHfAprs(pkt: AprsPacket): void {
const packet = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
}
renderHfAprsHistory();
@@ -310,7 +278,4 @@ renderHfAprsHistory();
restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView,
rerender: renderHfAprsHistory,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry); },
});
@@ -9,10 +9,6 @@ export interface DecoderPlugin<TMessage = unknown> {
restore?(messages: TMessage[]): void;
reset?(): void;
prune?(): void;
/** Repaint the plugin's panel from what it already holds. Called when the
* selected rig changes: the panels show one rig's traffic, and the decodes
* behind them arrived long before the switch. */
rerender?(): void;
/** Replay everything the plugin is holding onto the map. Called when the map
* module attaches, which can happen long after the decodes arrived. */
syncMap?(): void;
@@ -26,7 +22,6 @@ export interface TrxPluginRuntime {
reset(id: string): boolean;
resetAll(): void;
prune(id: string): boolean;
rerenderAll(): void;
syncMapAll(): void;
clearQueued(): void;
hasDecoder(id: string): boolean;
@@ -14,13 +14,11 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
interface SstvImage {
rig_id?: string | null;
ts_ms?: number;
vis?: number;
mode?: string;
@@ -34,7 +32,6 @@ interface SstvImage {
}
interface SstvProgress {
rig_id?: string | null;
state?: string;
mode?: string;
width?: number;
@@ -188,9 +185,6 @@ function paintRow(line: number, rgb: Uint8Array): void {
// ── Server messages ─────────────────────────────────────────────────
function onProgress(msg: SstvProgress): void {
// The live canvas draws one picture at a time: a background rig's rows would
// paint straight over the one being watched.
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state;
// A state update carries the geometry; a row update carries only the row.
@@ -214,9 +208,6 @@ function onImage(msg: SstvImage): void {
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory();
// Kept in the history either way, so switching to that rig finds it there.
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) {
sstvDom.status.textContent = image.complete
? `Received ${image.mode ?? "picture"}`
@@ -228,15 +219,9 @@ function onImage(msg: SstvImage): void {
// ── Rendering ───────────────────────────────────────────────────────
/** The pictures the selected rig decoded, oldest first, as the history is kept. */
function sstvRigImages(): SstvImage[] {
return forActiveRig(sstvHistory);
}
function renderLatestCard(): void {
if (!sstvDom.liveLatest) return;
const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
const latest = sstvHistory[sstvHistory.length - 1];
if (!latest) {
sstvDom.liveLatest.innerHTML = "";
return;
@@ -260,12 +245,10 @@ function renderLatestCard(): void {
}
function filteredHistory(): SstvImage[] {
// Every rig's pictures are kept; the panel shows the selected rig's.
const rigImages = sstvRigImages();
const text = filterText.trim().toLowerCase();
const matching = text
? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
: rigImages;
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
: sstvHistory.slice();
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching;
@@ -365,7 +348,6 @@ sstvWindow.trxPluginRuntime.registerDecoder({
restore: restoreHistory,
prune: renderHistoryTable,
reset: resetHistoryView,
rerender: () => { renderLatestCard(); renderHistoryTable(); },
});
sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress",
@@ -6,7 +6,6 @@ import { hostCore } from "./host.js";
export {};
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface VdesMessage {
@@ -117,24 +116,17 @@ function vdesHexPreview(rawBytes: number[] | undefined): string {
.toUpperCase();
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function vdesRigMessages(): VdesMessage[] {
return forActiveRig(vdesMessageHistory);
}
function updateVdesSummary() {
pruneVdesMessageHistory();
if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText();
}
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) {
const count = rigMessages.length;
const count = vdesMessageHistory.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
}
if (vdesLatestSeenEl) {
const latest = rigMessages[0];
const latest = vdesMessageHistory[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
}
}
@@ -236,9 +228,7 @@ function updateVdesBar() {
updateVdesSummary();
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory
.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id))
.slice(0, 6);
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = "";
@@ -288,7 +278,7 @@ function renderVdesHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of vdesRigMessages()) {
for (const message of vdesMessageHistory) {
fragment.appendChild(renderVdesRow(message));
}
vdesMessagesEl.replaceChildren(fragment);
@@ -319,11 +309,10 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
function onServerVdesBatch(messages: VdesMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized: VdesMessage[] = [];
for (const msg of messages) {
const next = normalizeServerVdesMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -365,8 +354,8 @@ function plotVdesMessage(msg: VdesMessage): void {
}
function onServerVdes(msg: VdesMessage): void {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next);
plotVdesMessage(next);
}
@@ -385,7 +374,6 @@ updateVdesSummary();
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
rerender: () => { updateVdesBar(); renderVdesHistory(); },
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
});
@@ -4,13 +4,11 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
interface WefaxImage {
rig_id?: string | null;
ts_ms?: number;
ioc?: number;
lpm?: number;
@@ -22,7 +20,6 @@ interface WefaxImage {
}
interface WefaxProgress {
rig_id?: string | null;
state?: string;
line_data?: string;
line_count?: number;
@@ -159,21 +156,15 @@ function paintLine(lineBytes: Uint8Array): void {
}
// ── Live view: latest image card ────────────────────────────────────
/** The pictures the selected rig decoded, newest first. */
function wefaxRigImages(): WefaxImage[] {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return;
const rigImages = wefaxRigImages();
if (rigImages.length === 0) {
if (wefaxImageHistory.length === 0) {
wefaxDom.liveLatest.innerHTML =
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
return;
}
const img = rigImages[0];
const img = wefaxImageHistory[0];
if (!img) return;
const ts = img._ts || '--';
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
@@ -202,8 +193,7 @@ function renderWefaxLatestCard() {
// ── History view: table ─────────────────────────────────────────────
function getWefaxFilteredHistory() {
// Every rig's pictures are kept; the panel shows the selected rig's.
let items = wefaxRigImages();
let items = wefaxImageHistory;
if (wefaxFilterText) {
items = items.filter(function (i) {
@@ -263,7 +253,7 @@ function renderWefaxHistoryTable() {
wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) {
const total = wefaxRigImages().length;
const total = wefaxImageHistory.length;
const shown = items.length;
wefaxDom.historyCount.textContent =
total === 0
@@ -308,9 +298,6 @@ function addWefaxImage(msg: WefaxImage): void {
// ── SSE event handlers (public API) ─────────────────────────────────
function onServerWefaxProgress(msg: WefaxProgress): void {
// The live canvas draws one picture at a time: a background rig's lines
// would paint straight over the one being watched.
if (!isActiveRigDecode(msg.rig_id)) return;
// State-only update (no image data): show decoder state in status.
if (msg.state && !msg.line_data) {
if (wefaxDom.status) {
@@ -344,8 +331,6 @@ function onServerWefaxProgress(msg: WefaxProgress): void {
function onServerWefax(msg: WefaxImage): void {
addWefaxImage(msg);
// Kept in the history either way, so switching to that rig finds it there.
if (!isActiveRigDecode(msg.rig_id)) return;
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
if (wefaxDom.status) {
@@ -453,7 +438,6 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView,
rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); },
});
wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress",
@@ -4,7 +4,6 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
@@ -16,10 +15,6 @@ interface WsprMessage {
snr_db?: number | undefined;
dt_s?: number | undefined;
freq_hz?: number | undefined;
rig_id?: string | null | undefined;
/** RF frequency the spot was heard on, kept so a map replay does not
* recompute it against wherever the dial has moved to since. */
_rfHz?: number | null | undefined;
receiver?: unknown;
[key: string]: unknown;
}
@@ -107,12 +102,10 @@ function renderWsprRow(msg: WsprMessage): HTMLDivElement {
function renderWsprHistory(): void {
pruneWsprMessageHistory();
if (!wsprMessagesEl) return;
// The history holds every rig's decodes, for the map; the panel is about the
// rig the operator is working.
const rigMessages = forActiveRig(wsprMessageHistory);
const fragment = document.createDocumentFragment();
for (const message of rigMessages) {
fragment.appendChild(renderWsprRow(message));
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
const message = wsprMessageHistory[i];
if (message) fragment.appendChild(renderWsprRow(message));
}
wsprMessagesEl.replaceChildren(fragment);
}
@@ -137,8 +130,6 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
station,
rfHz,
history: {
rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms,
snr_db: msg.snr_db,
@@ -151,12 +142,16 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
function onServerWsprBatch(messages: WsprMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized: WsprMessage[] = [];
for (const msg of messages) {
const next = normalizeServerWsprMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
plotWsprLocator(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
});
}
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
@@ -316,24 +311,15 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
})();
});
/** Hands a spot's grid squares to the map, if the map module is loaded yet.
* The module is lazy, so a spot heard before it arrived has to be replayable. */
function plotWsprLocator(msg: WsprMessage): void {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
// A replayed spot carries the frequency it was heard on; a fresh one has it
// worked out from the dial it just arrived against.
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(rfHz === null ? {} : { freq_hz: rfHz }),
});
}
function onServerWspr(msg: WsprMessage): void {
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
if (wsprStatus) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg);
plotWsprLocator(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
});
}
addWsprMessage(next.history);
}
@@ -344,7 +330,4 @@ wsprWindow.trxPluginRuntime.registerDecoder({
restore: onServerWsprBatch,
prune: pruneWsprHistoryView,
reset: resetWsprHistoryView,
rerender: renderWsprHistory,
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => { for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message); },
});
@@ -1,348 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The radio page and the digital modes page both describe one rig: the one the
// operator selected. Every rig a client is connected to feeds the same decode
// stream, so both the mini views over the waterfall and the panels behind them
// filter on the rig that heard each decode. The map is the exception and keeps
// all of them — it has its own rig filter — which is why the histories these
// views read from are never pruned by rig.
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
class ElementFixture {
constructor(value = "") {
this.children = [];
this.innerHTML = "";
this.textContent = "";
this.value = value;
this.style = {};
this.dataset = {};
this.classList = { add() {}, remove() {}, toggle() {} };
}
appendChild(child) { this.children.push(child); return child; }
removeChild(child) { this.children.splice(this.children.indexOf(child), 1); }
replaceChildren(...nodes) { this.children = nodes.flatMap((node) => node.children ?? [node]); }
addEventListener() {}
setAttribute() {}
querySelector() { return null; }
querySelectorAll() { return []; }
get firstChild() { return this.children[0] ?? null; }
get lastElementChild() { return this.children.at(-1) ?? null; }
get scrollHeight() { return this.children.length; }
}
/** A document whose named elements exist and whose unknown ones do not. */
function createDocument(elements) {
return {
documentElement: {},
getElementById: (id) => elements.get(id) ?? null,
querySelector: () => null,
querySelectorAll: () => [],
createElement: () => new ElementFixture(),
createDocumentFragment: () => new ElementFixture(),
};
}
async function runPlugin(entry, { window, document: doc, extras = {} }) {
const context = vm.createContext({
window,
document: doc,
navigator: {},
requestAnimationFrame(callback) { callback(); return 1; },
getComputedStyle: () => ({ getPropertyValue: () => "" }),
Date, Number, String, Math, Array, Map, Set, Reflect, console,
...extras,
});
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL(`../src/plugins/${entry}.ts`, import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
return context;
}
test("the APRS mini view and panel both keep to the active rig", async () => {
const overlay = new ElementFixture();
const packets = new ElementFixture();
const totals = new ElementFixture();
const elements = new Map([
["aprs-bar-overlay", overlay],
["aprs-packets", packets],
["aprs-total-count", totals],
["mode", new ElementFixture("PKT")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
aprsMapAddStation: () => {},
};
await runPlugin("aprs", { window, document: createDocument(elements) });
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
assert.match(overlay.innerHTML, /SP1AAA/, "the active rig's frame is missing from the mini view");
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's frame reached the mini view");
assert.equal(packets.children.length, 1, "the APRS panel listed a background rig's frame");
assert.equal(totals.textContent, "1 total", `the APRS panel counted "${totals.textContent}"`);
});
test("the APRS mini view shows every frame until a rig is known", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["aprs-bar-overlay", overlay],
["mode", new ElementFixture("PKT")],
]);
const window = {
...createHost(),
trxUi: { confirm: async () => true },
aprsMapAddStation: () => {},
};
await runPlugin("aprs", { window, document: createDocument(elements) });
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB" });
assert.match(overlay.innerHTML, /SP1AAA/);
assert.match(overlay.innerHTML, /SP2BBB/);
});
test("the AIS mini view keeps to the active rig", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["ais-bar-overlay", overlay],
["mode", new ElementFixture("AIS")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
aisMapAddVessel: () => {},
};
await runPlugin("ais", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
assert.match(overlay.innerHTML, /NEARBY/);
assert.doesNotMatch(overlay.innerHTML, /ELSEWHERE/, "a background rig's vessel reached the mini view");
});
test("the VDES mini view keeps to the active rig", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["vdes-bar-overlay", overlay],
["mode", new ElementFixture("VDES")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
vdesMapAddPoint: () => {},
};
await runPlugin("vdes", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
assert.match(overlay.innerHTML, /SP1AAA/);
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's burst reached the mini view");
});
test("the CW mini view keeps to the active rig and does not braid two rigs into a line", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["cw-bar-overlay", overlay],
["cw-output", new ElementFixture()],
["mode", new ElementFixture("CW")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
addEventListener() {},
};
await runPlugin("cw", { window, document: createDocument(elements) });
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("cw", { text: "DX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
assert.match(overlay.innerHTML, /CQ SP1AAA/, "the active rig's line was broken up or lost");
assert.doesNotMatch(overlay.innerHTML, /DX/, "a background rig's characters reached the mini view");
});
test("the FT8 mini view keeps to the active rig", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["ft8-bar-overlay", overlay],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 7_074_000,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
setInterval() { return 1; },
};
await runPlugin("ft8", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
assert.match(overlay.innerHTML, /SP1AAA/);
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view");
});
// ── The digital modes panels ───────────────────────────────────────────────
// The same rule one page over: a panel lists the selected rig's decodes only.
test("the AIS panel lists the active rig's vessels only", async () => {
const messages = new ElementFixture();
const count = new ElementFixture();
const elements = new Map([
["ais-messages", messages],
["ais-vessel-count", count],
["mode", new ElementFixture("AIS")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
aisMapAddVessel: () => {},
};
await runPlugin("ais", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the AIS panel listed a background rig's vessel");
assert.equal(count.textContent, "1 vessel", `the panel counted "${count.textContent}"`);
});
test("the VDES panel lists the active rig's bursts only", async () => {
const messages = new ElementFixture();
const count = new ElementFixture();
const elements = new Map([
["vdes-messages", messages],
["vdes-frame-count", count],
["mode", new ElementFixture("VDES")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
vdesMapAddPoint: () => {},
};
await runPlugin("vdes", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the VDES panel listed a background rig's burst");
assert.equal(count.textContent, "1 burst", `the panel counted "${count.textContent}"`);
});
test("the FT8 panel lists the active rig's decodes only", async () => {
const messages = new ElementFixture();
const elements = new Map([
["ft8-messages", messages],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 7_074_000,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
setInterval() { return 1; },
};
await runPlugin("ft8", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the FT8 panel listed a background rig's decode");
});
test("the WSPR panel lists the active rig's spots only", async () => {
const messages = new ElementFixture();
const elements = new Map([
["wspr-messages", messages],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 14_095_600,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
};
await runPlugin("wspr", {
window,
document: createDocument(elements),
extras: { setInterval: () => 1 },
});
const now = Date.now();
window.trxPluginRuntime.dispatch("wspr", { message: "SP1AAA JO91 30", freq_hz: 1_500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("wspr", { message: "SP2BBB JO94 27", freq_hz: 1_520, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the WSPR panel listed a background rig's spot");
});
test("the CW pane copies the active rig only", async () => {
const output = new ElementFixture();
const elements = new Map([
["cw-output", output],
["cw-bar-overlay", new ElementFixture()],
["mode", new ElementFixture("CW")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
addEventListener() {},
};
await runPlugin("cw", { window, document: createDocument(elements) });
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("cw", { text: "XX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
const copied = output.children.map((line) => line.textContent).join("");
assert.equal(copied, "CQ SP1AAA", `the pane copied "${copied}"`);
});
test("switching rigs repaints the panels through the runtime", async () => {
const packets = new ElementFixture();
const elements = new Map([
["aprs-packets", packets],
["aprs-bar-overlay", new ElementFixture()],
["mode", new ElementFixture("PKT")],
]);
// A mutable host: the rig picker writes lastActiveRigId, and the panels read
// it as they paint.
const host = createHost({ state: { lastActiveRigId: "rig-a" } });
let activeRigId = "rig-a";
Object.defineProperty(host.trx.state, "lastActiveRigId", { get: () => activeRigId });
const window = { ...host, trxUi: { confirm: async () => true }, aprsMapAddStation: () => {} };
await runPlugin("aprs", { window, document: createDocument(elements) });
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
assert.equal(packets.children.length, 1);
activeRigId = "rig-b";
window.trxPluginRuntime.rerenderAll();
assert.equal(packets.children.length, 1, "the panel did not follow the switch");
const shown = packets.children.map((row) => row.innerHTML).join("");
assert.match(shown, /SP2BBB/, "the panel still shows the rig that was switched away from");
assert.doesNotMatch(shown, /SP1AAA/);
});
@@ -1,138 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The background decode panel: one list that both picks the bookmarks and says
// what each one is doing. It used to be two lists of the same bookmarks — one
// to choose from, one to read state off — and it filled neither until the
// operator switched rigs, because it took whatever rig it was handed at load
// and that was nothing yet.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document */
const BOOKMARKS = [
{ id: "bm-ft8-20", name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoders: ["ft8"] },
{ id: "bm-ft8-40", name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoders: ["ft8"] },
{ id: "bm-ft4-20", name: "FT4 20 m", freq_hz: 14_080_000, mode: "USB", decoders: ["ft4"] },
{ id: "bm-aprs", name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoders: ["aprs"] },
// Nothing that runs in the background can decode CW, so it must not be offered.
{ id: "bm-cw", name: "CW practice", freq_hz: 7_030_000, mode: "CW", decoders: ["cw"] },
];
const backgroundDecode = {
config: { remote: "rig-a", enabled: true, bookmark_ids: ["bm-ft8-20", "bm-ft8-40", "bm-aprs"] },
status: {
active_rig: true,
center_hz: 14_100_000,
sample_rate: 2_400_000,
entries: [
{ bookmark_id: "bm-ft8-20", bookmark_name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "active" },
{ bookmark_id: "bm-ft8-40", bookmark_name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "out_of_span" },
{ bookmark_id: "bm-aprs", bookmark_name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoder_kinds: ["aprs"], state: "handled_by_scheduler" },
],
},
};
const readRows = () => page.evaluate(() => {
const rows = [...document.querySelectorAll("#bgd-bookmark-checklist .bgd-checklist-row")];
return rows.map((row) => ({
name: row.querySelector(".bgd-checklist-name")?.textContent ?? "",
checked: row.querySelector("input[type=checkbox]")?.checked ?? false,
state: row.querySelector(".bgd-state")?.dataset.state ?? null,
stateText: row.querySelector(".bgd-state")?.textContent?.trim() ?? "",
}));
});
const openPanel = async () => {
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-settings").waitFor({ state: "visible" });
await page.waitForTimeout(2000);
await page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
await page.waitForTimeout(1200);
};
const fixture = await startWebFixture({ spectrum: true, bookmarks: BOOKMARKS, backgroundDecode });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.setViewportSize({ width: 1400, height: 950 });
await openPanel();
// Nothing was switched, clicked or reloaded: the panel asked the host which
// rig it is on and filled itself.
const rows = await readRows();
assert.deepEqual(rows.map((row) => row.name), ["FT8 20 m", "FT8 40 m", "FT4 20 m", "APRS 2 m"],
"the list does not hold the bookmarks a background channel can decode");
assert.deepEqual(rows.map((row) => row.checked), [true, true, false, true],
"the saved selection is not reflected in the list");
// Each row carries its own state, so there is no second list to consult.
assert.deepEqual(rows.map((row) => row.state),
["active", "out_of_span", "unselected", "handled_by_scheduler"],
"the rows do not carry the state of the bookmark they name");
assert.match(rows[0].stateText, /Decoding/);
assert.match(rows[1].stateText, /Out of span/);
// What the rig can hear, and what the selection adds up to.
const context = await page.evaluate(() => ({
span: document.getElementById("bgd-span-summary")?.textContent?.trim(),
summary: document.getElementById("bgd-selection-summary")?.textContent?.trim(),
}));
assert.match(context.span, /14\.1 MHz/, `the span reads "${context.span}"`);
assert.match(context.summary, /3 bookmarks selected, 1 decoding now\./, `the summary reads "${context.summary}"`);
// Save has nothing to do until something changes, and says so again once it
// has been done.
const saveState = () => page.evaluate(() => {
const btn = document.getElementById("background-decode-save-btn");
return { disabled: btn.disabled, dirty: btn.classList.contains("sch-dirty") };
});
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save offers itself with nothing to save");
await page.locator('#bgd-bookmark-checklist .bgd-checklist-row:nth-child(3) input').click();
await page.waitForTimeout(300);
assert.deepEqual(await saveState(), { disabled: false, dirty: true }, "Save stayed inert after a change");
await page.locator("#background-decode-save-btn").click();
await page.waitForTimeout(1000);
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save stayed live after saving");
// The selection reached the server and comes back on the next load.
await openPanel();
const saved = await readRows();
assert.deepEqual(saved.map((row) => row.checked), [true, true, true, true],
"the newly selected bookmark did not survive a reload");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
// With no bookmarks at all the panel says what to do about it rather than
// showing an empty box.
const emptyFixture = await startWebFixture({ spectrum: true, bookmarks: [] });
const empty = await startBrowser(chromium);
try {
await empty.page.setViewportSize({ width: 1400, height: 950 });
await empty.page.goto(`${emptyFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await empty.page.locator("#tab-settings").waitFor({ state: "visible" });
await empty.page.waitForTimeout(2000);
await empty.page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
await empty.page.waitForTimeout(1000);
const text = await empty.page.evaluate(() =>
document.getElementById("bgd-bookmark-checklist")?.textContent?.trim() ?? "");
assert.match(text, /No bookmarks yet/, `the empty list reads "${text}"`);
const summary = await empty.page.evaluate(() =>
document.getElementById("bgd-selection-summary")?.textContent?.trim() ?? "");
assert.match(summary, /Nothing selected/, `the summary reads "${summary}"`);
} finally {
await empty.browser.close();
await emptyFixture.close();
}
console.log("background decode panel tests passed");
@@ -22,25 +22,9 @@ const BEACON = {
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
// A second rig listening in the background, on its own band. Both pages
// describe the rig on screen, so its traffic belongs in neither the panel nor
// the mini view — only on the map, which shows the whole station.
// HF APRS travels a different band and a different path from the VHF list, so
// it goes on the map under a source of its own that the filter can hide.
const HF_BEACON = {
type: "hf_aprs", src_call: "SP5HF-7", dest_call: "APRS", path: "WIDE2-2", info: "HF beacon",
packet_type: "position", crc_ok: true, lat: 50.06, lon: 19.94,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
const OTHER_RIG_VESSEL = {
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
lat: 51.92, lon: 4.48, rig_id: "rig-b",
};
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
const fixture = await startWebFixture({
spectrum: true, decodes: [VESSEL, BEACON, HF_BEACON, OTHER_RIG_VESSEL], mode: "AIS",
});
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
@@ -57,14 +41,11 @@ try {
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false,
aisRows: document.getElementById("ais-messages")?.children.length ?? 0,
mapLoaded: !!window.trx.modules.map,
}));
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
assert.equal(panels.otherRig, false, "the AIS panel listed a background rig's vessel");
// The mini view rides over the waterfall on the radio page.
await page.locator('.tab[data-tab="main"]').click();
@@ -75,13 +56,11 @@ try {
shown: getComputedStyle(bar).display !== "none",
pins: bar.querySelectorAll(".aprs-bar-pin").length,
names: bar.textContent.includes("NEDERLAND"),
otherRig: bar.textContent.includes("ELDERBERRY"),
};
});
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
assert.equal(miniView.names, true, "the mini view does not name the vessel");
assert.equal(miniView.otherRig, false, "the mini view shows a background rig's vessel");
// Following the pin: the map opens, on the vessel. This is the path that was
// broken for every decoder — the module that owned the navigation had not
@@ -110,41 +89,8 @@ try {
: Object.keys(collection ?? {}).length);
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
});
assert.ok(markers.ais > 0, "the vessel never reached the map");
assert.ok(markers.stations > 0, "the APRS station never reached the map");
// Both APRS stations are on it, each under its own source: the HF one used
// to be dropped entirely, since the HF list never plotted anything.
const aprsSources = await page.evaluate(() => {
const entries = [...(window.trx.modules.map?.stationMarkers ?? new Map()).entries()];
return entries.map(([key, entry]) => `${entry?.type ?? "?"}:${entry?.call ?? key}`).sort();
});
assert.deepEqual(aprsSources, ["aprs:SP2SJG-9", "hf_aprs:SP5HF-7"],
`the map holds ${JSON.stringify(aprsSources)}`);
// And the filter offers HF APRS on a chip of its own, next to the VHF one.
await page.locator('#map-locator-phase .map-locator-phase-btn[data-phase="type"]').click();
await page.waitForTimeout(400);
const chips = await page.evaluate(() => [...document.querySelectorAll("#map-locator-choice-filter .map-locator-chip")]
.map((chip) => chip.dataset.filterKey));
assert.ok(chips.includes("aprs") && chips.includes("hf_aprs"),
`the Show chips are ${JSON.stringify(chips)}`);
// Turning that chip off takes the HF station off the map and leaves the VHF
// one on: the two are filtered apart, which is the whole point of the split.
await page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="hf_aprs"]').click();
await page.waitForTimeout(500);
const shownAfterFilter = await page.evaluate(() => {
const map = window.trx.modules.map;
const onMap = [];
(map?.stationMarkers ?? new Map()).forEach((entry, key) => {
if (entry?.marker && map.aprsMap?.hasLayer(entry.marker)) onMap.push(entry?.call ?? key);
});
return onMap.sort();
});
assert.deepEqual(shownAfterFilter, ["SP2SJG-9"],
`hiding HF APRS left ${JSON.stringify(shownAfterFilter)} on the map`);
// The map is the whole station's view — the one place a background rig's
// traffic belongs — so both vessels are on it even though the panel and the
// mini view show only the selected rig's.
assert.equal(markers.ais, 2, `${markers.ais} of 2 vessels reached the map`);
assert.deepEqual(runtimeErrors, []);
} finally {
@@ -157,8 +103,6 @@ try {
// left the client on its retry path and the history path untested.
const HISTORY_AIS = 900;
const HISTORY_APRS = 300;
const HISTORY_FT8 = 12;
const HISTORY_WSPR = 8;
const historyFixture = await startWebFixture({
spectrum: true,
mode: "AIS",
@@ -168,14 +112,6 @@ const historyFixture = await startWebFixture({
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({
message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2,
freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({
message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5,
freq_hz: 1500 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
info: `history ${index}`, packet_type: "position", crc_ok: true,
@@ -263,27 +199,6 @@ try {
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
// The Statistics page counts the same history. Its decode log lives in the
// map module, which is lazy, so every decode that arrived before the module
// did used to be recorded into nothing at all — the page opened empty and
// only filled in from decodes heard afterwards, and it took a reload landing
// on the tab (module loaded at startup, before the history) to show the lot.
await replay.page.evaluate(() => window.navigateToTab("statistics"));
await replay.page.waitForTimeout(1500);
// The counters are written with toLocaleString(), so a four-figure count
// arrives as "1,220" — whichever separator the runner's locale picks. Read
// the digits rather than the formatting.
const counted = await replay.page.evaluate(() => {
const count = (id) => Number((document.getElementById(id)?.textContent ?? "").replace(/\D/g, ""));
return { decodes: count("stats-total-decodes"), grids: count("stats-unique-grids") };
});
assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR,
`the statistics counted ${counted.decodes} decodes`);
// Grid squares come from the FT8 and WSPR spots, which had no map replay of
// their own: the locators of everything heard before the map loaded were lost.
assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR,
`the statistics counted ${counted.grids} grid squares`);
assert.deepEqual(replay.runtimeErrors, []);
} finally {
await replay.browser.close();
@@ -27,21 +27,6 @@ const dial = () => page.evaluate(() => ({
path: window.location.pathname,
}));
// Typed, not filled. The app holds back its own refreshes of the frequency
// field from the first keystroke until Enter, so that a state update arriving
// mid-edit does not rewrite what is being typed. `fill()` sets the value
// without a keystroke, leaving the field unguarded: on a slow machine a state
// update could land between the fill and the Enter and put the old frequency
// back, and the Enter would then re-apply the frequency the radio was already
// on. Selecting first arms the guard before a single character changes.
async function tuneByHand(text) {
const field = page.locator("#freq");
await field.click();
await field.press("ControlOrMeta+a");
await field.pressSequentially(text);
await field.press("Enter");
}
try {
await page.setViewportSize({ width: 1500, height: 950 });
@@ -64,7 +49,8 @@ try {
// Tuning by hand rewrites the link. This is the part that makes the address
// bar shareable at any moment rather than only at load.
await tuneByHand("7.040M");
await page.locator("#freq").fill("7.040M");
await page.locator("#freq").press("Enter");
await page.waitForTimeout(1500);
const tuned = await dial();
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
@@ -73,7 +59,8 @@ try {
// Tuning is not navigation: a swept dial must not bury the back button.
const historyLength = await page.evaluate(() => window.history.length);
await tuneByHand("7.100M");
await page.locator("#freq").fill("7.100M");
await page.locator("#freq").press("Enter");
await page.waitForTimeout(1200);
assert.equal(await page.evaluate(() => window.history.length), historyLength,
"tuning pushed a history entry instead of replacing one");
@@ -34,14 +34,7 @@ const DECODER_REGISTRY = [
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
].map((decoder) => ({
...decoder,
// Which decoders a background channel can run, as the server's own registry
// has it: the background decode panel offers a bookmark only if one of these
// can decode it, so marking them all false left that panel with nothing.
background_decode: ["ft8", "ft4", "ft2", "wspr", "ais", "aprs", "hf-aprs"].includes(decoder.id),
bookmark_selectable: true,
}));
].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true }));
const CONTENT_TYPES = new Map([
[".css", "text/css; charset=utf-8"],
@@ -139,7 +132,6 @@ export async function startWebFixture({
mode = "FM",
history = {},
bookmarks = [],
backgroundDecode = null,
bandplan = {},
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
@@ -279,34 +271,6 @@ export async function startWebFixture({
response.writeHead(200).end();
return;
}
// Background decode: the panel reads its config, its status, and the
// bookmark list, and writes the config back.
if (url.pathname.startsWith("/background-decode/")) {
const isStatus = url.pathname.endsWith("/status");
if (isStatus) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(backgroundDecode?.status ?? { entries: [] }));
return;
}
if (request.method === "PUT") {
const body = await new Promise((resolve) => {
let raw = "";
request.on("data", (chunk) => { raw += chunk; });
request.on("end", () => resolve(raw));
});
try {
const parsed = JSON.parse(body);
if (backgroundDecode) backgroundDecode.config = parsed;
} catch { /* leave the config as it was */ }
}
if (request.method === "DELETE" && backgroundDecode) {
backgroundDecode.config = { remote: "rig-a", enabled: false, bookmark_ids: [] };
}
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(backgroundDecode?.config
?? { remote: "rig-a", enabled: false, bookmark_ids: [] }));
return;
}
if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote");
if (remote) {
@@ -10,9 +10,7 @@ use actix_web::{get, HttpRequest, HttpResponse, Responder};
use std::collections::HashMap;
use std::sync::OnceLock;
use super::{
gz_cache_entry, static_asset_response, AssetCaching, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES,
};
use super::{gz_cache_entry, static_asset_response, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES};
use crate::server::status;
// ---------------------------------------------------------------------------
@@ -64,100 +62,55 @@ define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css");
#[get("/")]
pub(crate) async fn index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/map")]
pub(crate) async fn map_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/digital-modes")]
pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/recorder")]
pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/settings")]
pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/about")]
pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/statistics")]
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/satellites")]
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/bookmarks")]
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
static_asset_response(&req, "text/html; charset=utf-8", c)
}
// ---------------------------------------------------------------------------
@@ -195,13 +148,13 @@ pub(crate) async fn logo() -> impl Responder {
#[get("/style.css")]
pub(crate) async fn style_css(req: HttpRequest) -> impl Responder {
let c = gz_style_css();
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
static_asset_response(&req, "text/css; charset=utf-8", c)
}
#[get("/themes.css")]
pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder {
let c = gz_themes_css();
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
static_asset_response(&req, "text/css; charset=utf-8", c)
}
// Generated filenames are supplied only by build.rs and resolved through this
@@ -225,22 +178,7 @@ pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path<String>) -
let Some(entry) = generated_asset_cache().get(filename.as_str()) else {
return HttpResponse::NotFound().finish();
};
static_asset_response(
&req,
content_type,
entry,
generated_asset_caching(&filename),
)
}
/// esbuild names shared chunks `chunk-<hash>.js` and leaves the entry points on
/// a fixed name, so only the chunks are safe to keep forever.
fn generated_asset_caching(filename: &str) -> AssetCaching {
if filename.starts_with("chunk-") {
AssetCaching::Immutable
} else {
AssetCaching::Revalidate
}
static_asset_response(&req, content_type, entry)
}
/// Serve a received SSTV picture out of the local cache.
@@ -283,12 +221,7 @@ fn cached_png(decoder: &str, filename: &str) -> HttpResponse {
#[get("/bandplan.json")]
pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder {
let c = gz_bandplan_json();
static_asset_response(
&req,
"application/json; charset=utf-8",
c,
AssetCaching::Revalidate,
)
static_asset_response(&req, "application/json; charset=utf-8", c)
}
// ---------------------------------------------------------------------------
@@ -306,12 +239,7 @@ pub(crate) async fn dseg14_classic_woff2() -> impl Responder {
#[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,
AssetCaching::Immutable,
)
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
// ---------------------------------------------------------------------------
@@ -321,18 +249,13 @@ pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
#[get("/vendor/leaflet.js")]
pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder {
let c = gz_leaflet_js();
static_asset_response(
&req,
"application/javascript; charset=utf-8",
c,
AssetCaching::Revalidate,
)
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/vendor/leaflet.css")]
pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder {
let c = gz_leaflet_css();
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
static_asset_response(&req, "text/css; charset=utf-8", c)
}
#[get("/vendor/marker-icon.png")]
@@ -432,44 +355,6 @@ mod tests {
assert!(!generated_asset_cache().contains_key("../app.js"));
}
/// A browser that visited before an upgrade must not keep the old page.
/// index.html, the stylesheets and the entry bundles all keep their URL
/// from one build to the next, so an immutable year-long policy on them
/// leaves a client running whatever it first downloaded — a fixed layout
/// stays broken in the browser that happened to cache it, and no reload
/// short of a forced one gets the fix.
#[test]
fn assets_that_keep_their_url_across_builds_are_revalidated() {
for name in ["app.js", "aprs.js", "map-core.js"] {
assert_eq!(
generated_asset_caching(name),
AssetCaching::Revalidate,
"{name} is served from the same URL after every build"
);
}
assert_eq!(
AssetCaching::Revalidate.header_value(),
"no-cache",
"revalidating assets must ask before they are reused"
);
}
/// Only the chunks carry a hash of their own bytes, so only they can be
/// kept forever.
#[test]
fn content_addressed_chunks_stay_immutable() {
let chunk = status::GENERATED_ASSETS
.iter()
.map(|(name, _)| *name)
.find(|name| name.starts_with("chunk-"))
.expect("the bundle splits into at least one shared chunk");
assert_eq!(generated_asset_caching(chunk), AssetCaching::Immutable);
assert!(
AssetCaching::Immutable.header_value().contains("immutable"),
"a hashed name is safe to keep"
);
}
#[test]
fn generated_asset_mime_types_are_restricted() {
assert_eq!(
@@ -307,35 +307,10 @@ where
}
/// Pre-compressed (gzip + brotli) + ETag-aware response for immutable embedded assets.
/// How long a browser may hold an asset before asking about it again.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum AssetCaching {
/// The name carries a hash of the bytes, so a change is a new URL and the
/// old one can be kept forever.
Immutable,
/// Served from the same URL for the life of the deployment, with different
/// bytes after an upgrade: index.html, the stylesheets, the entry bundles.
/// These must be revalidated, or a browser that visited before the upgrade
/// keeps running the old page — for a year, with the ETag never consulted,
/// which is how a fixed layout stays broken in one browser and not another.
/// The ETag makes the revalidation a 304 in the usual case.
Revalidate,
}
impl AssetCaching {
pub(crate) fn header_value(self) -> &'static str {
match self {
Self::Immutable => "public, max-age=31536000, immutable",
Self::Revalidate => "no-cache",
}
}
}
fn static_asset_response(
req: &HttpRequest,
content_type: &'static str,
entry: &GzCacheEntry,
caching: AssetCaching,
) -> HttpResponse {
let etag = &entry.etag;
// Check If-None-Match for conditional GET.
@@ -344,7 +319,7 @@ fn static_asset_response(
if val == etag || val == "*" {
return HttpResponse::NotModified()
.insert_header((header::ETAG, etag.to_owned()))
.insert_header((header::CACHE_CONTROL, caching.header_value()))
.insert_header((header::CACHE_CONTROL, "public, max-age=31536000, immutable"))
.finish();
}
}
@@ -364,7 +339,7 @@ fn static_asset_response(
.insert_header((header::CONTENT_TYPE, content_type))
.insert_header((header::CONTENT_ENCODING, encoding))
.insert_header((header::ETAG, etag.to_owned()))
.insert_header((header::CACHE_CONTROL, caching.header_value()))
.insert_header((header::CACHE_CONTROL, "public, max-age=31536000, immutable"))
.body(Bytes::copy_from_slice(body))
}
@@ -865,76 +840,6 @@ mod tests {
// Endpoint tests using actix_web::test
// ======================================================================
/// The page and the bundles it pulls in are served from the same URLs after
/// every upgrade, so the browser has to ask whether they changed. Served
/// as immutable for a year, a browser that visited once kept the old page
/// and never saw a fix again -- which is how a corrected layout stays
/// broken in one browser while every other one has it.
#[actix_web::test]
async fn documents_and_entry_bundles_are_revalidated_not_frozen() {
let app = actix_test::init_service(
App::new()
.service(assets::index)
.service(assets::style_css)
.service(assets::generated_asset),
)
.await;
for path in ["/", "/style.css", "/app.js"] {
let req = actix_test::TestRequest::get().uri(path).to_request();
let resp = actix_test::call_service(&app, req).await;
assert_eq!(resp.status(), 200, "{path} should be served");
let cache_control = resp
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
assert_eq!(cache_control, "no-cache", "{path} must be revalidated");
assert!(
resp.headers().contains_key(header::ETAG),
"{path} needs an ETag, or revalidating costs a full download"
);
}
}
/// Revalidation has to stay cheap: an unchanged asset answers 304, and the
/// policy on that answer matches the one on the body.
#[actix_web::test]
async fn an_unchanged_document_answers_not_modified() {
let app = actix_test::init_service(App::new().service(assets::style_css)).await;
let first = actix_test::call_service(
&app,
actix_test::TestRequest::get()
.uri("/style.css")
.to_request(),
)
.await;
let etag = first
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.expect("style.css carries an ETag")
.to_owned();
let conditional = actix_test::call_service(
&app,
actix_test::TestRequest::get()
.uri("/style.css")
.insert_header((header::IF_NONE_MATCH, etag))
.to_request(),
)
.await;
assert_eq!(conditional.status(), 304);
assert_eq!(
conditional
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-cache")
);
}
/// GET /status returns 200 with valid JSON containing rig snapshot fields.
#[actix_web::test]
async fn test_status_endpoint_returns_json() {
-24
View File
@@ -1,24 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
[package]
name = "trx-config"
version.workspace = true
edition = "2021"
license = "GPL-2.0-or-later"
[dependencies]
serde = { workspace = true, features = ["derive"] }
toml = { workspace = true }
tracing = { workspace = true }
dirs = "6"
thiserror = "2"
trx-core = { path = "../trx-core" }
trx-decode-log = { path = "../decoders/trx-decode-log" }
trx-reporting = { path = "../trx-reporting" }
serde_ignored = "0.1"
toml_edit = "0.22"
[dev-dependencies]
tempfile = "3"
@@ -1,29 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Regenerate `trx-rs.toml.example` from the config structs.
//!
//! Run from anywhere in the workspace:
//!
//! ```text
//! cargo run -p trx-config --example generate_example
//! ```
//!
//! A test in `trx_config::example` fails when the checked-in file no longer
//! matches, which is the reminder to run this.
use std::path::PathBuf;
fn main() -> std::io::Result<()> {
let target: PathBuf = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../trx-rs.toml.example")
});
std::fs::write(&target, trx_config::example::combined_example())?;
println!("Wrote {}", target.display());
Ok(())
}
File diff suppressed because it is too large Load Diff
-295
View File
@@ -1,295 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Generating the example configuration from the config structs.
//!
//! `trx-rs.toml.example` used to be maintained by hand and had fallen years
//! behind the code — no `[[rigs]]`, no `[[remotes]]`, no `[timeouts]`, no
//! bandplan settings. It is now produced from the structs themselves, so a new
//! field appears in the example the moment it exists, and a test fails if the
//! checked-in copy drifts.
//!
//! Section comments come from the table below. A section without an entry is
//! still emitted — only its explanatory text is missing — so forgetting to add
//! one can never drop a setting from the example.
use toml_edit::{DocumentMut, Item};
use crate::{ClientConfig, ServerConfig};
const HEADER: &str = "\
# trx-rs example configuration
#
# Generated from the config structs; regenerate with:
# cargo run -p trx-config --example generate_example
#
# Both sections are optional: trx-server reads [trx-server], trx-client reads
# [trx-client], and either may live in its own file with the section header
# omitted. Any string may use ${ENV_VAR}, and credentials may be moved out of
# this file with the matching *_file keys.
#
# Check a config without starting anything:
# trx-server --check-config --config trx-rs.toml
# trx-client --check-config --config trx-rs.toml
";
/// Explanatory comments for config sections, keyed by dotted path.
const SECTION_COMMENTS: &[(&str, &str)] = &[
("trx-server", "Server: drives the radio hardware."),
(
"trx-server.general",
"Station identity. Coordinates feed PSKReporter and the map.",
),
(
"trx-server.rig",
"Single-rig layout. For several radios, delete this and use [[rigs]].",
),
(
"trx-server.rig.access",
"How to reach the radio: serial, tcp, or sdr.",
),
("trx-server.behavior", "CAT polling and retry behaviour."),
(
"trx-server.listen",
"JSON control listener that trx-client connects to.",
),
(
"trx-server.listen.auth",
"Tokens clients must present. Empty means no authentication.\n\
Use tokens_file = \"/etc/trx-rs/tokens\" to keep them out of this file.",
),
("trx-server.audio", "Opus audio stream for trx-client."),
(
"trx-server.decoders",
"Which decoders run. Trimming this list saves real CPU on small boxes.\n\
Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr.\n\
output_dir sets where sstv/wefax/lrpt write images (default: user cache dir).",
),
(
"trx-server.pskreporter",
"Report FT8/FT4/WSPR spots to pskreporter.info.",
),
(
"trx-server.aprsfi",
"Forward received APRS frames to APRS-IS.",
),
(
"trx-server.decode_logs",
"Write decodes to JSON Lines files.",
),
(
"trx-server.sdr",
"SoapySDR pipeline; used when [rig.access] type = \"sdr\".",
),
(
"trx-server.sdr.gain",
"\"auto\" for hardware AGC, or \"manual\".",
),
(
"trx-server.sdr.squelch",
"Software squelch on demodulated audio.",
),
(
"trx-server.sdr.noise_blanker",
"Impulse-noise suppression on the IQ stream.",
),
(
"trx-server.timeouts",
"Timeout and buffer tuning. The defaults suit most setups.",
),
("trx-client", "Client: exposes the radio to users."),
("trx-client.general", "Labels shown in the web UI."),
(
"trx-client.remote",
"Legacy single-remote form; prefer [[remotes]] below.",
),
(
"trx-client.frontends.http",
"Web UI. default_rig_name and the per-rig maps are keyed by the\n\
[[remotes]] name, not the server-side rig id.",
),
(
"trx-client.frontends.http.auth",
"Passphrase login for the web UI. rx_passphrase_file and\n\
control_passphrase_file keep the secrets out of this file.",
),
(
"trx-client.frontends.rigctl",
"Hamlib-compatible TCP interface, one listener per rig.",
),
(
"trx-client.frontends.http_json",
"JSON-over-TCP control interface.",
),
(
"trx-client.frontends.audio",
"Where to fetch the audio stream from.",
),
(
"trx-client.frontends.audio.bridge",
"Play RX audio on a local sound device and capture TX from one.",
),
];
/// Render the combined `trx-rs.toml.example` contents.
pub fn combined_example() -> String {
let mut doc = DocumentMut::new();
doc.decor_mut().set_prefix(HEADER);
doc.insert("trx-server", section_item(&ServerConfig::example_config()));
doc.insert("trx-client", section_item(&ClientConfig::example_config()));
// Each section was serialized on its own, so both carry table positions
// starting at zero and would otherwise render interleaved.
renumber_tables(&mut doc);
for (path, comment) in SECTION_COMMENTS {
annotate(&mut doc, path, comment);
}
doc.to_string()
}
/// Renumber every table so the document renders in tree order.
fn renumber_tables(doc: &mut DocumentMut) {
fn walk(item: &mut Item, next: &mut usize) {
match item {
Item::Table(table) => {
table.set_position(*next);
*next += 1;
for (_, child) in table.iter_mut() {
walk(child, next);
}
}
Item::ArrayOfTables(array) => {
for table in array.iter_mut() {
table.set_position(*next);
*next += 1;
for (_, child) in table.iter_mut() {
walk(child, next);
}
}
}
_ => {}
}
}
let mut next = 0;
for (_, item) in doc.as_table_mut().iter_mut() {
walk(item, &mut next);
}
}
/// Serialize one config into a toml_edit table.
fn section_item<T: serde::Serialize>(config: &T) -> Item {
let rendered = toml::to_string_pretty(config).unwrap_or_default();
let doc: DocumentMut = rendered.parse().expect("serialized config must re-parse");
Item::Table(doc.as_table().clone())
}
/// Attach a comment above the table at `path`, if it exists.
fn annotate(doc: &mut DocumentMut, path: &str, comment: &str) {
let mut item: Option<&mut Item> = None;
for segment in path.split('.') {
let next = match item {
None => doc.get_mut(segment),
Some(current) => current.as_table_mut().and_then(|t| t.get_mut(segment)),
};
match next {
Some(found) => item = Some(found),
None => return,
}
}
let Some(table) = item.and_then(|i| i.as_table_mut()) else {
return;
};
let body: String = comment
.lines()
.map(|line| format!("# {}\n", line.trim_start()))
.collect();
table.decor_mut().set_prefix(format!("\n{body}"));
}
#[cfg(test)]
mod tests {
use super::*;
/// The checked-in example must match what the structs produce, so a new
/// config field cannot land without showing up in the example.
#[test]
fn test_checked_in_example_is_up_to_date() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../trx-rs.toml.example")
.canonicalize()
.expect("example file must exist");
let on_disk = std::fs::read_to_string(&path).expect("example file must be readable");
assert_eq!(
on_disk,
combined_example(),
"trx-rs.toml.example is out of date; regenerate with \
`cargo run -p trx-config --example generate_example`"
);
}
/// Nothing in the example may be derived from the machine that generated
/// it: [decode_logs].dir defaults to the running user's cache directory,
/// which made the generated file differ between a developer's laptop and
/// CI, and the up-to-date test fail for everyone but its author.
#[test]
fn test_example_has_no_machine_specific_paths() {
let example = combined_example();
for dir in [dirs::home_dir(), dirs::cache_dir(), dirs::config_dir()]
.into_iter()
.flatten()
{
let dir = dir.to_string_lossy().into_owned();
assert!(
!example.contains(&dir),
"the example contains this machine's {dir}; pin the value in example_config()"
);
}
}
#[test]
fn test_example_loads_and_validates() {
let mut file = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut file, combined_example().as_bytes()).unwrap();
let server = ServerConfig::load_from_file(file.path()).expect("server section loads");
assert!(
server.unknown_keys.is_empty(),
"the generated example must not contain unknown keys: {:?}",
server.unknown_keys
);
server.config.validate().expect("server section validates");
let client = ClientConfig::load_from_file(file.path()).expect("client section loads");
assert!(
client.unknown_keys.is_empty(),
"the generated example must not contain unknown keys: {:?}",
client.unknown_keys
);
client.config.validate().expect("client section validates");
}
/// Every section that gained a comment must still exist under that path.
#[test]
fn test_section_comments_match_real_sections() {
let doc: DocumentMut = combined_example().parse().unwrap();
for (path, _) in SECTION_COMMENTS {
let mut item = None;
for segment in path.split('.') {
item = match item {
None => doc.get(segment),
Some(current) => current.as_table().and_then(|t| t.get(segment)),
};
assert!(
item.is_some(),
"commented section [{path}] no longer exists"
);
}
}
}
}
-252
View File
@@ -1,252 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Loading a config section out of a TOML file.
//!
//! Two file shapes are accepted:
//!
//! - **Sectioned** — a combined `trx-rs.toml` with `[trx-server]` and/or
//! `[trx-client]` tables. This is what `--print-config` and
//! `trx-configurator` emit.
//! - **Bare** — a standalone file whose root *is* the section, i.e. `[general]`
//! and `[rig]` at the top level. Hand-written per-binary configs use this.
//!
//! A file that carries some other component's section but not ours is treated
//! as "section absent" rather than as a bare file, so loading a client-only
//! config with the server reports the missing section instead of silently
//! falling back to defaults.
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::path::{Path, PathBuf};
use thiserror::Error;
use std::collections::BTreeSet;
use crate::unknown::{describe, flatten_paths, UnknownKey};
/// Every section key that may appear at the root of a combined config file.
pub const SECTION_KEYS: &[&str] = &["trx-server", "trx-client"];
/// A loaded config plus what the loader noticed on the way in.
#[derive(Debug, Clone)]
pub struct ConfigLoad<T> {
/// The deserialized configuration.
pub config: T,
/// File the config came from; `None` when nothing was found and defaults
/// were used.
pub path: Option<PathBuf>,
/// Keys present in the file that no config field claimed.
pub unknown_keys: Vec<UnknownKey>,
/// Every key path the file actually set. Defaults are indistinguishable
/// from explicit values once deserialized, so deprecation checks need this.
pub present_keys: BTreeSet<String>,
}
impl<T: ConfigFile> ConfigLoad<T> {
/// Log a warning for every deprecated key the file sets.
pub fn report_deprecations(&self) {
for message in T::deprecations(&self.present_keys) {
tracing::warn!("{}", message);
}
}
}
impl<T> ConfigLoad<T> {
/// Log every unknown key as a warning. With `strict`, also return an error
/// so the caller can refuse to start.
pub fn report_unknown_keys(&self, strict: bool) -> Result<(), String> {
for key in &self.unknown_keys {
tracing::warn!("{}", key);
}
if strict && !self.unknown_keys.is_empty() {
return Err(format!(
"{} unknown config key(s); refusing to start because --strict-config is set",
self.unknown_keys.len()
));
}
Ok(())
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file {0}: {1}")]
ReadError(PathBuf, String),
#[error("Failed to parse config file {0}: {1}")]
ParseError(PathBuf, String),
}
/// Returns the default search paths for `trx-rs.toml`
/// (current directory → XDG config → /etc).
fn config_search_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("trx-rs.toml")];
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("trx-rs").join("trx-rs.toml"));
}
paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml"));
paths
}
/// Pick the table holding `key`'s settings out of a parsed document.
///
/// Returns the named section when present, the whole document when it carries
/// no section headers at all (a bare standalone file), or `None` when the file
/// is sectioned but has no section for `key`.
fn select_section(table: &toml::Table, key: &str) -> Option<toml::Value> {
if let Some(section) = table.get(key) {
return Some(section.clone());
}
let is_sectioned = SECTION_KEYS.iter().any(|k| table.contains_key(*k));
if is_sectioned {
return None;
}
Some(toml::Value::Table(table.clone()))
}
/// Extract and deserialize a named section from a TOML file.
///
/// Returns `Ok(Some((cfg, unknown_keys)))` when the section is present and
/// parses cleanly, `Ok(None)` when the section is absent, or `Err` on I/O /
/// parse failure.
type LoadedSection<T> = (T, Vec<UnknownKey>, BTreeSet<String>);
fn load_section_from_file<T: ConfigFile>(
path: &Path,
key: &str,
) -> Result<Option<LoadedSection<T>>, ConfigError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
let table: toml::Table = toml::from_str(&content)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
let Some(mut section) = select_section(&table, key) else {
return Ok(None);
};
// ${VAR} references are expanded before deserializing, so any string in the
// file can come from the environment.
crate::secrets::expand_env_vars(&mut section)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e))?;
let present_keys = flatten_paths(&section);
// Deserialize straight from the TOML value so serde applies every default,
// recording any key no field claimed.
let mut ignored: Vec<String> = Vec::new();
let cfg: T = serde_ignored::deserialize(section, |path| ignored.push(path.to_string()))
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
Ok(Some((
cfg,
describe(&ignored, &T::reference_value()),
present_keys,
)))
}
/// Trait for loading configuration from a `trx-rs.toml` section.
pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize {
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
fn section_key() -> &'static str;
/// Warnings for deprecated keys the file sets, given every key path present
/// in it. Defaults to none.
fn deprecations(_present_keys: &BTreeSet<String>) -> Vec<String> {
Vec::new()
}
/// A TOML rendering of a populated config, used to suggest corrections for
/// unknown keys. Implementations should fill in list-valued sections such
/// as `[[rigs]]` so keys nested inside them can be suggested too.
fn reference_value() -> toml::Value {
toml::Value::try_from(Self::default())
.unwrap_or_else(|_| toml::Value::Table(toml::Table::new()))
}
/// Load the section from a specific file path.
///
/// Accepts both a sectioned file (`[<section_key>]` at the root) and a bare
/// file whose root is the section itself. Returns an error if the file
/// cannot be read, is not valid TOML, or is sectioned for some other
/// component only.
fn load_from_file(path: &Path) -> Result<ConfigLoad<Self>, ConfigError> {
let (config, unknown_keys, present_keys) =
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
ConfigError::ParseError(
path.to_path_buf(),
format!("missing [{}] section", Self::section_key()),
)
})?;
Ok(ConfigLoad {
config,
path: Some(path.to_path_buf()),
unknown_keys,
present_keys,
})
}
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
/// the first file that contains the expected section.
///
/// Falls back to `Self::default()` with no path when nothing is found.
fn load_from_default_paths() -> Result<ConfigLoad<Self>, ConfigError> {
for path in config_search_paths() {
if path.exists() {
if let Some((config, unknown_keys, present_keys)) =
load_section_from_file::<Self>(&path, Self::section_key())?
{
return Ok(ConfigLoad {
config,
path: Some(path),
unknown_keys,
present_keys,
});
}
}
}
Ok(ConfigLoad {
config: Self::default(),
path: None,
unknown_keys: Vec::new(),
present_keys: BTreeSet::new(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn table(s: &str) -> toml::Table {
toml::from_str(s).unwrap()
}
#[test]
fn test_select_section_prefers_named_section() {
let t = table("[trx-server]\n[trx-server.general]\ncallsign = \"W1AW\"\n");
let section = select_section(&t, "trx-server").unwrap();
assert!(section.get("general").is_some());
}
#[test]
fn test_select_section_falls_back_to_root_for_bare_file() {
let t = table("[general]\ncallsign = \"W1AW\"\n");
let section = select_section(&t, "trx-server").unwrap();
assert!(section.get("general").is_some());
}
#[test]
fn test_select_section_absent_when_other_section_present() {
let t = table("[trx-client]\n[trx-client.general]\ncallsign = \"W1AW\"\n");
assert!(select_section(&t, "trx-server").is_none());
}
#[test]
fn test_select_section_empty_file_is_bare() {
let t = table("");
assert!(select_section(&t, "trx-server").is_some());
}
}
-26
View File
@@ -1,26 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Configuration types shared by `trx-server`, `trx-client` and
//! `trx-configurator`.
//!
//! Keeping the structs, the loader and the validators in one crate means the
//! setup wizard checks a config with exactly the same code the binaries load
//! it with, so the two can never drift apart.
pub mod client;
pub mod example;
pub mod file;
pub mod secrets;
pub mod server;
pub mod shared;
pub mod unknown;
pub mod url;
pub use client::ClientConfig;
pub use file::{ConfigError, ConfigFile, ConfigLoad};
pub use server::ServerConfig;
pub use shared::{validate_log_level, validate_tokens};
pub use unknown::UnknownKey;
pub use url::{parse_audio_url, parse_remote_url, RemoteEndpoint};
-297
View File
@@ -1,297 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Keeping credentials out of the config file.
//!
//! Tokens and passphrases used to have exactly one representation: written in
//! plain text in `trx-rs.toml`, which is awkward when the config is deployed by
//! a config-management tool, committed to a private repo, or shared between
//! machines. Two alternatives are offered:
//!
//! - `${VAR}` anywhere in a config string, expanded from the environment.
//! - A `*_file` sibling of any secret key, read from disk at startup.
//!
//! Plus a nudge: a config that holds secrets and is readable by group or others
//! gets a warning.
use std::path::Path;
/// Expand `${VAR}` references in every string in a TOML value.
///
/// An unset variable is an error rather than an empty string — a silently blank
/// passphrase is the kind of thing that disables authentication by accident.
pub fn expand_env_vars(value: &mut toml::Value) -> Result<(), String> {
match value {
toml::Value::String(s) => {
if let Some(expanded) = expand_str(s)? {
*s = expanded;
}
}
toml::Value::Table(table) => {
for (_, child) in table.iter_mut() {
expand_env_vars(child)?;
}
}
toml::Value::Array(items) => {
for item in items.iter_mut() {
expand_env_vars(item)?;
}
}
_ => {}
}
Ok(())
}
/// Expand `${VAR}` in one string; `None` when there was nothing to expand.
fn expand_str(input: &str) -> Result<Option<String>, String> {
if !input.contains("${") {
return Ok(None);
}
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
// Unterminated: leave the rest exactly as written.
out.push_str(&rest[start..]);
return Ok(Some(out));
};
let name = &after[..end];
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
// Not a variable reference; pass it through untouched.
out.push_str(&rest[start..start + 2 + end + 1]);
} else {
let value = std::env::var(name)
.map_err(|_| format!("config references unset environment variable ${{{name}}}"))?;
out.push_str(&value);
}
rest = &after[end + 1..];
}
out.push_str(rest);
Ok(Some(out))
}
/// Read a single secret from a file: the whole file, trimmed.
pub fn read_secret_file(path: &str, what: &str) -> Result<String, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?;
let secret = content.trim().to_string();
if secret.is_empty() {
return Err(format!("{what}: secret file {path} is empty"));
}
warn_if_group_readable(Path::new(path), what);
Ok(secret)
}
/// Read a list of secrets, one per line. Blank lines and `#` comments are
/// skipped.
pub fn read_secret_list_file(path: &str, what: &str) -> Result<Vec<String>, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?;
let secrets: Vec<String> = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_string)
.collect();
if secrets.is_empty() {
return Err(format!("{what}: secret file {path} contains no entries"));
}
warn_if_group_readable(Path::new(path), what);
Ok(secrets)
}
/// Fill `inline` from `file` when the config used the `*_file` form.
///
/// Setting both is an error: which one wins would be a guess.
pub fn resolve_secret(
inline: &mut Option<String>,
file: &Option<String>,
what: &str,
) -> Result<(), String> {
let Some(path) = file else {
return Ok(());
};
if inline.is_some() {
return Err(format!(
"{what}: set either the value or its _file form, not both"
));
}
*inline = Some(read_secret_file(path, what)?);
Ok(())
}
/// Fill a token list from `file` when the config used the `*_file` form.
pub fn resolve_secret_list(
inline: &mut Vec<String>,
file: &Option<String>,
what: &str,
) -> Result<(), String> {
let Some(path) = file else {
return Ok(());
};
if !inline.is_empty() {
return Err(format!(
"{what}: set either the value or its _file form, not both"
));
}
*inline = read_secret_list_file(path, what)?;
Ok(())
}
/// Warn when a file holding secrets is readable beyond its owner.
///
/// Advisory only: plenty of valid setups (a dedicated service user, an
/// immutable image) are fine, so this never fails the load.
pub fn warn_if_group_readable(path: &Path, what: &str) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(path) else {
return;
};
let mode = meta.permissions().mode() & 0o077;
if mode != 0 {
tracing::warn!(
"{} is readable by group or others (mode {:o}); it holds secrets ({}). \
Consider: chmod 600 {}",
path.display(),
meta.permissions().mode() & 0o777,
what,
path.display()
);
}
}
#[cfg(not(unix))]
{
let _ = (path, what);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_expand_leaves_plain_strings_alone() {
assert_eq!(expand_str("plain").unwrap(), None);
assert_eq!(expand_str("TRXRS-%YYYY%.log").unwrap(), None);
}
#[test]
fn test_expand_substitutes_variable() {
std::env::set_var("TRX_TEST_TOKEN", "s3cret");
assert_eq!(
expand_str("Bearer ${TRX_TEST_TOKEN}!").unwrap().as_deref(),
Some("Bearer s3cret!")
);
}
#[test]
fn test_expand_errors_on_unset_variable() {
let err = expand_str("${TRX_DEFINITELY_NOT_SET_12345}").unwrap_err();
assert!(err.contains("unset environment variable"), "{err}");
}
#[test]
fn test_expand_passes_through_non_variables() {
assert_eq!(
expand_str("${not a var}").unwrap().as_deref(),
Some("${not a var}")
);
assert_eq!(
expand_str("unterminated ${VAR").unwrap().as_deref(),
Some("unterminated ${VAR")
);
}
#[test]
fn test_expand_walks_nested_tables_and_arrays() {
std::env::set_var("TRX_TEST_HOST", "radio.example.com");
let mut value: toml::Value = toml::from_str(
r#"
[remote]
url = "${TRX_TEST_HOST}:4530"
hosts = ["${TRX_TEST_HOST}"]
"#,
)
.unwrap();
expand_env_vars(&mut value).unwrap();
assert_eq!(
value["remote"]["url"].as_str(),
Some("radio.example.com:4530")
);
assert_eq!(
value["remote"]["hosts"][0].as_str(),
Some("radio.example.com")
);
}
fn temp_file(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn test_read_secret_file_trims() {
let f = temp_file(" hunter2\n");
assert_eq!(read_secret_file(path_of(&f), "test").unwrap(), "hunter2");
}
#[test]
fn test_read_secret_file_rejects_empty() {
let f = temp_file(" \n");
assert!(read_secret_file(path_of(&f), "test").is_err());
}
#[test]
fn test_read_secret_list_skips_blanks_and_comments() {
let f = temp_file("# tokens\nalpha\n\n beta \n");
assert_eq!(
read_secret_list_file(path_of(&f), "test").unwrap(),
vec!["alpha".to_string(), "beta".to_string()]
);
}
#[test]
fn test_resolve_secret_fills_from_file() {
let f = temp_file("from-file");
let mut inline = None;
resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap();
assert_eq!(inline.as_deref(), Some("from-file"));
}
#[test]
fn test_resolve_secret_rejects_both_forms() {
let f = temp_file("from-file");
let mut inline = Some("inline".to_string());
let err = resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err();
assert!(err.contains("not both"), "{err}");
}
#[test]
fn test_resolve_secret_is_a_no_op_without_file() {
let mut inline = Some("inline".to_string());
resolve_secret(&mut inline, &None, "test").unwrap();
assert_eq!(inline.as_deref(), Some("inline"));
}
#[test]
fn test_resolve_secret_list_rejects_both_forms() {
let f = temp_file("alpha");
let mut inline = vec!["inline".to_string()];
let err =
resolve_secret_list(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err();
assert!(err.contains("not both"), "{err}");
}
fn path_of(f: &tempfile::NamedTempFile) -> &str {
f.path().to_str().unwrap()
}
}
File diff suppressed because it is too large Load Diff
-263
View File
@@ -1,263 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Reporting for config keys the deserializer ignored.
//!
//! Every config struct is `#[serde(default)]`, so a misspelled key used to be
//! dropped without a word and the setting silently kept its default. The
//! loader now collects the ignored key paths and pairs each with the closest
//! known key at the same level, so `prot = 9999` reads as a typo instead of
//! looking like it worked.
use std::collections::BTreeSet;
use std::fmt;
/// A config key that the deserializer did not recognise.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownKey {
/// Dotted path of the key, e.g. `listen.prot` or `rigs.0.audio.prot`.
pub path: String,
/// Closest known key at the same level, when one is near enough to suggest.
pub suggestion: Option<String>,
}
impl fmt::Display for UnknownKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.suggestion {
Some(s) => write!(
f,
"unknown config key '{}' (did you mean '{}'?)",
self.path, s
),
None => write!(f, "unknown config key '{}'", self.path),
}
}
}
/// Flatten a TOML value into the set of dotted key paths it contains.
///
/// Array indices are normalised to `0` so a path inside `[[rigs]]` matches
/// whichever entry it came from.
pub fn flatten_paths(value: &toml::Value) -> BTreeSet<String> {
let mut paths = BTreeSet::new();
walk(value, "", &mut paths);
paths
}
fn walk(value: &toml::Value, prefix: &str, paths: &mut BTreeSet<String>) {
let join = |seg: &str| {
if prefix.is_empty() {
seg.to_string()
} else {
format!("{prefix}.{seg}")
}
};
match value {
toml::Value::Table(table) => {
for (key, child) in table {
let path = join(key);
paths.insert(path.clone());
walk(child, &path, paths);
}
}
toml::Value::Array(items) => {
// Every entry of an array of tables has the same shape, so collapse
// them onto index 0 and let one entry stand for all.
for item in items {
let path = join("0");
walk(item, &path, paths);
}
}
_ => {}
}
}
/// Replace numeric path segments with `0` so array entries compare equal.
fn normalize(path: &str) -> String {
path.split('.')
.map(|seg| {
if !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit()) {
"0"
} else {
seg
}
})
.collect::<Vec<_>>()
.join(".")
}
/// Suggest the closest known key that sits at the same level as `path`.
///
/// Returns `None` when nothing is close enough to be worth printing.
pub fn suggest(path: &str, known: &BTreeSet<String>) -> Option<String> {
let normalized = normalize(path);
let (parent, leaf) = match normalized.rsplit_once('.') {
Some((parent, leaf)) => (parent, leaf),
None => ("", normalized.as_str()),
};
// Anything longer than this is a different word, not a typo.
let limit = (leaf.chars().count() / 3).clamp(1, 3);
let mut best: Option<(usize, &str)> = None;
for candidate in known {
let (cand_parent, cand_leaf) = match candidate.rsplit_once('.') {
Some((p, l)) => (p, l),
None => ("", candidate.as_str()),
};
if cand_parent != parent || cand_leaf == leaf {
continue;
}
let distance = edit_distance(leaf, cand_leaf);
if distance <= limit && best.is_none_or(|(best_d, _)| distance < best_d) {
best = Some((distance, cand_leaf));
}
}
best.map(|(_, leaf)| {
if parent.is_empty() {
leaf.to_string()
} else {
format!("{parent}.{leaf}")
}
})
}
/// Pair each ignored path with a suggestion drawn from `reference`.
pub fn describe(paths: &[String], reference: &toml::Value) -> Vec<UnknownKey> {
let known = flatten_paths(reference);
paths
.iter()
.map(|path| UnknownKey {
path: path.clone(),
suggestion: suggest(path, &known),
})
.collect()
}
/// Optimal string alignment distance: Levenshtein plus transpositions, so the
/// common `port` → `prot` slip counts as one mistake rather than two.
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut rows = vec![vec![0usize; b.len() + 1]; a.len() + 1];
for (i, row) in rows.iter_mut().enumerate() {
row[0] = i;
}
for (j, cell) in rows[0].iter_mut().enumerate() {
*cell = j;
}
for i in 1..=a.len() {
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
let mut best = (rows[i - 1][j] + 1)
.min(rows[i][j - 1] + 1)
.min(rows[i - 1][j - 1] + cost);
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
best = best.min(rows[i - 2][j - 2] + 1);
}
rows[i][j] = best;
}
}
rows[a.len()][b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
fn reference() -> toml::Value {
toml::from_str(
r#"
[general]
callsign = "N0CALL"
log_level = "info"
[listen]
enabled = true
port = 4530
[[rigs]]
id = "hf"
[rigs.audio]
port = 4531
sample_rate = 48000
"#,
)
.unwrap()
}
#[test]
fn test_flatten_collects_nested_paths() {
let paths = flatten_paths(&reference());
assert!(paths.contains("general.callsign"));
assert!(paths.contains("listen.port"));
assert!(paths.contains("rigs.0.audio.sample_rate"));
}
#[test]
fn test_suggest_finds_close_sibling() {
let known = flatten_paths(&reference());
assert_eq!(
suggest("listen.prot", &known).as_deref(),
Some("listen.port")
);
}
#[test]
fn test_suggest_inside_array_entry() {
let known = flatten_paths(&reference());
assert_eq!(
suggest("rigs.1.audio.prot", &known).as_deref(),
Some("rigs.0.audio.port")
);
}
#[test]
fn test_suggest_ignores_distant_names() {
let known = flatten_paths(&reference());
assert_eq!(suggest("listen.bananas", &known), None);
}
#[test]
fn test_suggest_does_not_cross_levels() {
let known = flatten_paths(&reference());
// `port` exists under [listen], but not under [general].
assert_eq!(suggest("general.port", &known), None);
}
#[test]
fn test_describe_formats_message() {
let described = describe(&["listen.prot".to_string()], &reference());
assert_eq!(
described[0].to_string(),
"unknown config key 'listen.prot' (did you mean 'listen.port'?)"
);
}
#[test]
fn test_describe_without_suggestion() {
let described = describe(&["listen.bananas".to_string()], &reference());
assert_eq!(
described[0].to_string(),
"unknown config key 'listen.bananas'"
);
}
#[test]
fn test_edit_distance_counts_transposition_once() {
assert_eq!(edit_distance("port", "prot"), 1);
assert_eq!(edit_distance("port", "port"), 0);
assert_eq!(edit_distance("", "port"), 4);
assert_eq!(edit_distance("sample_rat", "sample_rate"), 1);
}
}
-173
View File
@@ -1,173 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Parsing for the `host:port` endpoint URLs used by the client's `[[remotes]]`
//! and `[frontends.audio]` settings.
//!
//! These live next to the config structs because validation needs them; the
//! client re-exports them for its connection code.
/// Default port for the server's JSON control listener.
pub const DEFAULT_REMOTE_PORT: u16 = 4530;
/// Default port for the server's Opus audio listener.
pub const DEFAULT_AUDIO_PORT: u16 = 4531;
/// A resolved `host:port` pair.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteEndpoint {
pub host: String,
pub port: u16,
}
impl RemoteEndpoint {
/// Format as a connect string, bracketing bare IPv6 hosts.
pub fn connect_addr(&self) -> String {
if self.host.contains(':') && !self.host.starts_with('[') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
}
/// Parse a remote control URL, defaulting to port 4530.
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
}
/// Parse an audio stream URL, defaulting to port 4531.
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
}
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(format!("{kind} url is empty"));
}
let addr = trimmed
.strip_prefix("tcp://")
.or_else(|| trimmed.strip_prefix("http-json://"))
.or_else(|| trimmed.strip_prefix("audio://"))
.unwrap_or(trimmed);
parse_host_port(addr, default_port, kind)
}
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
if let Some(rest) = input.strip_prefix('[') {
let closing = rest
.find(']')
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
let host = &rest[..closing];
let remainder = &rest[closing + 1..];
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
let port = if remainder.is_empty() {
default_port
} else if let Some(port_str) = remainder.strip_prefix(':') {
parse_port(port_str, kind)?
} else {
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
};
return Ok(RemoteEndpoint {
host: host.to_string(),
port,
});
}
if input.contains(':') {
if input.matches(':').count() > 1 {
return Err(format!(
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
));
}
let (host, port_str) = input
.rsplit_once(':')
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
return Ok(RemoteEndpoint {
host: host.to_string(),
port: parse_port(port_str, kind)?,
});
}
Ok(RemoteEndpoint {
host: input.to_string(),
port: default_port,
})
}
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
let port: u16 = port_str
.parse()
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
if port == 0 {
return Err(format!("invalid {kind} port: 0"));
}
Ok(port)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_remote_url_defaults_port() {
let ep = parse_remote_url("example.com").unwrap();
assert_eq!(ep.host, "example.com");
assert_eq!(ep.port, DEFAULT_REMOTE_PORT);
}
#[test]
fn test_parse_audio_url_defaults_port() {
let ep = parse_audio_url("example.com").unwrap();
assert_eq!(ep.port, DEFAULT_AUDIO_PORT);
}
#[test]
fn test_parse_strips_schemes() {
for url in &[
"tcp://host:9000",
"http-json://host:9000",
"audio://host:9000",
] {
let ep = parse_remote_url(url).unwrap();
assert_eq!(ep.host, "host");
assert_eq!(ep.port, 9000);
}
}
#[test]
fn test_parse_bracketed_ipv6() {
let ep = parse_remote_url("[::1]:4532").unwrap();
assert_eq!(ep.host, "::1");
assert_eq!(ep.port, 4532);
assert_eq!(ep.connect_addr(), "[::1]:4532");
}
#[test]
fn test_parse_rejects_unbracketed_ipv6() {
assert!(parse_remote_url("::1:4532").is_err());
}
#[test]
fn test_parse_rejects_empty_host() {
assert!(parse_remote_url(":4530").is_err());
}
#[test]
fn test_parse_rejects_zero_port() {
assert!(parse_remote_url("host:0").is_err());
}
#[test]
fn test_parse_rejects_empty_url() {
assert!(parse_remote_url(" ").is_err());
}
}
-2
View File
@@ -15,9 +15,7 @@ path = "src/main.rs"
clap = { workspace = true, features = ["derive"] }
dialoguer = "0.11"
tokio-serial = { workspace = true }
toml = "0.8"
toml_edit = "0.22"
trx-config = { path = "../trx-config" }
[dev-dependencies]
tempfile = "3"
+319 -136
View File
@@ -2,37 +2,80 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! `trx-configurator --check`: run a config file through the same loader and
//! validators the binaries use.
//!
//! This used to be a second, hand-maintained implementation — lists of known
//! keys and a handful of re-implemented range checks — which drifted out of
//! date as soon as a field was added. It now defers entirely to `trx-config`,
//! so a config that checks clean here is one the binaries will accept.
use std::fmt::Write as _;
use std::path::Path;
use trx_config::{ClientConfig, ServerConfig};
use toml_edit::DocumentMut;
/// Top-level keys that only appear in a server config. Used solely to guess
/// what a section-less file is meant to be; the real key checking is done by
/// the loader.
const SERVER_MARKERS: &[&str] = &[
/// Known top-level keys for a standalone server config.
const SERVER_KEYS: &[&str] = &[
"general",
"rig",
"rigs",
"listen",
"behavior",
"listen",
"audio",
"sdr",
"pskreporter",
"aprsfi",
"decode_logs",
"timeouts",
"audio",
];
/// Top-level keys that only appear in a client config.
const CLIENT_MARKERS: &[&str] = &["remote", "remotes", "frontends"];
/// Known top-level keys for a standalone client config.
const CLIENT_KEYS: &[&str] = &["general", "remote", "remotes", "frontends"];
/// Known top-level keys for a combined trx-rs.toml.
const COMBINED_KEYS: &[&str] = &["trx-server", "trx-client"];
/// Known sub-keys within [general] (server).
const SERVER_GENERAL_KEYS: &[&str] = &["callsign", "log_level", "latitude", "longitude"];
/// Known sub-keys within [general] (client).
const CLIENT_GENERAL_KEYS: &[&str] = &[
"callsign",
"log_level",
"website_url",
"website_name",
"ais_vessel_url_base",
];
/// Known sub-keys within [rig].
const RIG_KEYS: &[&str] = &["model", "initial_freq_hz", "initial_mode", "access"];
/// Known sub-keys within [rig.access].
const ACCESS_KEYS: &[&str] = &["type", "port", "baud", "host", "tcp_port", "args"];
/// Known sub-keys within [listen].
const LISTEN_KEYS: &[&str] = &["enabled", "listen", "port", "auth"];
/// Known sub-keys within [audio] (server).
const AUDIO_KEYS: &[&str] = &[
"enabled",
"listen",
"port",
"rx_enabled",
"tx_enabled",
"device",
"sample_rate",
"channels",
"frame_duration_ms",
"bitrate_bps",
];
/// Known sub-keys within [behavior].
const BEHAVIOR_KEYS: &[&str] = &[
"poll_interval_ms",
"poll_interval_tx_ms",
"max_retries",
"retry_base_delay_ms",
"vfo_prime",
];
/// Known sub-keys within [remote].
const REMOTE_KEYS: &[&str] = &["url", "rig_id", "auth", "poll_interval_ms"];
/// Known sub-keys within [frontends].
const FRONTENDS_KEYS: &[&str] = &["http", "rigctl", "http_json", "audio"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DetectedType {
@@ -57,38 +100,49 @@ pub fn check_file(path: &Path) -> Result<String, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let table: toml::Table = toml::from_str(&content)
// Step 1: TOML syntax check
let doc: DocumentMut = content
.parse()
.map_err(|e| format!("{}: TOML syntax error: {}", path.display(), e))?;
let detected = detect_type(&table);
let mut report = String::new();
let mut warnings: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
let table = doc.as_table();
// Step 2: Detect config type
let detected = detect_type(table);
writeln!(report, "{}: valid TOML", path.display()).unwrap();
writeln!(report, " Detected type: {}", detected).unwrap();
// Step 3: Structural validation
match detected {
DetectedType::Server => check_server(path, &mut warnings, &mut errors),
DetectedType::Client => check_client(path, &mut warnings, &mut errors),
DetectedType::Server => {
check_unknown_keys(table, SERVER_KEYS, "", &mut warnings);
check_server_sections(table, "", &mut warnings, &mut errors);
}
DetectedType::Client => {
check_unknown_keys(table, CLIENT_KEYS, "", &mut warnings);
check_client_sections(table, "", &mut warnings, &mut errors);
}
DetectedType::Combined => {
if table.contains_key("trx-server") {
check_server(path, &mut warnings, &mut errors);
check_unknown_keys(table, COMBINED_KEYS, "", &mut warnings);
if let Some(server) = table.get("trx-server").and_then(|v| v.as_table()) {
check_unknown_keys(server, SERVER_KEYS, "[trx-server].", &mut warnings);
check_server_sections(server, "[trx-server].", &mut warnings, &mut errors);
}
if table.contains_key("trx-client") {
check_client(path, &mut warnings, &mut errors);
if let Some(client) = table.get("trx-client").and_then(|v| v.as_table()) {
check_unknown_keys(client, CLIENT_KEYS, "[trx-client].", &mut warnings);
check_client_sections(client, "[trx-client].", &mut warnings, &mut errors);
}
}
DetectedType::Unknown => {
warnings.push(
"Could not detect config type. Expected server, client, or combined \
(trx-rs.toml) layout."
.to_string(),
);
warnings.push("Could not detect config type. Expected server, client, or combined (trx-rs.toml) layout.".to_string());
}
}
// Step 4: Format report
for w in &warnings {
writeln!(report, " warning: {}", w).unwrap();
}
@@ -115,54 +169,23 @@ pub fn check_file(path: &Path) -> Result<String, String> {
}
}
fn check_server(path: &Path, warnings: &mut Vec<String>, errors: &mut Vec<String>) {
match ServerConfig::load_from_file(path) {
Ok(loaded) => {
warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string()));
if let Err(e) = loaded.config.validate() {
errors.push(format!("[trx-server] {}", e));
}
errors.extend(
loaded
.config
.validate_sdr()
.into_iter()
.map(|e| format!("[trx-server] {}", e)),
);
}
Err(e) => errors.push(e.to_string()),
}
}
fn check_client(path: &Path, warnings: &mut Vec<String>, errors: &mut Vec<String>) {
match ClientConfig::load_from_file(path) {
Ok(loaded) => {
warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string()));
if let Err(e) = loaded.config.validate() {
errors.push(format!("[trx-client] {}", e));
}
}
Err(e) => errors.push(e.to_string()),
}
}
fn detect_type(table: &toml::Table) -> DetectedType {
fn detect_type(table: &toml_edit::Table) -> DetectedType {
if table.contains_key("trx-server") || table.contains_key("trx-client") {
return DetectedType::Combined;
}
let keys: Vec<&str> = table.keys().map(|k| k.as_str()).collect();
let keys: Vec<&str> = table.iter().map(|(k, _)| k).collect();
// Distinguishing keys first, then a simple majority.
if keys.iter().any(|k| ["rig", "rigs", "listen"].contains(k)) {
let server_score = keys.iter().filter(|k| SERVER_KEYS.contains(k)).count();
let client_score = keys.iter().filter(|k| CLIENT_KEYS.contains(k)).count();
// Use distinguishing keys to break ties
if keys.contains(&"rig") || keys.contains(&"rigs") || keys.contains(&"listen") {
return DetectedType::Server;
}
if keys.iter().any(|k| CLIENT_MARKERS.contains(k)) {
if keys.contains(&"remote") || keys.contains(&"remotes") || keys.contains(&"frontends") {
return DetectedType::Client;
}
let server_score = keys.iter().filter(|k| SERVER_MARKERS.contains(k)).count();
let client_score = keys.iter().filter(|k| CLIENT_MARKERS.contains(k)).count();
if server_score > client_score {
DetectedType::Server
} else if client_score > server_score {
@@ -174,6 +197,194 @@ fn detect_type(table: &toml::Table) -> DetectedType {
}
}
fn check_unknown_keys(
table: &toml_edit::Table,
known: &[&str],
prefix: &str,
warnings: &mut Vec<String>,
) {
for (key, _) in table.iter() {
if !known.contains(&key) {
warnings.push(format!("{}unknown key '{}'", prefix, key));
}
}
}
fn check_server_sections(
table: &toml_edit::Table,
prefix: &str,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) {
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
check_unknown_keys(
general,
SERVER_GENERAL_KEYS,
&format!("{}[general].", prefix),
warnings,
);
validate_log_level(general, &format!("{}[general]", prefix), errors);
validate_coordinates(general, &format!("{}[general]", prefix), errors);
}
if let Some(rig) = table.get("rig").and_then(|v| v.as_table()) {
check_unknown_keys(rig, RIG_KEYS, &format!("{}[rig].", prefix), warnings);
if let Some(access) = rig.get("access").and_then(|v| v.as_table()) {
check_unknown_keys(
access,
ACCESS_KEYS,
&format!("{}[rig.access].", prefix),
warnings,
);
validate_access(access, &format!("{}[rig.access]", prefix), errors);
}
}
if let Some(listen) = table.get("listen").and_then(|v| v.as_table()) {
check_unknown_keys(
listen,
LISTEN_KEYS,
&format!("{}[listen].", prefix),
warnings,
);
validate_port(listen, "port", &format!("{}[listen]", prefix), errors);
}
if let Some(audio) = table.get("audio").and_then(|v| v.as_table()) {
check_unknown_keys(audio, AUDIO_KEYS, &format!("{}[audio].", prefix), warnings);
validate_port(audio, "port", &format!("{}[audio]", prefix), errors);
}
if let Some(behavior) = table.get("behavior").and_then(|v| v.as_table()) {
check_unknown_keys(
behavior,
BEHAVIOR_KEYS,
&format!("{}[behavior].", prefix),
warnings,
);
}
}
fn check_client_sections(
table: &toml_edit::Table,
prefix: &str,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) {
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
check_unknown_keys(
general,
CLIENT_GENERAL_KEYS,
&format!("{}[general].", prefix),
warnings,
);
validate_log_level(general, &format!("{}[general]", prefix), errors);
}
if let Some(remote) = table.get("remote").and_then(|v| v.as_table()) {
check_unknown_keys(
remote,
REMOTE_KEYS,
&format!("{}[remote].", prefix),
warnings,
);
}
if let Some(frontends) = table.get("frontends").and_then(|v| v.as_table()) {
check_unknown_keys(
frontends,
FRONTENDS_KEYS,
&format!("{}[frontends].", prefix),
warnings,
);
if let Some(http) = frontends.get("http").and_then(|v| v.as_table()) {
validate_port(http, "port", &format!("{}[frontends.http]", prefix), errors);
}
if let Some(rigctl) = frontends.get("rigctl").and_then(|v| v.as_table()) {
validate_port(
rigctl,
"port",
&format!("{}[frontends.rigctl]", prefix),
errors,
);
}
}
}
// ── Value validators ────────────────────────────────────────────────────
fn validate_log_level(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(level) = table.get("log_level").and_then(|v| v.as_str()) {
if !["trace", "debug", "info", "warn", "error"].contains(&level) {
errors.push(format!(
"{}.log_level '{}' is invalid (expected: trace, debug, info, warn, error)",
context, level
));
}
}
}
fn validate_coordinates(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(lat) = table
.get("latitude")
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
{
if !(-90.0..=90.0).contains(&lat) {
errors.push(format!(
"{}.latitude {} is out of range (-90..90)",
context, lat
));
}
}
if let Some(lon) = table
.get("longitude")
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
{
if !(-180.0..=180.0).contains(&lon) {
errors.push(format!(
"{}.longitude {} is out of range (-180..180)",
context, lon
));
}
}
let has_lat = table.contains_key("latitude");
let has_lon = table.contains_key("longitude");
if has_lat != has_lon {
errors.push(format!(
"{}: latitude and longitude must be set together or both omitted",
context
));
}
}
fn validate_port(table: &toml_edit::Table, key: &str, context: &str, errors: &mut Vec<String>) {
if let Some(port) = table.get(key).and_then(|v| v.as_integer()) {
if let Some(enabled) = table.get("enabled").and_then(|v| v.as_bool()) {
if enabled && port <= 0 {
errors.push(format!("{}.{} must be > 0 when enabled", context, key));
}
}
if !(0..=65535).contains(&port) {
errors.push(format!(
"{}.{} {} is out of range (0..65535)",
context, key, port
));
}
}
}
fn validate_access(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(access_type) = table.get("type").and_then(|v| v.as_str()) {
if !["serial", "tcp", "sdr"].contains(&access_type) {
errors.push(format!(
"{}.type '{}' is invalid (expected: serial, tcp, sdr)",
context, access_type
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -207,7 +418,8 @@ enabled = true
port = 4530
"#,
);
let report = result.expect("expected a clean report");
assert!(result.is_ok());
let report = result.unwrap();
assert!(report.contains("Detected type: server"));
assert!(report.contains("No issues found"));
}
@@ -220,40 +432,41 @@ port = 4530
callsign = "W1AW"
[remote]
url = "192.168.1.10:4530"
url = "localhost:4530"
[frontends.http]
enabled = true
port = 8080
"#,
);
let report = result.expect("expected a clean report");
assert!(result.is_ok());
let report = result.unwrap();
assert!(report.contains("Detected type: client"));
assert!(report.contains("No issues found"));
}
#[test]
fn test_valid_combined_config() {
let result = check_toml(
r#"
[trx-server.rig]
model = "ft817"
[trx-server.rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[trx-server.general]
callsign = "W1AW"
[trx-client.general]
callsign = "W1AW"
[trx-client.remote]
url = "127.0.0.1:4530"
url = "localhost:4530"
"#,
);
let report = result.expect("expected a clean report");
assert!(result.is_ok());
let report = result.unwrap();
assert!(report.contains("Detected type: combined"));
}
#[test]
fn test_invalid_toml_syntax() {
let result = check_toml("[general\ncallsign = \"W1AW\"\n");
let result = check_toml("this is not [valid toml");
assert!(result.is_err());
assert!(result.unwrap_err().contains("TOML syntax error"));
}
@@ -261,22 +474,19 @@ url = "127.0.0.1:4530"
fn test_unknown_key_warning() {
let result = check_toml(
r#"
[general]
callsign = "W1AW"
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[listen]
prot = 4530
[bogus_section]
foo = "bar"
"#,
);
let report = result.expect("unknown keys are warnings, not errors");
assert!(
report.contains("unknown config key 'listen.prot' (did you mean 'listen.port'?)"),
"unexpected report: {report}"
);
assert!(result.is_ok());
let report = result.unwrap();
assert!(report.contains("unknown key 'bogus_section'"));
}
#[test]
@@ -288,13 +498,11 @@ log_level = "verbose"
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#,
);
assert!(result.unwrap_err().contains("log_level"));
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("log_level 'verbose' is invalid"));
}
#[test]
@@ -302,17 +510,15 @@ baud = 9600
let result = check_toml(
r#"
[general]
latitude = 52.0
latitude = 45.0
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#,
);
assert!(result.unwrap_err().contains("longitude"));
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("latitude and longitude must be set together"));
}
#[test]
@@ -320,18 +526,16 @@ baud = 9600
let result = check_toml(
r#"
[general]
latitude = 120.0
latitude = 95.0
longitude = 10.0
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#,
);
assert!(result.unwrap_err().contains("latitude"));
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("latitude 95 is out of range"));
}
#[test]
@@ -343,31 +547,10 @@ model = "ft817"
[rig.access]
type = "usb"
port = "/dev/ttyUSB0"
baud = 9600
"#,
);
assert!(result.unwrap_err().contains("access"));
}
/// The old checker only knew a fixed list of top-level keys and a few range
/// rules, so it passed configs the server rejects at startup.
#[test]
fn test_catches_errors_the_key_list_checker_missed() {
let result = check_toml(
r#"
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[audio]
enabled = true
frame_duration_ms = 7
"#,
);
assert!(result.unwrap_err().contains("frame_duration_ms"));
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("type 'usb' is invalid"));
}
}
+8 -68
View File
@@ -211,13 +211,10 @@ pub fn build_server(general: ServerGeneral, rig: RigSetup, listen: ListenSetup)
let mut doc = DocumentMut::new();
doc.decor_mut()
.set_prefix("# trx-server configuration\n# Generated by trx-configurator\n");
// Emit the sectioned shape (`[trx-server]`) that trx-server writes with
// --print-config, so a generated file can be dropped into a combined
// trx-rs.toml unchanged.
doc.insert(
"trx-server",
Item::Table(build_server_tables(general, rig, listen)),
);
let tables = build_server_tables(general, rig, listen);
for (key, item) in tables.iter() {
doc.insert(key, item.clone());
}
doc
}
@@ -353,10 +350,10 @@ pub fn build_client(
let mut doc = DocumentMut::new();
doc.decor_mut()
.set_prefix("# trx-client configuration\n# Generated by trx-configurator\n");
doc.insert(
"trx-client",
Item::Table(build_client_tables(general, remote, frontends)),
);
let tables = build_client_tables(general, remote, frontends);
for (key, item) in tables.iter() {
doc.insert(key, item.clone());
}
doc
}
@@ -456,60 +453,3 @@ pub fn write_file(doc: &DocumentMut, path: &Path) -> Result<(), String> {
println!("Wrote {}", path.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use trx_config::{ClientConfig, ServerConfig};
fn write_temp(doc: &DocumentMut) -> tempfile::NamedTempFile {
let file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
std::fs::write(file.path(), doc.to_string()).unwrap();
file
}
/// The wizard used to emit root-level `[general]` / `[rig]` tables while the
/// loader demanded a `[trx-server]` section, so every generated standalone
/// config was rejected by the binary it was generated for.
#[test]
fn test_generated_server_config_loads_and_validates() {
let file = write_temp(&build_default(ConfigType::Server));
let cfg = ServerConfig::load_from_file(file.path())
.expect("generated config must load")
.config;
cfg.validate().expect("generated config must validate");
assert_eq!(cfg.rig.model.as_deref(), Some("ft817"));
assert_eq!(cfg.listen.port, 4530);
}
#[test]
fn test_generated_client_config_loads_and_validates() {
let file = write_temp(&build_default(ConfigType::Client));
let cfg = ClientConfig::load_from_file(file.path())
.expect("generated config must load")
.config;
cfg.validate().expect("generated config must validate");
assert_eq!(cfg.remote.url.as_deref(), Some("localhost:4530"));
}
#[test]
fn test_generated_combined_config_loads_both_sections() {
let file = write_temp(&build_default(ConfigType::Combined));
let server = ServerConfig::load_from_file(file.path())
.expect("server section must load")
.config;
server.validate().expect("server section must validate");
let client = ClientConfig::load_from_file(file.path())
.expect("client section must load")
.config;
client.validate().expect("client section must validate");
}
#[test]
fn test_generated_docs_are_sectioned() {
let doc = build_default(ConfigType::Server);
assert!(doc.as_table().contains_key("trx-server"));
let doc = build_default(ConfigType::Client);
assert!(doc.as_table().contains_key("trx-client"));
}
}
-1
View File
@@ -17,4 +17,3 @@ uuid = { workspace = true }
ts-rs = { version = "12.0.1", features = ["uuid-impl"] }
sgp4 = "2"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
base64 = "0.22"
-1
View File
@@ -19,7 +19,6 @@ pub mod command;
pub mod controller;
pub mod request;
pub mod response;
pub mod spectrum_wire;
pub mod state;
/// How this backend communicates with the rig.
-124
View File
@@ -1,124 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Compact wire encoding for spectrum bins.
//!
//! Bins are dBFS magnitudes, and the web UI has always drawn them from `i8`
//! values — the SSE hop to the browser quantizes and base64-encodes them. The
//! server→client hop, which is the one that crosses the operator's network,
//! used to send the same information as a JSON array of `f32`: around ten bytes
//! per bin instead of one, or roughly 10 KB per 1024-bin frame.
//!
//! Bins therefore travel as base64-encoded `i8` dBFS, about an eighth of the
//! size, at a resolution the display already rounds to. Decoding still accepts
//! the old array form, so a new client can read an older server.
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use serde::de::{SeqAccess, Visitor};
use serde::{Deserializer, Serializer};
use std::fmt;
/// Quantize to whole dBFS and encode as base64.
pub fn serialize<S: Serializer>(bins: &[f32], serializer: S) -> Result<S::Ok, S::Error> {
let quantized: Vec<u8> = bins
.iter()
.map(|&db| {
let clamped = if db.is_finite() { db } else { -128.0 };
clamped.round().clamp(-128.0, 127.0) as i8 as u8
})
.collect();
serializer.serialize_str(&BASE64.encode(quantized))
}
/// Decode base64 bins, or a plain array of numbers from an older server.
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<f32>, D::Error> {
deserializer.deserialize_any(BinsVisitor)
}
struct BinsVisitor;
impl<'de> Visitor<'de> for BinsVisitor {
type Value = Vec<f32>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("base64-encoded i8 dBFS bins, or an array of numbers")
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
let bytes = BASE64
.decode(value)
.map_err(|e| E::custom(format!("invalid base64 spectrum bins: {e}")))?;
Ok(bytes.into_iter().map(|byte| byte as i8 as f32).collect())
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut bins = Vec::with_capacity(seq.size_hint().unwrap_or(1024));
while let Some(value) = seq.next_element::<f32>()? {
bins.push(value);
}
Ok(bins)
}
}
#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Frame {
#[serde(with = "super")]
bins: Vec<f32>,
}
#[test]
fn test_round_trip_quantizes_to_whole_db() {
let frame = Frame {
bins: vec![-73.4, -20.6, 0.0, -120.2],
};
let json = serde_json::to_string(&frame).unwrap();
let back: Frame = serde_json::from_str(&json).unwrap();
assert_eq!(back.bins, vec![-73.0, -21.0, 0.0, -120.0]);
}
#[test]
fn test_serializes_as_a_base64_string() {
let json = serde_json::to_string(&Frame {
bins: vec![-1.0, 0.0],
})
.unwrap();
assert!(json.contains('"'), "bins should be a string: {json}");
assert!(!json.contains('['), "bins should not be an array: {json}");
}
#[test]
fn test_clamps_out_of_range_and_non_finite() {
let frame = Frame {
bins: vec![-400.0, 400.0, f32::NAN, f32::NEG_INFINITY],
};
let json = serde_json::to_string(&frame).unwrap();
let back: Frame = serde_json::from_str(&json).unwrap();
assert_eq!(back.bins, vec![-128.0, 127.0, -128.0, -128.0]);
}
#[test]
fn test_reads_the_old_array_form() {
let back: Frame = serde_json::from_str(r#"{"bins":[-73.25,-20.5]}"#).unwrap();
assert_eq!(back.bins, vec![-73.25, -20.5]);
}
/// The point of the change: an ordinary frame gets much smaller.
#[test]
fn test_frame_is_far_smaller_than_the_array_form() {
let bins: Vec<f32> = (0..1024).map(|i| -60.0 - (i % 40) as f32 * 0.37).collect();
let compact = serde_json::to_string(&Frame { bins: bins.clone() }).unwrap();
let verbose = serde_json::to_string(&bins).unwrap();
assert!(
compact.len() * 5 < verbose.len(),
"compact {} bytes vs array {} bytes",
compact.len(),
verbose.len()
);
}
}
-6
View File
@@ -394,12 +394,6 @@ fn default_wfm_denoise_level() -> WfmDenoiseLevel {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
pub struct SpectrumData {
/// FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
///
/// On the wire these are base64-encoded `i8` whole dBFS (see
/// `spectrum_wire`), which is what the display draws anyway; the TypeScript
/// type describes the decoded array the browser receives over SSE.
#[serde(with = "crate::rig::spectrum_wire")]
#[ts(type = "Array<number>")]
pub bins: Vec<f32>,
/// Centre frequency of the SDR capture in Hz.
#[ts(type = "number")]
+1 -3
View File
@@ -18,6 +18,4 @@ pub use auth::{NoAuthValidator, SimpleTokenValidator, TokenValidator};
pub use codec::{mode_to_string, parse_envelope, parse_mode};
pub use decoders::{DecoderActivation, DecoderDescriptor, DECODER_REGISTRY};
pub use mapping::{client_command_to_rig, rig_command_to_client};
pub use types::{
ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry, SpectrumFrame,
};
pub use types::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry};
+1 -1
View File
@@ -102,7 +102,7 @@ macro_rules! define_command_mapping {
define_command_mapping! {
// ── Client-only variants (no RigCommand counterpart) ─────────────
client_only: GetRigs, GetSatPasses, SubscribeMeter, SubscribeSpectrum;
client_only: GetRigs, GetSatPasses, SubscribeMeter;
// ── Unit variants (no payload) ───────────────────────────────────
unit:
-22
View File
@@ -130,28 +130,6 @@ pub enum ClientCommand {
/// newline-delimited `MeterUpdate` JSON frames and no further commands or
/// regular responses are sent. Intended for a dedicated TCP connection.
SubscribeMeter,
/// Subscribe to a per-rig spectrum stream on this connection. Like
/// `SubscribeMeter`, the connection becomes a one-way flow of
/// newline-delimited `SpectrumFrame` JSON and no further commands or
/// regular responses are sent.
///
/// Polling `GetSpectrum` costs a round trip per frame, which caps the frame
/// rate at 1/RTT no matter how often the client asks; the server pushes at
/// its own cadence instead. Clients fall back to polling when the server
/// rejects this command.
SubscribeSpectrum,
}
/// One spectrum frame pushed by the server on a dedicated spectrum stream.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SpectrumFrame {
/// Rig identifier this frame belongs to.
pub rig_id: String,
/// The frame itself; bins travel base64-encoded (see `spectrum_wire`).
pub spectrum: trx_core::rig::state::SpectrumData,
/// Virtual-channel RDS state, mirroring what `GetSpectrum` returned.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vchan_rds: Option<Vec<trx_core::rig::state::VchanRdsEntry>>,
}
/// Fast meter sample pushed by the server on a dedicated meter stream.
-1
View File
@@ -32,7 +32,6 @@ cpal = "0.15"
num-complex = "0.4"
opus = "0.3"
trx-app = { path = "../trx-app" }
trx-config = { path = "../trx-config" }
trx-backend = { path = "trx-backend", features = ["soapysdr"] }
trx-ais = { path = "../decoders/trx-ais" }
trx-vdes = { path = "../decoders/trx-vdes" }
+4 -1
View File
@@ -2291,12 +2291,15 @@ pub async fn run_wefax_decoder(
mut state_rx: watch::Receiver<RigState>,
decode_tx: broadcast::Sender<DecodedMessage>,
histories: Arc<DecoderHistories>,
wefax_output_dir: std::path::PathBuf,
) {
use trx_wefax::{WefaxConfig, WefaxDecoder, WefaxEvent};
info!("WEFAX decoder started ({}Hz, {} ch)", sample_rate, channels);
let wefax_output_dir = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("wefax");
let config = WefaxConfig {
output_dir: Some(wefax_output_dir.to_string_lossy().into_owned()),
..WefaxConfig::default()
File diff suppressed because it is too large Load Diff
-117
View File
@@ -494,58 +494,6 @@ where
}
};
// SubscribeSpectrum: turns this connection into a one-way spectrum
// stream. Polling GetSpectrum costs a round trip per frame, so a
// client on a slow link could never reach the frame rate it asked for;
// pushing decouples the rate from the latency.
if matches!(envelope.cmd, ClientCommand::SubscribeSpectrum) {
let mut spectrum_rx = handle.spectrum_tx.subscribe();
let io_timeout = timeouts.io_timeout;
info!(
"Client {} subscribed to spectrum stream for rig '{}'",
addr, target_rig_id
);
loop {
tokio::select! {
frame = spectrum_rx.recv() => {
match frame {
Ok(frame) => {
let Ok(mut line) = serde_json::to_string(&frame) else { continue };
line.push('\n');
let write = time::timeout(
io_timeout,
writer.write_all(line.as_bytes()),
).await;
match write {
Ok(Ok(())) => {}
Ok(Err(e)) => {
info!("Client {} spectrum write failed: {}", addr, e);
break;
}
Err(_) => {
info!("Client {} spectrum write timed out", addr);
break;
}
}
}
// A client that cannot keep up skips to the newest
// frame; stale spectrum is not worth drawing.
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
changed = shutdown_rx.changed() => {
match changed {
Ok(()) if *shutdown_rx.borrow() => break,
Ok(()) => {}
Err(_) => break,
}
}
}
}
break;
}
// SubscribeMeter: turns this connection into a one-way meter stream.
// No regular responses are produced; the connection lives until the
// client disconnects or shutdown fires.
@@ -784,7 +732,6 @@ mod tests {
let (state_tx, state_rx) = watch::channel(state);
let _state_tx = state_tx;
let (meter_tx, _) = tokio::sync::broadcast::channel(8);
let (spectrum_tx, _) = tokio::sync::broadcast::channel(4);
let handle = RigHandle {
rig_id: "default".to_string(),
display_name: "Default Rig".to_string(),
@@ -792,7 +739,6 @@ mod tests {
state_rx,
audio_port: 4531,
meter_tx,
spectrum_tx,
};
let mut map = HashMap::new();
map.insert("default".to_string(), handle);
@@ -987,7 +933,6 @@ mod tests {
state_rx: state_rx_a,
audio_port: 4531,
meter_tx: meter_tx_a,
spectrum_tx: tokio::sync::broadcast::channel(4).0,
};
let (tx_b, rx_b) = mpsc::channel::<RigRequest>(8);
@@ -1000,7 +945,6 @@ mod tests {
state_rx: state_rx_b,
audio_port: 4532,
meter_tx: meter_tx_b,
spectrum_tx: tokio::sync::broadcast::channel(4).0,
};
let mut map = HashMap::new();
@@ -1009,67 +953,6 @@ mod tests {
(Arc::new(map), "rig_hf".to_string(), rx_a, rx_b)
}
/// Polling spectrum costs a round trip per frame, so the rate a client can
/// reach is capped by latency rather than by what it asked for. Subscribed
/// clients get frames pushed instead; this is that path end to end.
#[tokio::test]
async fn subscribe_spectrum_pushes_frames() {
use trx_core::rig::state::SpectrumData;
let (rigs, default_id) = make_rigs(sample_state());
let spectrum_tx = rigs.get("default").expect("rig").spectrum_tx.clone();
let ctx = make_ctx(rigs, default_id, HashSet::new());
let (mut reader, mut writer, handle, shutdown_tx) = spawn_client_io(ctx);
writer
.write_all(br#"{"cmd":"subscribe_spectrum"}"#)
.await
.expect("write");
writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush");
// The subscription is registered asynchronously; publish until it takes.
let frame = trx_protocol::SpectrumFrame {
rig_id: "default".to_string(),
spectrum: SpectrumData {
bins: vec![-73.4, -20.6, 0.0],
center_hz: 14_200_000,
sample_rate: 1_920_000,
rds: None,
},
vchan_rds: None,
};
let mut line = String::new();
for _ in 0..50 {
let _ = spectrum_tx.send(frame.clone());
tokio::select! {
read = reader.read_line(&mut line) => {
if read.expect("read") > 0 && !line.trim().is_empty() {
break;
}
line.clear();
}
_ = tokio::time::sleep(Duration::from_millis(20)) => {}
}
}
let received: trx_protocol::SpectrumFrame =
serde_json::from_str(line.trim()).unwrap_or_else(|e| panic!("frame {line:?}: {e}"));
assert_eq!(received.rig_id, "default");
assert_eq!(received.spectrum.center_hz, 14_200_000);
// Bins survive the trip quantized to whole dBFS, which is the
// resolution the display draws at anyway.
assert_eq!(received.spectrum.bins, vec![-73.0, -21.0, 0.0]);
// And they travel as base64 rather than a JSON array of floats.
assert!(
!line.contains("-73"),
"bins should not be spelled out on the wire: {line}"
);
let _ = shutdown_tx.send(true);
let _ = handle.await;
}
#[tokio::test]
async fn multi_rig_state_isolation() {
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
+142 -267
View File
@@ -30,8 +30,6 @@ use trx_core::audio::AudioStreamInfo;
use trx_app::{init_logging, normalize_name};
use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess};
use trx_config::shared::BoundSocket;
use trx_config::ConfigFile;
use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff};
use trx_core::rig::request::RigRequest;
use trx_core::rig::state::RigState;
@@ -58,12 +56,6 @@ struct Cli {
/// Print example configuration and exit
#[arg(long = "print-config")]
print_config: bool,
/// Treat unknown configuration keys as a fatal error
#[arg(long = "strict-config")]
strict_config: bool,
/// Validate the configuration and exit without starting anything
#[arg(long = "check-config")]
check_config: bool,
/// Rig backend to use (e.g. ft817, ft450d)
#[arg(short = 'r', long = "rig")]
rig: Option<String>,
@@ -360,7 +352,6 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
})?;
let pcm_rx = sdr_rig.subscribe_pcm();
@@ -461,7 +452,6 @@ fn build_rig_task_config(
prebuilt_rig: None,
command_exec_timeout: Duration::from_millis(timeouts.command_exec_timeout_ms),
poll_refresh_timeout: Duration::from_millis(timeouts.poll_refresh_timeout_ms),
spectrum_interval_ms: rig_cfg.sdr.spectrum_interval_ms,
}
}
@@ -663,44 +653,38 @@ fn spawn_rig_audio_stack(
}
// Spawn APRS decoder task
if rig_cfg.decoders.is_enabled("aprs") {
let aprs_pcm_rx = pcm_tx.subscribe();
let aprs_state_rx = state_rx.clone();
let aprs_decode_tx = decode_tx.clone();
let aprs_sr = rig_cfg.audio.sample_rate;
let aprs_ch = rig_cfg.audio.channels;
let aprs_shutdown_rx = shutdown_rx.clone();
let aprs_logs = decoder_logs.clone();
let aprs_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs, aprs_histories) => {}
_ = wait_for_shutdown(aprs_shutdown_rx) => {}
}
}));
}
let aprs_pcm_rx = pcm_tx.subscribe();
let aprs_state_rx = state_rx.clone();
let aprs_decode_tx = decode_tx.clone();
let aprs_sr = rig_cfg.audio.sample_rate;
let aprs_ch = rig_cfg.audio.channels;
let aprs_shutdown_rx = shutdown_rx.clone();
let aprs_logs = decoder_logs.clone();
let aprs_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs, aprs_histories) => {}
_ = wait_for_shutdown(aprs_shutdown_rx) => {}
}
}));
// Spawn HF APRS decoder task
if rig_cfg.decoders.is_enabled("aprs_hf") {
let hf_aprs_pcm_rx = pcm_tx.subscribe();
let hf_aprs_state_rx = state_rx.clone();
let hf_aprs_decode_tx = decode_tx.clone();
let hf_aprs_sr = rig_cfg.audio.sample_rate;
let hf_aprs_ch = rig_cfg.audio.channels;
let hf_aprs_shutdown_rx = shutdown_rx.clone();
let hf_aprs_logs = decoder_logs.clone();
let hf_aprs_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_hf_aprs_decoder(hf_aprs_sr, hf_aprs_ch as u16, hf_aprs_pcm_rx, hf_aprs_state_rx, hf_aprs_decode_tx, hf_aprs_logs, hf_aprs_histories) => {}
_ = wait_for_shutdown(hf_aprs_shutdown_rx) => {}
}
}));
}
let hf_aprs_pcm_rx = pcm_tx.subscribe();
let hf_aprs_state_rx = state_rx.clone();
let hf_aprs_decode_tx = decode_tx.clone();
let hf_aprs_sr = rig_cfg.audio.sample_rate;
let hf_aprs_ch = rig_cfg.audio.channels;
let hf_aprs_shutdown_rx = shutdown_rx.clone();
let hf_aprs_logs = decoder_logs.clone();
let hf_aprs_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_hf_aprs_decoder(hf_aprs_sr, hf_aprs_ch as u16, hf_aprs_pcm_rx, hf_aprs_state_rx, hf_aprs_decode_tx, hf_aprs_logs, hf_aprs_histories) => {}
_ = wait_for_shutdown(hf_aprs_shutdown_rx) => {}
}
}));
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) =
sdr_ais_pcm_rx.filter(|_| rig_cfg.decoders.is_enabled("ais"))
{
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = sdr_ais_pcm_rx {
let ais_state_rx = state_rx.clone();
let ais_decode_tx = decode_tx.clone();
let ais_shutdown_rx = shutdown_rx.clone();
@@ -715,7 +699,7 @@ fn spawn_rig_audio_stack(
}));
}
if let Some(vdes_iq_rx) = sdr_vdes_iq_rx.filter(|_| rig_cfg.decoders.is_enabled("vdes")) {
if let Some(vdes_iq_rx) = sdr_vdes_iq_rx {
let vdes_state_rx = state_rx.clone();
let vdes_decode_tx = decode_tx.clone();
let vdes_shutdown_rx = shutdown_rx.clone();
@@ -737,61 +721,55 @@ fn spawn_rig_audio_stack(
}
// Spawn CW decoder task
if rig_cfg.decoders.is_enabled("cw") {
let cw_pcm_rx = pcm_tx.subscribe();
let cw_state_rx = state_rx.clone();
let cw_decode_tx = decode_tx.clone();
let cw_sr = rig_cfg.audio.sample_rate;
let cw_ch = rig_cfg.audio.channels;
let cw_shutdown_rx = shutdown_rx.clone();
let cw_logs = decoder_logs.clone();
let cw_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs, cw_histories) => {}
_ = wait_for_shutdown(cw_shutdown_rx) => {}
}
}));
}
let cw_pcm_rx = pcm_tx.subscribe();
let cw_state_rx = state_rx.clone();
let cw_decode_tx = decode_tx.clone();
let cw_sr = rig_cfg.audio.sample_rate;
let cw_ch = rig_cfg.audio.channels;
let cw_shutdown_rx = shutdown_rx.clone();
let cw_logs = decoder_logs.clone();
let cw_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs, cw_histories) => {}
_ = wait_for_shutdown(cw_shutdown_rx) => {}
}
}));
// Spawn FT8 decoder task
if rig_cfg.decoders.is_enabled("ft8") {
let ft8_pcm_rx = pcm_tx.subscribe();
let ft8_state_rx = state_rx.clone();
let ft8_decode_tx = decode_tx.clone();
let ft8_sr = rig_cfg.audio.sample_rate;
let ft8_ch = rig_cfg.audio.channels;
let ft8_shutdown_rx = shutdown_rx.clone();
let ft8_logs = decoder_logs.clone();
let ft8_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs, ft8_histories) => {}
_ = wait_for_shutdown(ft8_shutdown_rx) => {}
}
}));
}
let ft8_pcm_rx = pcm_tx.subscribe();
let ft8_state_rx = state_rx.clone();
let ft8_decode_tx = decode_tx.clone();
let ft8_sr = rig_cfg.audio.sample_rate;
let ft8_ch = rig_cfg.audio.channels;
let ft8_shutdown_rx = shutdown_rx.clone();
let ft8_logs = decoder_logs.clone();
let ft8_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs, ft8_histories) => {}
_ = wait_for_shutdown(ft8_shutdown_rx) => {}
}
}));
// Spawn FT4 decoder task
if rig_cfg.decoders.is_enabled("ft4") {
let ft4_pcm_rx = pcm_tx.subscribe();
let ft4_state_rx = state_rx.clone();
let ft4_decode_tx = decode_tx.clone();
let ft4_sr = rig_cfg.audio.sample_rate;
let ft4_ch = rig_cfg.audio.channels;
let ft4_shutdown_rx = shutdown_rx.clone();
let ft4_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {}
_ = wait_for_shutdown(ft4_shutdown_rx) => {}
}
}));
}
let ft4_pcm_rx = pcm_tx.subscribe();
let ft4_state_rx = state_rx.clone();
let ft4_decode_tx = decode_tx.clone();
let ft4_sr = rig_cfg.audio.sample_rate;
let ft4_ch = rig_cfg.audio.channels;
let ft4_shutdown_rx = shutdown_rx.clone();
let ft4_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {}
_ = wait_for_shutdown(ft4_shutdown_rx) => {}
}
}));
// Spawn FT2 decoder task
#[cfg(feature = "ft2")]
if rig_cfg.decoders.is_enabled("ft2") {
{
let ft2_pcm_rx = pcm_tx.subscribe();
let ft2_state_rx = state_rx.clone();
let ft2_decode_tx = decode_tx.clone();
@@ -808,76 +786,73 @@ fn spawn_rig_audio_stack(
}
// Spawn WSPR decoder task
if rig_cfg.decoders.is_enabled("wspr") {
let wspr_pcm_rx = pcm_tx.subscribe();
let wspr_state_rx = state_rx.clone();
let wspr_decode_tx = decode_tx.clone();
let wspr_sr = rig_cfg.audio.sample_rate;
let wspr_ch = rig_cfg.audio.channels;
let wspr_shutdown_rx = shutdown_rx.clone();
let wspr_logs = decoder_logs.clone();
let wspr_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs, wspr_histories) => {}
_ = wait_for_shutdown(wspr_shutdown_rx) => {}
}
}));
}
let wspr_pcm_rx = pcm_tx.subscribe();
let wspr_state_rx = state_rx.clone();
let wspr_decode_tx = decode_tx.clone();
let wspr_sr = rig_cfg.audio.sample_rate;
let wspr_ch = rig_cfg.audio.channels;
let wspr_shutdown_rx = shutdown_rx.clone();
let wspr_logs = decoder_logs.clone();
let wspr_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs, wspr_histories) => {}
_ = wait_for_shutdown(wspr_shutdown_rx) => {}
}
}));
// Spawn Meteor-M LRPT decoder task
if rig_cfg.decoders.is_enabled("lrpt") {
let lrpt_pcm_rx = pcm_tx.subscribe();
let lrpt_state_rx = state_rx.clone();
let lrpt_decode_tx = decode_tx.clone();
let lrpt_sr = rig_cfg.audio.sample_rate;
let lrpt_ch = rig_cfg.audio.channels;
let lrpt_shutdown_rx = shutdown_rx.clone();
let lrpt_histories = histories.clone();
let lrpt_output_dir = rig_cfg.decoders.output_dir_for("lrpt");
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {}
_ = wait_for_shutdown(lrpt_shutdown_rx) => {}
}
}));
}
let lrpt_pcm_rx = pcm_tx.subscribe();
let lrpt_state_rx = state_rx.clone();
let lrpt_decode_tx = decode_tx.clone();
let lrpt_sr = rig_cfg.audio.sample_rate;
let lrpt_ch = rig_cfg.audio.channels;
let lrpt_shutdown_rx = shutdown_rx.clone();
let lrpt_histories = histories.clone();
let lrpt_output_dir = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("lrpt");
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {}
_ = wait_for_shutdown(lrpt_shutdown_rx) => {}
}
}));
// Spawn WEFAX decoder task
if rig_cfg.decoders.is_enabled("wefax") {
let wefax_pcm_rx = pcm_tx.subscribe();
let wefax_state_rx = state_rx.clone();
let wefax_decode_tx = decode_tx.clone();
let wefax_sr = rig_cfg.audio.sample_rate;
let wefax_ch = rig_cfg.audio.channels;
let wefax_shutdown_rx = shutdown_rx.clone();
let wefax_histories = histories.clone();
let wefax_output_dir = rig_cfg.decoders.output_dir_for("wefax");
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories, wefax_output_dir) => {}
_ = wait_for_shutdown(wefax_shutdown_rx) => {}
}
}));
}
let wefax_pcm_rx = pcm_tx.subscribe();
let wefax_state_rx = state_rx.clone();
let wefax_decode_tx = decode_tx.clone();
let wefax_sr = rig_cfg.audio.sample_rate;
let wefax_ch = rig_cfg.audio.channels;
let wefax_shutdown_rx = shutdown_rx.clone();
let wefax_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories) => {}
_ = wait_for_shutdown(wefax_shutdown_rx) => {}
}
}));
// Spawn SSTV decoder task
if rig_cfg.decoders.is_enabled("sstv") {
let sstv_pcm_rx = pcm_tx.subscribe();
let sstv_state_rx = state_rx.clone();
let sstv_decode_tx = decode_tx.clone();
let sstv_sr = rig_cfg.audio.sample_rate;
let sstv_ch = rig_cfg.audio.channels;
let sstv_shutdown_rx = shutdown_rx.clone();
let sstv_histories = histories.clone();
let sstv_output_dir = rig_cfg.decoders.output_dir_for("sstv");
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {}
_ = wait_for_shutdown(sstv_shutdown_rx) => {}
}
}));
}
let sstv_pcm_rx = pcm_tx.subscribe();
let sstv_state_rx = state_rx.clone();
let sstv_decode_tx = decode_tx.clone();
let sstv_sr = rig_cfg.audio.sample_rate;
let sstv_ch = rig_cfg.audio.channels;
let sstv_shutdown_rx = shutdown_rx.clone();
let sstv_histories = histories.clone();
let sstv_output_dir = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("sstv");
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {}
_ = wait_for_shutdown(sstv_shutdown_rx) => {}
}
}));
}
if rig_cfg.audio.tx_enabled {
@@ -907,82 +882,6 @@ fn spawn_rig_audio_stack(
handles
}
/// Sockets this process will bind, given the config and the CLI overrides.
///
/// `--listen` overrides the bind address of both the control listener and every
/// rig's audio listener, so the two callers of this must agree on the rules.
fn bound_sockets(cli: &Cli, cfg: &ServerConfig, rigs: &[RigInstanceConfig]) -> Vec<BoundSocket> {
let mut sockets = Vec::new();
if cfg.listen.enabled {
sockets.push(BoundSocket::new(
cli.listen.unwrap_or(cfg.listen.listen),
cli.port.unwrap_or(cfg.listen.port),
"[listen]",
));
}
let audio_ip = cli.listen.unwrap_or(cfg.audio.listen);
for rig in rigs {
if rig.audio.enabled {
sockets.push(BoundSocket::new(
audio_ip,
rig.audio.port,
format!("rig \"{}\" [audio]", rig.id),
));
}
}
sockets
}
/// `--check-config`: report everything wrong with the configuration and exit.
///
/// Unlike startup, this reports every problem it finds rather than stopping at
/// the first, so a config can be fixed in one pass.
fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad<ServerConfig>) -> DynResult<()> {
match &loaded.path {
Some(path) => println!("{}", path.display()),
None => println!("(no config file found; checking built-in defaults)"),
}
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
warnings.extend(ServerConfig::deprecations(&loaded.present_keys));
for warning in &warnings {
println!(" warning: {}", warning);
}
let mut cfg = loaded.config.clone();
let mut errors = Vec::new();
if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) {
errors.push(e);
}
let cfg = &cfg;
let rigs = cfg.resolved_rigs();
errors.extend(cfg.validate_all());
errors.extend(cfg.validate_sdr());
errors.extend(cfg.validate_resolved_all(&rigs, &bound_sockets(cli, cfg, &rigs)));
for e in &errors {
println!(" error: {}", e);
}
if errors.is_empty() {
println!(
" OK: {} rig(s) configured: {}",
rigs.len(),
rigs.iter()
.map(|r| r.id.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if !warnings.is_empty() {
println!(" {} warning(s)", warnings.len());
}
Ok(())
} else {
Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
}
}
#[tokio::main]
async fn main() -> DynResult<()> {
let mut bootstrap_ctx = RegistrationContext::new();
@@ -995,30 +894,12 @@ async fn main() -> DynResult<()> {
return Ok(());
}
let loaded = if let Some(ref path) = cli.config {
ServerConfig::load_from_file(path)?
let (cfg, config_path) = if let Some(ref path) = cli.config {
let cfg = ServerConfig::load_from_file(path)?;
(cfg, Some(path.clone()))
} else {
ServerConfig::load_from_default_paths()?
};
let config_path = loaded.path.clone();
if cli.check_config {
return check_config(&cli, &loaded);
}
// Logging comes up before any config complaint so the warnings are visible.
init_logging(loaded.config.general.log_level.as_deref());
if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display());
}
loaded.report_unknown_keys(cli.strict_config)?;
loaded.report_deprecations();
let mut cfg = loaded.config;
// Secrets configured as *_file are read before validation, so everything
// downstream sees resolved values.
cfg.resolve_secrets(config_path.as_deref())?;
cfg.validate()
.map_err(|e| format!("Invalid server configuration: {}", e))?;
@@ -1031,6 +912,12 @@ async fn main() -> DynResult<()> {
std::process::exit(1);
}
init_logging(cfg.general.log_level.as_deref());
if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display());
}
let registry = Arc::new(bootstrap_ctx);
// --- Resolve the effective rig list ---
@@ -1092,12 +979,6 @@ async fn main() -> DynResult<()> {
(callsign, cfg.general.latitude, cfg.general.longitude)
};
// Second validation phase: now that CLI overrides have been folded in, check
// the things that need the final rig list — chiefly that no two listeners
// claim the same socket.
cfg.validate_resolved(&resolved_rigs, &bound_sockets(&cli, &cfg, &resolved_rigs))
.map_err(|e| format!("Invalid server configuration: {}", e))?;
info!(
"Starting trx-server with {} rig(s): {}",
resolved_rigs.len(),
@@ -1217,9 +1098,6 @@ async fn main() -> DynResult<()> {
let (state_tx, state_rx) = watch::channel(initial_state);
let (meter_tx, _) =
broadcast::channel::<trx_protocol::MeterUpdate>(rig_handle::METER_BROADCAST_CAPACITY);
let (spectrum_tx, _) = broadcast::channel::<trx_protocol::SpectrumFrame>(
rig_handle::SPECTRUM_BROADCAST_CAPACITY,
);
let mut task_config = build_rig_task_config(
rig_cfg,
@@ -1247,14 +1125,12 @@ async fn main() -> DynResult<()> {
let rig_shutdown_rx = shutdown_rx.clone();
let rig_id_supervisor = rig_cfg.id.clone();
let meter_tx_task = meter_tx.clone();
let spectrum_tx_task = spectrum_tx.clone();
task_handles.push(tokio::spawn(async move {
let result = rig_task::run_rig_task(
task_config,
rig_rx,
state_tx.clone(),
meter_tx_task,
spectrum_tx_task,
rig_shutdown_rx,
)
.await;
@@ -1301,7 +1177,6 @@ async fn main() -> DynResult<()> {
rig_handles.insert(
rig_cfg.id.clone(),
RigHandle {
spectrum_tx: spectrum_tx.clone(),
rig_id: rig_cfg.id.clone(),
display_name: rig_cfg.display_name().to_string(),
rig_tx,
+1 -10
View File
@@ -8,18 +8,13 @@ use tokio::sync::{broadcast, mpsc, watch};
use trx_core::rig::request::RigRequest;
use trx_core::rig::state::RigState;
use trx_protocol::{MeterUpdate, SpectrumFrame};
use trx_protocol::MeterUpdate;
/// Bounded broadcast capacity for the meter stream. Keeps ~0.5 s of buffered
/// samples at 30 Hz — more than enough slack to tolerate a scheduling blip
/// without forcing the producer to block or drop silently.
pub const METER_BROADCAST_CAPACITY: usize = 16;
/// Bounded broadcast capacity for the spectrum stream. Frames are large and
/// only the newest one is worth drawing, so the buffer stays shallow: a slow
/// client lags and skips rather than making the server hold stale frames.
pub const SPECTRUM_BROADCAST_CAPACITY: usize = 4;
/// A handle to a single running rig backend.
///
/// One `RigHandle` is created per rig in `main.rs` and stored in the shared
@@ -39,8 +34,4 @@ pub struct RigHandle {
/// ~67 Hz (CAT). Consumed by `SubscribeMeter` clients; independent of
/// the slower `state_rx` snapshot path.
pub meter_tx: broadcast::Sender<MeterUpdate>,
/// Per-rig spectrum frames published by `rig_task` while at least one
/// client is subscribed. Consumed by `SubscribeSpectrum` clients; the
/// producer skips the work entirely when nobody is listening.
pub spectrum_tx: broadcast::Sender<SpectrumFrame>,
}
+1 -27
View File
@@ -23,7 +23,7 @@ use trx_core::rig::request::RigRequest;
use trx_core::rig::state::{RigMode, RigSnapshot, RigState};
use trx_core::rig::{RigCat, RigRxStatus, RigTxStatus};
use trx_core::{DynResult, RigError, RigResult};
use trx_protocol::{MeterUpdate, SpectrumFrame};
use trx_protocol::MeterUpdate;
use crate::audio::DecoderHistories;
use crate::error::is_invalid_bcd_error;
@@ -64,8 +64,6 @@ pub struct RigTaskConfig {
pub command_exec_timeout: Duration,
/// Maximum time for a CAT poll refresh cycle.
pub poll_refresh_timeout: Duration,
/// How often to push a spectrum frame to subscribed clients, in ms.
pub spectrum_interval_ms: u64,
}
impl Default for RigTaskConfig {
@@ -96,7 +94,6 @@ impl Default for RigTaskConfig {
prebuilt_rig: None,
command_exec_timeout: DEFAULT_COMMAND_EXEC_TIMEOUT,
poll_refresh_timeout: DEFAULT_POLL_REFRESH_TIMEOUT,
spectrum_interval_ms: 50,
}
}
}
@@ -118,7 +115,6 @@ pub async fn run_rig_task(
mut rx: mpsc::Receiver<RigRequest>,
state_tx: watch::Sender<RigState>,
meter_tx: broadcast::Sender<MeterUpdate>,
spectrum_tx: broadcast::Sender<SpectrumFrame>,
mut shutdown_rx: watch::Receiver<bool>,
) -> DynResult<()> {
let histories = config.histories.clone();
@@ -277,13 +273,6 @@ pub async fn run_rig_task(
} else {
Duration::from_millis(150)
};
// Spectrum frames get their own tick so a subscribed client never pays a
// round trip per frame. The FFT is computed by the SDR thread either way;
// this only reads the latest result, and only while somebody is subscribed.
let spectrum_tick_duration = Duration::from_millis(config.spectrum_interval_ms.max(1));
let mut spectrum_tick: std::pin::Pin<Box<tokio::time::Sleep>> =
Box::pin(tokio::time::sleep(spectrum_tick_duration));
let meter_task_start = Instant::now();
let meter_state_delta_db: f64 = 0.25;
let rig_id = config.rig_id.clone();
@@ -313,21 +302,6 @@ pub async fn run_rig_task(
Err(_) => break,
}
}
// Push the latest spectrum frame to subscribed clients.
_ = &mut spectrum_tick => {
spectrum_tick = Box::pin(tokio::time::sleep(spectrum_tick_duration));
// `send` fails only when nobody is listening, but building the
// frame clones a few KB of bins, so check before doing the work.
if spectrum_tx.receiver_count() > 0 {
if let Some(spectrum) = rig.as_sdr_ref().and_then(|s| s.get_spectrum()) {
let _ = spectrum_tx.send(SpectrumFrame {
rig_id: rig_id.clone(),
spectrum,
vchan_rds: rig.as_sdr_ref().and_then(|s| s.get_vchan_rds()),
});
}
}
}
// Fast meter-only refresh between full polls.
_ = &mut meter_tick => {
meter_tick = Box::pin(tokio::time::sleep(meter_tick_duration));
@@ -154,7 +154,6 @@ impl SdrPipeline {
squelch_cfg: VirtualSquelchConfig,
nb_cfg: NoiseBlankerConfig,
channels: &[(f64, RigMode, u32)],
spectrum_fft_size: usize,
) -> Self {
const IQ_BROADCAST_CAPACITY: usize = 64;
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
@@ -220,7 +219,6 @@ impl SdrPipeline {
.name("sdr-iq-read".to_string())
.spawn(move || {
iq_read_loop(
spectrum_fft_size,
source,
sdr_sample_rate,
thread_dsps,
@@ -319,7 +317,6 @@ pub const IQ_BLOCK_SIZE: usize = 4096;
#[allow(clippy::too_many_arguments)]
fn iq_read_loop(
spectrum_fft_size: usize,
mut source: Box<dyn IqSource>,
sdr_sample_rate: u32,
channel_dsps: Arc<RwLock<Vec<Arc<Mutex<ChannelDsp>>>>>,
@@ -338,7 +335,7 @@ fn iq_read_loop(
};
let throttle = !source.is_blocking();
let mut spectrum = SpectrumSnapshotter::new(spectrum_fft_size);
let mut spectrum = SpectrumSnapshotter::new();
let mut read_error_streak: u32 = 0;
let mut zero_read_streak: u32 = 0;
let mut overflow_log_window_start: Option<Instant> = None;
@@ -575,7 +572,6 @@ mod tests {
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
&[(200_000.0, RigMode::USB, 3000)],
1024,
);
assert_eq!(pipeline.pcm_senders.len(), 1);
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1);
@@ -594,7 +590,6 @@ mod tests {
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
&[],
1024,
);
assert_eq!(pipeline.pcm_senders.len(), 0);
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 0);
@@ -9,36 +9,28 @@ use num_complex::Complex;
use rustfft::num_complex::Complex as FftComplex;
use rustfft::FftPlanner;
/// Default number of FFT bins for the spectrum display, used when the config
/// does not say otherwise.
pub(super) const DEFAULT_SPECTRUM_FFT_SIZE: usize = 1024;
/// Number of FFT bins for the spectrum display.
pub(super) const SPECTRUM_FFT_SIZE: usize = 1024;
/// Update the spectrum buffer every this many IQ blocks (~10 Hz at 1.92 MHz / 4096 block).
pub(super) const SPECTRUM_UPDATE_BLOCKS: usize = 4;
pub(super) struct SpectrumSnapshotter {
fft_size: usize,
hann_window: Vec<f32>,
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
counter: usize,
}
impl SpectrumSnapshotter {
pub(super) fn new(fft_size: usize) -> Self {
let fft_size = if fft_size >= 2 {
fft_size
} else {
DEFAULT_SPECTRUM_FFT_SIZE
};
let hann_window: Vec<f32> = (0..fft_size)
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (fft_size - 1) as f32).cos()))
pub(super) fn new() -> Self {
let hann_window: Vec<f32> = (0..SPECTRUM_FFT_SIZE)
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (SPECTRUM_FFT_SIZE - 1) as f32).cos()))
.collect();
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(fft_size);
let fft = planner.plan_fft_forward(SPECTRUM_FFT_SIZE);
Self {
fft_size,
hann_window,
fft,
counter: 0,
@@ -56,7 +48,7 @@ impl SpectrumSnapshotter {
}
self.counter = 0;
let take = samples.len().min(self.fft_size);
let take = samples.len().min(SPECTRUM_FFT_SIZE);
let mut buf: Vec<FftComplex<f32>> = samples[..take]
.iter()
.enumerate()
@@ -67,15 +59,16 @@ impl SpectrumSnapshotter {
)
})
.collect();
buf.resize(self.fft_size, FftComplex::new(0.0, 0.0));
buf.resize(SPECTRUM_FFT_SIZE, FftComplex::new(0.0, 0.0));
self.fft.process(&mut buf);
let half = self.fft_size / 2;
let half = SPECTRUM_FFT_SIZE / 2;
let bins: Vec<f32> = buf[half..]
.iter()
.chain(buf[..half].iter())
.map(|value| {
let mag = (value.re * value.re + value.im * value.im).sqrt() / self.fft_size as f32;
let mag =
(value.re * value.re + value.im * value.im).sqrt() / SPECTRUM_FFT_SIZE as f32;
20.0 * mag.max(1e-10_f32).log10()
})
.collect();
@@ -73,11 +73,6 @@ pub struct SoapySdrConfig {
pub nb_enabled: bool,
/// Noise blanker impulse threshold multiplier.
pub nb_threshold: f64,
/// FFT bin count for the spectrum display; a power of two.
///
/// Fewer bins cost less DSP and put fewer bytes on the network per frame,
/// which is what a slow server↔client link cares about.
pub spectrum_fft_size: usize,
}
impl Default for SoapySdrConfig {
@@ -104,7 +99,6 @@ impl Default for SoapySdrConfig {
max_virtual_channels: 4,
nb_enabled: false,
nb_threshold: 10.0,
spectrum_fft_size: 1024,
}
}
}
@@ -200,7 +194,6 @@ impl SoapySdrRig {
let max_virtual_channels = config.max_virtual_channels;
let nb_enabled = config.nb_enabled;
let nb_threshold = config.nb_threshold;
let spectrum_fft_size = config.spectrum_fft_size;
tracing::info!(
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
args,
@@ -300,7 +293,6 @@ impl SoapySdrRig {
threshold: nb_threshold as f32,
},
&all_channels,
spectrum_fft_size,
));
let info = RigInfo {
@@ -426,7 +418,6 @@ impl SoapySdrRig {
nb_threshold: f64,
) -> DynResult<Self> {
Self::new_from_config(SoapySdrConfig {
spectrum_fft_size: 1024,
args: args.to_string(),
channels: channels.to_vec(),
gain_mode: gain_mode.to_string(),
@@ -439,7 +439,6 @@ mod tests {
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
&[(0.0, RigMode::USB, 3_000)],
1024,
))
}
+4 -111
View File
@@ -1,60 +1,36 @@
# trx-rs example configuration
#
# Generated from the config structs; regenerate with:
# cargo run -p trx-config --example generate_example
#
# Both sections are optional: trx-server reads [trx-server], trx-client reads
# [trx-client], and either may live in its own file with the section header
# omitted. Any string may use ${ENV_VAR}, and credentials may be moved out of
# this file with the matching *_file keys.
#
# Check a config without starting anything:
# trx-server --check-config --config trx-rs.toml
# trx-client --check-config --config trx-rs.toml
# Server: drives the radio hardware.
[trx-server]
rigs = []
# Station identity. Coordinates feed PSKReporter and the map.
[trx-server.general]
callsign = "N0CALL"
log_level = "info"
latitude = 52.2297
longitude = 21.0122
# Single-rig layout. For several radios, delete this and use [[rigs]].
[trx-server.rig]
model = "ft817"
initial_freq_hz = 144300000
initial_mode = "USB"
# How to reach the radio: serial, tcp, or sdr.
[trx-server.rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
# CAT polling and retry behaviour.
[trx-server.behavior]
poll_interval_ms = 500
poll_interval_tx_ms = 100
max_retries = 3
retry_base_delay_ms = 100
vfo_prime = true
# JSON control listener that trx-client connects to.
[trx-server.listen]
enabled = true
listen = "127.0.0.1"
port = 4530
# Tokens clients must present. Empty means no authentication.
# Use tokens_file = "/etc/trx-rs/tokens" to keep them out of this file.
[trx-server.listen.auth]
tokens = []
# Opus audio stream for trx-client.
[trx-server.audio]
enabled = true
listen = "127.0.0.1"
@@ -66,53 +42,25 @@ channels = 2
frame_duration_ms = 20
bitrate_bps = 256000
# Report FT8/FT4/WSPR spots to pskreporter.info.
[trx-server.pskreporter]
enabled = false
host = "report.pskreporter.info"
port = 4739
# Forward received APRS frames to APRS-IS.
[trx-server.aprsfi]
enabled = false
host = "rotate.aprs.net"
port = 14580
passcode = -1
beacon = false
beacon_interval_secs = 1200
beacon_symbol_table = "/"
beacon_symbol_code = "-"
# Write decodes to JSON Lines files.
[trx-server.decode_logs]
enabled = false
dir = "/var/lib/trx-rs/decoders"
dir = "/path/to/log/dir"
aprs_file = "TRXRS-APRS-%YYYY%-%MM%-%DD%.log"
cw_file = "TRXRS-CW-%YYYY%-%MM%-%DD%.log"
ft8_file = "TRXRS-FT8-%YYYY%-%MM%-%DD%.log"
wspr_file = "TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"
wefax_file = "TRXRS-WEFAX-%YYYY%-%MM%-%DD%.log"
# Which decoders run. Trimming this list saves real CPU on small boxes.
# Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr.
# output_dir sets where sstv/wefax/lrpt write images (default: user cache dir).
[trx-server.decoders]
enabled = [
"aprs",
"aprs_hf",
"ais",
"cw",
"ft2",
"ft4",
"ft8",
"lrpt",
"sstv",
"vdes",
"wefax",
"wspr",
]
# SoapySDR pipeline; used when [rig.access] type = "sdr".
[trx-server.sdr]
sample_rate = 1920000
bandwidth = 1500000
@@ -120,38 +68,17 @@ wfm_deemphasis_us = 50
center_offset_hz = 100000
channels = []
max_virtual_channels = 4
spectrum_fft_size = 1024
spectrum_interval_ms = 50
# "auto" for hardware AGC, or "manual".
[trx-server.sdr.gain]
mode = "auto"
value = 30.0
# Software squelch on demodulated audio.
[trx-server.sdr.squelch]
enabled = false
threshold_db = -65.0
hysteresis_db = 3.0
tail_ms = 180
# Impulse-noise suppression on the IQ stream.
[trx-server.sdr.noise_blanker]
enabled = false
threshold = 10.0
# Timeout and buffer tuning. The defaults suit most setups.
[trx-server.timeouts]
command_exec_timeout_ms = 10000
poll_refresh_timeout_ms = 8000
io_timeout_ms = 10000
request_timeout_ms = 12000
rig_task_channel_buffer = 32
# Client: exposes the radio to users.
[trx-client]
# Labels shown in the web UI.
[trx-client.general]
callsign = "N0CALL"
website_url = "https://haxx.space"
@@ -159,52 +86,24 @@ website_name = "haxx.space"
ais_vessel_url_base = "https://www.vesselfinder.com/?mmsi="
log_level = "info"
# Legacy single-remote form; prefer [[remotes]] below.
[trx-client.remote]
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remote.auth]
[[trx-client.remotes]]
name = "home-hf"
url = "192.168.1.100:4530"
url = "192.168.1.100:9000"
rig_id = "hf"
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remotes.auth]
[trx-client.remote.auth]
token = "my-token"
[[trx-client.remotes]]
name = "home-vhf"
url = "192.168.1.100:4530"
rig_id = "vhf"
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remotes.auth]
token = "my-token"
# Web UI. default_rig_name and the per-rig maps are keyed by the
# [[remotes]] name, not the server-side rig id.
[trx-client.frontends.http]
enabled = true
listen = "127.0.0.1"
port = 8080
default_rig_name = "home-hf"
default_rig_id = "hf"
initial_map_zoom = 10
spectrum_coverage_margin_hz = 50000
spectrum_usable_span_ratio = 0.9200000166893005
show_sdr_gain_control = true
bandplan_enabled = true
bandplan_region = "iaru_r1"
decode_history_retention_min = 1440
[trx-client.frontends.http.decode_history_retention_min_by_rig]
# Passphrase login for the web UI. rx_passphrase_file and
# control_passphrase_file keep the secrets out of this file.
[trx-client.frontends.http.auth]
enabled = false
rx_passphrase = "rx-passphrase-example"
@@ -214,7 +113,6 @@ session_ttl_min = 480
cookie_secure = false
cookie_same_site = "Lax"
# Hamlib-compatible TCP interface, one listener per rig.
[trx-client.frontends.rigctl]
enabled = false
listen = "127.0.0.1"
@@ -222,7 +120,6 @@ port = 4532
[trx-client.frontends.rigctl.rig_ports]
# JSON-over-TCP control interface.
[trx-client.frontends.http_json]
enabled = true
listen = "127.0.0.1"
@@ -231,16 +128,12 @@ port = 0
[trx-client.frontends.http_json.auth]
tokens = []
# Where to fetch the audio stream from.
[trx-client.frontends.audio]
enabled = true
server_port = 4531
[trx-client.frontends.audio.rig_urls]
[trx-client.frontends.audio.rig_ports]
# Play RX audio on a local sound device and capture TX from one.
[trx-client.frontends.audio.bridge]
enabled = false
bitrate_bps = 192000