Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83b453135a | ||
|
|
1794f899f3 | ||
|
|
ebe0462b45 | ||
|
|
a19633e81f | ||
|
|
c8b6f2d536 | ||
|
|
17300170cc | ||
|
|
e41c13917d | ||
|
|
ae7df31d91 | ||
|
|
bf3bcc8a84 | ||
|
|
86dd36312e | ||
|
|
15ff686542 | ||
|
|
b78c4a4dd4 | ||
|
|
f396e8f235 | ||
|
|
46c9827e8a | ||
|
|
0fc977f19f | ||
|
|
084f629b5b | ||
|
|
bc63ded583 | ||
|
|
cfaeb6ee15 | ||
|
|
76bcce8c54 | ||
|
|
88ed3da6cc | ||
|
|
7c69e0de08 | ||
|
|
fbc4f6e398 | ||
|
|
d42ca4f030 | ||
|
|
335922fecc | ||
|
|
bede2e34fe | ||
|
|
da58a004fe |
@@ -24,6 +24,12 @@ cargo test -p trx-core
|
|||||||
./target/release/trx-server --print-config > trx-server.toml
|
./target/release/trx-server --print-config > trx-server.toml
|
||||||
./target/release/trx-client --print-config > trx-client.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
|
# Run server
|
||||||
./target/release/trx-server --config trx-server.toml
|
./target/release/trx-server --config trx-server.toml
|
||||||
# or via CLI args:
|
# or via CLI args:
|
||||||
@@ -41,7 +47,8 @@ This is a Cargo workspace. All crates live under `src/`:
|
|||||||
src/
|
src/
|
||||||
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
|
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
|
||||||
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
|
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
|
||||||
trx-app/ # Shared application helpers (config paths, logging init)
|
trx-app/ # Shared application helpers (logging init, name normalization)
|
||||||
|
trx-config/ # Client + server config structs, loader, validators (~2,500 LOC)
|
||||||
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
|
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-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
|
||||||
trx-backend/ # Backend abstraction trait + factory + dummy
|
trx-backend/ # Backend abstraction trait + factory + dummy
|
||||||
|
|||||||
Generated
+32
-4
@@ -2412,6 +2412,16 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.149"
|
version = "1.0.149"
|
||||||
@@ -3031,10 +3041,6 @@ dependencies = [
|
|||||||
name = "trx-app"
|
name = "trx-app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
|
||||||
"serde",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"toml",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
@@ -3115,6 +3121,7 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
"trx-app",
|
"trx-app",
|
||||||
|
"trx-config",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
"trx-frontend",
|
"trx-frontend",
|
||||||
"trx-frontend-http",
|
"trx-frontend-http",
|
||||||
@@ -3124,6 +3131,23 @@ dependencies = [
|
|||||||
"uuid",
|
"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]]
|
[[package]]
|
||||||
name = "trx-configurator"
|
name = "trx-configurator"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3132,13 +3156,16 @@ dependencies = [
|
|||||||
"dialoguer",
|
"dialoguer",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio-serial",
|
"tokio-serial",
|
||||||
|
"toml",
|
||||||
"toml_edit 0.22.27",
|
"toml_edit 0.22.27",
|
||||||
|
"trx-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-core"
|
name = "trx-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"flate2",
|
"flate2",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3294,6 +3321,7 @@ dependencies = [
|
|||||||
"trx-app",
|
"trx-app",
|
||||||
"trx-aprs",
|
"trx-aprs",
|
||||||
"trx-backend",
|
"trx-backend",
|
||||||
|
"trx-config",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
"trx-cw",
|
"trx-cw",
|
||||||
"trx-decode-log",
|
"trx-decode-log",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ members = [
|
|||||||
"src/trx-core",
|
"src/trx-core",
|
||||||
"src/trx-protocol",
|
"src/trx-protocol",
|
||||||
"src/trx-app",
|
"src/trx-app",
|
||||||
|
"src/trx-config",
|
||||||
"src/trx-reporting",
|
"src/trx-reporting",
|
||||||
"src/trx-server",
|
"src/trx-server",
|
||||||
"src/trx-server/trx-backend",
|
"src/trx-server/trx-backend",
|
||||||
|
|||||||
@@ -93,13 +93,17 @@ The wizard walks you through rig selection, serial port detection, audio
|
|||||||
settings, and frontend options, then writes `trx-server.toml` and
|
settings, and frontend options, then writes `trx-server.toml` and
|
||||||
`trx-client.toml`.
|
`trx-client.toml`.
|
||||||
|
|
||||||
Alternatively, generate example configs and edit them by hand:
|
Alternatively, copy `trx-rs.toml.example` — a commented example covering every
|
||||||
|
setting — and edit it by hand:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./target/release/trx-server --print-config > trx-server.toml
|
cp trx-rs.toml.example trx-rs.toml
|
||||||
./target/release/trx-client --print-config > trx-client.toml
|
./target/release/trx-server --check-config --config trx-rs.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--check-config` reports everything wrong with a config without starting
|
||||||
|
anything. `--print-config` prints the same settings without comments.
|
||||||
|
|
||||||
### 4. Run
|
### 4. Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -107,6 +111,9 @@ Alternatively, generate example configs and edit them by hand:
|
|||||||
./target/release/trx-client --config trx-client.toml
|
./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`).
|
Open the configured HTTP frontend address in a browser (default `http://localhost:8080`).
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|||||||
@@ -322,3 +322,255 @@ trx-configurator
|
|||||||
| `trx-app` | Config types and validation | Yes |
|
| `trx-app` | Config types and validation | Yes |
|
||||||
| `serialport` | Serial port enumeration | Yes (transitive) |
|
| `serialport` | Serial port enumeration | Yes (transitive) |
|
||||||
| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) |
|
| `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.
|
||||||
|
|||||||
+199
-22
@@ -17,30 +17,61 @@ frontends.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Both `trx-server` and `trx-client` use TOML configuration files. Use
|
Both `trx-server` and `trx-client` read TOML. The server takes its settings
|
||||||
`--print-config` to generate a fully commented example.
|
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.
|
||||||
|
|
||||||
### File Locations
|
### File Locations
|
||||||
|
|
||||||
**trx-server** lookup order:
|
Both binaries use the same lookup order:
|
||||||
1. `--config <FILE>`
|
|
||||||
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>`
|
1. `--config <FILE>`
|
||||||
2. `./trx-client.toml`
|
2. `./trx-rs.toml`
|
||||||
3. `~/.config/trx-rs/client.toml`
|
3. `~/.config/trx-rs/trx-rs.toml`
|
||||||
4. `/etc/trx-rs/client.toml`
|
4. `/etc/trx-rs/trx-rs.toml`
|
||||||
|
|
||||||
CLI arguments override config file values.
|
CLI arguments override config file values.
|
||||||
|
|
||||||
### Environment Variables
|
### Checking a Config
|
||||||
|
|
||||||
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by
|
`--check-config` loads the file, reports every problem it finds — unknown keys,
|
||||||
both server and client.
|
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.
|
||||||
|
|
||||||
### Server Options
|
### Server Options
|
||||||
|
|
||||||
@@ -96,6 +127,7 @@ CLI arguments override config file values.
|
|||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
|
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
|
||||||
|
| `tokens_file` | string | — | Read tokens from this file, one per line |
|
||||||
|
|
||||||
#### `[audio]`
|
#### `[audio]`
|
||||||
|
|
||||||
@@ -121,6 +153,13 @@ 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 |
|
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
|
||||||
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
|
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
|
||||||
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
|
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
|
||||||
|
| `spectrum_fft_size` | usize | `1024` | Spectrum FFT bins; power of two, 128–8192 |
|
||||||
|
| `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]`
|
#### `[sdr.gain]`
|
||||||
|
|
||||||
@@ -197,6 +236,29 @@ Notes:
|
|||||||
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
|
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
|
||||||
`%MM%`, `%DD%` (UTC).
|
`%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
|
#### Multi-Rig Configuration
|
||||||
|
|
||||||
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
|
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
|
||||||
@@ -240,12 +302,32 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `url` | string | — | Server address (e.g. `localhost:4530`) |
|
| `url` | string | — | Server address (e.g. `localhost:4530`) |
|
||||||
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
||||||
|
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
|
||||||
|
|
||||||
#### `[remote.auth]`
|
#### `[remote.auth]`
|
||||||
|
|
||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `token` | string | — | Auth token (must not be empty if set) |
|
| `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]`
|
#### `[frontends.http]`
|
||||||
|
|
||||||
@@ -254,6 +336,31 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
| `enabled` | bool | `true` | Enable web UI |
|
| `enabled` | bool | `true` | Enable web UI |
|
||||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||||
| `port` | u16 | `8080` | Bind port |
|
| `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]`
|
#### `[frontends.rigctl]`
|
||||||
|
|
||||||
@@ -261,7 +368,11 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `enabled` | bool | `false` | Enable Hamlib rigctl |
|
| `enabled` | bool | `false` | Enable Hamlib rigctl |
|
||||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||||
| `port` | u16 | `4532` | Bind port |
|
| `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.
|
||||||
|
|
||||||
#### `[frontends.http_json]`
|
#### `[frontends.http_json]`
|
||||||
|
|
||||||
@@ -271,13 +382,17 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||||
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
|
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
|
||||||
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
|
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
|
||||||
|
| `auth.tokens_file` | string | — | Read tokens from this file, one per line |
|
||||||
|
|
||||||
#### `[frontends.audio]`
|
#### `[frontends.audio]`
|
||||||
|
|
||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `enabled` | bool | `true` | Enable audio client |
|
| `enabled` | bool | `true` | Enable audio client |
|
||||||
| `server_port` | u16 | `4531` | Server audio port |
|
| `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` |
|
||||||
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
|
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
|
||||||
| `bridge.rx_output_device` | string | — | Local playback device |
|
| `bridge.rx_output_device` | string | — | Local playback device |
|
||||||
| `bridge.tx_input_device` | string | — | Local capture device |
|
| `bridge.tx_input_device` | string | — | Local capture device |
|
||||||
@@ -287,16 +402,78 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
|
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
|
||||||
loopback on Linux, BlackHole on macOS).
|
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
|
### CLI Override Summary
|
||||||
|
|
||||||
**trx-server:**
|
**trx-server:**
|
||||||
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
|
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`,
|
||||||
`--port`. SDR options are file-only.
|
`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only.
|
||||||
|
|
||||||
**trx-client:**
|
**trx-client:**
|
||||||
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
|
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`,
|
||||||
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
|
`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`,
|
||||||
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
|
`--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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,5 @@ edition = "2021"
|
|||||||
license = "GPL-2.0-or-later"
|
license = "GPL-2.0-or-later"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
|
||||||
toml = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
dirs = "6"
|
|
||||||
thiserror = "2"
|
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
// 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>(§ion_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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
pub mod config;
|
//! Shared application helpers.
|
||||||
|
//!
|
||||||
|
//! Configuration types and their loader live in the `trx-config` crate.
|
||||||
|
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod shared_config;
|
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
pub use config::{ConfigError, ConfigFile};
|
|
||||||
pub use logging::init_logging;
|
pub use logging::init_logging;
|
||||||
pub use shared_config::{validate_log_level, validate_tokens};
|
|
||||||
pub use util::normalize_name;
|
pub use util::normalize_name;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ uuid = { workspace = true }
|
|||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
opus = "0.3"
|
opus = "0.3"
|
||||||
trx-app = { path = "../trx-app" }
|
trx-app = { path = "../trx-app" }
|
||||||
|
trx-config = { path = "../trx-config" }
|
||||||
trx-core = { path = "../trx-core" }
|
trx-core = { path = "../trx-core" }
|
||||||
trx-protocol = { path = "../trx-protocol" }
|
trx-protocol = { path = "../trx-protocol" }
|
||||||
trx-frontend = { path = "trx-frontend" }
|
trx-frontend = { path = "trx-frontend" }
|
||||||
|
|||||||
+5
-1214
File diff suppressed because it is too large
Load Diff
+135
-8
@@ -20,6 +20,7 @@ use tokio::task::JoinHandle;
|
|||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use trx_app::{init_logging, normalize_name};
|
use trx_app::{init_logging, normalize_name};
|
||||||
|
use trx_config::ConfigFile;
|
||||||
use trx_core::audio::AudioStreamInfo;
|
use trx_core::audio::AudioStreamInfo;
|
||||||
|
|
||||||
use trx_core::decode::DecodedMessage;
|
use trx_core::decode::DecodedMessage;
|
||||||
@@ -50,6 +51,12 @@ struct Cli {
|
|||||||
/// Print example configuration and exit
|
/// Print example configuration and exit
|
||||||
#[arg(long = "print-config")]
|
#[arg(long = "print-config")]
|
||||||
print_config: bool,
|
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)
|
/// Remote server URL (host:port)
|
||||||
#[arg(short = 'u', long = "url")]
|
#[arg(short = 'u', long = "url")]
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
@@ -74,7 +81,7 @@ struct Cli {
|
|||||||
/// rigctl frontend listen address
|
/// rigctl frontend listen address
|
||||||
#[arg(long = "rigctl-listen")]
|
#[arg(long = "rigctl-listen")]
|
||||||
rigctl_listen: Option<IpAddr>,
|
rigctl_listen: Option<IpAddr>,
|
||||||
/// rigctl frontend listen port
|
/// Deprecated: ignored, use [frontends.rigctl].rig_ports
|
||||||
#[arg(long = "rigctl-port")]
|
#[arg(long = "rigctl-port")]
|
||||||
rigctl_port: Option<u16>,
|
rigctl_port: Option<u16>,
|
||||||
/// JSON TCP frontend listen address
|
/// JSON TCP frontend listen address
|
||||||
@@ -109,6 +116,65 @@ async fn main() -> DynResult<()> {
|
|||||||
Ok(())
|
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.
|
/// Holds the state needed after async initialization completes.
|
||||||
struct AppState {
|
struct AppState {
|
||||||
shutdown_tx: watch::Sender<bool>,
|
shutdown_tx: watch::Sender<bool>,
|
||||||
@@ -135,20 +201,44 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (cfg, config_path) = if let Some(ref path) = cli.config {
|
let loaded = if let Some(ref path) = cli.config {
|
||||||
let cfg = ClientConfig::load_from_file(path)?;
|
ClientConfig::load_from_file(path)?
|
||||||
(cfg, Some(path.clone()))
|
|
||||||
} else {
|
} else {
|
||||||
ClientConfig::load_from_default_paths()?
|
ClientConfig::load_from_default_paths()?
|
||||||
};
|
};
|
||||||
cfg.validate()
|
let config_path = loaded.path.clone();
|
||||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
|
||||||
|
|
||||||
init_logging(cfg.general.log_level.as_deref());
|
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());
|
||||||
|
|
||||||
if let Some(ref path) = config_path {
|
if let Some(ref path) = config_path {
|
||||||
info!("Loaded configuration from {}", path.display());
|
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
|
frontend_runtime.http_auth.tokens = cfg
|
||||||
.frontends
|
.frontends
|
||||||
@@ -198,10 +288,14 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
|
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);
|
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
|
||||||
vec![RemoteEntry {
|
vec![RemoteEntry {
|
||||||
|
spectrum_interval_ms: cfg.remote.spectrum_interval_ms,
|
||||||
name,
|
name,
|
||||||
url: url.clone(),
|
url: url.clone(),
|
||||||
rig_id,
|
rig_id,
|
||||||
auth: config::RemoteAuthConfig { token },
|
auth: config::RemoteAuthConfig {
|
||||||
|
token,
|
||||||
|
token_file: None,
|
||||||
|
},
|
||||||
poll_interval_ms,
|
poll_interval_ms,
|
||||||
}]
|
}]
|
||||||
} else {
|
} else {
|
||||||
@@ -262,6 +356,30 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
.http_json_listen
|
.http_json_listen
|
||||||
.unwrap_or(cfg.frontends.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);
|
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
|
let callsign = cli
|
||||||
.callsign
|
.callsign
|
||||||
.clone()
|
.clone()
|
||||||
@@ -366,6 +484,13 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
.map(|e| e.poll_interval_ms)
|
.map(|e| e.poll_interval_ms)
|
||||||
.min()
|
.min()
|
||||||
.unwrap_or(750);
|
.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);
|
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
@@ -379,6 +504,8 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
|
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
|
||||||
rig_states: frontend_runtime.routing.rig_states.clone(),
|
rig_states: frontend_runtime.routing.rig_states.clone(),
|
||||||
poll_interval: Duration::from_millis(poll_interval),
|
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(),
|
spectrum: frontend_runtime.spectrum.sender.clone(),
|
||||||
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
|
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
|
||||||
server_connected: frontend_runtime.routing.server_connected.clone(),
|
server_connected: frontend_runtime.routing.server_connected.clone(),
|
||||||
|
|||||||
+275
-103
@@ -20,35 +20,22 @@ use trx_core::{RigError, RigResult};
|
|||||||
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
|
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
|
||||||
use trx_protocol::rig_command_to_client;
|
use trx_protocol::rig_command_to_client;
|
||||||
use trx_protocol::types::RigEntry;
|
use trx_protocol::types::RigEntry;
|
||||||
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate};
|
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};
|
||||||
|
|
||||||
const DEFAULT_REMOTE_PORT: u16 = 4530;
|
|
||||||
const DEFAULT_AUDIO_PORT: u16 = 4531;
|
|
||||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const IO_TIMEOUT: Duration = Duration::from_secs(15);
|
const IO_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
|
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
|
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
|
||||||
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
|
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
// Default spectrum cadence when a config does not specify one. Both the push
|
||||||
pub struct RemoteEndpoint {
|
// stream and the poll fallback run at the configured rate; see
|
||||||
pub host: String,
|
// `[[remotes]].spectrum_interval_ms`.
|
||||||
pub port: u16,
|
pub const DEFAULT_SPECTRUM_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
}
|
|
||||||
|
|
||||||
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)]
|
#[derive(Clone)]
|
||||||
pub struct RemoteClientConfig {
|
pub struct RemoteClientConfig {
|
||||||
@@ -57,6 +44,12 @@ pub struct RemoteClientConfig {
|
|||||||
pub selected_rig_id: Arc<Mutex<Option<String>>>,
|
pub selected_rig_id: Arc<Mutex<Option<String>>>,
|
||||||
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
||||||
pub poll_interval: Duration,
|
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.
|
/// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe.
|
||||||
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
|
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
|
||||||
/// Shared flag: `true` while a TCP connection to trx-server is active.
|
/// Shared flag: `true` while a TCP connection to trx-server is active.
|
||||||
@@ -505,6 +498,100 @@ 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(
|
async fn handle_spectrum_connection(
|
||||||
config: &RemoteClientConfig,
|
config: &RemoteClientConfig,
|
||||||
stream: TcpStream,
|
stream: TcpStream,
|
||||||
@@ -512,7 +599,33 @@ async fn handle_spectrum_connection(
|
|||||||
) -> RigResult<()> {
|
) -> RigResult<()> {
|
||||||
let (reader, mut writer) = stream.into_split();
|
let (reader, mut writer) = stream.into_split();
|
||||||
let mut reader = BufReader::new(reader);
|
let mut reader = BufReader::new(reader);
|
||||||
let mut interval = time::interval(SPECTRUM_POLL_INTERVAL);
|
|
||||||
|
// 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);
|
||||||
// Cache the token outside the poll loop to avoid cloning it every 50ms.
|
// Cache the token outside the poll loop to avoid cloning it every 50ms.
|
||||||
let cached_token = config.token.clone();
|
let cached_token = config.token.clone();
|
||||||
|
|
||||||
@@ -1195,92 +1308,13 @@ 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
|
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
|
||||||
use super::{
|
use super::{
|
||||||
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
|
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
|
||||||
|
DEFAULT_SPECTRUM_INTERVAL,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
@@ -1475,6 +1509,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(100),
|
poll_interval: Duration::from_millis(100),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1520,6 +1556,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1535,6 +1573,130 @@ mod tests {
|
|||||||
assert_eq!(envelope.rig_id.as_deref(), Some("sdr"));
|
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]
|
#[test]
|
||||||
fn build_envelope_translates_short_name_to_server_rig_id() {
|
fn build_envelope_translates_short_name_to_server_rig_id() {
|
||||||
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
||||||
@@ -1548,6 +1710,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
|
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1580,6 +1744,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1604,6 +1770,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1644,6 +1812,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: known_rigs.clone(),
|
known_rigs: known_rigs.clone(),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1717,6 +1887,8 @@ mod tests {
|
|||||||
selected_rig_id,
|
selected_rig_id,
|
||||||
known_rigs,
|
known_rigs,
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -140,18 +144,22 @@ function aisLatestByVessel(messages) {
|
|||||||
}
|
}
|
||||||
return Array.from(byMmsi.values());
|
return Array.from(byMmsi.values());
|
||||||
}
|
}
|
||||||
|
function aisRigMessages() {
|
||||||
|
return forActiveRig(aisMessageHistory);
|
||||||
|
}
|
||||||
function updateAisSummary() {
|
function updateAisSummary() {
|
||||||
const plan = currentAisChannelPlan();
|
const plan = currentAisChannelPlan();
|
||||||
if (aisChannelSummaryEl) {
|
if (aisChannelSummaryEl) {
|
||||||
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||||
}
|
}
|
||||||
const vessels = aisLatestByVessel(aisMessageHistory);
|
const rigMessages = aisRigMessages();
|
||||||
|
const vessels = aisLatestByVessel(rigMessages);
|
||||||
if (aisVesselCountEl) {
|
if (aisVesselCountEl) {
|
||||||
const count = vessels.length;
|
const count = vessels.length;
|
||||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (aisLatestSeenEl) {
|
if (aisLatestSeenEl) {
|
||||||
const latest = aisMessageHistory[0];
|
const latest = rigMessages[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aisLatestSeenEl.textContent = "No traffic yet";
|
aisLatestSeenEl.textContent = "No traffic yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -222,7 +230,9 @@ function updateAisBar() {
|
|||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
const recent = aisMessageHistory.filter(
|
||||||
|
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)
|
||||||
|
);
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
if (!isAis || messages.length === 0) {
|
if (!isAis || messages.length === 0) {
|
||||||
aisBarOverlay.style.display = "none";
|
aisBarOverlay.style.display = "none";
|
||||||
@@ -267,7 +277,7 @@ function renderAisHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of aisMessageHistory) {
|
for (const message of aisRigMessages()) {
|
||||||
fragment.appendChild(renderAisRow(message));
|
fragment.appendChild(renderAisRow(message));
|
||||||
}
|
}
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
@@ -299,10 +309,10 @@ function normalizeServerAisMessage(msg) {
|
|||||||
}
|
}
|
||||||
function onServerAisBatch(messages) {
|
function onServerAisBatch(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerAisMessage(msg);
|
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();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -342,8 +352,9 @@ if (aisFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function onServerAis(msg) {
|
function onServerAis(msg) {
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
const message = normalizeServerAisMessage(msg);
|
||||||
addAisMessage(normalizeServerAisMessage(msg));
|
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
||||||
|
addAisMessage(message);
|
||||||
}
|
}
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
window.trxPluginRuntime.registerDecoder({
|
||||||
@@ -353,6 +364,10 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
restore: onServerAisBatch,
|
restore: onServerAisBatch,
|
||||||
reset: resetAisHistoryView,
|
reset: resetAisHistoryView,
|
||||||
prune: pruneAisHistoryView,
|
prune: pruneAisHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
updateAisBar();
|
||||||
|
renderAisHistory();
|
||||||
|
},
|
||||||
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => {
|
syncMap: () => {
|
||||||
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
|
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
|
||||||
|
|||||||
@@ -1065,6 +1065,9 @@ var runtime = {
|
|||||||
plugin.prune();
|
plugin.prune();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
rerenderAll() {
|
||||||
|
for (const plugin of decoders.values()) plugin.rerender?.();
|
||||||
|
},
|
||||||
syncMapAll() {
|
syncMapAll() {
|
||||||
for (const plugin of decoders.values()) plugin.syncMap?.();
|
for (const plugin of decoders.values()) plugin.syncMap?.();
|
||||||
},
|
},
|
||||||
@@ -2902,9 +2905,9 @@ function applyRigList(activeRigId, rigIds, displayNames) {
|
|||||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||||
updateRigSubtitle(lastActiveRigId);
|
updateRigSubtitle(lastActiveRigId);
|
||||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||||
|
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||||||
|
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
||||||
if (rigListChanged) {
|
if (rigListChanged) {
|
||||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
|
||||||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
|
||||||
window.trx.modules.bookmarks?.populateScopePicker();
|
window.trx.modules.bookmarks?.populateScopePicker();
|
||||||
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
|
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
|
||||||
}
|
}
|
||||||
@@ -3555,6 +3558,13 @@ function positionRdsPsOverlay() {
|
|||||||
function resetRdsDisplay() {
|
function resetRdsDisplay() {
|
||||||
updateRdsPsOverlay(primaryRds);
|
updateRdsPsOverlay(primaryRds);
|
||||||
}
|
}
|
||||||
|
function refreshDecodeBars() {
|
||||||
|
window.updateAisBar?.();
|
||||||
|
window.updateVdesBar?.();
|
||||||
|
window.updateAprsBar?.();
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
window.updateCwBar?.();
|
||||||
|
}
|
||||||
function resetDecoderStateOnRigSwitch() {
|
function resetDecoderStateOnRigSwitch() {
|
||||||
primaryRds = null;
|
primaryRds = null;
|
||||||
vchanRdsById = /* @__PURE__ */ new Map();
|
vchanRdsById = /* @__PURE__ */ new Map();
|
||||||
@@ -3569,6 +3579,8 @@ function resetDecoderStateOnRigSwitch() {
|
|||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.textContent = "--";
|
if (el) el.textContent = "--";
|
||||||
});
|
});
|
||||||
|
refreshDecodeBars();
|
||||||
|
window.trxPluginRuntime.rerenderAll();
|
||||||
}
|
}
|
||||||
function resetWfmStereoIndicator() {
|
function resetWfmStereoIndicator() {
|
||||||
if (!wfmStFlagEl) return;
|
if (!wfmStFlagEl) return;
|
||||||
@@ -4788,10 +4800,7 @@ function render(update) {
|
|||||||
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
||||||
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
||||||
}
|
}
|
||||||
if (window.updateAisBar) window.updateAisBar();
|
refreshDecodeBars();
|
||||||
if (window.updateVdesBar) window.updateVdesBar();
|
|
||||||
if (window.updateAprsBar) window.updateAprsBar();
|
|
||||||
if (window.updateFt8Bar) window.updateFt8Bar();
|
|
||||||
for (const d of decoderRegistry) {
|
for (const d of decoderRegistry) {
|
||||||
if (d.activation !== "toggle") continue;
|
if (d.activation !== "toggle") continue;
|
||||||
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
|
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||||||
@@ -5880,6 +5889,7 @@ function navigateToTab(name, options = {}) {
|
|||||||
if (leavingSatellites) window.clearSatPredictionDom?.();
|
if (leavingSatellites) window.clearSatPredictionDom?.();
|
||||||
void loadPluginsForTab(name).then(() => {
|
void loadPluginsForTab(name).then(() => {
|
||||||
if (name === "satellites") window.refreshSatPredictions?.();
|
if (name === "satellites") window.refreshSatPredictions?.();
|
||||||
|
flushPendingDecodeStats();
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
@@ -5991,7 +6001,9 @@ async function initializeApp() {
|
|||||||
showAuthGate(allowGuest);
|
showAuthGate(allowGuest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var settingsUiReady = false;
|
||||||
function initSettingsUI() {
|
function initSettingsUI() {
|
||||||
|
settingsUiReady = true;
|
||||||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
||||||
window.trx.modules.scheduler?.wireEvents();
|
window.trx.modules.scheduler?.wireEvents();
|
||||||
if (window.trx.modules.backgroundDecode) {
|
if (window.trx.modules.backgroundDecode) {
|
||||||
@@ -6262,7 +6274,9 @@ Object.defineProperties(trxState, {
|
|||||||
} }
|
} }
|
||||||
});
|
});
|
||||||
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
|
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
|
||||||
void loadEagerPlugins().catch((error) => {
|
void loadEagerPlugins().then(() => {
|
||||||
|
if (settingsUiReady) initSettingsUI();
|
||||||
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
void initializeApp();
|
void initializeApp();
|
||||||
@@ -7610,11 +7624,35 @@ var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
|
|||||||
"sstv",
|
"sstv",
|
||||||
"sstv_progress"
|
"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) {
|
function dispatchDecodeMessage(msg, skipStats = false) {
|
||||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
scheduleStatsRenderIfLoaded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
|
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
|
||||||
@@ -7660,9 +7698,9 @@ function restoreDecodeHistoryGroup(kind, messages) {
|
|||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
||||||
}
|
}
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
scheduleStatsRenderIfLoaded();
|
||||||
}
|
}
|
||||||
window.trxPluginRuntime.restore(kind, messages);
|
window.trxPluginRuntime.restore(kind, messages);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsPacketRow
|
renderAprsPacketRow
|
||||||
} from "./chunk-OPEIVJGD.js";
|
} from "./chunk-OPEIVJGD.js";
|
||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -82,20 +86,25 @@ function aprsFilterMatch(pkt) {
|
|||||||
].filter(Boolean).join(" ").toUpperCase();
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
return haystack.includes(aprsFilterText);
|
return haystack.includes(aprsFilterText);
|
||||||
}
|
}
|
||||||
|
function aprsRigPackets() {
|
||||||
|
return forActiveRig(aprsPacketHistory);
|
||||||
|
}
|
||||||
function aprsVisiblePackets() {
|
function aprsVisiblePackets() {
|
||||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
const rigPackets = aprsRigPackets();
|
||||||
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
|
||||||
return packets.filter(aprsFilterMatch);
|
return packets.filter(aprsFilterMatch);
|
||||||
}
|
}
|
||||||
function updateAprsSummary() {
|
function updateAprsSummary() {
|
||||||
|
const rigPackets = aprsRigPackets();
|
||||||
const visible = aprsVisiblePackets();
|
const visible = aprsVisiblePackets();
|
||||||
if (aprsTotalCountEl) {
|
if (aprsTotalCountEl) {
|
||||||
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
}
|
}
|
||||||
if (aprsVisibleCountEl) {
|
if (aprsVisibleCountEl) {
|
||||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (aprsLatestSeenEl) {
|
if (aprsLatestSeenEl) {
|
||||||
const latest = aprsPacketHistory[0];
|
const latest = rigPackets[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aprsLatestSeenEl.textContent = "No packets yet";
|
aprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -153,7 +162,9 @@ function updateAprsBar() {
|
|||||||
if (!aprsBarOverlay) return;
|
if (!aprsBarOverlay) return;
|
||||||
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
const okFrames = aprsPacketHistory.filter(
|
||||||
|
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id)
|
||||||
|
);
|
||||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
@@ -215,11 +226,11 @@ function normalizeServerAprsPacket(pkt) {
|
|||||||
}
|
}
|
||||||
function onServerAprsBatch(packets) {
|
function onServerAprsBatch(packets) {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
let hasCrcOk = false;
|
let hasCrcOk = false;
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerAprsPacket(pkt);
|
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();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
@@ -277,8 +288,9 @@ if (aprsFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function onServerAprs(pkt) {
|
function onServerAprs(pkt) {
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
const packet = normalizeServerAprsPacket(pkt);
|
||||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
|
||||||
|
addAprsPacket(packet);
|
||||||
}
|
}
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
window.trxPluginRuntime.registerDecoder({
|
||||||
@@ -288,6 +300,10 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
restore: onServerAprsBatch,
|
restore: onServerAprsBatch,
|
||||||
reset: resetAprsHistoryView,
|
reset: resetAprsHistoryView,
|
||||||
prune: pruneAprsHistoryView,
|
prune: pruneAprsHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
updateAprsBar();
|
||||||
|
renderAprsHistory();
|
||||||
|
},
|
||||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => {
|
syncMap: () => {
|
||||||
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
|
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
|
||||||
|
|||||||
+116
-47
@@ -19,9 +19,11 @@ var bgdWindow = window;
|
|||||||
let bookmarkList = [];
|
let bookmarkList = [];
|
||||||
let statusInterval = null;
|
let statusInterval = null;
|
||||||
let bgdDirty = false;
|
let bgdDirty = false;
|
||||||
|
let statusByBookmark = /* @__PURE__ */ new Map();
|
||||||
|
let lastStatus = null;
|
||||||
function initBackgroundDecode(rigId, role) {
|
function initBackgroundDecode(rigId, role) {
|
||||||
backgroundDecodeRole = role;
|
backgroundDecodeRole = role;
|
||||||
currentRigId = rigId || null;
|
currentRigId = rigId || hostState.lastActiveRigId || null;
|
||||||
if (currentRigId) loadBackgroundDecode();
|
if (currentRigId) loadBackgroundDecode();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
}
|
}
|
||||||
@@ -108,7 +110,7 @@ var bgdWindow = window;
|
|||||||
}
|
}
|
||||||
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||||
renderBookmarkChecklist();
|
renderBookmarkChecklist();
|
||||||
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
const isControl = isControlRole();
|
||||||
const panel = document.getElementById("background-decode-panel");
|
const panel = document.getElementById("background-decode-panel");
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
|
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
|
||||||
@@ -119,6 +121,10 @@ var bgdWindow = window;
|
|||||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||||
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||||
|
syncSaveButton();
|
||||||
|
}
|
||||||
|
function currentFilterText() {
|
||||||
|
return document.getElementById("bgd-bookmark-filter")?.value ?? "";
|
||||||
}
|
}
|
||||||
function renderBookmarkChecklist(filterText = "") {
|
function renderBookmarkChecklist(filterText = "") {
|
||||||
const container = document.getElementById("bgd-bookmark-checklist");
|
const container = document.getElementById("bgd-bookmark-checklist");
|
||||||
@@ -134,20 +140,49 @@ var bgdWindow = window;
|
|||||||
return text.indexOf(filter) >= 0;
|
return text.indexOf(filter) >= 0;
|
||||||
}) : all;
|
}) : all;
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
container.innerHTML = '<div class="bgd-checklist-empty">' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "</div>";
|
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
|
||||||
|
renderSelectionSummary();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
filtered.forEach(function(bookmark) {
|
filtered.forEach(function(bookmark) {
|
||||||
const row = document.createElement("label");
|
const row = document.createElement("label");
|
||||||
row.className = "bgd-checklist-row";
|
row.className = "bgd-checklist-row";
|
||||||
|
row.dataset.bmId = bookmark.id;
|
||||||
const decoders = bookmarkDecoderKinds(bookmark);
|
const decoders = bookmarkDecoderKinds(bookmark);
|
||||||
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
const selected = selectedIds.has(bookmark.id);
|
||||||
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>";
|
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);
|
||||||
row.querySelector("input")?.addEventListener("change", function(e) {
|
row.querySelector("input")?.addEventListener("change", function(e) {
|
||||||
onChecklistToggle(bookmark.id, e.currentTarget.checked);
|
onChecklistToggle(bookmark.id, e.currentTarget.checked);
|
||||||
});
|
});
|
||||||
container.appendChild(row);
|
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) {
|
function onChecklistToggle(bookmarkId, checked) {
|
||||||
if (!currentConfig) {
|
if (!currentConfig) {
|
||||||
@@ -182,7 +217,7 @@ var bgdWindow = window;
|
|||||||
}).catch(function(err) {
|
}).catch(function(err) {
|
||||||
showToast(`Save failed: ${errorMessage(err)}`, true);
|
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||||
}).finally(function() {
|
}).finally(function() {
|
||||||
if (btn) btn.disabled = false;
|
syncSaveButton();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function resetBackgroundDecode() {
|
async function resetBackgroundDecode() {
|
||||||
@@ -210,59 +245,83 @@ var bgdWindow = window;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function renderStatus(status) {
|
function renderStatus(status) {
|
||||||
const card = document.getElementById("background-decode-status-card");
|
lastStatus = status;
|
||||||
if (!card) return;
|
statusByBookmark = new Map(
|
||||||
const entries = status.entries ?? [];
|
(status.entries ?? []).filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0).map((entry) => [entry.bookmark_id, entry])
|
||||||
if (!entries.length) {
|
);
|
||||||
card.textContent = "No background decode bookmarks configured.";
|
renderSpanSummary();
|
||||||
|
renderBookmarkChecklist(currentFilterText());
|
||||||
|
}
|
||||||
|
function renderSpanSummary() {
|
||||||
|
const el = document.getElementById("bgd-span-summary");
|
||||||
|
if (!el) return;
|
||||||
|
const status = lastStatus;
|
||||||
|
if (!status) {
|
||||||
|
el.textContent = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const summary = [];
|
if (!status.active_rig) {
|
||||||
if (status.active_rig) {
|
el.textContent = "This rig is not the one playing audio.";
|
||||||
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
el.dataset.tone = "warn";
|
||||||
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
return;
|
||||||
} 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}` : "";
|
||||||
|
}
|
||||||
|
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.";
|
||||||
}
|
}
|
||||||
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) {
|
function prettyState(state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "active":
|
case "active":
|
||||||
return "✓ Active";
|
return "Decoding";
|
||||||
case "out_of_span":
|
case "out_of_span":
|
||||||
return "△ Out of span";
|
return "Out of span";
|
||||||
case "waiting_for_spectrum":
|
case "waiting_for_spectrum":
|
||||||
return "△ Waiting";
|
return "Waiting for spectrum";
|
||||||
case "waiting_for_user":
|
case "waiting_for_user":
|
||||||
return "△ No user";
|
return "Nobody listening";
|
||||||
case "missing_bookmark":
|
case "missing_bookmark":
|
||||||
return "✗ Missing";
|
return "Bookmark gone";
|
||||||
case "no_supported_decoders":
|
case "no_supported_decoders":
|
||||||
return "✗ Unsupported";
|
return "No decoder";
|
||||||
case "disabled":
|
case "disabled":
|
||||||
return "△ Disabled";
|
return "Off";
|
||||||
case "handled_by_scheduler":
|
case "handled_by_scheduler":
|
||||||
return "△ Scheduler";
|
|
||||||
case "scheduler_has_control":
|
case "scheduler_has_control":
|
||||||
return "△ Scheduler";
|
return "Scheduler has it";
|
||||||
case "handled_by_virtual_channel":
|
case "handled_by_virtual_channel":
|
||||||
return "△ VChan";
|
return "On a channel";
|
||||||
|
case "pending":
|
||||||
|
return "Not saved";
|
||||||
default:
|
default:
|
||||||
return "△ Inactive";
|
return "Idle";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function setCheckbox(id, value) {
|
function setCheckbox(id, value) {
|
||||||
@@ -285,13 +344,21 @@ var bgdWindow = window;
|
|||||||
function markBgdDirty() {
|
function markBgdDirty() {
|
||||||
if (bgdDirty) return;
|
if (bgdDirty) return;
|
||||||
bgdDirty = true;
|
bgdDirty = true;
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
syncSaveButton();
|
||||||
if (btn) btn.classList.add("sch-dirty");
|
|
||||||
}
|
}
|
||||||
function clearBgdDirty() {
|
function clearBgdDirty() {
|
||||||
bgdDirty = false;
|
bgdDirty = false;
|
||||||
|
syncSaveButton();
|
||||||
|
}
|
||||||
|
function syncSaveButton() {
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
if (btn) btn.classList.remove("sch-dirty");
|
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;
|
||||||
}
|
}
|
||||||
function showToast(msg, isError) {
|
function showToast(msg, isError) {
|
||||||
const el = document.getElementById("background-decode-toast");
|
const el = document.getElementById("background-decode-toast");
|
||||||
@@ -311,7 +378,7 @@ var bgdWindow = window;
|
|||||||
return bm.id;
|
return bm.id;
|
||||||
});
|
});
|
||||||
currentConfig.bookmark_ids = ids;
|
currentConfig.bookmark_ids = ids;
|
||||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
renderBookmarkChecklist(currentFilterText());
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
function deselectAllBookmarks() {
|
function deselectAllBookmarks() {
|
||||||
@@ -319,7 +386,7 @@ var bgdWindow = window;
|
|||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
currentConfig.bookmark_ids = [];
|
currentConfig.bookmark_ids = [];
|
||||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
renderBookmarkChecklist(currentFilterText());
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
function wireBackgroundDecodeEvents() {
|
function wireBackgroundDecodeEvents() {
|
||||||
@@ -334,7 +401,9 @@ var bgdWindow = window;
|
|||||||
if (enabledCb && !enabledCb._wired) {
|
if (enabledCb && !enabledCb._wired) {
|
||||||
enabledCb._wired = true;
|
enabledCb._wired = true;
|
||||||
enabledCb.addEventListener("change", function() {
|
enabledCb.addEventListener("change", function() {
|
||||||
|
if (currentConfig) currentConfig.enabled = enabledCb.checked;
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
|
renderBookmarkChecklist(currentFilterText());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
||||||
|
|||||||
+33
-12
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -153,9 +157,10 @@ function initializeFtxDecoder(config) {
|
|||||||
const render = () => {
|
const render = () => {
|
||||||
prune();
|
prune();
|
||||||
if (!messagesElement) return;
|
if (!messagesElement) return;
|
||||||
|
const rigMessages = forActiveRig(history);
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const message of history) {
|
for (const message of rigMessages) {
|
||||||
if (count >= 200) break;
|
if (count >= 200) break;
|
||||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||||
fragment.appendChild(renderRow(message));
|
fragment.appendChild(renderRow(message));
|
||||||
@@ -163,20 +168,26 @@ function initializeFtxDecoder(config) {
|
|||||||
}
|
}
|
||||||
messagesElement.replaceChildren(fragment);
|
messagesElement.replaceChildren(fragment);
|
||||||
};
|
};
|
||||||
const normalize = (message) => {
|
const plotLocator = (message) => {
|
||||||
const raw = message.message ?? "";
|
const raw = message.message ?? "";
|
||||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(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 station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
if (grids.length > 0) {
|
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
...message,
|
||||||
...message,
|
freq_hz: frequency ?? message.freq_hz,
|
||||||
freq_hz: frequency ?? message.freq_hz,
|
locator_details: locatorDetails
|
||||||
locator_details: locatorDetails
|
});
|
||||||
});
|
};
|
||||||
}
|
const normalize = (message) => {
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
plotLocator(message);
|
||||||
return {
|
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,
|
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||||
ts_ms: message.ts_ms,
|
ts_ms: message.ts_ms,
|
||||||
snr_db: message.snr_db,
|
snr_db: message.snr_db,
|
||||||
@@ -188,7 +199,9 @@ function initializeFtxDecoder(config) {
|
|||||||
};
|
};
|
||||||
const receiveBatch = (messages) => {
|
const receiveBatch = (messages) => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
if (status) status.textContent = "Receiving";
|
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
|
||||||
|
status.textContent = "Receiving";
|
||||||
|
}
|
||||||
history = messages.map(normalize).reverse().concat(history);
|
history = messages.map(normalize).reverse().concat(history);
|
||||||
prune();
|
prune();
|
||||||
bridge.setFt8FamilyBarDecoder?.(id);
|
bridge.setFt8FamilyBarDecoder?.(id);
|
||||||
@@ -202,7 +215,7 @@ function initializeFtxDecoder(config) {
|
|||||||
bridge.clearMapMarkersByType?.(id);
|
bridge.clearMapMarkersByType?.(id);
|
||||||
};
|
};
|
||||||
const barFrames = () => {
|
const barFrames = () => {
|
||||||
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
|
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5 && isActiveRigDecode(message.rig_id)).slice(0, 8);
|
||||||
let html = "";
|
let html = "";
|
||||||
for (const message of recent) {
|
for (const message of recent) {
|
||||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||||
@@ -226,7 +239,15 @@ function initializeFtxDecoder(config) {
|
|||||||
prune();
|
prune();
|
||||||
render();
|
render();
|
||||||
},
|
},
|
||||||
reset
|
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);
|
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||||
const updatePeriod = () => {
|
const updatePeriod = () => {
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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,3 +1,6 @@
|
|||||||
|
import {
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -25,7 +28,7 @@ var CW_BAR_LINE_GAP_MS = 5e3;
|
|||||||
var cwLastAppendTime = 0;
|
var cwLastAppendTime = 0;
|
||||||
var cwTonePickerRaf = null;
|
var cwTonePickerRaf = null;
|
||||||
var cwBarHistory = [];
|
var cwBarHistory = [];
|
||||||
var cwBarCurrentLine = null;
|
var cwBarCurrentLines = /* @__PURE__ */ new Map();
|
||||||
var cwBarDismissedAtMs = 0;
|
var cwBarDismissedAtMs = 0;
|
||||||
var cwAutoLocalOverride = null;
|
var cwAutoLocalOverride = null;
|
||||||
function escapeCwHtml(input) {
|
function escapeCwHtml(input) {
|
||||||
@@ -50,20 +53,22 @@ cwWindow.applyCwAutoUiFromServer = function(enabled) {
|
|||||||
if (cwAutoLocalOverride !== null) return;
|
if (cwAutoLocalOverride !== null) return;
|
||||||
applyCwAutoUi(enabled);
|
applyCwAutoUi(enabled);
|
||||||
};
|
};
|
||||||
function cwBarFlushCurrentLine() {
|
function cwBarFlushCurrentLine(key) {
|
||||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
const line = cwBarCurrentLines.get(key);
|
||||||
cwBarHistory.unshift(cwBarCurrentLine);
|
cwBarCurrentLines.delete(key);
|
||||||
|
if (line?.text.trim()) {
|
||||||
|
cwBarHistory.unshift(line);
|
||||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
}
|
}
|
||||||
cwBarCurrentLine = null;
|
|
||||||
}
|
}
|
||||||
function updateCwBar() {
|
function updateCwBar() {
|
||||||
if (!cwBarOverlay) return;
|
if (!cwBarOverlay) return;
|
||||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
const isCw = mode === "CW" || mode === "CWR";
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
|
||||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
const inProgress = [...cwBarCurrentLines.values()].filter((l) => l.text && isActiveRigDecode(l.rigId)).sort((a, b) => b.tsMs - a.tsMs);
|
||||||
|
const liveLines = [...inProgress, ...recent];
|
||||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
@@ -308,7 +313,7 @@ function resetCwHistoryView() {
|
|||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
cwLastAppendTime = 0;
|
cwLastAppendTime = 0;
|
||||||
cwBarHistory = [];
|
cwBarHistory = [];
|
||||||
cwBarCurrentLine = null;
|
cwBarCurrentLines.clear();
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
}
|
}
|
||||||
@@ -324,8 +329,9 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
function onServerCw(evt) {
|
function onServerCw(evt) {
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
|
||||||
if (evt.text && cwOutputEl) {
|
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
|
||||||
|
if (evt.text && cwOutputEl && forSelectedRig) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
||||||
const line = document.createElement("div");
|
const line = document.createElement("div");
|
||||||
@@ -346,25 +352,29 @@ function onServerCw(evt) {
|
|||||||
}
|
}
|
||||||
if (evt.text) {
|
if (evt.text) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const rigId = evt.rig_id ?? null;
|
||||||
|
const key = rigId ?? "";
|
||||||
if (evt.text === "\n") {
|
if (evt.text === "\n") {
|
||||||
cwBarFlushCurrentLine();
|
cwBarFlushCurrentLine(key);
|
||||||
} else {
|
} else {
|
||||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
let line = cwBarCurrentLines.get(key);
|
||||||
cwBarFlushCurrentLine();
|
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||||
|
cwBarFlushCurrentLine(key);
|
||||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||||
|
cwBarCurrentLines.set(key, line);
|
||||||
}
|
}
|
||||||
cwBarCurrentLine.text += evt.text;
|
line.text += evt.text;
|
||||||
cwBarCurrentLine.lastMs = now;
|
line.lastMs = now;
|
||||||
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
|
||||||
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
|
||||||
}
|
}
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
}
|
}
|
||||||
if (cwSignalIndicator) {
|
if (cwSignalIndicator && forSelectedRig) {
|
||||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||||
}
|
}
|
||||||
if (!cwAutoInput || cwAutoInput.checked) {
|
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
|
||||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||||
}
|
}
|
||||||
@@ -389,7 +399,14 @@ cwWindow.trxPluginRuntime.registerDecoder({
|
|||||||
id: "cw",
|
id: "cw",
|
||||||
onMessage: onServerCw,
|
onMessage: onServerCw,
|
||||||
restore: restoreCwHistory,
|
restore: restoreCwHistory,
|
||||||
reset: resetCwHistoryView
|
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() {
|
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
initializeFtxDecoder
|
initializeFtxDecoder
|
||||||
} from "./chunk-O2Y7YEVQ.js";
|
} from "./chunk-PJ5Q7CWJ.js";
|
||||||
|
import "./chunk-S57W63QN.js";
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
// src/plugins/ft2.ts
|
// src/plugins/ft2.ts
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
initializeFtxDecoder
|
initializeFtxDecoder
|
||||||
} from "./chunk-O2Y7YEVQ.js";
|
} from "./chunk-PJ5Q7CWJ.js";
|
||||||
|
import "./chunk-S57W63QN.js";
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
// src/plugins/ft4.ts
|
// src/plugins/ft4.ts
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import {
|
|||||||
initializeFt8FamilyBar,
|
initializeFt8FamilyBar,
|
||||||
initializeFtxDecoder,
|
initializeFtxDecoder,
|
||||||
installFtxCompatibilityHelpers
|
installFtxCompatibilityHelpers
|
||||||
} from "./chunk-O2Y7YEVQ.js";
|
} from "./chunk-PJ5Q7CWJ.js";
|
||||||
|
import "./chunk-S57W63QN.js";
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
// src/plugins/ft8.ts
|
// src/plugins/ft8.ts
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsPacketRow
|
renderAprsPacketRow
|
||||||
} from "./chunk-OPEIVJGD.js";
|
} from "./chunk-OPEIVJGD.js";
|
||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -67,21 +71,26 @@ function hfAprsFilterMatch(pkt) {
|
|||||||
].filter(Boolean).join(" ").toUpperCase();
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
return haystack.includes(hfAprsFilterText);
|
return haystack.includes(hfAprsFilterText);
|
||||||
}
|
}
|
||||||
|
function hfAprsRigPackets() {
|
||||||
|
return forActiveRig(hfAprsPacketHistory);
|
||||||
|
}
|
||||||
function hfAprsVisiblePackets() {
|
function hfAprsVisiblePackets() {
|
||||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
const rigPackets = hfAprsRigPackets();
|
||||||
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
|
||||||
return packets.filter(hfAprsFilterMatch);
|
return packets.filter(hfAprsFilterMatch);
|
||||||
}
|
}
|
||||||
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||||
function updateHfAprsSummary() {
|
function updateHfAprsSummary() {
|
||||||
|
const rigPackets = hfAprsRigPackets();
|
||||||
const visible = hfAprsVisiblePackets();
|
const visible = hfAprsVisiblePackets();
|
||||||
if (hfAprsTotalCountEl) {
|
if (hfAprsTotalCountEl) {
|
||||||
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
}
|
}
|
||||||
if (hfAprsVisibleCountEl) {
|
if (hfAprsVisibleCountEl) {
|
||||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (hfAprsLatestSeenEl) {
|
if (hfAprsLatestSeenEl) {
|
||||||
const latest = hfAprsPacketHistory[0];
|
const latest = rigPackets[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -140,17 +149,32 @@ function resetHfAprsHistoryView() {
|
|||||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
hfAprsPacketHistory = [];
|
hfAprsPacketHistory = [];
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
|
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
|
||||||
}
|
}
|
||||||
function pruneHfAprsHistoryView() {
|
function pruneHfAprsHistoryView() {
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
renderHfAprsHistory();
|
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) {
|
function addHfAprsPacket(pkt) {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
hfAprsPacketHistory.unshift(pkt);
|
hfAprsPacketHistory.unshift(pkt);
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
|
plotHfAprsPacket(pkt);
|
||||||
scheduleHfAprsHistoryRender();
|
scheduleHfAprsHistoryRender();
|
||||||
}
|
}
|
||||||
function normalizeServerHfAprsPacket(pkt) {
|
function normalizeServerHfAprsPacket(pkt) {
|
||||||
@@ -158,13 +182,14 @@ function normalizeServerHfAprsPacket(pkt) {
|
|||||||
}
|
}
|
||||||
function onServerHfAprsBatch(packets) {
|
function onServerHfAprsBatch(packets) {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerHfAprsPacket(pkt);
|
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();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
plotHfAprsPacket(next);
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -227,8 +252,9 @@ if (hfAprsFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function onServerHfAprs(pkt) {
|
function onServerHfAprs(pkt) {
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
const packet = normalizeServerHfAprsPacket(pkt);
|
||||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||||
|
addHfAprsPacket(packet);
|
||||||
}
|
}
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
window.trxPluginRuntime.registerDecoder({
|
||||||
@@ -237,5 +263,10 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerHfAprsBatch,
|
onBatch: onServerHfAprsBatch,
|
||||||
restore: onServerHfAprsBatch,
|
restore: onServerHfAprsBatch,
|
||||||
reset: resetHfAprsHistoryView,
|
reset: resetHfAprsHistoryView,
|
||||||
prune: pruneHfAprsHistoryView
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ var mapWindow = window;
|
|||||||
const decodeContactPaths = /* @__PURE__ */ new Map();
|
const decodeContactPaths = /* @__PURE__ */ new Map();
|
||||||
let selectedMapQsoKey = null;
|
let selectedMapQsoKey = null;
|
||||||
const mapMarkers = /* @__PURE__ */ new Set();
|
const mapMarkers = /* @__PURE__ */ new Set();
|
||||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, 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, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||||
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||||
const MAP_FILTER_ALL_KEY = "__all";
|
const MAP_FILTER_ALL_KEY = "__all";
|
||||||
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
|
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
|
||||||
@@ -128,6 +128,12 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
return trimmed;
|
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) {
|
function refreshAprsTrack(call, entry) {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
||||||
@@ -149,7 +155,7 @@ var mapWindow = window;
|
|||||||
lineJoin: "round",
|
lineJoin: "round",
|
||||||
interactive: false
|
interactive: false
|
||||||
});
|
});
|
||||||
track.__trxType = "aprs";
|
track.__trxType = aprsEntrySource(entry);
|
||||||
track._aprsCall = call;
|
track._aprsCall = call;
|
||||||
entry.track = track;
|
entry.track = track;
|
||||||
}
|
}
|
||||||
@@ -381,6 +387,7 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
function mapSourceLabel(type) {
|
function mapSourceLabel(type) {
|
||||||
if (type === "bookmark") return "Bookmarks";
|
if (type === "bookmark") return "Bookmarks";
|
||||||
|
if (type === "hf_aprs") return "HF APRS";
|
||||||
return String(type || "").toUpperCase();
|
return String(type || "").toUpperCase();
|
||||||
}
|
}
|
||||||
function locatorFilterColor(type) {
|
function locatorFilterColor(type) {
|
||||||
@@ -396,6 +403,7 @@ var mapWindow = window;
|
|||||||
if (type === "vdes") return "#a78bfa";
|
if (type === "vdes") return "#a78bfa";
|
||||||
if (type === "sat") return "#f59e0b";
|
if (type === "sat") return "#f59e0b";
|
||||||
if (type === "aprs") return "#00d17f";
|
if (type === "aprs") return "#00d17f";
|
||||||
|
if (type === "hf_aprs") return "#fb7185";
|
||||||
return locatorFilterColor(type);
|
return locatorFilterColor(type);
|
||||||
}
|
}
|
||||||
function bandForHz(hz) {
|
function bandForHz(hz) {
|
||||||
@@ -931,10 +939,8 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const entry of stationMarkers.values()) {
|
for (const entry of stationMarkers.values()) {
|
||||||
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
|
if (!entry?.visibleInHistoryWindow) continue;
|
||||||
availableSources.add("aprs");
|
availableSources.add(aprsEntrySource(entry));
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const bandMap = /* @__PURE__ */ new Map();
|
const bandMap = /* @__PURE__ */ new Map();
|
||||||
for (const entry of locatorMarkers.values()) {
|
for (const entry of locatorMarkers.values()) {
|
||||||
@@ -966,7 +972,7 @@ var mapWindow = window;
|
|||||||
for (const key of Array.from(mapLocatorFilter.bands)) {
|
for (const key of Array.from(mapLocatorFilter.bands)) {
|
||||||
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
||||||
}
|
}
|
||||||
const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
|
const sourceItems = ["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
|
||||||
key,
|
key,
|
||||||
label: mapSourceLabel(key),
|
label: mapSourceLabel(key),
|
||||||
color: mapSourceColor(key),
|
color: mapSourceColor(key),
|
||||||
@@ -1028,9 +1034,10 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
return parts.join(" ").toLowerCase();
|
return parts.join(" ").toLowerCase();
|
||||||
}
|
}
|
||||||
if (type === "aprs") {
|
if (type === "aprs" || type === "hf_aprs") {
|
||||||
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
|
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||||
const entry = stationMarkers.get(call);
|
const entry = stationMarkers.get(key);
|
||||||
|
const call = entry?.call ?? key;
|
||||||
const info = entry?.info ? String(entry.info) : "";
|
const info = entry?.info ? String(entry.info) : "";
|
||||||
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
||||||
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
||||||
@@ -1200,9 +1207,10 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
mapWindow.clearMapMarkersByType = function(type) {
|
mapWindow.clearMapMarkersByType = function(type) {
|
||||||
if (type === "aprs") {
|
if (type === "aprs" || type === "hf_aprs") {
|
||||||
selectedAprsTrackCall = null;
|
selectedAprsTrackCall = null;
|
||||||
stationMarkers.forEach((entry) => {
|
stationMarkers.forEach((entry, key) => {
|
||||||
|
if (aprsEntrySource(entry) !== type) return;
|
||||||
if (entry && entry.marker) {
|
if (entry && entry.marker) {
|
||||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.marker);
|
mapMarkers.delete(entry.marker);
|
||||||
@@ -1211,8 +1219,8 @@ var mapWindow = window;
|
|||||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.track);
|
mapMarkers.delete(entry.track);
|
||||||
}
|
}
|
||||||
|
stationMarkers.delete(key);
|
||||||
});
|
});
|
||||||
stationMarkers.clear();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === "ais") {
|
if (type === "ais") {
|
||||||
@@ -1448,13 +1456,15 @@ var mapWindow = window;
|
|||||||
if (!ll) return;
|
if (!ll) return;
|
||||||
const entry = stationMarkers.get(marker._aprsCall);
|
const entry = stationMarkers.get(marker._aprsCall);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
const source = aprsEntrySource(entry);
|
||||||
|
const call = entry.call ?? String(marker._aprsCall);
|
||||||
|
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||||
refreshAprsTrack(String(marker._aprsCall), entry);
|
refreshAprsTrack(String(marker._aprsCall), entry);
|
||||||
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
|
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
|
||||||
entry.track.addTo(aprsMap);
|
entry.track.addTo(aprsMap);
|
||||||
}
|
}
|
||||||
selectedAprsTrackCall = String(marker._aprsCall);
|
selectedAprsTrackCall = String(marker._aprsCall);
|
||||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
|
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (marker._aisMmsi) {
|
if (marker._aisMmsi) {
|
||||||
@@ -1848,28 +1858,33 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
function _aprsAddMarkerToMap(call, entry) {
|
function _aprsAddMarkerToMap(key, entry) {
|
||||||
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
||||||
refreshAprsTrack(call, entry);
|
refreshAprsTrack(key, entry);
|
||||||
|
const source = aprsEntrySource(entry);
|
||||||
|
const call = entry.call ?? key;
|
||||||
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
||||||
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
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], {
|
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
|
||||||
radius: 6,
|
radius: 6,
|
||||||
color: "#00d17f",
|
color,
|
||||||
fillColor: "#00d17f",
|
fillColor: color,
|
||||||
fillOpacity: 0.8
|
fillOpacity: 0.8
|
||||||
}).addTo(aprsMap).bindPopup(popupContent);
|
}).addTo(aprsMap).bindPopup(popupContent);
|
||||||
marker.__trxType = "aprs";
|
marker.__trxType = source;
|
||||||
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
|
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
|
||||||
marker._aprsCall = call;
|
marker._aprsCall = key;
|
||||||
entry.marker = marker;
|
entry.marker = marker;
|
||||||
mapMarkers.add(marker);
|
mapMarkers.add(marker);
|
||||||
}
|
}
|
||||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
|
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
|
||||||
const nextPoint = [lat, lon];
|
const nextPoint = [lat, lon];
|
||||||
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
||||||
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
||||||
const existing = stationMarkers.get(call);
|
const stationSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||||
|
const key = aprsStationKey(call, stationSource);
|
||||||
|
const existing = stationMarkers.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.pkt = pkt;
|
existing.pkt = pkt;
|
||||||
existing.lat = lat;
|
existing.lat = lat;
|
||||||
@@ -1888,7 +1903,8 @@ var mapWindow = window;
|
|||||||
} else if (prevPoint) {
|
} else if (prevPoint) {
|
||||||
prevPoint.tsMs = tsMs;
|
prevPoint.tsMs = tsMs;
|
||||||
}
|
}
|
||||||
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
|
existing.call = call;
|
||||||
|
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
|
||||||
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
||||||
existing.marker.setLatLng([lat, lon]);
|
existing.marker.setLatLng([lat, lon]);
|
||||||
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
||||||
@@ -1899,7 +1915,8 @@ var mapWindow = window;
|
|||||||
track: null,
|
track: null,
|
||||||
trackHistory: [{ lat, lon, tsMs }],
|
trackHistory: [{ lat, lon, tsMs }],
|
||||||
trackPoints: [nextPoint],
|
trackPoints: [nextPoint],
|
||||||
type: "aprs",
|
type: stationSource,
|
||||||
|
call,
|
||||||
pkt,
|
pkt,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
@@ -1908,9 +1925,9 @@ var mapWindow = window;
|
|||||||
symbolCode,
|
symbolCode,
|
||||||
rigIds: new Set(msgRigId ? [msgRigId] : [])
|
rigIds: new Set(msgRigId ? [msgRigId] : [])
|
||||||
};
|
};
|
||||||
stationMarkers.set(call, entry);
|
stationMarkers.set(key, entry);
|
||||||
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
|
||||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
|
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
|
||||||
if (aprsMap) scheduleDecodeMapMaintenance();
|
if (aprsMap) scheduleDecodeMapMaintenance();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -111,6 +115,7 @@ function paintRow(line, rgb) {
|
|||||||
liveRows = Math.max(liveRows, line + 1);
|
liveRows = Math.max(liveRows, line + 1);
|
||||||
}
|
}
|
||||||
function onProgress(msg) {
|
function onProgress(msg) {
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
if (msg.state) {
|
if (msg.state) {
|
||||||
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||||
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
||||||
@@ -131,15 +136,20 @@ function onImage(msg) {
|
|||||||
sstvHistory.push(image);
|
sstvHistory.push(image);
|
||||||
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||||
pruneHistory();
|
pruneHistory();
|
||||||
|
if (!isActiveRigDecode(image.rig_id)) return;
|
||||||
if (sstvDom.status) {
|
if (sstvDom.status) {
|
||||||
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
||||||
}
|
}
|
||||||
scheduleUi("sstv-latest", renderLatestCard);
|
scheduleUi("sstv-latest", renderLatestCard);
|
||||||
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
||||||
}
|
}
|
||||||
|
function sstvRigImages() {
|
||||||
|
return forActiveRig(sstvHistory);
|
||||||
|
}
|
||||||
function renderLatestCard() {
|
function renderLatestCard() {
|
||||||
if (!sstvDom.liveLatest) return;
|
if (!sstvDom.liveLatest) return;
|
||||||
const latest = sstvHistory[sstvHistory.length - 1];
|
const rigImages = sstvRigImages();
|
||||||
|
const latest = rigImages[rigImages.length - 1];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
sstvDom.liveLatest.innerHTML = "";
|
sstvDom.liveLatest.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
@@ -160,8 +170,9 @@ function renderLatestCard() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
function filteredHistory() {
|
function filteredHistory() {
|
||||||
|
const rigImages = sstvRigImages();
|
||||||
const text = filterText.trim().toLowerCase();
|
const text = filterText.trim().toLowerCase();
|
||||||
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice();
|
const matching = text ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : rigImages;
|
||||||
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||||
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||||
return matching;
|
return matching;
|
||||||
@@ -241,7 +252,11 @@ sstvWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onMessage: onImage,
|
onMessage: onImage,
|
||||||
restore: restoreHistory,
|
restore: restoreHistory,
|
||||||
prune: renderHistoryTable,
|
prune: renderHistoryTable,
|
||||||
reset: resetHistoryView
|
reset: resetHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
renderLatestCard();
|
||||||
|
renderHistoryTable();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "sstv_progress",
|
id: "sstv_progress",
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -60,17 +64,21 @@ function vdesHexPreview(rawBytes) {
|
|||||||
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
||||||
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
||||||
}
|
}
|
||||||
|
function vdesRigMessages() {
|
||||||
|
return forActiveRig(vdesMessageHistory);
|
||||||
|
}
|
||||||
function updateVdesSummary() {
|
function updateVdesSummary() {
|
||||||
pruneVdesMessageHistory();
|
pruneVdesMessageHistory();
|
||||||
if (vdesChannelSummaryEl) {
|
if (vdesChannelSummaryEl) {
|
||||||
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||||
}
|
}
|
||||||
|
const rigMessages = vdesRigMessages();
|
||||||
if (vdesFrameCountEl) {
|
if (vdesFrameCountEl) {
|
||||||
const count = vdesMessageHistory.length;
|
const count = rigMessages.length;
|
||||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (vdesLatestSeenEl) {
|
if (vdesLatestSeenEl) {
|
||||||
const latest = vdesMessageHistory[0];
|
const latest = rigMessages[0];
|
||||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +145,7 @@ function updateVdesBar() {
|
|||||||
updateVdesSummary();
|
updateVdesSummary();
|
||||||
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
||||||
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)).slice(0, 6);
|
||||||
if (!isVdes || messages.length === 0) {
|
if (!isVdes || messages.length === 0) {
|
||||||
vdesBarOverlay.style.display = "none";
|
vdesBarOverlay.style.display = "none";
|
||||||
vdesBarOverlay.innerHTML = "";
|
vdesBarOverlay.innerHTML = "";
|
||||||
@@ -182,7 +190,7 @@ function renderVdesHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of vdesMessageHistory) {
|
for (const message of vdesRigMessages()) {
|
||||||
fragment.appendChild(renderVdesRow(message));
|
fragment.appendChild(renderVdesRow(message));
|
||||||
}
|
}
|
||||||
vdesMessagesEl.replaceChildren(fragment);
|
vdesMessagesEl.replaceChildren(fragment);
|
||||||
@@ -209,10 +217,10 @@ function normalizeServerVdesMessage(msg) {
|
|||||||
}
|
}
|
||||||
function onServerVdesBatch(messages) {
|
function onServerVdesBatch(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerVdesMessage(msg);
|
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();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -251,8 +259,8 @@ function plotVdesMessage(msg) {
|
|||||||
vdesWindow.vdesMapAddPoint(msg);
|
vdesWindow.vdesMapAddPoint(msg);
|
||||||
}
|
}
|
||||||
function onServerVdes(msg) {
|
function onServerVdes(msg) {
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||||
addVdesMessage(next);
|
addVdesMessage(next);
|
||||||
plotVdesMessage(next);
|
plotVdesMessage(next);
|
||||||
}
|
}
|
||||||
@@ -269,6 +277,10 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
restore: onServerVdesBatch,
|
restore: onServerVdesBatch,
|
||||||
reset: resetVdesHistoryView,
|
reset: resetVdesHistoryView,
|
||||||
prune: pruneVdesHistoryView,
|
prune: pruneVdesHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
},
|
||||||
// Oldest first, so tracks are rebuilt in the order they happened.
|
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => {
|
syncMap: () => {
|
||||||
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
|
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -102,13 +106,17 @@ function paintLine(lineBytes) {
|
|||||||
wefaxLiveCtx.putImageData(imgData, 0, y);
|
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||||
wefaxLiveLineCount++;
|
wefaxLiveLineCount++;
|
||||||
}
|
}
|
||||||
|
function wefaxRigImages() {
|
||||||
|
return forActiveRig(wefaxImageHistory);
|
||||||
|
}
|
||||||
function renderWefaxLatestCard() {
|
function renderWefaxLatestCard() {
|
||||||
if (!wefaxDom.liveLatest) return;
|
if (!wefaxDom.liveLatest) return;
|
||||||
if (wefaxImageHistory.length === 0) {
|
const rigImages = wefaxRigImages();
|
||||||
|
if (rigImages.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>';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const img = wefaxImageHistory[0];
|
const img = rigImages[0];
|
||||||
if (!img) return;
|
if (!img) return;
|
||||||
const ts = img._ts || "--";
|
const ts = img._ts || "--";
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
@@ -129,7 +137,7 @@ function renderWefaxLatestCard() {
|
|||||||
wefaxDom.liveLatest.innerHTML = html;
|
wefaxDom.liveLatest.innerHTML = html;
|
||||||
}
|
}
|
||||||
function getWefaxFilteredHistory() {
|
function getWefaxFilteredHistory() {
|
||||||
let items = wefaxImageHistory;
|
let items = wefaxRigImages();
|
||||||
if (wefaxFilterText) {
|
if (wefaxFilterText) {
|
||||||
items = items.filter(function(i) {
|
items = items.filter(function(i) {
|
||||||
const haystack = [
|
const haystack = [
|
||||||
@@ -173,7 +181,7 @@ function renderWefaxHistoryTable() {
|
|||||||
}
|
}
|
||||||
wefaxDom.historyList.replaceChildren(fragment);
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
if (wefaxDom.historyCount) {
|
if (wefaxDom.historyCount) {
|
||||||
const total = wefaxImageHistory.length;
|
const total = wefaxRigImages().length;
|
||||||
const shown = items.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`;
|
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
|
||||||
}
|
}
|
||||||
@@ -208,6 +216,7 @@ function addWefaxImage(msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onServerWefaxProgress(msg) {
|
function onServerWefaxProgress(msg) {
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
if (msg.state && !msg.line_data) {
|
if (msg.state && !msg.line_data) {
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
wefaxDom.status.textContent = msg.state;
|
wefaxDom.status.textContent = msg.state;
|
||||||
@@ -234,6 +243,7 @@ function onServerWefaxProgress(msg) {
|
|||||||
}
|
}
|
||||||
function onServerWefax(msg) {
|
function onServerWefax(msg) {
|
||||||
addWefaxImage(msg);
|
addWefaxImage(msg);
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
|
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
|
||||||
@@ -328,7 +338,11 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onMessage: onServerWefax,
|
onMessage: onServerWefax,
|
||||||
restore: restoreWefaxHistory,
|
restore: restoreWefaxHistory,
|
||||||
prune: pruneWefaxHistoryView,
|
prune: pruneWefaxHistoryView,
|
||||||
reset: resetWefaxHistoryView
|
reset: resetWefaxHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "wefax_progress",
|
id: "wefax_progress",
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -64,10 +68,10 @@ function renderWsprRow(msg) {
|
|||||||
function renderWsprHistory() {
|
function renderWsprHistory() {
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
if (!wsprMessagesEl) return;
|
if (!wsprMessagesEl) return;
|
||||||
|
const rigMessages = forActiveRig(wsprMessageHistory);
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
for (const message of rigMessages) {
|
||||||
const message = wsprMessageHistory[i];
|
fragment.appendChild(renderWsprRow(message));
|
||||||
if (message) fragment.appendChild(renderWsprRow(message));
|
|
||||||
}
|
}
|
||||||
wsprMessagesEl.replaceChildren(fragment);
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
}
|
}
|
||||||
@@ -90,6 +94,8 @@ function normalizeServerWsprMessage(msg) {
|
|||||||
station,
|
station,
|
||||||
rfHz,
|
rfHz,
|
||||||
history: {
|
history: {
|
||||||
|
rig_id: msg.rig_id ?? null,
|
||||||
|
_rfHz: rfHz,
|
||||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||||
ts_ms: msg.ts_ms,
|
ts_ms: msg.ts_ms,
|
||||||
snr_db: msg.snr_db,
|
snr_db: msg.snr_db,
|
||||||
@@ -101,16 +107,11 @@ function normalizeServerWsprMessage(msg) {
|
|||||||
}
|
}
|
||||||
function onServerWsprBatch(messages) {
|
function onServerWsprBatch(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
plotWsprLocator(msg);
|
||||||
...msg,
|
|
||||||
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
normalized.push(next.history);
|
normalized.push(next.history);
|
||||||
}
|
}
|
||||||
@@ -250,15 +251,19 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
function onServerWspr(msg) {
|
function plotWsprLocator(msg) {
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
|
||||||
...msg,
|
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
...msg,
|
||||||
});
|
...rfHz === null ? {} : { freq_hz: rfHz }
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
function onServerWspr(msg) {
|
||||||
|
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||||
|
const next = normalizeServerWsprMessage(msg);
|
||||||
|
plotWsprLocator(msg);
|
||||||
addWsprMessage(next.history);
|
addWsprMessage(next.history);
|
||||||
}
|
}
|
||||||
wsprWindow.trxPluginRuntime.registerDecoder({
|
wsprWindow.trxPluginRuntime.registerDecoder({
|
||||||
@@ -267,5 +272,10 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerWsprBatch,
|
onBatch: onServerWsprBatch,
|
||||||
restore: onServerWsprBatch,
|
restore: onServerWsprBatch,
|
||||||
prune: pruneWsprHistoryView,
|
prune: pruneWsprHistoryView,
|
||||||
reset: resetWsprHistoryView
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1467,30 +1467,31 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
|
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
|
||||||
<div id="background-decode-panel" class="sch-panel">
|
<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>
|
<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">
|
||||||
<div class="sch-section-title">Configuration</div>
|
<div class="sch-section-title">Background Decode</div>
|
||||||
<div class="sch-row">
|
<p class="bgd-intro">
|
||||||
<label class="sch-label bgd-toggle-wrap">Background decode
|
Decodes saved bookmarks on hidden channels while you work another band.
|
||||||
<span class="bgd-toggle-row">
|
Needs an SDR rig, and a bookmark is only decoded while it falls inside
|
||||||
<input type="checkbox" id="background-decode-enabled" />
|
the span the rig is tuned across.
|
||||||
<span>Enable hidden background decoder channels</span>
|
</p>
|
||||||
</span>
|
<div class="bgd-controls">
|
||||||
|
<label class="bgd-enable">
|
||||||
|
<input type="checkbox" id="background-decode-enabled" />
|
||||||
|
<span>Enabled</span>
|
||||||
</label>
|
</label>
|
||||||
|
<span id="bgd-span-summary" class="bgd-span" aria-live="polite"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sch-row" style="flex-direction:column;gap:0.5rem;">
|
<div class="bgd-list-toolbar">
|
||||||
<label class="sch-label" style="min-width:100%;">Bookmarks
|
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter"
|
||||||
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter" placeholder="Filter bookmarks..." />
|
placeholder="Filter bookmarks…" aria-label="Filter bookmarks" />
|
||||||
</label>
|
|
||||||
<div class="bgd-select-actions">
|
<div class="bgd-select-actions">
|
||||||
<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-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="Deselect all bookmarks">Deselect All</button>
|
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Select no bookmarks">None</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="bgd-bookmark-checklist" class="bgd-checklist"></div>
|
|
||||||
</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>
|
||||||
|
|
||||||
<div class="sch-actions">
|
<div class="sch-actions">
|
||||||
|
|||||||
@@ -5589,18 +5589,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
#sch-sat-form-cancel:hover {
|
#sch-sat-form-cancel:hover {
|
||||||
background: var(--border);
|
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 {
|
.bgd-add-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
@@ -5624,48 +5612,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.78rem;
|
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" status card (top of scheduler & bgd panels) ──── */
|
||||||
.now-playing-card {
|
.now-playing-card {
|
||||||
border-left: 3px solid var(--accent-green);
|
border-left: 3px solid var(--accent-green);
|
||||||
@@ -5881,18 +5827,65 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
opacity: 0.7;
|
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 {
|
.bgd-checklist {
|
||||||
max-height: 16rem;
|
max-height: 22rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
/* One row per bookmark: pick it here, and read what it is doing here too. */
|
||||||
.bgd-checklist-row {
|
.bgd-checklist-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(6rem, 1fr) auto auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
padding: 0.45rem 0.65rem;
|
padding: 0.5rem 0.65rem;
|
||||||
border-bottom: 1px solid var(--border-light);
|
border-bottom: 1px solid var(--border-light);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -5904,28 +5897,83 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
.bgd-checklist-row:hover {
|
.bgd-checklist-row:hover {
|
||||||
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
|
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"] {
|
.bgd-checklist-row input[type="checkbox"] {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.bgd-checklist-name {
|
.bgd-checklist-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
overflow: hidden;
|
||||||
.bgd-checklist-meta {
|
text-overflow: ellipsis;
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
margin-left: auto;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.bgd-checklist-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
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 {
|
.bgd-checklist-empty {
|
||||||
padding: 0.75rem;
|
padding: 1rem 0.75rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
/* ── Select All / Deselect All buttons ────────────────────────────── */
|
/* 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 ─────────────────────────────────────── */
|
||||||
.bgd-select-actions {
|
.bgd-select-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.bgd-select-btn {
|
.bgd-select-btn {
|
||||||
@@ -5943,15 +5991,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
color: var(--text);
|
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) {
|
@media (max-width: 600px) {
|
||||||
.channel-scheduler-controls {
|
.channel-scheduler-controls {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -5992,8 +6031,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
.bm-page-summary {
|
.bm-page-summary {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.bgd-add-row,
|
.bgd-add-row {
|
||||||
.bgd-status-row {
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
@@ -6003,8 +6041,17 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
.interleave-ring-label {
|
.interleave-ring-label {
|
||||||
max-width: 8rem;
|
max-width: 8rem;
|
||||||
}
|
}
|
||||||
.bgd-checklist-meta {
|
/* Two lines rather than four columns: the name and its state stay legible
|
||||||
margin-left: 0;
|
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;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"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",
|
"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",
|
||||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ export type RdsData = { pi?: number | null, program_service?: string | null, rad
|
|||||||
export type SpectrumData = {
|
export type SpectrumData = {
|
||||||
/**
|
/**
|
||||||
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
|
* 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>,
|
bins: Array<number>,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ declare global {
|
|||||||
updateVdesBar?(value?: number): void;
|
updateVdesBar?(value?: number): void;
|
||||||
updateAprsBar?(value?: number): void;
|
updateAprsBar?(value?: number): void;
|
||||||
updateFt8Bar?(value?: number): void;
|
updateFt8Bar?(value?: number): void;
|
||||||
|
updateCwBar?(value?: number): void;
|
||||||
updateSatLiveState?(value: unknown): void;
|
updateSatLiveState?(value: unknown): void;
|
||||||
applyCwAutoUi?(enabled: boolean): void;
|
applyCwAutoUi?(enabled: boolean): void;
|
||||||
applyCwAutoUiFromServer?(enabled: boolean): void;
|
applyCwAutoUiFromServer?(enabled: boolean): void;
|
||||||
@@ -1467,9 +1468,13 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
|
|||||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||||
updateRigSubtitle(lastActiveRigId);
|
updateRigSubtitle(lastActiveRigId);
|
||||||
window.trxUi?.setActiveRig(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) {
|
if (rigListChanged) {
|
||||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
|
||||||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
|
||||||
window.trx.modules.bookmarks?.populateScopePicker();
|
window.trx.modules.bookmarks?.populateScopePicker();
|
||||||
void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
|
void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
|
||||||
}
|
}
|
||||||
@@ -2207,6 +2212,17 @@ function resetRdsDisplay() {
|
|||||||
updateRdsPsOverlay(primaryRds);
|
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() {
|
function resetDecoderStateOnRigSwitch() {
|
||||||
// RDS
|
// RDS
|
||||||
primaryRds = null;
|
primaryRds = null;
|
||||||
@@ -2226,6 +2242,12 @@ function resetDecoderStateOnRigSwitch() {
|
|||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.textContent = "--";
|
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() {
|
function resetWfmStereoIndicator() {
|
||||||
@@ -3676,10 +3698,7 @@ function render(update: AppUpdate) {
|
|||||||
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
||||||
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
||||||
}
|
}
|
||||||
if (window.updateAisBar) window.updateAisBar();
|
refreshDecodeBars();
|
||||||
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.
|
// Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong.
|
||||||
for (const d of decoderRegistry) {
|
for (const d of decoderRegistry) {
|
||||||
if (d.activation !== "toggle") continue;
|
if (d.activation !== "toggle") continue;
|
||||||
@@ -4897,6 +4916,8 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
|
|||||||
// Passes go stale while the page is closed, so each visit reloads them.
|
// 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.
|
// The first visit is what imports the module, which renders on its own.
|
||||||
if (name === "satellites") window.refreshSatPredictions?.();
|
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); });
|
}).catch((error: unknown) => { console.error(error); });
|
||||||
if (name === "map") {
|
if (name === "map") {
|
||||||
_initMapWhenReady();
|
_initMapWhenReady();
|
||||||
@@ -5019,7 +5040,12 @@ 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() {
|
function initSettingsUI() {
|
||||||
|
settingsUiReady = true;
|
||||||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
||||||
window.trx.modules.scheduler?.wireEvents();
|
window.trx.modules.scheduler?.wireEvents();
|
||||||
if (window.trx.modules.backgroundDecode) {
|
if (window.trx.modules.backgroundDecode) {
|
||||||
@@ -5172,7 +5198,15 @@ window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules
|
|||||||
|
|
||||||
// Load plugin scripts now that window.trx is populated. Dynamic scripts are
|
// Load plugin scripts now that window.trx is populated. Dynamic scripts are
|
||||||
// async so they must not be created before the namespace they depend on exists.
|
// async so they must not be created before the namespace they depend on exists.
|
||||||
void loadEagerPlugins().catch((error: unknown) => { console.error(error); });
|
//
|
||||||
|
// 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); });
|
||||||
|
|
||||||
// Start the app
|
// Start the app
|
||||||
void initializeApp();
|
void initializeApp();
|
||||||
@@ -6582,11 +6616,49 @@ const IMAGE_DECODE_KINDS = new Set([
|
|||||||
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
|
"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) {
|
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
|
||||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
scheduleStatsRenderIfLoaded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6636,9 +6708,9 @@ function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
|
|||||||
// Record statistics for restored history messages.
|
// Record statistics for restored history messages.
|
||||||
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
||||||
}
|
}
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
scheduleStatsRenderIfLoaded();
|
||||||
}
|
}
|
||||||
window.trxPluginRuntime.restore(kind, messages);
|
window.trxPluginRuntime.restore(kind, messages);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function mapEl(id: string): MapElement {
|
|||||||
return element as MapElement;
|
return element as MapElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DecoderSource = "ais" | "vdes" | "aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
|
type DecoderSource = "ais" | "vdes" | "aprs" | "hf_aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
|
||||||
interface TrackPoint { lat: number; lon: number; tsMs: number }
|
interface TrackPoint { lat: number; lon: number; tsMs: number }
|
||||||
interface DecodeDetail {
|
interface DecodeDetail {
|
||||||
station?: string | null; source?: string | null; target?: string | null;
|
station?: string | null; source?: string | null; target?: string | null;
|
||||||
@@ -83,6 +83,9 @@ interface MapEntry {
|
|||||||
bookmarks?: Array<Record<string, unknown>>;
|
bookmarks?: Array<Record<string, unknown>>;
|
||||||
bounds?: Leaflet.LatLngBoundsExpression;
|
bounds?: Leaflet.LatLngBoundsExpression;
|
||||||
symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
|
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;
|
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
|
||||||
pathKey?: string; sourceGrid?: string; targetGrid?: string;
|
pathKey?: string; sourceGrid?: string; targetGrid?: string;
|
||||||
from?: LatLon; to?: LatLon;
|
from?: LatLon; to?: LatLon;
|
||||||
@@ -171,7 +174,7 @@ interface MapWindow {
|
|||||||
clearMapMarkersByType?(type: DecoderSource): void;
|
clearMapMarkersByType?(type: DecoderSource): void;
|
||||||
navigateToAprsMap?(lat: number, lon: number): void;
|
navigateToAprsMap?(lat: number, lon: number): void;
|
||||||
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
|
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
|
||||||
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage): void;
|
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage, source?: "aprs" | "hf_aprs"): void;
|
||||||
aisMapAddVessel?(message: MapMessage): void;
|
aisMapAddVessel?(message: MapMessage): void;
|
||||||
vdesMapAddPoint?(message: MapMessage): void;
|
vdesMapAddPoint?(message: MapMessage): void;
|
||||||
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
|
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
|
||||||
@@ -228,7 +231,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
const decodeContactPaths = new Map<string, MapEntry>();
|
const decodeContactPaths = new Map<string, MapEntry>();
|
||||||
let selectedMapQsoKey: string | null = null;
|
let selectedMapQsoKey: string | null = null;
|
||||||
const mapMarkers = new Set<TrxLayer>();
|
const mapMarkers = new Set<TrxLayer>();
|
||||||
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 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 mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
|
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||||
/** Chip key that clears a selection rather than naming a band or a source. */
|
/** Chip key that clears a selection rather than naming a band or a source. */
|
||||||
const MAP_FILTER_ALL_KEY = "__all";
|
const MAP_FILTER_ALL_KEY = "__all";
|
||||||
@@ -302,6 +305,18 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
return trimmed;
|
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 {
|
function refreshAprsTrack(call: string, entry: MapEntry): void {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
||||||
@@ -323,7 +338,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
lineJoin: "round",
|
lineJoin: "round",
|
||||||
interactive: false,
|
interactive: false,
|
||||||
}) as unknown as TrxLayer;
|
}) as unknown as TrxLayer;
|
||||||
track.__trxType = "aprs";
|
track.__trxType = aprsEntrySource(entry);
|
||||||
track._aprsCall = call;
|
track._aprsCall = call;
|
||||||
entry.track = track;
|
entry.track = track;
|
||||||
}
|
}
|
||||||
@@ -575,6 +590,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
|
|
||||||
function mapSourceLabel(type: DecoderSource): string {
|
function mapSourceLabel(type: DecoderSource): string {
|
||||||
if (type === "bookmark") return "Bookmarks";
|
if (type === "bookmark") return "Bookmarks";
|
||||||
|
if (type === "hf_aprs") return "HF APRS";
|
||||||
return String(type || "").toUpperCase();
|
return String(type || "").toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,6 +610,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (type === "vdes") return "#a78bfa";
|
if (type === "vdes") return "#a78bfa";
|
||||||
if (type === "sat") return "#f59e0b";
|
if (type === "sat") return "#f59e0b";
|
||||||
if (type === "aprs") return "#00d17f";
|
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);
|
return locatorFilterColor(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1188,10 +1206,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const entry of stationMarkers.values()) {
|
for (const entry of stationMarkers.values()) {
|
||||||
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
|
if (!entry?.visibleInHistoryWindow) continue;
|
||||||
availableSources.add("aprs");
|
availableSources.add(aprsEntrySource(entry));
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const bandMap = new Map<string, FilterChip>();
|
const bandMap = new Map<string, FilterChip>();
|
||||||
for (const entry of locatorMarkers.values()) {
|
for (const entry of locatorMarkers.values()) {
|
||||||
@@ -1225,7 +1241,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
|
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
|
||||||
.filter((key) => availableSources.has(key))
|
.filter((key) => availableSources.has(key))
|
||||||
.map((key) => ({
|
.map((key) => ({
|
||||||
key,
|
key,
|
||||||
@@ -1296,9 +1312,10 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
}
|
}
|
||||||
return parts.join(" ").toLowerCase();
|
return parts.join(" ").toLowerCase();
|
||||||
}
|
}
|
||||||
if (type === "aprs") {
|
if (type === "aprs" || type === "hf_aprs") {
|
||||||
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
|
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||||
const entry = stationMarkers.get(call);
|
const entry = stationMarkers.get(key);
|
||||||
|
const call = entry?.call ?? key;
|
||||||
const info = entry?.info ? String(entry.info) : "";
|
const info = entry?.info ? String(entry.info) : "";
|
||||||
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
||||||
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
||||||
@@ -1496,9 +1513,10 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
};
|
};
|
||||||
|
|
||||||
mapWindow.clearMapMarkersByType = function(type) {
|
mapWindow.clearMapMarkersByType = function(type) {
|
||||||
if (type === "aprs") {
|
if (type === "aprs" || type === "hf_aprs") {
|
||||||
selectedAprsTrackCall = null;
|
selectedAprsTrackCall = null;
|
||||||
stationMarkers.forEach((entry) => {
|
stationMarkers.forEach((entry, key) => {
|
||||||
|
if (aprsEntrySource(entry) !== type) return;
|
||||||
if (entry && entry.marker) {
|
if (entry && entry.marker) {
|
||||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.marker);
|
mapMarkers.delete(entry.marker);
|
||||||
@@ -1507,8 +1525,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.track);
|
mapMarkers.delete(entry.track);
|
||||||
}
|
}
|
||||||
|
stationMarkers.delete(key);
|
||||||
});
|
});
|
||||||
stationMarkers.clear();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1781,13 +1799,15 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (!ll) return;
|
if (!ll) return;
|
||||||
const entry = stationMarkers.get(marker._aprsCall);
|
const entry = stationMarkers.get(marker._aprsCall);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
const source = aprsEntrySource(entry);
|
||||||
|
const call = entry.call ?? String(marker._aprsCall);
|
||||||
|
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||||
refreshAprsTrack(String(marker._aprsCall), entry);
|
refreshAprsTrack(String(marker._aprsCall), entry);
|
||||||
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
|
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
|
||||||
entry.track.addTo(aprsMap);
|
entry.track.addTo(aprsMap);
|
||||||
}
|
}
|
||||||
selectedAprsTrackCall = String(marker._aprsCall);
|
selectedAprsTrackCall = String(marker._aprsCall);
|
||||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
|
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2270,28 +2290,33 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _aprsAddMarkerToMap(call: string, entry: MapEntry): void {
|
function _aprsAddMarkerToMap(key: string, entry: MapEntry): void {
|
||||||
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
||||||
refreshAprsTrack(call, entry);
|
refreshAprsTrack(key, entry);
|
||||||
|
const source = aprsEntrySource(entry);
|
||||||
|
const call = entry.call ?? key;
|
||||||
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
||||||
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
||||||
|
const color = mapSourceColor(source);
|
||||||
const marker = (icon
|
const marker = (icon
|
||||||
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
|
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
|
||||||
: L.circleMarker([entry.lat, entry.lon], {
|
: L.circleMarker([entry.lat, entry.lon], {
|
||||||
radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8
|
radius: 6, color, fillColor: color, fillOpacity: 0.8
|
||||||
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
|
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
|
||||||
marker.__trxType = "aprs";
|
marker.__trxType = source;
|
||||||
marker.__trxRigIds = entry.rigIds || new Set();
|
marker.__trxRigIds = entry.rigIds || new Set();
|
||||||
marker._aprsCall = call;
|
marker._aprsCall = key;
|
||||||
entry.marker = marker;
|
entry.marker = marker;
|
||||||
mapMarkers.add(marker);
|
mapMarkers.add(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
|
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
|
||||||
const nextPoint: Leaflet.LatLngTuple = [lat, lon];
|
const nextPoint: Leaflet.LatLngTuple = [lat, lon];
|
||||||
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
||||||
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
||||||
const existing = stationMarkers.get(call);
|
const stationSource: DecoderSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||||
|
const key = aprsStationKey(call, stationSource);
|
||||||
|
const existing = stationMarkers.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.pkt = pkt;
|
existing.pkt = pkt;
|
||||||
existing.lat = lat;
|
existing.lat = lat;
|
||||||
@@ -2310,7 +2335,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
} else if (prevPoint) {
|
} else if (prevPoint) {
|
||||||
prevPoint.tsMs = tsMs;
|
prevPoint.tsMs = tsMs;
|
||||||
}
|
}
|
||||||
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
|
existing.call = call;
|
||||||
|
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
|
||||||
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
||||||
existing.marker.setLatLng([lat, lon]);
|
existing.marker.setLatLng([lat, lon]);
|
||||||
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
||||||
@@ -2321,7 +2347,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
track: null,
|
track: null,
|
||||||
trackHistory: [{ lat, lon, tsMs }],
|
trackHistory: [{ lat, lon, tsMs }],
|
||||||
trackPoints: [nextPoint],
|
trackPoints: [nextPoint],
|
||||||
type: "aprs",
|
type: stationSource,
|
||||||
|
call,
|
||||||
pkt,
|
pkt,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
@@ -2330,9 +2357,9 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
symbolCode,
|
symbolCode,
|
||||||
rigIds: new Set(msgRigId ? [msgRigId] : []),
|
rigIds: new Set(msgRigId ? [msgRigId] : []),
|
||||||
};
|
};
|
||||||
stationMarkers.set(call, entry);
|
stationMarkers.set(key, entry);
|
||||||
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
|
||||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
|
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
|
||||||
if (aprsMap) scheduleDecodeMapMaintenance();
|
if (aprsMap) scheduleDecodeMapMaintenance();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
|
|||||||
plugin.prune();
|
plugin.prune();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
rerenderAll() { for (const plugin of decoders.values()) plugin.rerender?.(); },
|
||||||
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
|
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
|
||||||
clearQueued() { queued.clear(); },
|
clearQueued() { queued.clear(); },
|
||||||
hasDecoder: (id) => decoders.has(id),
|
hasDecoder: (id) => decoders.has(id),
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// 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,6 +6,7 @@ import { hostCore, hostState } from "./host.js";
|
|||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||||
|
|
||||||
interface AisMessage {
|
interface AisMessage {
|
||||||
@@ -190,20 +191,27 @@ function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
|
|||||||
return Array.from(byMmsi.values());
|
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() {
|
function updateAisSummary() {
|
||||||
const plan = currentAisChannelPlan();
|
const plan = currentAisChannelPlan();
|
||||||
if (aisChannelSummaryEl) {
|
if (aisChannelSummaryEl) {
|
||||||
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const vessels = aisLatestByVessel(aisMessageHistory);
|
const rigMessages = aisRigMessages();
|
||||||
|
const vessels = aisLatestByVessel(rigMessages);
|
||||||
if (aisVesselCountEl) {
|
if (aisVesselCountEl) {
|
||||||
const count = vessels.length;
|
const count = vessels.length;
|
||||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aisLatestSeenEl) {
|
if (aisLatestSeenEl) {
|
||||||
const latest = aisMessageHistory[0];
|
const latest = rigMessages[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aisLatestSeenEl.textContent = "No traffic yet";
|
aisLatestSeenEl.textContent = "No traffic yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -314,7 +322,9 @@ function updateAisBar() {
|
|||||||
|
|
||||||
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
|
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
const recent = aisMessageHistory.filter(
|
||||||
|
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id),
|
||||||
|
);
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
if (!isAis || messages.length === 0) {
|
if (!isAis || messages.length === 0) {
|
||||||
aisBarOverlay.style.display = "none";
|
aisBarOverlay.style.display = "none";
|
||||||
@@ -368,7 +378,7 @@ function renderAisHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of aisMessageHistory) {
|
for (const message of aisRigMessages()) {
|
||||||
fragment.appendChild(renderAisRow(message));
|
fragment.appendChild(renderAisRow(message));
|
||||||
}
|
}
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
@@ -407,10 +417,11 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage {
|
|||||||
|
|
||||||
function onServerAisBatch(messages: AisMessage[]): void {
|
function onServerAisBatch(messages: AisMessage[]): void {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
|
||||||
const normalized: AisMessage[] = [];
|
const normalized: AisMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerAisMessage(msg);
|
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();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -452,8 +463,9 @@ if (aisFilterInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onServerAis(msg: AisMessage): void {
|
function onServerAis(msg: AisMessage): void {
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
const message = normalizeServerAisMessage(msg);
|
||||||
addAisMessage(normalizeServerAisMessage(msg));
|
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
||||||
|
addAisMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
@@ -464,6 +476,7 @@ updateAisSummary();
|
|||||||
restore: onServerAisBatch,
|
restore: onServerAisBatch,
|
||||||
reset: resetAisHistoryView,
|
reset: resetAisHistoryView,
|
||||||
prune: pruneAisHistoryView,
|
prune: pruneAisHistoryView,
|
||||||
|
rerender: () => { updateAisBar(); renderAisHistory(); },
|
||||||
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
|
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
@@ -106,21 +107,29 @@ function aprsFilterMatch(pkt: AprsPacket): boolean {
|
|||||||
return haystack.includes(aprsFilterText);
|
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[] {
|
function aprsVisiblePackets(): AprsPacket[] {
|
||||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
const rigPackets = aprsRigPackets();
|
||||||
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
|
||||||
return packets.filter(aprsFilterMatch);
|
return packets.filter(aprsFilterMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAprsSummary() {
|
function updateAprsSummary() {
|
||||||
|
const rigPackets = aprsRigPackets();
|
||||||
const visible = aprsVisiblePackets();
|
const visible = aprsVisiblePackets();
|
||||||
if (aprsTotalCountEl) {
|
if (aprsTotalCountEl) {
|
||||||
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
}
|
}
|
||||||
if (aprsVisibleCountEl) {
|
if (aprsVisibleCountEl) {
|
||||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (aprsLatestSeenEl) {
|
if (aprsLatestSeenEl) {
|
||||||
const latest = aprsPacketHistory[0];
|
const latest = rigPackets[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aprsLatestSeenEl.textContent = "No packets yet";
|
aprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -179,7 +188,9 @@ function updateAprsBar() {
|
|||||||
if (!aprsBarOverlay) return;
|
if (!aprsBarOverlay) return;
|
||||||
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
|
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
|
||||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
const okFrames = aprsPacketHistory.filter(
|
||||||
|
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id),
|
||||||
|
);
|
||||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
@@ -256,11 +267,12 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
|
|||||||
|
|
||||||
function onServerAprsBatch(packets: AprsPacket[]): void {
|
function onServerAprsBatch(packets: AprsPacket[]): void {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
|
||||||
const normalized: AprsPacket[] = [];
|
const normalized: AprsPacket[] = [];
|
||||||
let hasCrcOk = false;
|
let hasCrcOk = false;
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerAprsPacket(pkt);
|
const next = normalizeServerAprsPacket(pkt);
|
||||||
|
// "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();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
@@ -324,8 +336,9 @@ if (aprsFilterInput) {
|
|||||||
|
|
||||||
// --- Server-side APRS decode handler ---
|
// --- Server-side APRS decode handler ---
|
||||||
function onServerAprs(pkt: AprsPacket): void {
|
function onServerAprs(pkt: AprsPacket): void {
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
const packet = normalizeServerAprsPacket(pkt);
|
||||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
|
||||||
|
addAprsPacket(packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
@@ -336,6 +349,7 @@ renderAprsHistory();
|
|||||||
restore: onServerAprsBatch,
|
restore: onServerAprsBatch,
|
||||||
reset: resetAprsHistoryView,
|
reset: resetAprsHistoryView,
|
||||||
prune: pruneAprsHistoryView,
|
prune: pruneAprsHistoryView,
|
||||||
|
rerender: () => { updateAprsBar(); renderAprsHistory(); },
|
||||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
|
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
+149
-64
@@ -66,10 +66,17 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
let bookmarkList: Bookmark[] = [];
|
let bookmarkList: Bookmark[] = [];
|
||||||
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let bgdDirty = false;
|
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 {
|
function initBackgroundDecode(rigId: string | null, role: string | null): void {
|
||||||
backgroundDecodeRole = role;
|
backgroundDecodeRole = role;
|
||||||
currentRigId = rigId || null;
|
// 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;
|
||||||
if (currentRigId) loadBackgroundDecode();
|
if (currentRigId) loadBackgroundDecode();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
}
|
}
|
||||||
@@ -171,7 +178,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||||
renderBookmarkChecklist();
|
renderBookmarkChecklist();
|
||||||
|
|
||||||
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
const isControl = isControlRole();
|
||||||
const panel = document.getElementById("background-decode-panel");
|
const panel = document.getElementById("background-decode-panel");
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
|
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
|
||||||
@@ -182,6 +189,11 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||||
if (resetBtn) resetBtn.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 {
|
function renderBookmarkChecklist(filterText = ""): void {
|
||||||
@@ -203,26 +215,65 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
: all;
|
: all;
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
container.innerHTML = '<div class="bgd-checklist-empty">' +
|
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
|
||||||
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
|
renderSelectionSummary();
|
||||||
'</div>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered.forEach(function (bookmark) {
|
filtered.forEach(function (bookmark) {
|
||||||
const row = document.createElement("label");
|
const row = document.createElement("label");
|
||||||
row.className = "bgd-checklist-row";
|
row.className = "bgd-checklist-row";
|
||||||
|
row.dataset.bmId = bookmark.id;
|
||||||
const decoders = bookmarkDecoderKinds(bookmark);
|
const decoders = bookmarkDecoderKinds(bookmark);
|
||||||
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
const selected = selectedIds.has(bookmark.id);
|
||||||
|
if (selected) row.classList.add("is-selected");
|
||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
'<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
||||||
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
||||||
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
|
'<span class="bgd-checklist-meta">'
|
||||||
|
+ escHtml(formatFreq(bookmark.freq_hz)) + '<span class="bgd-checklist-mode">'
|
||||||
|
+ escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span></span>'
|
||||||
|
+ stateBadgeHtml(bookmark.id, selected);
|
||||||
row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) {
|
row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) {
|
||||||
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
|
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
|
||||||
});
|
});
|
||||||
container.appendChild(row);
|
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 {
|
function onChecklistToggle(bookmarkId: string, checked: boolean): void {
|
||||||
@@ -260,7 +311,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
showToast(`Save failed: ${errorMessage(err)}`, true);
|
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||||
})
|
})
|
||||||
.finally(function () {
|
.finally(function () {
|
||||||
if (btn) btn.disabled = false;
|
syncSaveButton();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,58 +346,77 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus(status: BackgroundDecodeStatus): void {
|
function renderStatus(status: BackgroundDecodeStatus): void {
|
||||||
const card = document.getElementById("background-decode-status-card");
|
lastStatus = status;
|
||||||
if (!card) return;
|
statusByBookmark = new Map(
|
||||||
const entries = status.entries ?? [];
|
(status.entries ?? [])
|
||||||
if (!entries.length) {
|
.filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0)
|
||||||
card.textContent = "No background decode bookmarks configured.";
|
.map((entry) => [entry.bookmark_id as string, entry]),
|
||||||
return;
|
);
|
||||||
}
|
renderSpanSummary();
|
||||||
const summary = [];
|
// Rows carry the state, so the list repaints — keeping the filter the
|
||||||
if (status.active_rig) {
|
// operator typed and the selection they have not saved yet.
|
||||||
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
renderBookmarkChecklist(currentFilterText());
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 = "";
|
||||||
|
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}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
function prettyState(state: string | undefined): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "active": return "\u2713 Active";
|
case "active": return "Decoding";
|
||||||
case "out_of_span": return "\u25B3 Out of span";
|
case "out_of_span": return "Out of span";
|
||||||
case "waiting_for_spectrum": return "\u25B3 Waiting";
|
case "waiting_for_spectrum": return "Waiting for spectrum";
|
||||||
case "waiting_for_user": return "\u25B3 No user";
|
case "waiting_for_user": return "Nobody listening";
|
||||||
case "missing_bookmark": return "\u2717 Missing";
|
case "missing_bookmark": return "Bookmark gone";
|
||||||
case "no_supported_decoders": return "\u2717 Unsupported";
|
case "no_supported_decoders": return "No decoder";
|
||||||
case "disabled": return "\u25B3 Disabled";
|
case "disabled": return "Off";
|
||||||
case "handled_by_scheduler": return "\u25B3 Scheduler";
|
case "handled_by_scheduler":
|
||||||
case "scheduler_has_control": return "\u25B3 Scheduler";
|
case "scheduler_has_control": return "Scheduler has it";
|
||||||
case "handled_by_virtual_channel": return "\u25B3 VChan";
|
case "handled_by_virtual_channel": return "On a channel";
|
||||||
default: return "\u25B3 Inactive";
|
case "pending": return "Not saved";
|
||||||
|
default: return "Idle";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,14 +450,25 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
function markBgdDirty() {
|
function markBgdDirty() {
|
||||||
if (bgdDirty) return;
|
if (bgdDirty) return;
|
||||||
bgdDirty = true;
|
bgdDirty = true;
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
syncSaveButton();
|
||||||
if (btn) btn.classList.add("sch-dirty");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearBgdDirty() {
|
function clearBgdDirty() {
|
||||||
bgdDirty = false;
|
bgdDirty = false;
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
syncSaveButton();
|
||||||
if (btn) btn.classList.remove("sch-dirty");
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToast(msg: string, isError: boolean): void {
|
function showToast(msg: string, isError: boolean): void {
|
||||||
@@ -407,7 +488,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
}
|
}
|
||||||
const ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
const ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
||||||
currentConfig.bookmark_ids = ids;
|
currentConfig.bookmark_ids = ids;
|
||||||
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
renderBookmarkChecklist(currentFilterText());
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,7 +497,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
currentConfig.bookmark_ids = [];
|
currentConfig.bookmark_ids = [];
|
||||||
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
renderBookmarkChecklist(currentFilterText());
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +513,11 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
|
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
|
||||||
if (enabledCb && !enabledCb._wired) {
|
if (enabledCb && !enabledCb._wired) {
|
||||||
enabledCb._wired = true;
|
enabledCb._wired = true;
|
||||||
enabledCb.addEventListener("change", function () { markBgdDirty(); });
|
enabledCb.addEventListener("change", function () {
|
||||||
|
if (currentConfig) currentConfig.enabled = enabledCb.checked;
|
||||||
|
markBgdDirty();
|
||||||
|
renderBookmarkChecklist(currentFilterText());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
|
const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
@@ -20,8 +21,11 @@ interface CwRenderer {
|
|||||||
drawPoints(points: number[], size: number, color: Rgba): void;
|
drawPoints(points: number[], size: number, color: Rgba): void;
|
||||||
}
|
}
|
||||||
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
|
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
|
||||||
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
|
interface CwEvent { rig_id?: string | null; 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 CwLine {
|
||||||
|
rigId: string | null; tsMs: number; ts: string; text: string;
|
||||||
|
wpm: number | null; tone_hz: number | null; lastMs: number;
|
||||||
|
}
|
||||||
interface CwToneRange {
|
interface CwToneRange {
|
||||||
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
|
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
|
||||||
toneSpanHz: number; lowerSideband: boolean; mode: string;
|
toneSpanHz: number; lowerSideband: boolean; mode: string;
|
||||||
@@ -66,7 +70,10 @@ const CW_BAR_LINE_GAP_MS = 5000;
|
|||||||
let cwLastAppendTime = 0;
|
let cwLastAppendTime = 0;
|
||||||
let cwTonePickerRaf: number | null = null;
|
let cwTonePickerRaf: number | null = null;
|
||||||
let cwBarHistory: CwLine[] = [];
|
let cwBarHistory: CwLine[] = [];
|
||||||
let cwBarCurrentLine: CwLine | null = null;
|
// 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 cwBarDismissedAtMs = 0;
|
let cwBarDismissedAtMs = 0;
|
||||||
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
||||||
// acknowledged). While set, server-state updates must not override the
|
// acknowledged). While set, server-state updates must not override the
|
||||||
@@ -101,12 +108,13 @@ cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
|
|||||||
applyCwAutoUi(enabled);
|
applyCwAutoUi(enabled);
|
||||||
};
|
};
|
||||||
|
|
||||||
function cwBarFlushCurrentLine(): void {
|
function cwBarFlushCurrentLine(key: string): void {
|
||||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
const line = cwBarCurrentLines.get(key);
|
||||||
cwBarHistory.unshift(cwBarCurrentLine);
|
cwBarCurrentLines.delete(key);
|
||||||
|
if (line?.text.trim()) {
|
||||||
|
cwBarHistory.unshift(line);
|
||||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
}
|
}
|
||||||
cwBarCurrentLine = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCwBar(): void {
|
function updateCwBar(): void {
|
||||||
@@ -114,9 +122,12 @@ function updateCwBar(): void {
|
|||||||
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
||||||
const isCw = mode === "CW" || mode === "CWR";
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
|
||||||
// Prepend the in-progress line so characters appear immediately
|
// Prepend the in-progress lines so characters appear immediately
|
||||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
const inProgress = [...cwBarCurrentLines.values()]
|
||||||
|
.filter((l) => l.text && isActiveRigDecode(l.rigId))
|
||||||
|
.sort((a, b) => b.tsMs - a.tsMs);
|
||||||
|
const liveLines = [...inProgress, ...recent];
|
||||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
@@ -402,7 +413,7 @@ function resetCwHistoryView(): void {
|
|||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
cwLastAppendTime = 0;
|
cwLastAppendTime = 0;
|
||||||
cwBarHistory = [];
|
cwBarHistory = [];
|
||||||
cwBarCurrentLine = null;
|
cwBarCurrentLines.clear();
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
}
|
}
|
||||||
@@ -421,8 +432,13 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
|||||||
|
|
||||||
// --- Server-side CW decode handler ---
|
// --- Server-side CW decode handler ---
|
||||||
function onServerCw(evt: CwEvent): void {
|
function onServerCw(evt: CwEvent): void {
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
// The panel is a single stream of copied text: characters from a background
|
||||||
if (evt.text && cwOutputEl) {
|
// 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) {
|
||||||
// Append decoded text to output
|
// Append decoded text to output
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
||||||
@@ -445,25 +461,29 @@ function onServerCw(evt: CwEvent): void {
|
|||||||
// Bar history accumulation (regardless of pause state)
|
// Bar history accumulation (regardless of pause state)
|
||||||
if (evt.text) {
|
if (evt.text) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const rigId = evt.rig_id ?? null;
|
||||||
|
const key = rigId ?? "";
|
||||||
if (evt.text === "\n") {
|
if (evt.text === "\n") {
|
||||||
cwBarFlushCurrentLine();
|
cwBarFlushCurrentLine(key);
|
||||||
} else {
|
} else {
|
||||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
let line = cwBarCurrentLines.get(key);
|
||||||
cwBarFlushCurrentLine();
|
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||||
|
cwBarFlushCurrentLine(key);
|
||||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||||
|
cwBarCurrentLines.set(key, line);
|
||||||
}
|
}
|
||||||
cwBarCurrentLine.text += evt.text;
|
line.text += evt.text;
|
||||||
cwBarCurrentLine.lastMs = now;
|
line.lastMs = now;
|
||||||
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
|
||||||
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
|
||||||
}
|
}
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
}
|
}
|
||||||
if (cwSignalIndicator) {
|
if (cwSignalIndicator && forSelectedRig) {
|
||||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||||
}
|
}
|
||||||
if (!cwAutoInput || cwAutoInput.checked) {
|
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
|
||||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||||
}
|
}
|
||||||
@@ -491,6 +511,13 @@ cwWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onMessage: onServerCw,
|
onMessage: onServerCw,
|
||||||
restore: restoreCwHistory,
|
restore: restoreCwHistory,
|
||||||
reset: resetCwHistoryView,
|
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() {
|
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
|
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
|
||||||
@@ -15,6 +16,7 @@ export interface FtxMessage {
|
|||||||
snr_db?: number | undefined;
|
snr_db?: number | undefined;
|
||||||
dt_s?: number | undefined;
|
dt_s?: number | undefined;
|
||||||
freq_hz?: number | undefined;
|
freq_hz?: number | undefined;
|
||||||
|
rig_id?: string | null | undefined;
|
||||||
receiver?: unknown;
|
receiver?: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -227,9 +229,12 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
const render = (): void => {
|
const render = (): void => {
|
||||||
prune();
|
prune();
|
||||||
if (!messagesElement) return;
|
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();
|
const fragment = document.createDocumentFragment();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const message of history) {
|
for (const message of rigMessages) {
|
||||||
if (count >= 200) break;
|
if (count >= 200) break;
|
||||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||||
fragment.appendChild(renderRow(message));
|
fragment.appendChild(renderRow(message));
|
||||||
@@ -237,20 +242,32 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
}
|
}
|
||||||
messagesElement.replaceChildren(fragment);
|
messagesElement.replaceChildren(fragment);
|
||||||
};
|
};
|
||||||
const normalize = (message: FtxMessage): FtxMessage => {
|
/** 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 raw = message.message ?? "";
|
const raw = message.message ?? "";
|
||||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
const grids = locatorDetails.length > 0
|
const grids = locatorDetails.length > 0
|
||||||
? locatorDetails.map(({ grid }) => grid)
|
? locatorDetails.map(({ grid }) => grid)
|
||||||
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
||||||
|
if (grids.length === 0) return;
|
||||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
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);
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
if (grids.length > 0) {
|
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
|
||||||
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
|
});
|
||||||
});
|
};
|
||||||
}
|
const normalize = (message: FtxMessage): FtxMessage => {
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
plotLocator(message);
|
||||||
return {
|
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,
|
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||||
ts_ms: message.ts_ms,
|
ts_ms: message.ts_ms,
|
||||||
snr_db: message.snr_db,
|
snr_db: message.snr_db,
|
||||||
@@ -262,7 +279,10 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
};
|
};
|
||||||
const receiveBatch = (messages: FtxMessage[]): void => {
|
const receiveBatch = (messages: FtxMessage[]): void => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
if (status) status.textContent = "Receiving";
|
// "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";
|
||||||
|
}
|
||||||
history = messages.map(normalize).reverse().concat(history);
|
history = messages.map(normalize).reverse().concat(history);
|
||||||
prune();
|
prune();
|
||||||
bridge.setFt8FamilyBarDecoder?.(id);
|
bridge.setFt8FamilyBarDecoder?.(id);
|
||||||
@@ -277,7 +297,8 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
};
|
};
|
||||||
const barFrames = (): BarFrames => {
|
const barFrames = (): BarFrames => {
|
||||||
const recent = history
|
const recent = history
|
||||||
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000)
|
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000
|
||||||
|
&& isActiveRigDecode(message.rig_id))
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
let html = "";
|
let html = "";
|
||||||
for (const message of recent) {
|
for (const message of recent) {
|
||||||
@@ -299,6 +320,9 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
restore: receiveBatch,
|
restore: receiveBatch,
|
||||||
prune: () => { prune(); render(); },
|
prune: () => { prune(); render(); },
|
||||||
reset,
|
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);
|
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
@@ -19,6 +20,12 @@ interface HfAprsBridge {
|
|||||||
getDecodeHistoryRetentionMs?: () => number;
|
getDecodeHistoryRetentionMs?: () => number;
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
navigateToAprsMap?: (lat: number, lon: number) => 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;
|
getDecodeRigMeta?: () => unknown;
|
||||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
@@ -90,23 +97,31 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
|
|||||||
return haystack.includes(hfAprsFilterText);
|
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[] {
|
function hfAprsVisiblePackets(): AprsPacket[] {
|
||||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
const rigPackets = hfAprsRigPackets();
|
||||||
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
|
||||||
return packets.filter(hfAprsFilterMatch);
|
return packets.filter(hfAprsFilterMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
const collapseHfAprsDuplicates = collapseAprsDuplicates;
|
const collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||||
|
|
||||||
function updateHfAprsSummary() {
|
function updateHfAprsSummary() {
|
||||||
|
const rigPackets = hfAprsRigPackets();
|
||||||
const visible = hfAprsVisiblePackets();
|
const visible = hfAprsVisiblePackets();
|
||||||
if (hfAprsTotalCountEl) {
|
if (hfAprsTotalCountEl) {
|
||||||
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
}
|
}
|
||||||
if (hfAprsVisibleCountEl) {
|
if (hfAprsVisibleCountEl) {
|
||||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (hfAprsLatestSeenEl) {
|
if (hfAprsLatestSeenEl) {
|
||||||
const latest = hfAprsPacketHistory[0];
|
const latest = rigPackets[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -168,6 +183,7 @@ function resetHfAprsHistoryView(): void {
|
|||||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
hfAprsPacketHistory = [];
|
hfAprsPacketHistory = [];
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
|
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneHfAprsHistoryView(): void {
|
function pruneHfAprsHistoryView(): void {
|
||||||
@@ -175,6 +191,17 @@ function pruneHfAprsHistoryView(): void {
|
|||||||
renderHfAprsHistory();
|
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 {
|
function addHfAprsPacket(pkt: AprsPacket): void {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
@@ -183,6 +210,8 @@ function addHfAprsPacket(pkt: AprsPacket): void {
|
|||||||
hfAprsPacketHistory.unshift(pkt);
|
hfAprsPacketHistory.unshift(pkt);
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
|
|
||||||
|
plotHfAprsPacket(pkt);
|
||||||
|
|
||||||
scheduleHfAprsHistoryRender();
|
scheduleHfAprsHistoryRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,13 +221,15 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
|
|||||||
|
|
||||||
function onServerHfAprsBatch(packets: AprsPacket[]): void {
|
function onServerHfAprsBatch(packets: AprsPacket[]): void {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
|
||||||
const normalized: AprsPacket[] = [];
|
const normalized: AprsPacket[] = [];
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerHfAprsPacket(pkt);
|
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();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
plotHfAprsPacket(next);
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -266,8 +297,9 @@ if (hfAprsFilterInput) {
|
|||||||
|
|
||||||
// --- Server-side HF APRS decode handler ---
|
// --- Server-side HF APRS decode handler ---
|
||||||
function onServerHfAprs(pkt: AprsPacket): void {
|
function onServerHfAprs(pkt: AprsPacket): void {
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
const packet = normalizeServerHfAprsPacket(pkt);
|
||||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||||
|
addHfAprsPacket(packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
@@ -278,4 +310,7 @@ renderHfAprsHistory();
|
|||||||
restore: onServerHfAprsBatch,
|
restore: onServerHfAprsBatch,
|
||||||
reset: resetHfAprsHistoryView,
|
reset: resetHfAprsHistoryView,
|
||||||
prune: pruneHfAprsHistoryView,
|
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,6 +9,10 @@ export interface DecoderPlugin<TMessage = unknown> {
|
|||||||
restore?(messages: TMessage[]): void;
|
restore?(messages: TMessage[]): void;
|
||||||
reset?(): void;
|
reset?(): void;
|
||||||
prune?(): 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
|
/** Replay everything the plugin is holding onto the map. Called when the map
|
||||||
* module attaches, which can happen long after the decodes arrived. */
|
* module attaches, which can happen long after the decodes arrived. */
|
||||||
syncMap?(): void;
|
syncMap?(): void;
|
||||||
@@ -22,6 +26,7 @@ export interface TrxPluginRuntime {
|
|||||||
reset(id: string): boolean;
|
reset(id: string): boolean;
|
||||||
resetAll(): void;
|
resetAll(): void;
|
||||||
prune(id: string): boolean;
|
prune(id: string): boolean;
|
||||||
|
rerenderAll(): void;
|
||||||
syncMapAll(): void;
|
syncMapAll(): void;
|
||||||
clearQueued(): void;
|
clearQueued(): void;
|
||||||
hasDecoder(id: string): boolean;
|
hasDecoder(id: string): boolean;
|
||||||
|
|||||||
@@ -14,11 +14,13 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
interface SstvImage {
|
interface SstvImage {
|
||||||
|
rig_id?: string | null;
|
||||||
ts_ms?: number;
|
ts_ms?: number;
|
||||||
vis?: number;
|
vis?: number;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
@@ -32,6 +34,7 @@ interface SstvImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SstvProgress {
|
interface SstvProgress {
|
||||||
|
rig_id?: string | null;
|
||||||
state?: string;
|
state?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -185,6 +188,9 @@ function paintRow(line: number, rgb: Uint8Array): void {
|
|||||||
// ── Server messages ─────────────────────────────────────────────────
|
// ── Server messages ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function onProgress(msg: SstvProgress): void {
|
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 (msg.state) {
|
||||||
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||||
// A state update carries the geometry; a row update carries only the row.
|
// A state update carries the geometry; a row update carries only the row.
|
||||||
@@ -208,6 +214,9 @@ function onImage(msg: SstvImage): void {
|
|||||||
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||||
pruneHistory();
|
pruneHistory();
|
||||||
|
|
||||||
|
// Kept in the history either way, so switching to that rig finds it there.
|
||||||
|
if (!isActiveRigDecode(image.rig_id)) return;
|
||||||
|
|
||||||
if (sstvDom.status) {
|
if (sstvDom.status) {
|
||||||
sstvDom.status.textContent = image.complete
|
sstvDom.status.textContent = image.complete
|
||||||
? `Received ${image.mode ?? "picture"}`
|
? `Received ${image.mode ?? "picture"}`
|
||||||
@@ -219,9 +228,15 @@ function onImage(msg: SstvImage): void {
|
|||||||
|
|
||||||
// ── Rendering ───────────────────────────────────────────────────────
|
// ── Rendering ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The pictures the selected rig decoded, oldest first, as the history is kept. */
|
||||||
|
function sstvRigImages(): SstvImage[] {
|
||||||
|
return forActiveRig(sstvHistory);
|
||||||
|
}
|
||||||
|
|
||||||
function renderLatestCard(): void {
|
function renderLatestCard(): void {
|
||||||
if (!sstvDom.liveLatest) return;
|
if (!sstvDom.liveLatest) return;
|
||||||
const latest = sstvHistory[sstvHistory.length - 1];
|
const rigImages = sstvRigImages();
|
||||||
|
const latest = rigImages[rigImages.length - 1];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
sstvDom.liveLatest.innerHTML = "";
|
sstvDom.liveLatest.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
@@ -245,10 +260,12 @@ function renderLatestCard(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filteredHistory(): SstvImage[] {
|
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 text = filterText.trim().toLowerCase();
|
||||||
const matching = text
|
const matching = text
|
||||||
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
|
? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
|
||||||
: sstvHistory.slice();
|
: rigImages;
|
||||||
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||||
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||||
return matching;
|
return matching;
|
||||||
@@ -348,6 +365,7 @@ sstvWindow.trxPluginRuntime.registerDecoder({
|
|||||||
restore: restoreHistory,
|
restore: restoreHistory,
|
||||||
prune: renderHistoryTable,
|
prune: renderHistoryTable,
|
||||||
reset: resetHistoryView,
|
reset: resetHistoryView,
|
||||||
|
rerender: () => { renderLatestCard(); renderHistoryTable(); },
|
||||||
});
|
});
|
||||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "sstv_progress",
|
id: "sstv_progress",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { hostCore } from "./host.js";
|
|||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||||
|
|
||||||
interface VdesMessage {
|
interface VdesMessage {
|
||||||
@@ -116,17 +117,24 @@ function vdesHexPreview(rawBytes: number[] | undefined): string {
|
|||||||
.toUpperCase();
|
.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() {
|
function updateVdesSummary() {
|
||||||
pruneVdesMessageHistory();
|
pruneVdesMessageHistory();
|
||||||
if (vdesChannelSummaryEl) {
|
if (vdesChannelSummaryEl) {
|
||||||
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||||
}
|
}
|
||||||
|
const rigMessages = vdesRigMessages();
|
||||||
if (vdesFrameCountEl) {
|
if (vdesFrameCountEl) {
|
||||||
const count = vdesMessageHistory.length;
|
const count = rigMessages.length;
|
||||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (vdesLatestSeenEl) {
|
if (vdesLatestSeenEl) {
|
||||||
const latest = vdesMessageHistory[0];
|
const latest = rigMessages[0];
|
||||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,7 +236,9 @@ function updateVdesBar() {
|
|||||||
updateVdesSummary();
|
updateVdesSummary();
|
||||||
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
|
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
|
||||||
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
const messages = vdesMessageHistory
|
||||||
|
.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id))
|
||||||
|
.slice(0, 6);
|
||||||
if (!isVdes || messages.length === 0) {
|
if (!isVdes || messages.length === 0) {
|
||||||
vdesBarOverlay.style.display = "none";
|
vdesBarOverlay.style.display = "none";
|
||||||
vdesBarOverlay.innerHTML = "";
|
vdesBarOverlay.innerHTML = "";
|
||||||
@@ -278,7 +288,7 @@ function renderVdesHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of vdesMessageHistory) {
|
for (const message of vdesRigMessages()) {
|
||||||
fragment.appendChild(renderVdesRow(message));
|
fragment.appendChild(renderVdesRow(message));
|
||||||
}
|
}
|
||||||
vdesMessagesEl.replaceChildren(fragment);
|
vdesMessagesEl.replaceChildren(fragment);
|
||||||
@@ -309,10 +319,11 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
|
|||||||
|
|
||||||
function onServerVdesBatch(messages: VdesMessage[]): void {
|
function onServerVdesBatch(messages: VdesMessage[]): void {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
||||||
const normalized: VdesMessage[] = [];
|
const normalized: VdesMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerVdesMessage(msg);
|
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();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -354,8 +365,8 @@ function plotVdesMessage(msg: VdesMessage): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onServerVdes(msg: VdesMessage): void {
|
function onServerVdes(msg: VdesMessage): void {
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||||
addVdesMessage(next);
|
addVdesMessage(next);
|
||||||
plotVdesMessage(next);
|
plotVdesMessage(next);
|
||||||
}
|
}
|
||||||
@@ -374,6 +385,7 @@ updateVdesSummary();
|
|||||||
restore: onServerVdesBatch,
|
restore: onServerVdesBatch,
|
||||||
reset: resetVdesHistoryView,
|
reset: resetVdesHistoryView,
|
||||||
prune: pruneVdesHistoryView,
|
prune: pruneVdesHistoryView,
|
||||||
|
rerender: () => { updateVdesBar(); renderVdesHistory(); },
|
||||||
// Oldest first, so tracks are rebuilt in the order they happened.
|
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
|
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
interface WefaxImage {
|
interface WefaxImage {
|
||||||
|
rig_id?: string | null;
|
||||||
ts_ms?: number;
|
ts_ms?: number;
|
||||||
ioc?: number;
|
ioc?: number;
|
||||||
lpm?: number;
|
lpm?: number;
|
||||||
@@ -20,6 +22,7 @@ interface WefaxImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface WefaxProgress {
|
interface WefaxProgress {
|
||||||
|
rig_id?: string | null;
|
||||||
state?: string;
|
state?: string;
|
||||||
line_data?: string;
|
line_data?: string;
|
||||||
line_count?: number;
|
line_count?: number;
|
||||||
@@ -156,15 +159,21 @@ function paintLine(lineBytes: Uint8Array): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Live view: latest image card ────────────────────────────────────
|
// ── Live view: latest image card ────────────────────────────────────
|
||||||
|
/** The pictures the selected rig decoded, newest first. */
|
||||||
|
function wefaxRigImages(): WefaxImage[] {
|
||||||
|
return forActiveRig(wefaxImageHistory);
|
||||||
|
}
|
||||||
|
|
||||||
function renderWefaxLatestCard() {
|
function renderWefaxLatestCard() {
|
||||||
if (!wefaxDom.liveLatest) return;
|
if (!wefaxDom.liveLatest) return;
|
||||||
if (wefaxImageHistory.length === 0) {
|
const rigImages = wefaxRigImages();
|
||||||
|
if (rigImages.length === 0) {
|
||||||
wefaxDom.liveLatest.innerHTML =
|
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>';
|
'<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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const img = wefaxImageHistory[0];
|
const img = rigImages[0];
|
||||||
if (!img) return;
|
if (!img) return;
|
||||||
const ts = img._ts || '--';
|
const ts = img._ts || '--';
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
||||||
@@ -193,7 +202,8 @@ function renderWefaxLatestCard() {
|
|||||||
|
|
||||||
// ── History view: table ─────────────────────────────────────────────
|
// ── History view: table ─────────────────────────────────────────────
|
||||||
function getWefaxFilteredHistory() {
|
function getWefaxFilteredHistory() {
|
||||||
let items = wefaxImageHistory;
|
// Every rig's pictures are kept; the panel shows the selected rig's.
|
||||||
|
let items = wefaxRigImages();
|
||||||
|
|
||||||
if (wefaxFilterText) {
|
if (wefaxFilterText) {
|
||||||
items = items.filter(function (i) {
|
items = items.filter(function (i) {
|
||||||
@@ -253,7 +263,7 @@ function renderWefaxHistoryTable() {
|
|||||||
wefaxDom.historyList.replaceChildren(fragment);
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
|
|
||||||
if (wefaxDom.historyCount) {
|
if (wefaxDom.historyCount) {
|
||||||
const total = wefaxImageHistory.length;
|
const total = wefaxRigImages().length;
|
||||||
const shown = items.length;
|
const shown = items.length;
|
||||||
wefaxDom.historyCount.textContent =
|
wefaxDom.historyCount.textContent =
|
||||||
total === 0
|
total === 0
|
||||||
@@ -298,6 +308,9 @@ function addWefaxImage(msg: WefaxImage): void {
|
|||||||
|
|
||||||
// ── SSE event handlers (public API) ─────────────────────────────────
|
// ── SSE event handlers (public API) ─────────────────────────────────
|
||||||
function onServerWefaxProgress(msg: WefaxProgress): void {
|
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.
|
// State-only update (no image data): show decoder state in status.
|
||||||
if (msg.state && !msg.line_data) {
|
if (msg.state && !msg.line_data) {
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
@@ -331,6 +344,8 @@ function onServerWefaxProgress(msg: WefaxProgress): void {
|
|||||||
|
|
||||||
function onServerWefax(msg: WefaxImage): void {
|
function onServerWefax(msg: WefaxImage): void {
|
||||||
addWefaxImage(msg);
|
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.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
@@ -438,6 +453,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
|
|||||||
restore: restoreWefaxHistory,
|
restore: restoreWefaxHistory,
|
||||||
prune: pruneWefaxHistoryView,
|
prune: pruneWefaxHistoryView,
|
||||||
reset: resetWefaxHistoryView,
|
reset: resetWefaxHistoryView,
|
||||||
|
rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); },
|
||||||
});
|
});
|
||||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "wefax_progress",
|
id: "wefax_progress",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
@@ -15,6 +16,10 @@ interface WsprMessage {
|
|||||||
snr_db?: number | undefined;
|
snr_db?: number | undefined;
|
||||||
dt_s?: number | undefined;
|
dt_s?: number | undefined;
|
||||||
freq_hz?: 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;
|
receiver?: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -102,10 +107,12 @@ function renderWsprRow(msg: WsprMessage): HTMLDivElement {
|
|||||||
function renderWsprHistory(): void {
|
function renderWsprHistory(): void {
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
if (!wsprMessagesEl) return;
|
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();
|
const fragment = document.createDocumentFragment();
|
||||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
for (const message of rigMessages) {
|
||||||
const message = wsprMessageHistory[i];
|
fragment.appendChild(renderWsprRow(message));
|
||||||
if (message) fragment.appendChild(renderWsprRow(message));
|
|
||||||
}
|
}
|
||||||
wsprMessagesEl.replaceChildren(fragment);
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
}
|
}
|
||||||
@@ -130,6 +137,8 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
|
|||||||
station,
|
station,
|
||||||
rfHz,
|
rfHz,
|
||||||
history: {
|
history: {
|
||||||
|
rig_id: msg.rig_id ?? null,
|
||||||
|
_rfHz: rfHz,
|
||||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||||
ts_ms: msg.ts_ms,
|
ts_ms: msg.ts_ms,
|
||||||
snr_db: msg.snr_db,
|
snr_db: msg.snr_db,
|
||||||
@@ -142,16 +151,12 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
|
|||||||
|
|
||||||
function onServerWsprBatch(messages: WsprMessage[]): void {
|
function onServerWsprBatch(messages: WsprMessage[]): void {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
|
||||||
const normalized: WsprMessage[] = [];
|
const normalized: WsprMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||||
...msg,
|
plotWsprLocator(msg);
|
||||||
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
normalized.push(next.history);
|
normalized.push(next.history);
|
||||||
}
|
}
|
||||||
@@ -311,15 +316,24 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
function onServerWspr(msg: WsprMessage): void {
|
/** Hands a spot's grid squares to the map, if the map module is loaded yet.
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
* The module is lazy, so a spot heard before it arrived has to be replayable. */
|
||||||
|
function plotWsprLocator(msg: WsprMessage): void {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
// A replayed spot carries the frequency it was heard on; a fresh one has it
|
||||||
...msg,
|
// worked out from the dial it just arrived against.
|
||||||
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
|
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";
|
||||||
|
const next = normalizeServerWsprMessage(msg);
|
||||||
|
plotWsprLocator(msg);
|
||||||
addWsprMessage(next.history);
|
addWsprMessage(next.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,4 +344,7 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
|||||||
restore: onServerWsprBatch,
|
restore: onServerWsprBatch,
|
||||||
prune: pruneWsprHistoryView,
|
prune: pruneWsprHistoryView,
|
||||||
reset: resetWsprHistoryView,
|
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); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// 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/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// 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,9 +22,25 @@ const BEACON = {
|
|||||||
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
|
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
|
||||||
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
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.
|
// 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], mode: "AIS" });
|
const fixture = await startWebFixture({
|
||||||
|
spectrum: true, decodes: [VESSEL, BEACON, HF_BEACON, OTHER_RIG_VESSEL], mode: "AIS",
|
||||||
|
});
|
||||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -41,11 +57,14 @@ try {
|
|||||||
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
|
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
|
||||||
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
|
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
|
||||||
aprsStatus: document.getElementById("aprs-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,
|
mapLoaded: !!window.trx.modules.map,
|
||||||
}));
|
}));
|
||||||
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
|
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.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
|
||||||
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
|
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.
|
// The mini view rides over the waterfall on the radio page.
|
||||||
await page.locator('.tab[data-tab="main"]').click();
|
await page.locator('.tab[data-tab="main"]').click();
|
||||||
@@ -56,11 +75,13 @@ try {
|
|||||||
shown: getComputedStyle(bar).display !== "none",
|
shown: getComputedStyle(bar).display !== "none",
|
||||||
pins: bar.querySelectorAll(".aprs-bar-pin").length,
|
pins: bar.querySelectorAll(".aprs-bar-pin").length,
|
||||||
names: bar.textContent.includes("NEDERLAND"),
|
names: bar.textContent.includes("NEDERLAND"),
|
||||||
|
otherRig: bar.textContent.includes("ELDERBERRY"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
|
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.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.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
|
// 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
|
// broken for every decoder — the module that owned the navigation had not
|
||||||
@@ -89,8 +110,41 @@ try {
|
|||||||
: Object.keys(collection ?? {}).length);
|
: Object.keys(collection ?? {}).length);
|
||||||
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
|
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");
|
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, []);
|
assert.deepEqual(runtimeErrors, []);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -103,6 +157,8 @@ try {
|
|||||||
// left the client on its retry path and the history path untested.
|
// left the client on its retry path and the history path untested.
|
||||||
const HISTORY_AIS = 900;
|
const HISTORY_AIS = 900;
|
||||||
const HISTORY_APRS = 300;
|
const HISTORY_APRS = 300;
|
||||||
|
const HISTORY_FT8 = 12;
|
||||||
|
const HISTORY_WSPR = 8;
|
||||||
const historyFixture = await startWebFixture({
|
const historyFixture = await startWebFixture({
|
||||||
spectrum: true,
|
spectrum: true,
|
||||||
mode: "AIS",
|
mode: "AIS",
|
||||||
@@ -112,6 +168,14 @@ const historyFixture = await startWebFixture({
|
|||||||
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
|
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
|
||||||
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
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) => ({
|
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
|
||||||
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
|
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
|
||||||
info: `history ${index}`, packet_type: "position", crc_ok: true,
|
info: `history ${index}`, packet_type: "position", crc_ok: true,
|
||||||
@@ -199,6 +263,27 @@ try {
|
|||||||
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
|
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`);
|
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, []);
|
assert.deepEqual(replay.runtimeErrors, []);
|
||||||
} finally {
|
} finally {
|
||||||
await replay.browser.close();
|
await replay.browser.close();
|
||||||
|
|||||||
@@ -27,6 +27,21 @@ const dial = () => page.evaluate(() => ({
|
|||||||
path: window.location.pathname,
|
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 {
|
try {
|
||||||
await page.setViewportSize({ width: 1500, height: 950 });
|
await page.setViewportSize({ width: 1500, height: 950 });
|
||||||
|
|
||||||
@@ -49,8 +64,7 @@ try {
|
|||||||
|
|
||||||
// Tuning by hand rewrites the link. This is the part that makes the address
|
// Tuning by hand rewrites the link. This is the part that makes the address
|
||||||
// bar shareable at any moment rather than only at load.
|
// bar shareable at any moment rather than only at load.
|
||||||
await page.locator("#freq").fill("7.040M");
|
await tuneByHand("7.040M");
|
||||||
await page.locator("#freq").press("Enter");
|
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1500);
|
||||||
const tuned = await dial();
|
const tuned = await dial();
|
||||||
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
|
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
|
||||||
@@ -59,8 +73,7 @@ try {
|
|||||||
|
|
||||||
// Tuning is not navigation: a swept dial must not bury the back button.
|
// Tuning is not navigation: a swept dial must not bury the back button.
|
||||||
const historyLength = await page.evaluate(() => window.history.length);
|
const historyLength = await page.evaluate(() => window.history.length);
|
||||||
await page.locator("#freq").fill("7.100M");
|
await tuneByHand("7.100M");
|
||||||
await page.locator("#freq").press("Enter");
|
|
||||||
await page.waitForTimeout(1200);
|
await page.waitForTimeout(1200);
|
||||||
assert.equal(await page.evaluate(() => window.history.length), historyLength,
|
assert.equal(await page.evaluate(() => window.history.length), historyLength,
|
||||||
"tuning pushed a history entry instead of replacing one");
|
"tuning pushed a history entry instead of replacing one");
|
||||||
|
|||||||
@@ -34,7 +34,14 @@ const DECODER_REGISTRY = [
|
|||||||
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
|
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
|
||||||
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
||||||
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
|
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
|
||||||
].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true }));
|
].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,
|
||||||
|
}));
|
||||||
|
|
||||||
const CONTENT_TYPES = new Map([
|
const CONTENT_TYPES = new Map([
|
||||||
[".css", "text/css; charset=utf-8"],
|
[".css", "text/css; charset=utf-8"],
|
||||||
@@ -132,6 +139,7 @@ export async function startWebFixture({
|
|||||||
mode = "FM",
|
mode = "FM",
|
||||||
history = {},
|
history = {},
|
||||||
bookmarks = [],
|
bookmarks = [],
|
||||||
|
backgroundDecode = null,
|
||||||
bandplan = {},
|
bandplan = {},
|
||||||
bandplanEnabled = false,
|
bandplanEnabled = false,
|
||||||
bandplanUnauthorizedFirst = false,
|
bandplanUnauthorizedFirst = false,
|
||||||
@@ -271,6 +279,34 @@ export async function startWebFixture({
|
|||||||
response.writeHead(200).end();
|
response.writeHead(200).end();
|
||||||
return;
|
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") {
|
if (url.pathname === "/select_rig" && request.method === "POST") {
|
||||||
const remote = url.searchParams.get("remote");
|
const remote = url.searchParams.get("remote");
|
||||||
if (remote) {
|
if (remote) {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use actix_web::{get, HttpRequest, HttpResponse, Responder};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use super::{gz_cache_entry, static_asset_response, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES};
|
use super::{
|
||||||
|
gz_cache_entry, static_asset_response, AssetCaching, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES,
|
||||||
|
};
|
||||||
use crate::server::status;
|
use crate::server::status;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -62,55 +64,100 @@ define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css");
|
|||||||
#[get("/")]
|
#[get("/")]
|
||||||
pub(crate) async fn index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
static_asset_response(
|
||||||
|
&req,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
c,
|
||||||
|
AssetCaching::Revalidate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/map")]
|
#[get("/map")]
|
||||||
pub(crate) async fn map_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn map_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
static_asset_response(
|
||||||
|
&req,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
c,
|
||||||
|
AssetCaching::Revalidate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/digital-modes")]
|
#[get("/digital-modes")]
|
||||||
pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
static_asset_response(
|
||||||
|
&req,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
c,
|
||||||
|
AssetCaching::Revalidate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/recorder")]
|
#[get("/recorder")]
|
||||||
pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
static_asset_response(
|
||||||
|
&req,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
c,
|
||||||
|
AssetCaching::Revalidate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/settings")]
|
#[get("/settings")]
|
||||||
pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
static_asset_response(
|
||||||
|
&req,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
c,
|
||||||
|
AssetCaching::Revalidate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/about")]
|
#[get("/about")]
|
||||||
pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
static_asset_response(
|
||||||
|
&req,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
c,
|
||||||
|
AssetCaching::Revalidate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/statistics")]
|
#[get("/statistics")]
|
||||||
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
static_asset_response(
|
||||||
|
&req,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
c,
|
||||||
|
AssetCaching::Revalidate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/satellites")]
|
#[get("/satellites")]
|
||||||
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
static_asset_response(
|
||||||
|
&req,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
c,
|
||||||
|
AssetCaching::Revalidate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/bookmarks")]
|
#[get("/bookmarks")]
|
||||||
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
static_asset_response(
|
||||||
|
&req,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
c,
|
||||||
|
AssetCaching::Revalidate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -148,13 +195,13 @@ pub(crate) async fn logo() -> impl Responder {
|
|||||||
#[get("/style.css")]
|
#[get("/style.css")]
|
||||||
pub(crate) async fn style_css(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn style_css(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_style_css();
|
let c = gz_style_css();
|
||||||
static_asset_response(&req, "text/css; charset=utf-8", c)
|
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/themes.css")]
|
#[get("/themes.css")]
|
||||||
pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_themes_css();
|
let c = gz_themes_css();
|
||||||
static_asset_response(&req, "text/css; charset=utf-8", c)
|
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generated filenames are supplied only by build.rs and resolved through this
|
// Generated filenames are supplied only by build.rs and resolved through this
|
||||||
@@ -178,7 +225,22 @@ pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path<String>) -
|
|||||||
let Some(entry) = generated_asset_cache().get(filename.as_str()) else {
|
let Some(entry) = generated_asset_cache().get(filename.as_str()) else {
|
||||||
return HttpResponse::NotFound().finish();
|
return HttpResponse::NotFound().finish();
|
||||||
};
|
};
|
||||||
static_asset_response(&req, content_type, entry)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serve a received SSTV picture out of the local cache.
|
/// Serve a received SSTV picture out of the local cache.
|
||||||
@@ -221,7 +283,12 @@ fn cached_png(decoder: &str, filename: &str) -> HttpResponse {
|
|||||||
#[get("/bandplan.json")]
|
#[get("/bandplan.json")]
|
||||||
pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_bandplan_json();
|
let c = gz_bandplan_json();
|
||||||
static_asset_response(&req, "application/json; charset=utf-8", c)
|
static_asset_response(
|
||||||
|
&req,
|
||||||
|
"application/json; charset=utf-8",
|
||||||
|
c,
|
||||||
|
AssetCaching::Revalidate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -239,7 +306,12 @@ pub(crate) async fn dseg14_classic_woff2() -> impl Responder {
|
|||||||
#[get("/vendor/opus-decoder-0.7.11.min.js")]
|
#[get("/vendor/opus-decoder-0.7.11.min.js")]
|
||||||
pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_opus_decoder_js();
|
let c = gz_opus_decoder_js();
|
||||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
static_asset_response(
|
||||||
|
&req,
|
||||||
|
"application/javascript; charset=utf-8",
|
||||||
|
c,
|
||||||
|
AssetCaching::Immutable,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -249,13 +321,18 @@ pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
|
|||||||
#[get("/vendor/leaflet.js")]
|
#[get("/vendor/leaflet.js")]
|
||||||
pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_leaflet_js();
|
let c = gz_leaflet_js();
|
||||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
static_asset_response(
|
||||||
|
&req,
|
||||||
|
"application/javascript; charset=utf-8",
|
||||||
|
c,
|
||||||
|
AssetCaching::Revalidate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/vendor/leaflet.css")]
|
#[get("/vendor/leaflet.css")]
|
||||||
pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_leaflet_css();
|
let c = gz_leaflet_css();
|
||||||
static_asset_response(&req, "text/css; charset=utf-8", c)
|
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/vendor/marker-icon.png")]
|
#[get("/vendor/marker-icon.png")]
|
||||||
@@ -355,6 +432,44 @@ mod tests {
|
|||||||
assert!(!generated_asset_cache().contains_key("../app.js"));
|
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]
|
#[test]
|
||||||
fn generated_asset_mime_types_are_restricted() {
|
fn generated_asset_mime_types_are_restricted() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -307,10 +307,35 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-compressed (gzip + brotli) + ETag-aware response for immutable embedded assets.
|
/// 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(
|
fn static_asset_response(
|
||||||
req: &HttpRequest,
|
req: &HttpRequest,
|
||||||
content_type: &'static str,
|
content_type: &'static str,
|
||||||
entry: &GzCacheEntry,
|
entry: &GzCacheEntry,
|
||||||
|
caching: AssetCaching,
|
||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
let etag = &entry.etag;
|
let etag = &entry.etag;
|
||||||
// Check If-None-Match for conditional GET.
|
// Check If-None-Match for conditional GET.
|
||||||
@@ -319,7 +344,7 @@ fn static_asset_response(
|
|||||||
if val == etag || val == "*" {
|
if val == etag || val == "*" {
|
||||||
return HttpResponse::NotModified()
|
return HttpResponse::NotModified()
|
||||||
.insert_header((header::ETAG, etag.to_owned()))
|
.insert_header((header::ETAG, etag.to_owned()))
|
||||||
.insert_header((header::CACHE_CONTROL, "public, max-age=31536000, immutable"))
|
.insert_header((header::CACHE_CONTROL, caching.header_value()))
|
||||||
.finish();
|
.finish();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,7 +364,7 @@ fn static_asset_response(
|
|||||||
.insert_header((header::CONTENT_TYPE, content_type))
|
.insert_header((header::CONTENT_TYPE, content_type))
|
||||||
.insert_header((header::CONTENT_ENCODING, encoding))
|
.insert_header((header::CONTENT_ENCODING, encoding))
|
||||||
.insert_header((header::ETAG, etag.to_owned()))
|
.insert_header((header::ETAG, etag.to_owned()))
|
||||||
.insert_header((header::CACHE_CONTROL, "public, max-age=31536000, immutable"))
|
.insert_header((header::CACHE_CONTROL, caching.header_value()))
|
||||||
.body(Bytes::copy_from_slice(body))
|
.body(Bytes::copy_from_slice(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,6 +865,76 @@ mod tests {
|
|||||||
// Endpoint tests using actix_web::test
|
// 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.
|
/// GET /status returns 200 with valid JSON containing rig snapshot fields.
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn test_status_endpoint_returns_json() {
|
async fn test_status_endpoint_returns_json() {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 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"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// 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
@@ -0,0 +1,295 @@
|
|||||||
|
// 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// 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(§ion);
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// 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};
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
// 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
@@ -19,6 +19,52 @@
|
|||||||
//! would either bloat both binaries with unused fields or require a trait
|
//! would either bloat both binaries with unused fields or require a trait
|
||||||
//! abstraction that adds complexity without clear benefit.
|
//! 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.
|
/// Validate that a log level string is one of the accepted values.
|
||||||
///
|
///
|
||||||
/// Returns `Ok(())` when `level` is `None` (defaulting is handled elsewhere)
|
/// Returns `Ok(())` when `level` is `None` (defaulting is handled elsewhere)
|
||||||
@@ -53,6 +99,47 @@ pub fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_validate_log_level_none() {
|
fn test_validate_log_level_none() {
|
||||||
assert!(validate_log_level(None).is_ok());
|
assert!(validate_log_level(None).is_ok());
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,9 @@ path = "src/main.rs"
|
|||||||
clap = { workspace = true, features = ["derive"] }
|
clap = { workspace = true, features = ["derive"] }
|
||||||
dialoguer = "0.11"
|
dialoguer = "0.11"
|
||||||
tokio-serial = { workspace = true }
|
tokio-serial = { workspace = true }
|
||||||
|
toml = "0.8"
|
||||||
toml_edit = "0.22"
|
toml_edit = "0.22"
|
||||||
|
trx-config = { path = "../trx-config" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
+136
-319
@@ -2,80 +2,37 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// 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::fmt::Write as _;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use toml_edit::DocumentMut;
|
use trx_config::{ClientConfig, ServerConfig};
|
||||||
|
|
||||||
/// Known top-level keys for a standalone server config.
|
/// Top-level keys that only appear in a server config. Used solely to guess
|
||||||
const SERVER_KEYS: &[&str] = &[
|
/// what a section-less file is meant to be; the real key checking is done by
|
||||||
"general",
|
/// the loader.
|
||||||
|
const SERVER_MARKERS: &[&str] = &[
|
||||||
"rig",
|
"rig",
|
||||||
"rigs",
|
"rigs",
|
||||||
"behavior",
|
|
||||||
"listen",
|
"listen",
|
||||||
"audio",
|
"behavior",
|
||||||
"sdr",
|
"sdr",
|
||||||
"pskreporter",
|
"pskreporter",
|
||||||
"aprsfi",
|
"aprsfi",
|
||||||
"decode_logs",
|
"decode_logs",
|
||||||
|
"timeouts",
|
||||||
|
"audio",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Known top-level keys for a standalone client config.
|
/// Top-level keys that only appear in a client config.
|
||||||
const CLIENT_KEYS: &[&str] = &["general", "remote", "remotes", "frontends"];
|
const CLIENT_MARKERS: &[&str] = &["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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum DetectedType {
|
enum DetectedType {
|
||||||
@@ -100,49 +57,38 @@ pub fn check_file(path: &Path) -> Result<String, String> {
|
|||||||
let content = std::fs::read_to_string(path)
|
let content = std::fs::read_to_string(path)
|
||||||
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
||||||
|
|
||||||
// Step 1: TOML syntax check
|
let table: toml::Table = toml::from_str(&content)
|
||||||
let doc: DocumentMut = content
|
|
||||||
.parse()
|
|
||||||
.map_err(|e| format!("{}: TOML syntax error: {}", path.display(), e))?;
|
.map_err(|e| format!("{}: TOML syntax error: {}", path.display(), e))?;
|
||||||
|
|
||||||
|
let detected = detect_type(&table);
|
||||||
|
|
||||||
let mut report = String::new();
|
let mut report = String::new();
|
||||||
let mut warnings: Vec<String> = Vec::new();
|
let mut warnings: Vec<String> = Vec::new();
|
||||||
let mut errors: 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, "{}: valid TOML", path.display()).unwrap();
|
||||||
writeln!(report, " Detected type: {}", detected).unwrap();
|
writeln!(report, " Detected type: {}", detected).unwrap();
|
||||||
|
|
||||||
// Step 3: Structural validation
|
|
||||||
match detected {
|
match detected {
|
||||||
DetectedType::Server => {
|
DetectedType::Server => check_server(path, &mut warnings, &mut errors),
|
||||||
check_unknown_keys(table, SERVER_KEYS, "", &mut warnings);
|
DetectedType::Client => check_client(path, &mut warnings, &mut errors),
|
||||||
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 => {
|
DetectedType::Combined => {
|
||||||
check_unknown_keys(table, COMBINED_KEYS, "", &mut warnings);
|
if table.contains_key("trx-server") {
|
||||||
if let Some(server) = table.get("trx-server").and_then(|v| v.as_table()) {
|
check_server(path, &mut warnings, &mut errors);
|
||||||
check_unknown_keys(server, SERVER_KEYS, "[trx-server].", &mut warnings);
|
|
||||||
check_server_sections(server, "[trx-server].", &mut warnings, &mut errors);
|
|
||||||
}
|
}
|
||||||
if let Some(client) = table.get("trx-client").and_then(|v| v.as_table()) {
|
if table.contains_key("trx-client") {
|
||||||
check_unknown_keys(client, CLIENT_KEYS, "[trx-client].", &mut warnings);
|
check_client(path, &mut warnings, &mut errors);
|
||||||
check_client_sections(client, "[trx-client].", &mut warnings, &mut errors);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DetectedType::Unknown => {
|
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 {
|
for w in &warnings {
|
||||||
writeln!(report, " warning: {}", w).unwrap();
|
writeln!(report, " warning: {}", w).unwrap();
|
||||||
}
|
}
|
||||||
@@ -169,23 +115,54 @@ pub fn check_file(path: &Path) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_type(table: &toml_edit::Table) -> DetectedType {
|
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 {
|
||||||
if table.contains_key("trx-server") || table.contains_key("trx-client") {
|
if table.contains_key("trx-server") || table.contains_key("trx-client") {
|
||||||
return DetectedType::Combined;
|
return DetectedType::Combined;
|
||||||
}
|
}
|
||||||
let keys: Vec<&str> = table.iter().map(|(k, _)| k).collect();
|
let keys: Vec<&str> = table.keys().map(|k| k.as_str()).collect();
|
||||||
|
|
||||||
let server_score = keys.iter().filter(|k| SERVER_KEYS.contains(k)).count();
|
// Distinguishing keys first, then a simple majority.
|
||||||
let client_score = keys.iter().filter(|k| CLIENT_KEYS.contains(k)).count();
|
if keys.iter().any(|k| ["rig", "rigs", "listen"].contains(k)) {
|
||||||
|
|
||||||
// Use distinguishing keys to break ties
|
|
||||||
if keys.contains(&"rig") || keys.contains(&"rigs") || keys.contains(&"listen") {
|
|
||||||
return DetectedType::Server;
|
return DetectedType::Server;
|
||||||
}
|
}
|
||||||
if keys.contains(&"remote") || keys.contains(&"remotes") || keys.contains(&"frontends") {
|
if keys.iter().any(|k| CLIENT_MARKERS.contains(k)) {
|
||||||
return DetectedType::Client;
|
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 {
|
if server_score > client_score {
|
||||||
DetectedType::Server
|
DetectedType::Server
|
||||||
} else if client_score > server_score {
|
} else if client_score > server_score {
|
||||||
@@ -197,194 +174,6 @@ fn detect_type(table: &toml_edit::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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -418,8 +207,7 @@ enabled = true
|
|||||||
port = 4530
|
port = 4530
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
assert!(result.is_ok());
|
let report = result.expect("expected a clean report");
|
||||||
let report = result.unwrap();
|
|
||||||
assert!(report.contains("Detected type: server"));
|
assert!(report.contains("Detected type: server"));
|
||||||
assert!(report.contains("No issues found"));
|
assert!(report.contains("No issues found"));
|
||||||
}
|
}
|
||||||
@@ -432,41 +220,40 @@ port = 4530
|
|||||||
callsign = "W1AW"
|
callsign = "W1AW"
|
||||||
|
|
||||||
[remote]
|
[remote]
|
||||||
url = "localhost:4530"
|
url = "192.168.1.10:4530"
|
||||||
|
|
||||||
[frontends.http]
|
[frontends.http]
|
||||||
enabled = true
|
enabled = true
|
||||||
port = 8080
|
port = 8080
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
assert!(result.is_ok());
|
let report = result.expect("expected a clean report");
|
||||||
let report = result.unwrap();
|
|
||||||
assert!(report.contains("Detected type: client"));
|
assert!(report.contains("Detected type: client"));
|
||||||
|
assert!(report.contains("No issues found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_valid_combined_config() {
|
fn test_valid_combined_config() {
|
||||||
let result = check_toml(
|
let result = check_toml(
|
||||||
r#"
|
r#"
|
||||||
[trx-server.general]
|
[trx-server.rig]
|
||||||
callsign = "W1AW"
|
model = "ft817"
|
||||||
|
[trx-server.rig.access]
|
||||||
[trx-client.general]
|
type = "serial"
|
||||||
callsign = "W1AW"
|
port = "/dev/ttyUSB0"
|
||||||
|
baud = 9600
|
||||||
|
|
||||||
[trx-client.remote]
|
[trx-client.remote]
|
||||||
url = "localhost:4530"
|
url = "127.0.0.1:4530"
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
assert!(result.is_ok());
|
let report = result.expect("expected a clean report");
|
||||||
let report = result.unwrap();
|
|
||||||
assert!(report.contains("Detected type: combined"));
|
assert!(report.contains("Detected type: combined"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_invalid_toml_syntax() {
|
fn test_invalid_toml_syntax() {
|
||||||
let result = check_toml("this is not [valid toml");
|
let result = check_toml("[general\ncallsign = \"W1AW\"\n");
|
||||||
assert!(result.is_err());
|
|
||||||
assert!(result.unwrap_err().contains("TOML syntax error"));
|
assert!(result.unwrap_err().contains("TOML syntax error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,19 +261,22 @@ url = "localhost:4530"
|
|||||||
fn test_unknown_key_warning() {
|
fn test_unknown_key_warning() {
|
||||||
let result = check_toml(
|
let result = check_toml(
|
||||||
r#"
|
r#"
|
||||||
[general]
|
|
||||||
callsign = "W1AW"
|
|
||||||
|
|
||||||
[rig]
|
[rig]
|
||||||
model = "ft817"
|
model = "ft817"
|
||||||
|
[rig.access]
|
||||||
|
type = "serial"
|
||||||
|
port = "/dev/ttyUSB0"
|
||||||
|
baud = 9600
|
||||||
|
|
||||||
[bogus_section]
|
[listen]
|
||||||
foo = "bar"
|
prot = 4530
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
assert!(result.is_ok());
|
let report = result.expect("unknown keys are warnings, not errors");
|
||||||
let report = result.unwrap();
|
assert!(
|
||||||
assert!(report.contains("unknown key 'bogus_section'"));
|
report.contains("unknown config key 'listen.prot' (did you mean 'listen.port'?)"),
|
||||||
|
"unexpected report: {report}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -498,11 +288,13 @@ log_level = "verbose"
|
|||||||
|
|
||||||
[rig]
|
[rig]
|
||||||
model = "ft817"
|
model = "ft817"
|
||||||
|
[rig.access]
|
||||||
|
type = "serial"
|
||||||
|
port = "/dev/ttyUSB0"
|
||||||
|
baud = 9600
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
assert!(result.is_err());
|
assert!(result.unwrap_err().contains("log_level"));
|
||||||
let report = result.unwrap_err();
|
|
||||||
assert!(report.contains("log_level 'verbose' is invalid"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -510,15 +302,17 @@ model = "ft817"
|
|||||||
let result = check_toml(
|
let result = check_toml(
|
||||||
r#"
|
r#"
|
||||||
[general]
|
[general]
|
||||||
latitude = 45.0
|
latitude = 52.0
|
||||||
|
|
||||||
[rig]
|
[rig]
|
||||||
model = "ft817"
|
model = "ft817"
|
||||||
|
[rig.access]
|
||||||
|
type = "serial"
|
||||||
|
port = "/dev/ttyUSB0"
|
||||||
|
baud = 9600
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
assert!(result.is_err());
|
assert!(result.unwrap_err().contains("longitude"));
|
||||||
let report = result.unwrap_err();
|
|
||||||
assert!(report.contains("latitude and longitude must be set together"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -526,16 +320,18 @@ model = "ft817"
|
|||||||
let result = check_toml(
|
let result = check_toml(
|
||||||
r#"
|
r#"
|
||||||
[general]
|
[general]
|
||||||
latitude = 95.0
|
latitude = 120.0
|
||||||
longitude = 10.0
|
longitude = 10.0
|
||||||
|
|
||||||
[rig]
|
[rig]
|
||||||
model = "ft817"
|
model = "ft817"
|
||||||
|
[rig.access]
|
||||||
|
type = "serial"
|
||||||
|
port = "/dev/ttyUSB0"
|
||||||
|
baud = 9600
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
assert!(result.is_err());
|
assert!(result.unwrap_err().contains("latitude"));
|
||||||
let report = result.unwrap_err();
|
|
||||||
assert!(report.contains("latitude 95 is out of range"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -547,10 +343,31 @@ model = "ft817"
|
|||||||
|
|
||||||
[rig.access]
|
[rig.access]
|
||||||
type = "usb"
|
type = "usb"
|
||||||
|
port = "/dev/ttyUSB0"
|
||||||
|
baud = 9600
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
assert!(result.is_err());
|
assert!(result.unwrap_err().contains("access"));
|
||||||
let report = result.unwrap_err();
|
}
|
||||||
assert!(report.contains("type 'usb' is invalid"));
|
|
||||||
|
/// 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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,10 +211,13 @@ pub fn build_server(general: ServerGeneral, rig: RigSetup, listen: ListenSetup)
|
|||||||
let mut doc = DocumentMut::new();
|
let mut doc = DocumentMut::new();
|
||||||
doc.decor_mut()
|
doc.decor_mut()
|
||||||
.set_prefix("# trx-server configuration\n# Generated by trx-configurator\n");
|
.set_prefix("# trx-server configuration\n# Generated by trx-configurator\n");
|
||||||
let tables = build_server_tables(general, rig, listen);
|
// Emit the sectioned shape (`[trx-server]`) that trx-server writes with
|
||||||
for (key, item) in tables.iter() {
|
// --print-config, so a generated file can be dropped into a combined
|
||||||
doc.insert(key, item.clone());
|
// trx-rs.toml unchanged.
|
||||||
}
|
doc.insert(
|
||||||
|
"trx-server",
|
||||||
|
Item::Table(build_server_tables(general, rig, listen)),
|
||||||
|
);
|
||||||
doc
|
doc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,10 +353,10 @@ pub fn build_client(
|
|||||||
let mut doc = DocumentMut::new();
|
let mut doc = DocumentMut::new();
|
||||||
doc.decor_mut()
|
doc.decor_mut()
|
||||||
.set_prefix("# trx-client configuration\n# Generated by trx-configurator\n");
|
.set_prefix("# trx-client configuration\n# Generated by trx-configurator\n");
|
||||||
let tables = build_client_tables(general, remote, frontends);
|
doc.insert(
|
||||||
for (key, item) in tables.iter() {
|
"trx-client",
|
||||||
doc.insert(key, item.clone());
|
Item::Table(build_client_tables(general, remote, frontends)),
|
||||||
}
|
);
|
||||||
doc
|
doc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,3 +456,60 @@ pub fn write_file(doc: &DocumentMut, path: &Path) -> Result<(), String> {
|
|||||||
println!("Wrote {}", path.display());
|
println!("Wrote {}", path.display());
|
||||||
Ok(())
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ uuid = { workspace = true }
|
|||||||
ts-rs = { version = "12.0.1", features = ["uuid-impl"] }
|
ts-rs = { version = "12.0.1", features = ["uuid-impl"] }
|
||||||
sgp4 = "2"
|
sgp4 = "2"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub mod command;
|
|||||||
pub mod controller;
|
pub mod controller;
|
||||||
pub mod request;
|
pub mod request;
|
||||||
pub mod response;
|
pub mod response;
|
||||||
|
pub mod spectrum_wire;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
/// How this backend communicates with the rig.
|
/// How this backend communicates with the rig.
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -394,6 +394,12 @@ fn default_wfm_denoise_level() -> WfmDenoiseLevel {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||||
pub struct SpectrumData {
|
pub struct SpectrumData {
|
||||||
/// FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
|
/// 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>,
|
pub bins: Vec<f32>,
|
||||||
/// Centre frequency of the SDR capture in Hz.
|
/// Centre frequency of the SDR capture in Hz.
|
||||||
#[ts(type = "number")]
|
#[ts(type = "number")]
|
||||||
|
|||||||
@@ -18,4 +18,6 @@ pub use auth::{NoAuthValidator, SimpleTokenValidator, TokenValidator};
|
|||||||
pub use codec::{mode_to_string, parse_envelope, parse_mode};
|
pub use codec::{mode_to_string, parse_envelope, parse_mode};
|
||||||
pub use decoders::{DecoderActivation, DecoderDescriptor, DECODER_REGISTRY};
|
pub use decoders::{DecoderActivation, DecoderDescriptor, DECODER_REGISTRY};
|
||||||
pub use mapping::{client_command_to_rig, rig_command_to_client};
|
pub use mapping::{client_command_to_rig, rig_command_to_client};
|
||||||
pub use types::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry};
|
pub use types::{
|
||||||
|
ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry, SpectrumFrame,
|
||||||
|
};
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ macro_rules! define_command_mapping {
|
|||||||
|
|
||||||
define_command_mapping! {
|
define_command_mapping! {
|
||||||
// ── Client-only variants (no RigCommand counterpart) ─────────────
|
// ── Client-only variants (no RigCommand counterpart) ─────────────
|
||||||
client_only: GetRigs, GetSatPasses, SubscribeMeter;
|
client_only: GetRigs, GetSatPasses, SubscribeMeter, SubscribeSpectrum;
|
||||||
|
|
||||||
// ── Unit variants (no payload) ───────────────────────────────────
|
// ── Unit variants (no payload) ───────────────────────────────────
|
||||||
unit:
|
unit:
|
||||||
|
|||||||
@@ -130,6 +130,28 @@ pub enum ClientCommand {
|
|||||||
/// newline-delimited `MeterUpdate` JSON frames and no further commands or
|
/// newline-delimited `MeterUpdate` JSON frames and no further commands or
|
||||||
/// regular responses are sent. Intended for a dedicated TCP connection.
|
/// regular responses are sent. Intended for a dedicated TCP connection.
|
||||||
SubscribeMeter,
|
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.
|
/// Fast meter sample pushed by the server on a dedicated meter stream.
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ cpal = "0.15"
|
|||||||
num-complex = "0.4"
|
num-complex = "0.4"
|
||||||
opus = "0.3"
|
opus = "0.3"
|
||||||
trx-app = { path = "../trx-app" }
|
trx-app = { path = "../trx-app" }
|
||||||
|
trx-config = { path = "../trx-config" }
|
||||||
trx-backend = { path = "trx-backend", features = ["soapysdr"] }
|
trx-backend = { path = "trx-backend", features = ["soapysdr"] }
|
||||||
trx-ais = { path = "../decoders/trx-ais" }
|
trx-ais = { path = "../decoders/trx-ais" }
|
||||||
trx-vdes = { path = "../decoders/trx-vdes" }
|
trx-vdes = { path = "../decoders/trx-vdes" }
|
||||||
|
|||||||
@@ -2291,15 +2291,12 @@ pub async fn run_wefax_decoder(
|
|||||||
mut state_rx: watch::Receiver<RigState>,
|
mut state_rx: watch::Receiver<RigState>,
|
||||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
histories: Arc<DecoderHistories>,
|
histories: Arc<DecoderHistories>,
|
||||||
|
wefax_output_dir: std::path::PathBuf,
|
||||||
) {
|
) {
|
||||||
use trx_wefax::{WefaxConfig, WefaxDecoder, WefaxEvent};
|
use trx_wefax::{WefaxConfig, WefaxDecoder, WefaxEvent};
|
||||||
|
|
||||||
info!("WEFAX decoder started ({}Hz, {} ch)", sample_rate, channels);
|
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 {
|
let config = WefaxConfig {
|
||||||
output_dir: Some(wefax_output_dir.to_string_lossy().into_owned()),
|
output_dir: Some(wefax_output_dir.to_string_lossy().into_owned()),
|
||||||
..WefaxConfig::default()
|
..WefaxConfig::default()
|
||||||
|
|||||||
+5
-1518
File diff suppressed because it is too large
Load Diff
@@ -494,6 +494,58 @@ 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.
|
// SubscribeMeter: turns this connection into a one-way meter stream.
|
||||||
// No regular responses are produced; the connection lives until the
|
// No regular responses are produced; the connection lives until the
|
||||||
// client disconnects or shutdown fires.
|
// client disconnects or shutdown fires.
|
||||||
@@ -732,6 +784,7 @@ mod tests {
|
|||||||
let (state_tx, state_rx) = watch::channel(state);
|
let (state_tx, state_rx) = watch::channel(state);
|
||||||
let _state_tx = state_tx;
|
let _state_tx = state_tx;
|
||||||
let (meter_tx, _) = tokio::sync::broadcast::channel(8);
|
let (meter_tx, _) = tokio::sync::broadcast::channel(8);
|
||||||
|
let (spectrum_tx, _) = tokio::sync::broadcast::channel(4);
|
||||||
let handle = RigHandle {
|
let handle = RigHandle {
|
||||||
rig_id: "default".to_string(),
|
rig_id: "default".to_string(),
|
||||||
display_name: "Default Rig".to_string(),
|
display_name: "Default Rig".to_string(),
|
||||||
@@ -739,6 +792,7 @@ mod tests {
|
|||||||
state_rx,
|
state_rx,
|
||||||
audio_port: 4531,
|
audio_port: 4531,
|
||||||
meter_tx,
|
meter_tx,
|
||||||
|
spectrum_tx,
|
||||||
};
|
};
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert("default".to_string(), handle);
|
map.insert("default".to_string(), handle);
|
||||||
@@ -933,6 +987,7 @@ mod tests {
|
|||||||
state_rx: state_rx_a,
|
state_rx: state_rx_a,
|
||||||
audio_port: 4531,
|
audio_port: 4531,
|
||||||
meter_tx: meter_tx_a,
|
meter_tx: meter_tx_a,
|
||||||
|
spectrum_tx: tokio::sync::broadcast::channel(4).0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (tx_b, rx_b) = mpsc::channel::<RigRequest>(8);
|
let (tx_b, rx_b) = mpsc::channel::<RigRequest>(8);
|
||||||
@@ -945,6 +1000,7 @@ mod tests {
|
|||||||
state_rx: state_rx_b,
|
state_rx: state_rx_b,
|
||||||
audio_port: 4532,
|
audio_port: 4532,
|
||||||
meter_tx: meter_tx_b,
|
meter_tx: meter_tx_b,
|
||||||
|
spectrum_tx: tokio::sync::broadcast::channel(4).0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
@@ -953,6 +1009,67 @@ mod tests {
|
|||||||
(Arc::new(map), "rig_hf".to_string(), rx_a, rx_b)
|
(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]
|
#[tokio::test]
|
||||||
async fn multi_rig_state_isolation() {
|
async fn multi_rig_state_isolation() {
|
||||||
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
|
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
|
||||||
|
|||||||
+267
-142
@@ -30,6 +30,8 @@ use trx_core::audio::AudioStreamInfo;
|
|||||||
|
|
||||||
use trx_app::{init_logging, normalize_name};
|
use trx_app::{init_logging, normalize_name};
|
||||||
use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess};
|
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::controller::{AdaptivePolling, ExponentialBackoff};
|
||||||
use trx_core::rig::request::RigRequest;
|
use trx_core::rig::request::RigRequest;
|
||||||
use trx_core::rig::state::RigState;
|
use trx_core::rig::state::RigState;
|
||||||
@@ -56,6 +58,12 @@ struct Cli {
|
|||||||
/// Print example configuration and exit
|
/// Print example configuration and exit
|
||||||
#[arg(long = "print-config")]
|
#[arg(long = "print-config")]
|
||||||
print_config: bool,
|
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)
|
/// Rig backend to use (e.g. ft817, ft450d)
|
||||||
#[arg(short = 'r', long = "rig")]
|
#[arg(short = 'r', long = "rig")]
|
||||||
rig: Option<String>,
|
rig: Option<String>,
|
||||||
@@ -352,6 +360,7 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
|
|||||||
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
|
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
|
||||||
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
|
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
|
||||||
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
|
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
|
||||||
|
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let pcm_rx = sdr_rig.subscribe_pcm();
|
let pcm_rx = sdr_rig.subscribe_pcm();
|
||||||
@@ -452,6 +461,7 @@ fn build_rig_task_config(
|
|||||||
prebuilt_rig: None,
|
prebuilt_rig: None,
|
||||||
command_exec_timeout: Duration::from_millis(timeouts.command_exec_timeout_ms),
|
command_exec_timeout: Duration::from_millis(timeouts.command_exec_timeout_ms),
|
||||||
poll_refresh_timeout: Duration::from_millis(timeouts.poll_refresh_timeout_ms),
|
poll_refresh_timeout: Duration::from_millis(timeouts.poll_refresh_timeout_ms),
|
||||||
|
spectrum_interval_ms: rig_cfg.sdr.spectrum_interval_ms,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,38 +663,44 @@ fn spawn_rig_audio_stack(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spawn APRS decoder task
|
// Spawn APRS decoder task
|
||||||
let aprs_pcm_rx = pcm_tx.subscribe();
|
if rig_cfg.decoders.is_enabled("aprs") {
|
||||||
let aprs_state_rx = state_rx.clone();
|
let aprs_pcm_rx = pcm_tx.subscribe();
|
||||||
let aprs_decode_tx = decode_tx.clone();
|
let aprs_state_rx = state_rx.clone();
|
||||||
let aprs_sr = rig_cfg.audio.sample_rate;
|
let aprs_decode_tx = decode_tx.clone();
|
||||||
let aprs_ch = rig_cfg.audio.channels;
|
let aprs_sr = rig_cfg.audio.sample_rate;
|
||||||
let aprs_shutdown_rx = shutdown_rx.clone();
|
let aprs_ch = rig_cfg.audio.channels;
|
||||||
let aprs_logs = decoder_logs.clone();
|
let aprs_shutdown_rx = shutdown_rx.clone();
|
||||||
let aprs_histories = histories.clone();
|
let aprs_logs = decoder_logs.clone();
|
||||||
handles.push(tokio::spawn(async move {
|
let aprs_histories = histories.clone();
|
||||||
tokio::select! {
|
handles.push(tokio::spawn(async move {
|
||||||
_ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs, aprs_histories) => {}
|
tokio::select! {
|
||||||
_ = wait_for_shutdown(aprs_shutdown_rx) => {}
|
_ = 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
|
// Spawn HF APRS decoder task
|
||||||
let hf_aprs_pcm_rx = pcm_tx.subscribe();
|
if rig_cfg.decoders.is_enabled("aprs_hf") {
|
||||||
let hf_aprs_state_rx = state_rx.clone();
|
let hf_aprs_pcm_rx = pcm_tx.subscribe();
|
||||||
let hf_aprs_decode_tx = decode_tx.clone();
|
let hf_aprs_state_rx = state_rx.clone();
|
||||||
let hf_aprs_sr = rig_cfg.audio.sample_rate;
|
let hf_aprs_decode_tx = decode_tx.clone();
|
||||||
let hf_aprs_ch = rig_cfg.audio.channels;
|
let hf_aprs_sr = rig_cfg.audio.sample_rate;
|
||||||
let hf_aprs_shutdown_rx = shutdown_rx.clone();
|
let hf_aprs_ch = rig_cfg.audio.channels;
|
||||||
let hf_aprs_logs = decoder_logs.clone();
|
let hf_aprs_shutdown_rx = shutdown_rx.clone();
|
||||||
let hf_aprs_histories = histories.clone();
|
let hf_aprs_logs = decoder_logs.clone();
|
||||||
handles.push(tokio::spawn(async move {
|
let hf_aprs_histories = histories.clone();
|
||||||
tokio::select! {
|
handles.push(tokio::spawn(async move {
|
||||||
_ = 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) => {}
|
tokio::select! {
|
||||||
_ = wait_for_shutdown(hf_aprs_shutdown_rx) => {}
|
_ = 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 {
|
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) =
|
||||||
|
sdr_ais_pcm_rx.filter(|_| rig_cfg.decoders.is_enabled("ais"))
|
||||||
|
{
|
||||||
let ais_state_rx = state_rx.clone();
|
let ais_state_rx = state_rx.clone();
|
||||||
let ais_decode_tx = decode_tx.clone();
|
let ais_decode_tx = decode_tx.clone();
|
||||||
let ais_shutdown_rx = shutdown_rx.clone();
|
let ais_shutdown_rx = shutdown_rx.clone();
|
||||||
@@ -699,7 +715,7 @@ fn spawn_rig_audio_stack(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(vdes_iq_rx) = sdr_vdes_iq_rx {
|
if let Some(vdes_iq_rx) = sdr_vdes_iq_rx.filter(|_| rig_cfg.decoders.is_enabled("vdes")) {
|
||||||
let vdes_state_rx = state_rx.clone();
|
let vdes_state_rx = state_rx.clone();
|
||||||
let vdes_decode_tx = decode_tx.clone();
|
let vdes_decode_tx = decode_tx.clone();
|
||||||
let vdes_shutdown_rx = shutdown_rx.clone();
|
let vdes_shutdown_rx = shutdown_rx.clone();
|
||||||
@@ -721,55 +737,61 @@ fn spawn_rig_audio_stack(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spawn CW decoder task
|
// Spawn CW decoder task
|
||||||
let cw_pcm_rx = pcm_tx.subscribe();
|
if rig_cfg.decoders.is_enabled("cw") {
|
||||||
let cw_state_rx = state_rx.clone();
|
let cw_pcm_rx = pcm_tx.subscribe();
|
||||||
let cw_decode_tx = decode_tx.clone();
|
let cw_state_rx = state_rx.clone();
|
||||||
let cw_sr = rig_cfg.audio.sample_rate;
|
let cw_decode_tx = decode_tx.clone();
|
||||||
let cw_ch = rig_cfg.audio.channels;
|
let cw_sr = rig_cfg.audio.sample_rate;
|
||||||
let cw_shutdown_rx = shutdown_rx.clone();
|
let cw_ch = rig_cfg.audio.channels;
|
||||||
let cw_logs = decoder_logs.clone();
|
let cw_shutdown_rx = shutdown_rx.clone();
|
||||||
let cw_histories = histories.clone();
|
let cw_logs = decoder_logs.clone();
|
||||||
handles.push(tokio::spawn(async move {
|
let cw_histories = histories.clone();
|
||||||
tokio::select! {
|
handles.push(tokio::spawn(async move {
|
||||||
_ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs, cw_histories) => {}
|
tokio::select! {
|
||||||
_ = wait_for_shutdown(cw_shutdown_rx) => {}
|
_ = 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
|
// Spawn FT8 decoder task
|
||||||
let ft8_pcm_rx = pcm_tx.subscribe();
|
if rig_cfg.decoders.is_enabled("ft8") {
|
||||||
let ft8_state_rx = state_rx.clone();
|
let ft8_pcm_rx = pcm_tx.subscribe();
|
||||||
let ft8_decode_tx = decode_tx.clone();
|
let ft8_state_rx = state_rx.clone();
|
||||||
let ft8_sr = rig_cfg.audio.sample_rate;
|
let ft8_decode_tx = decode_tx.clone();
|
||||||
let ft8_ch = rig_cfg.audio.channels;
|
let ft8_sr = rig_cfg.audio.sample_rate;
|
||||||
let ft8_shutdown_rx = shutdown_rx.clone();
|
let ft8_ch = rig_cfg.audio.channels;
|
||||||
let ft8_logs = decoder_logs.clone();
|
let ft8_shutdown_rx = shutdown_rx.clone();
|
||||||
let ft8_histories = histories.clone();
|
let ft8_logs = decoder_logs.clone();
|
||||||
handles.push(tokio::spawn(async move {
|
let ft8_histories = histories.clone();
|
||||||
tokio::select! {
|
handles.push(tokio::spawn(async move {
|
||||||
_ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs, ft8_histories) => {}
|
tokio::select! {
|
||||||
_ = wait_for_shutdown(ft8_shutdown_rx) => {}
|
_ = 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
|
// Spawn FT4 decoder task
|
||||||
let ft4_pcm_rx = pcm_tx.subscribe();
|
if rig_cfg.decoders.is_enabled("ft4") {
|
||||||
let ft4_state_rx = state_rx.clone();
|
let ft4_pcm_rx = pcm_tx.subscribe();
|
||||||
let ft4_decode_tx = decode_tx.clone();
|
let ft4_state_rx = state_rx.clone();
|
||||||
let ft4_sr = rig_cfg.audio.sample_rate;
|
let ft4_decode_tx = decode_tx.clone();
|
||||||
let ft4_ch = rig_cfg.audio.channels;
|
let ft4_sr = rig_cfg.audio.sample_rate;
|
||||||
let ft4_shutdown_rx = shutdown_rx.clone();
|
let ft4_ch = rig_cfg.audio.channels;
|
||||||
let ft4_histories = histories.clone();
|
let ft4_shutdown_rx = shutdown_rx.clone();
|
||||||
handles.push(tokio::spawn(async move {
|
let ft4_histories = histories.clone();
|
||||||
tokio::select! {
|
handles.push(tokio::spawn(async move {
|
||||||
_ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {}
|
tokio::select! {
|
||||||
_ = wait_for_shutdown(ft4_shutdown_rx) => {}
|
_ = 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
|
// Spawn FT2 decoder task
|
||||||
#[cfg(feature = "ft2")]
|
#[cfg(feature = "ft2")]
|
||||||
{
|
if rig_cfg.decoders.is_enabled("ft2") {
|
||||||
let ft2_pcm_rx = pcm_tx.subscribe();
|
let ft2_pcm_rx = pcm_tx.subscribe();
|
||||||
let ft2_state_rx = state_rx.clone();
|
let ft2_state_rx = state_rx.clone();
|
||||||
let ft2_decode_tx = decode_tx.clone();
|
let ft2_decode_tx = decode_tx.clone();
|
||||||
@@ -786,73 +808,76 @@ fn spawn_rig_audio_stack(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spawn WSPR decoder task
|
// Spawn WSPR decoder task
|
||||||
let wspr_pcm_rx = pcm_tx.subscribe();
|
if rig_cfg.decoders.is_enabled("wspr") {
|
||||||
let wspr_state_rx = state_rx.clone();
|
let wspr_pcm_rx = pcm_tx.subscribe();
|
||||||
let wspr_decode_tx = decode_tx.clone();
|
let wspr_state_rx = state_rx.clone();
|
||||||
let wspr_sr = rig_cfg.audio.sample_rate;
|
let wspr_decode_tx = decode_tx.clone();
|
||||||
let wspr_ch = rig_cfg.audio.channels;
|
let wspr_sr = rig_cfg.audio.sample_rate;
|
||||||
let wspr_shutdown_rx = shutdown_rx.clone();
|
let wspr_ch = rig_cfg.audio.channels;
|
||||||
let wspr_logs = decoder_logs.clone();
|
let wspr_shutdown_rx = shutdown_rx.clone();
|
||||||
let wspr_histories = histories.clone();
|
let wspr_logs = decoder_logs.clone();
|
||||||
handles.push(tokio::spawn(async move {
|
let wspr_histories = histories.clone();
|
||||||
tokio::select! {
|
handles.push(tokio::spawn(async move {
|
||||||
_ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs, wspr_histories) => {}
|
tokio::select! {
|
||||||
_ = wait_for_shutdown(wspr_shutdown_rx) => {}
|
_ = 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
|
// Spawn Meteor-M LRPT decoder task
|
||||||
let lrpt_pcm_rx = pcm_tx.subscribe();
|
if rig_cfg.decoders.is_enabled("lrpt") {
|
||||||
let lrpt_state_rx = state_rx.clone();
|
let lrpt_pcm_rx = pcm_tx.subscribe();
|
||||||
let lrpt_decode_tx = decode_tx.clone();
|
let lrpt_state_rx = state_rx.clone();
|
||||||
let lrpt_sr = rig_cfg.audio.sample_rate;
|
let lrpt_decode_tx = decode_tx.clone();
|
||||||
let lrpt_ch = rig_cfg.audio.channels;
|
let lrpt_sr = rig_cfg.audio.sample_rate;
|
||||||
let lrpt_shutdown_rx = shutdown_rx.clone();
|
let lrpt_ch = rig_cfg.audio.channels;
|
||||||
let lrpt_histories = histories.clone();
|
let lrpt_shutdown_rx = shutdown_rx.clone();
|
||||||
let lrpt_output_dir = dirs::cache_dir()
|
let lrpt_histories = histories.clone();
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
let lrpt_output_dir = rig_cfg.decoders.output_dir_for("lrpt");
|
||||||
.join("trx-rs")
|
handles.push(tokio::spawn(async move {
|
||||||
.join("lrpt");
|
tokio::select! {
|
||||||
handles.push(tokio::spawn(async move {
|
_ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {}
|
||||||
tokio::select! {
|
_ = wait_for_shutdown(lrpt_shutdown_rx) => {}
|
||||||
_ = 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
|
// Spawn WEFAX decoder task
|
||||||
let wefax_pcm_rx = pcm_tx.subscribe();
|
if rig_cfg.decoders.is_enabled("wefax") {
|
||||||
let wefax_state_rx = state_rx.clone();
|
let wefax_pcm_rx = pcm_tx.subscribe();
|
||||||
let wefax_decode_tx = decode_tx.clone();
|
let wefax_state_rx = state_rx.clone();
|
||||||
let wefax_sr = rig_cfg.audio.sample_rate;
|
let wefax_decode_tx = decode_tx.clone();
|
||||||
let wefax_ch = rig_cfg.audio.channels;
|
let wefax_sr = rig_cfg.audio.sample_rate;
|
||||||
let wefax_shutdown_rx = shutdown_rx.clone();
|
let wefax_ch = rig_cfg.audio.channels;
|
||||||
let wefax_histories = histories.clone();
|
let wefax_shutdown_rx = shutdown_rx.clone();
|
||||||
handles.push(tokio::spawn(async move {
|
let wefax_histories = histories.clone();
|
||||||
tokio::select! {
|
let wefax_output_dir = rig_cfg.decoders.output_dir_for("wefax");
|
||||||
_ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories) => {}
|
handles.push(tokio::spawn(async move {
|
||||||
_ = wait_for_shutdown(wefax_shutdown_rx) => {}
|
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) => {}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn SSTV decoder task
|
// Spawn SSTV decoder task
|
||||||
let sstv_pcm_rx = pcm_tx.subscribe();
|
if rig_cfg.decoders.is_enabled("sstv") {
|
||||||
let sstv_state_rx = state_rx.clone();
|
let sstv_pcm_rx = pcm_tx.subscribe();
|
||||||
let sstv_decode_tx = decode_tx.clone();
|
let sstv_state_rx = state_rx.clone();
|
||||||
let sstv_sr = rig_cfg.audio.sample_rate;
|
let sstv_decode_tx = decode_tx.clone();
|
||||||
let sstv_ch = rig_cfg.audio.channels;
|
let sstv_sr = rig_cfg.audio.sample_rate;
|
||||||
let sstv_shutdown_rx = shutdown_rx.clone();
|
let sstv_ch = rig_cfg.audio.channels;
|
||||||
let sstv_histories = histories.clone();
|
let sstv_shutdown_rx = shutdown_rx.clone();
|
||||||
let sstv_output_dir = dirs::cache_dir()
|
let sstv_histories = histories.clone();
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
let sstv_output_dir = rig_cfg.decoders.output_dir_for("sstv");
|
||||||
.join("trx-rs")
|
handles.push(tokio::spawn(async move {
|
||||||
.join("sstv");
|
tokio::select! {
|
||||||
handles.push(tokio::spawn(async move {
|
_ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {}
|
||||||
tokio::select! {
|
_ = wait_for_shutdown(sstv_shutdown_rx) => {}
|
||||||
_ = 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 {
|
if rig_cfg.audio.tx_enabled {
|
||||||
@@ -882,6 +907,82 @@ fn spawn_rig_audio_stack(
|
|||||||
handles
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> DynResult<()> {
|
async fn main() -> DynResult<()> {
|
||||||
let mut bootstrap_ctx = RegistrationContext::new();
|
let mut bootstrap_ctx = RegistrationContext::new();
|
||||||
@@ -894,12 +995,30 @@ async fn main() -> DynResult<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let (cfg, config_path) = if let Some(ref path) = cli.config {
|
let loaded = if let Some(ref path) = cli.config {
|
||||||
let cfg = ServerConfig::load_from_file(path)?;
|
ServerConfig::load_from_file(path)?
|
||||||
(cfg, Some(path.clone()))
|
|
||||||
} else {
|
} else {
|
||||||
ServerConfig::load_from_default_paths()?
|
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()
|
cfg.validate()
|
||||||
.map_err(|e| format!("Invalid server configuration: {}", e))?;
|
.map_err(|e| format!("Invalid server configuration: {}", e))?;
|
||||||
|
|
||||||
@@ -912,12 +1031,6 @@ async fn main() -> DynResult<()> {
|
|||||||
std::process::exit(1);
|
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);
|
let registry = Arc::new(bootstrap_ctx);
|
||||||
|
|
||||||
// --- Resolve the effective rig list ---
|
// --- Resolve the effective rig list ---
|
||||||
@@ -979,6 +1092,12 @@ async fn main() -> DynResult<()> {
|
|||||||
(callsign, cfg.general.latitude, cfg.general.longitude)
|
(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!(
|
info!(
|
||||||
"Starting trx-server with {} rig(s): {}",
|
"Starting trx-server with {} rig(s): {}",
|
||||||
resolved_rigs.len(),
|
resolved_rigs.len(),
|
||||||
@@ -1098,6 +1217,9 @@ async fn main() -> DynResult<()> {
|
|||||||
let (state_tx, state_rx) = watch::channel(initial_state);
|
let (state_tx, state_rx) = watch::channel(initial_state);
|
||||||
let (meter_tx, _) =
|
let (meter_tx, _) =
|
||||||
broadcast::channel::<trx_protocol::MeterUpdate>(rig_handle::METER_BROADCAST_CAPACITY);
|
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(
|
let mut task_config = build_rig_task_config(
|
||||||
rig_cfg,
|
rig_cfg,
|
||||||
@@ -1125,12 +1247,14 @@ async fn main() -> DynResult<()> {
|
|||||||
let rig_shutdown_rx = shutdown_rx.clone();
|
let rig_shutdown_rx = shutdown_rx.clone();
|
||||||
let rig_id_supervisor = rig_cfg.id.clone();
|
let rig_id_supervisor = rig_cfg.id.clone();
|
||||||
let meter_tx_task = meter_tx.clone();
|
let meter_tx_task = meter_tx.clone();
|
||||||
|
let spectrum_tx_task = spectrum_tx.clone();
|
||||||
task_handles.push(tokio::spawn(async move {
|
task_handles.push(tokio::spawn(async move {
|
||||||
let result = rig_task::run_rig_task(
|
let result = rig_task::run_rig_task(
|
||||||
task_config,
|
task_config,
|
||||||
rig_rx,
|
rig_rx,
|
||||||
state_tx.clone(),
|
state_tx.clone(),
|
||||||
meter_tx_task,
|
meter_tx_task,
|
||||||
|
spectrum_tx_task,
|
||||||
rig_shutdown_rx,
|
rig_shutdown_rx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1177,6 +1301,7 @@ async fn main() -> DynResult<()> {
|
|||||||
rig_handles.insert(
|
rig_handles.insert(
|
||||||
rig_cfg.id.clone(),
|
rig_cfg.id.clone(),
|
||||||
RigHandle {
|
RigHandle {
|
||||||
|
spectrum_tx: spectrum_tx.clone(),
|
||||||
rig_id: rig_cfg.id.clone(),
|
rig_id: rig_cfg.id.clone(),
|
||||||
display_name: rig_cfg.display_name().to_string(),
|
display_name: rig_cfg.display_name().to_string(),
|
||||||
rig_tx,
|
rig_tx,
|
||||||
|
|||||||
@@ -8,13 +8,18 @@ use tokio::sync::{broadcast, mpsc, watch};
|
|||||||
|
|
||||||
use trx_core::rig::request::RigRequest;
|
use trx_core::rig::request::RigRequest;
|
||||||
use trx_core::rig::state::RigState;
|
use trx_core::rig::state::RigState;
|
||||||
use trx_protocol::MeterUpdate;
|
use trx_protocol::{MeterUpdate, SpectrumFrame};
|
||||||
|
|
||||||
/// Bounded broadcast capacity for the meter stream. Keeps ~0.5 s of buffered
|
/// 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
|
/// samples at 30 Hz — more than enough slack to tolerate a scheduling blip
|
||||||
/// without forcing the producer to block or drop silently.
|
/// without forcing the producer to block or drop silently.
|
||||||
pub const METER_BROADCAST_CAPACITY: usize = 16;
|
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.
|
/// A handle to a single running rig backend.
|
||||||
///
|
///
|
||||||
/// One `RigHandle` is created per rig in `main.rs` and stored in the shared
|
/// One `RigHandle` is created per rig in `main.rs` and stored in the shared
|
||||||
@@ -34,4 +39,8 @@ pub struct RigHandle {
|
|||||||
/// ~6–7 Hz (CAT). Consumed by `SubscribeMeter` clients; independent of
|
/// ~6–7 Hz (CAT). Consumed by `SubscribeMeter` clients; independent of
|
||||||
/// the slower `state_rx` snapshot path.
|
/// the slower `state_rx` snapshot path.
|
||||||
pub meter_tx: broadcast::Sender<MeterUpdate>,
|
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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use trx_core::rig::request::RigRequest;
|
|||||||
use trx_core::rig::state::{RigMode, RigSnapshot, RigState};
|
use trx_core::rig::state::{RigMode, RigSnapshot, RigState};
|
||||||
use trx_core::rig::{RigCat, RigRxStatus, RigTxStatus};
|
use trx_core::rig::{RigCat, RigRxStatus, RigTxStatus};
|
||||||
use trx_core::{DynResult, RigError, RigResult};
|
use trx_core::{DynResult, RigError, RigResult};
|
||||||
use trx_protocol::MeterUpdate;
|
use trx_protocol::{MeterUpdate, SpectrumFrame};
|
||||||
|
|
||||||
use crate::audio::DecoderHistories;
|
use crate::audio::DecoderHistories;
|
||||||
use crate::error::is_invalid_bcd_error;
|
use crate::error::is_invalid_bcd_error;
|
||||||
@@ -64,6 +64,8 @@ pub struct RigTaskConfig {
|
|||||||
pub command_exec_timeout: Duration,
|
pub command_exec_timeout: Duration,
|
||||||
/// Maximum time for a CAT poll refresh cycle.
|
/// Maximum time for a CAT poll refresh cycle.
|
||||||
pub poll_refresh_timeout: Duration,
|
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 {
|
impl Default for RigTaskConfig {
|
||||||
@@ -94,6 +96,7 @@ impl Default for RigTaskConfig {
|
|||||||
prebuilt_rig: None,
|
prebuilt_rig: None,
|
||||||
command_exec_timeout: DEFAULT_COMMAND_EXEC_TIMEOUT,
|
command_exec_timeout: DEFAULT_COMMAND_EXEC_TIMEOUT,
|
||||||
poll_refresh_timeout: DEFAULT_POLL_REFRESH_TIMEOUT,
|
poll_refresh_timeout: DEFAULT_POLL_REFRESH_TIMEOUT,
|
||||||
|
spectrum_interval_ms: 50,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +118,7 @@ pub async fn run_rig_task(
|
|||||||
mut rx: mpsc::Receiver<RigRequest>,
|
mut rx: mpsc::Receiver<RigRequest>,
|
||||||
state_tx: watch::Sender<RigState>,
|
state_tx: watch::Sender<RigState>,
|
||||||
meter_tx: broadcast::Sender<MeterUpdate>,
|
meter_tx: broadcast::Sender<MeterUpdate>,
|
||||||
|
spectrum_tx: broadcast::Sender<SpectrumFrame>,
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
) -> DynResult<()> {
|
) -> DynResult<()> {
|
||||||
let histories = config.histories.clone();
|
let histories = config.histories.clone();
|
||||||
@@ -273,6 +277,13 @@ pub async fn run_rig_task(
|
|||||||
} else {
|
} else {
|
||||||
Duration::from_millis(150)
|
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_task_start = Instant::now();
|
||||||
let meter_state_delta_db: f64 = 0.25;
|
let meter_state_delta_db: f64 = 0.25;
|
||||||
let rig_id = config.rig_id.clone();
|
let rig_id = config.rig_id.clone();
|
||||||
@@ -302,6 +313,21 @@ pub async fn run_rig_task(
|
|||||||
Err(_) => break,
|
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.
|
// Fast meter-only refresh between full polls.
|
||||||
_ = &mut meter_tick => {
|
_ = &mut meter_tick => {
|
||||||
meter_tick = Box::pin(tokio::time::sleep(meter_tick_duration));
|
meter_tick = Box::pin(tokio::time::sleep(meter_tick_duration));
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ impl SdrPipeline {
|
|||||||
squelch_cfg: VirtualSquelchConfig,
|
squelch_cfg: VirtualSquelchConfig,
|
||||||
nb_cfg: NoiseBlankerConfig,
|
nb_cfg: NoiseBlankerConfig,
|
||||||
channels: &[(f64, RigMode, u32)],
|
channels: &[(f64, RigMode, u32)],
|
||||||
|
spectrum_fft_size: usize,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
const IQ_BROADCAST_CAPACITY: usize = 64;
|
const IQ_BROADCAST_CAPACITY: usize = 64;
|
||||||
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
|
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
|
||||||
@@ -219,6 +220,7 @@ impl SdrPipeline {
|
|||||||
.name("sdr-iq-read".to_string())
|
.name("sdr-iq-read".to_string())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
iq_read_loop(
|
iq_read_loop(
|
||||||
|
spectrum_fft_size,
|
||||||
source,
|
source,
|
||||||
sdr_sample_rate,
|
sdr_sample_rate,
|
||||||
thread_dsps,
|
thread_dsps,
|
||||||
@@ -317,6 +319,7 @@ pub const IQ_BLOCK_SIZE: usize = 4096;
|
|||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn iq_read_loop(
|
fn iq_read_loop(
|
||||||
|
spectrum_fft_size: usize,
|
||||||
mut source: Box<dyn IqSource>,
|
mut source: Box<dyn IqSource>,
|
||||||
sdr_sample_rate: u32,
|
sdr_sample_rate: u32,
|
||||||
channel_dsps: Arc<RwLock<Vec<Arc<Mutex<ChannelDsp>>>>>,
|
channel_dsps: Arc<RwLock<Vec<Arc<Mutex<ChannelDsp>>>>>,
|
||||||
@@ -335,7 +338,7 @@ fn iq_read_loop(
|
|||||||
};
|
};
|
||||||
let throttle = !source.is_blocking();
|
let throttle = !source.is_blocking();
|
||||||
|
|
||||||
let mut spectrum = SpectrumSnapshotter::new();
|
let mut spectrum = SpectrumSnapshotter::new(spectrum_fft_size);
|
||||||
let mut read_error_streak: u32 = 0;
|
let mut read_error_streak: u32 = 0;
|
||||||
let mut zero_read_streak: u32 = 0;
|
let mut zero_read_streak: u32 = 0;
|
||||||
let mut overflow_log_window_start: Option<Instant> = None;
|
let mut overflow_log_window_start: Option<Instant> = None;
|
||||||
@@ -572,6 +575,7 @@ mod tests {
|
|||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
NoiseBlankerConfig::default(),
|
NoiseBlankerConfig::default(),
|
||||||
&[(200_000.0, RigMode::USB, 3000)],
|
&[(200_000.0, RigMode::USB, 3000)],
|
||||||
|
1024,
|
||||||
);
|
);
|
||||||
assert_eq!(pipeline.pcm_senders.len(), 1);
|
assert_eq!(pipeline.pcm_senders.len(), 1);
|
||||||
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1);
|
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1);
|
||||||
@@ -590,6 +594,7 @@ mod tests {
|
|||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
NoiseBlankerConfig::default(),
|
NoiseBlankerConfig::default(),
|
||||||
&[],
|
&[],
|
||||||
|
1024,
|
||||||
);
|
);
|
||||||
assert_eq!(pipeline.pcm_senders.len(), 0);
|
assert_eq!(pipeline.pcm_senders.len(), 0);
|
||||||
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 0);
|
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 0);
|
||||||
|
|||||||
@@ -9,28 +9,36 @@ use num_complex::Complex;
|
|||||||
use rustfft::num_complex::Complex as FftComplex;
|
use rustfft::num_complex::Complex as FftComplex;
|
||||||
use rustfft::FftPlanner;
|
use rustfft::FftPlanner;
|
||||||
|
|
||||||
/// Number of FFT bins for the spectrum display.
|
/// Default number of FFT bins for the spectrum display, used when the config
|
||||||
pub(super) const SPECTRUM_FFT_SIZE: usize = 1024;
|
/// does not say otherwise.
|
||||||
|
pub(super) const DEFAULT_SPECTRUM_FFT_SIZE: usize = 1024;
|
||||||
|
|
||||||
/// Update the spectrum buffer every this many IQ blocks (~10 Hz at 1.92 MHz / 4096 block).
|
/// 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) const SPECTRUM_UPDATE_BLOCKS: usize = 4;
|
||||||
|
|
||||||
pub(super) struct SpectrumSnapshotter {
|
pub(super) struct SpectrumSnapshotter {
|
||||||
|
fft_size: usize,
|
||||||
hann_window: Vec<f32>,
|
hann_window: Vec<f32>,
|
||||||
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
|
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
|
||||||
counter: usize,
|
counter: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpectrumSnapshotter {
|
impl SpectrumSnapshotter {
|
||||||
pub(super) fn new() -> Self {
|
pub(super) fn new(fft_size: usize) -> Self {
|
||||||
let hann_window: Vec<f32> = (0..SPECTRUM_FFT_SIZE)
|
let fft_size = if fft_size >= 2 {
|
||||||
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (SPECTRUM_FFT_SIZE - 1) as f32).cos()))
|
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()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut planner = FftPlanner::<f32>::new();
|
let mut planner = FftPlanner::<f32>::new();
|
||||||
let fft = planner.plan_fft_forward(SPECTRUM_FFT_SIZE);
|
let fft = planner.plan_fft_forward(fft_size);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
fft_size,
|
||||||
hann_window,
|
hann_window,
|
||||||
fft,
|
fft,
|
||||||
counter: 0,
|
counter: 0,
|
||||||
@@ -48,7 +56,7 @@ impl SpectrumSnapshotter {
|
|||||||
}
|
}
|
||||||
self.counter = 0;
|
self.counter = 0;
|
||||||
|
|
||||||
let take = samples.len().min(SPECTRUM_FFT_SIZE);
|
let take = samples.len().min(self.fft_size);
|
||||||
let mut buf: Vec<FftComplex<f32>> = samples[..take]
|
let mut buf: Vec<FftComplex<f32>> = samples[..take]
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -59,16 +67,15 @@ impl SpectrumSnapshotter {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
buf.resize(SPECTRUM_FFT_SIZE, FftComplex::new(0.0, 0.0));
|
buf.resize(self.fft_size, FftComplex::new(0.0, 0.0));
|
||||||
self.fft.process(&mut buf);
|
self.fft.process(&mut buf);
|
||||||
|
|
||||||
let half = SPECTRUM_FFT_SIZE / 2;
|
let half = self.fft_size / 2;
|
||||||
let bins: Vec<f32> = buf[half..]
|
let bins: Vec<f32> = buf[half..]
|
||||||
.iter()
|
.iter()
|
||||||
.chain(buf[..half].iter())
|
.chain(buf[..half].iter())
|
||||||
.map(|value| {
|
.map(|value| {
|
||||||
let mag =
|
let mag = (value.re * value.re + value.im * value.im).sqrt() / self.fft_size as f32;
|
||||||
(value.re * value.re + value.im * value.im).sqrt() / SPECTRUM_FFT_SIZE as f32;
|
|
||||||
20.0 * mag.max(1e-10_f32).log10()
|
20.0 * mag.max(1e-10_f32).log10()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ pub struct SoapySdrConfig {
|
|||||||
pub nb_enabled: bool,
|
pub nb_enabled: bool,
|
||||||
/// Noise blanker impulse threshold multiplier.
|
/// Noise blanker impulse threshold multiplier.
|
||||||
pub nb_threshold: f64,
|
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 {
|
impl Default for SoapySdrConfig {
|
||||||
@@ -99,6 +104,7 @@ impl Default for SoapySdrConfig {
|
|||||||
max_virtual_channels: 4,
|
max_virtual_channels: 4,
|
||||||
nb_enabled: false,
|
nb_enabled: false,
|
||||||
nb_threshold: 10.0,
|
nb_threshold: 10.0,
|
||||||
|
spectrum_fft_size: 1024,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,6 +200,7 @@ impl SoapySdrRig {
|
|||||||
let max_virtual_channels = config.max_virtual_channels;
|
let max_virtual_channels = config.max_virtual_channels;
|
||||||
let nb_enabled = config.nb_enabled;
|
let nb_enabled = config.nb_enabled;
|
||||||
let nb_threshold = config.nb_threshold;
|
let nb_threshold = config.nb_threshold;
|
||||||
|
let spectrum_fft_size = config.spectrum_fft_size;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
|
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
|
||||||
args,
|
args,
|
||||||
@@ -293,6 +300,7 @@ impl SoapySdrRig {
|
|||||||
threshold: nb_threshold as f32,
|
threshold: nb_threshold as f32,
|
||||||
},
|
},
|
||||||
&all_channels,
|
&all_channels,
|
||||||
|
spectrum_fft_size,
|
||||||
));
|
));
|
||||||
|
|
||||||
let info = RigInfo {
|
let info = RigInfo {
|
||||||
@@ -418,6 +426,7 @@ impl SoapySdrRig {
|
|||||||
nb_threshold: f64,
|
nb_threshold: f64,
|
||||||
) -> DynResult<Self> {
|
) -> DynResult<Self> {
|
||||||
Self::new_from_config(SoapySdrConfig {
|
Self::new_from_config(SoapySdrConfig {
|
||||||
|
spectrum_fft_size: 1024,
|
||||||
args: args.to_string(),
|
args: args.to_string(),
|
||||||
channels: channels.to_vec(),
|
channels: channels.to_vec(),
|
||||||
gain_mode: gain_mode.to_string(),
|
gain_mode: gain_mode.to_string(),
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ mod tests {
|
|||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
NoiseBlankerConfig::default(),
|
NoiseBlankerConfig::default(),
|
||||||
&[(0.0, RigMode::USB, 3_000)],
|
&[(0.0, RigMode::USB, 3_000)],
|
||||||
|
1024,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+111
-4
@@ -1,36 +1,60 @@
|
|||||||
|
# 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]
|
[trx-server]
|
||||||
rigs = []
|
rigs = []
|
||||||
|
|
||||||
|
# Station identity. Coordinates feed PSKReporter and the map.
|
||||||
[trx-server.general]
|
[trx-server.general]
|
||||||
callsign = "N0CALL"
|
callsign = "N0CALL"
|
||||||
log_level = "info"
|
log_level = "info"
|
||||||
latitude = 52.2297
|
latitude = 52.2297
|
||||||
longitude = 21.0122
|
longitude = 21.0122
|
||||||
|
|
||||||
|
# Single-rig layout. For several radios, delete this and use [[rigs]].
|
||||||
[trx-server.rig]
|
[trx-server.rig]
|
||||||
model = "ft817"
|
model = "ft817"
|
||||||
initial_freq_hz = 144300000
|
initial_freq_hz = 144300000
|
||||||
initial_mode = "USB"
|
initial_mode = "USB"
|
||||||
|
|
||||||
|
# How to reach the radio: serial, tcp, or sdr.
|
||||||
[trx-server.rig.access]
|
[trx-server.rig.access]
|
||||||
type = "serial"
|
type = "serial"
|
||||||
port = "/dev/ttyUSB0"
|
port = "/dev/ttyUSB0"
|
||||||
baud = 9600
|
baud = 9600
|
||||||
|
|
||||||
|
# CAT polling and retry behaviour.
|
||||||
[trx-server.behavior]
|
[trx-server.behavior]
|
||||||
poll_interval_ms = 500
|
poll_interval_ms = 500
|
||||||
poll_interval_tx_ms = 100
|
poll_interval_tx_ms = 100
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
retry_base_delay_ms = 100
|
retry_base_delay_ms = 100
|
||||||
|
vfo_prime = true
|
||||||
|
|
||||||
|
# JSON control listener that trx-client connects to.
|
||||||
[trx-server.listen]
|
[trx-server.listen]
|
||||||
enabled = true
|
enabled = true
|
||||||
listen = "127.0.0.1"
|
listen = "127.0.0.1"
|
||||||
port = 4530
|
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]
|
[trx-server.listen.auth]
|
||||||
tokens = []
|
tokens = []
|
||||||
|
|
||||||
|
# Opus audio stream for trx-client.
|
||||||
[trx-server.audio]
|
[trx-server.audio]
|
||||||
enabled = true
|
enabled = true
|
||||||
listen = "127.0.0.1"
|
listen = "127.0.0.1"
|
||||||
@@ -42,25 +66,53 @@ channels = 2
|
|||||||
frame_duration_ms = 20
|
frame_duration_ms = 20
|
||||||
bitrate_bps = 256000
|
bitrate_bps = 256000
|
||||||
|
|
||||||
|
# Report FT8/FT4/WSPR spots to pskreporter.info.
|
||||||
[trx-server.pskreporter]
|
[trx-server.pskreporter]
|
||||||
enabled = false
|
enabled = false
|
||||||
host = "report.pskreporter.info"
|
host = "report.pskreporter.info"
|
||||||
port = 4739
|
port = 4739
|
||||||
|
|
||||||
|
# Forward received APRS frames to APRS-IS.
|
||||||
[trx-server.aprsfi]
|
[trx-server.aprsfi]
|
||||||
enabled = false
|
enabled = false
|
||||||
host = "rotate.aprs.net"
|
host = "rotate.aprs.net"
|
||||||
port = 14580
|
port = 14580
|
||||||
passcode = -1
|
passcode = -1
|
||||||
|
beacon = false
|
||||||
|
beacon_interval_secs = 1200
|
||||||
|
beacon_symbol_table = "/"
|
||||||
|
beacon_symbol_code = "-"
|
||||||
|
|
||||||
|
# Write decodes to JSON Lines files.
|
||||||
[trx-server.decode_logs]
|
[trx-server.decode_logs]
|
||||||
enabled = false
|
enabled = false
|
||||||
dir = "/path/to/log/dir"
|
dir = "/var/lib/trx-rs/decoders"
|
||||||
aprs_file = "TRXRS-APRS-%YYYY%-%MM%-%DD%.log"
|
aprs_file = "TRXRS-APRS-%YYYY%-%MM%-%DD%.log"
|
||||||
cw_file = "TRXRS-CW-%YYYY%-%MM%-%DD%.log"
|
cw_file = "TRXRS-CW-%YYYY%-%MM%-%DD%.log"
|
||||||
ft8_file = "TRXRS-FT8-%YYYY%-%MM%-%DD%.log"
|
ft8_file = "TRXRS-FT8-%YYYY%-%MM%-%DD%.log"
|
||||||
wspr_file = "TRXRS-WSPR-%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]
|
[trx-server.sdr]
|
||||||
sample_rate = 1920000
|
sample_rate = 1920000
|
||||||
bandwidth = 1500000
|
bandwidth = 1500000
|
||||||
@@ -68,17 +120,38 @@ wfm_deemphasis_us = 50
|
|||||||
center_offset_hz = 100000
|
center_offset_hz = 100000
|
||||||
channels = []
|
channels = []
|
||||||
max_virtual_channels = 4
|
max_virtual_channels = 4
|
||||||
|
spectrum_fft_size = 1024
|
||||||
|
spectrum_interval_ms = 50
|
||||||
|
|
||||||
|
# "auto" for hardware AGC, or "manual".
|
||||||
[trx-server.sdr.gain]
|
[trx-server.sdr.gain]
|
||||||
mode = "auto"
|
mode = "auto"
|
||||||
value = 30.0
|
value = 30.0
|
||||||
|
|
||||||
|
# Software squelch on demodulated audio.
|
||||||
[trx-server.sdr.squelch]
|
[trx-server.sdr.squelch]
|
||||||
enabled = false
|
enabled = false
|
||||||
threshold_db = -65.0
|
threshold_db = -65.0
|
||||||
hysteresis_db = 3.0
|
hysteresis_db = 3.0
|
||||||
tail_ms = 180
|
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]
|
[trx-client.general]
|
||||||
callsign = "N0CALL"
|
callsign = "N0CALL"
|
||||||
website_url = "https://haxx.space"
|
website_url = "https://haxx.space"
|
||||||
@@ -86,24 +159,52 @@ website_name = "haxx.space"
|
|||||||
ais_vessel_url_base = "https://www.vesselfinder.com/?mmsi="
|
ais_vessel_url_base = "https://www.vesselfinder.com/?mmsi="
|
||||||
log_level = "info"
|
log_level = "info"
|
||||||
|
|
||||||
|
# Legacy single-remote form; prefer [[remotes]] below.
|
||||||
[trx-client.remote]
|
[trx-client.remote]
|
||||||
url = "192.168.1.100:9000"
|
|
||||||
rig_id = "hf"
|
|
||||||
poll_interval_ms = 750
|
poll_interval_ms = 750
|
||||||
|
spectrum_interval_ms = 50
|
||||||
|
|
||||||
[trx-client.remote.auth]
|
[trx-client.remote.auth]
|
||||||
|
|
||||||
|
[[trx-client.remotes]]
|
||||||
|
name = "home-hf"
|
||||||
|
url = "192.168.1.100:4530"
|
||||||
|
rig_id = "hf"
|
||||||
|
poll_interval_ms = 750
|
||||||
|
spectrum_interval_ms = 50
|
||||||
|
|
||||||
|
[trx-client.remotes.auth]
|
||||||
token = "my-token"
|
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]
|
[trx-client.frontends.http]
|
||||||
enabled = true
|
enabled = true
|
||||||
listen = "127.0.0.1"
|
listen = "127.0.0.1"
|
||||||
port = 8080
|
port = 8080
|
||||||
default_rig_id = "hf"
|
default_rig_name = "home-hf"
|
||||||
initial_map_zoom = 10
|
initial_map_zoom = 10
|
||||||
spectrum_coverage_margin_hz = 50000
|
spectrum_coverage_margin_hz = 50000
|
||||||
spectrum_usable_span_ratio = 0.9200000166893005
|
spectrum_usable_span_ratio = 0.9200000166893005
|
||||||
show_sdr_gain_control = true
|
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]
|
[trx-client.frontends.http.auth]
|
||||||
enabled = false
|
enabled = false
|
||||||
rx_passphrase = "rx-passphrase-example"
|
rx_passphrase = "rx-passphrase-example"
|
||||||
@@ -113,6 +214,7 @@ session_ttl_min = 480
|
|||||||
cookie_secure = false
|
cookie_secure = false
|
||||||
cookie_same_site = "Lax"
|
cookie_same_site = "Lax"
|
||||||
|
|
||||||
|
# Hamlib-compatible TCP interface, one listener per rig.
|
||||||
[trx-client.frontends.rigctl]
|
[trx-client.frontends.rigctl]
|
||||||
enabled = false
|
enabled = false
|
||||||
listen = "127.0.0.1"
|
listen = "127.0.0.1"
|
||||||
@@ -120,6 +222,7 @@ port = 4532
|
|||||||
|
|
||||||
[trx-client.frontends.rigctl.rig_ports]
|
[trx-client.frontends.rigctl.rig_ports]
|
||||||
|
|
||||||
|
# JSON-over-TCP control interface.
|
||||||
[trx-client.frontends.http_json]
|
[trx-client.frontends.http_json]
|
||||||
enabled = true
|
enabled = true
|
||||||
listen = "127.0.0.1"
|
listen = "127.0.0.1"
|
||||||
@@ -128,12 +231,16 @@ port = 0
|
|||||||
[trx-client.frontends.http_json.auth]
|
[trx-client.frontends.http_json.auth]
|
||||||
tokens = []
|
tokens = []
|
||||||
|
|
||||||
|
# Where to fetch the audio stream from.
|
||||||
[trx-client.frontends.audio]
|
[trx-client.frontends.audio]
|
||||||
enabled = true
|
enabled = true
|
||||||
server_port = 4531
|
server_port = 4531
|
||||||
|
|
||||||
|
[trx-client.frontends.audio.rig_urls]
|
||||||
|
|
||||||
[trx-client.frontends.audio.rig_ports]
|
[trx-client.frontends.audio.rig_ports]
|
||||||
|
|
||||||
|
# Play RX audio on a local sound device and capture TX from one.
|
||||||
[trx-client.frontends.audio.bridge]
|
[trx-client.frontends.audio.bridge]
|
||||||
enabled = false
|
enabled = false
|
||||||
bitrate_bps = 192000
|
bitrate_bps = 192000
|
||||||
|
|||||||
Reference in New Issue
Block a user