Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83b453135a | ||
|
|
1794f899f3 | ||
|
|
ebe0462b45 | ||
|
|
a19633e81f | ||
|
|
c8b6f2d536 |
@@ -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.
|
||||||
|
|||||||
@@ -2905,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 || "");
|
||||||
}
|
}
|
||||||
@@ -6001,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) {
|
||||||
@@ -6272,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();
|
||||||
|
|||||||
+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");
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -1468,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 || "");
|
||||||
}
|
}
|
||||||
@@ -5036,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) {
|
||||||
@@ -5189,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();
|
||||||
|
|||||||
+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;
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user