Compare commits

..
Author SHA1 Message Date
sjgandClaude Opus 5 83b453135a [docs](trx-rs): settle the logbook's remaining questions
The clock is the server's, as asked: it is the machine at the radio, where
the browser may be a phone in another timezone with a clock nobody checked.
When the two disagree by more than a second the panel says so, rather than
logging a time the operator did not expect.

The rest, decided against how logging is actually done:

One station log, not one per rig.  DXCC, WAS and LoTW count the callsign, not
the radio, and a station worked on the second rig is still worked.  The rig
goes on the QSO as MY_RIG.  Station location does follow the rig, though —
these rigs can be in different places, so MY_GRIDSQUARE comes from the one
that made the contact, which is what LoTW's station locations expect too.

The operator is a per-QSO field set once per session.  ADIF separates the
callsign used on the air from the person at the key, and multi-operator
stations rotate people through one station callsign.  It defaults from the
configured callsign, so a single operator never touches it.  It cannot come
from the session: the auth roles are control and rx, with nobody's name on
them.

The log file is configurable, defaulting to the user's data directory.
Bookmarks sit in the config directory because they are settings and decode
logs in the cache directory because they are disposable; a QSO log is
irreplaceable, and cache directories get swept.

Import collisions match on callsign, band, mode and a two-minute window.
Loggers rarely agree to the second on the same QSO — one stamps the contact,
the other the entry — so an exact-minute key duplicates half of what it is
asked to merge.  Two minutes absorbs that without swallowing a legitimate
re-work, since contest rules forbid a second contact on the same band and
mode.  Times compare as instants so midnight matches, and modes are
normalised or an imported SSB would miss our USB.

That normalisation is now written down: a rig mode is not an ADIF mode.  DIG
is the one the rig cannot answer — a rig in DIG is in FT8 or FT4 depending on
what is decoding — and WSPR never opens an entry at all, because hearing a
beacon is not a contact.

Refs #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 20:37:04 +02:00
sjgandClaude Opus 5 1794f899f3 [docs](trx-rs): settle the logbook's panel, its prefill, and its formats
Three answers from the issue, folded into the proposal.

The logbook is a panel of its own rather than a strip on the radio page, and
it stands in every layout: a log can be kept without adopting the ham layout,
and read while another is selected.  The ham layout is then the arrangement
that starts from it, with the radio controls around it.

Prefill is exactly six fields — frequency, mode, rig name, time, callsign and
locator — and nothing else.  A signal report in particular stays empty: an
FT8 SNR is not what was sent, and prefilling one would put a number in the
log that nobody exchanged.  The station's own callsign and locator are not
per-entry fields at all; they are station identity, shown once at the top of
the panel and written into the QSO from configuration.

The file format was left to me.  ADIF stays, because it is not one option
among several: LoTW, eQSL, Club Log, QRZ and every other logger read it and
nothing else, so a log that cannot write it cannot be uploaded, confirmed or
moved.  Nothing on disk is ADI regardless — the store is JSON Lines.  The
second format is Cabrillo 3.0, which ADIF cannot replace: contest logs are
submitted in it and rejected in anything else.  It lands with the contest
exchange fields, since without a serial or a zone it has nothing to write.

Refs #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 20:28:28 +02:00
sjgandClaude Opus 5 ebe0462b45 [docs](trx-rs): propose the logbook and the ham radio layout
Issue #54 asks for a ham radio layout and an ADIF logbook, with no detail
behind either.  This is what they would be: what the logbook has to hold, how
it is stored, where ADIF is read and written, and which of the existing parts
each half hangs off.

Two things it settles before any code is written.  The log is append-only
JSON Lines rather than the whole-file dump the bookmarks use, because a
station with forty thousand QSOs would rewrite megabytes to log one contact
and lose the lot if the power went mid-dump.  And a decode is not a QSO: the
decoders only ever heard something, so a decoded callsign pre-fills an entry
and never writes one.

The layout is a fifth entry in the operator layouts that already exist,
gated on the rig being able to transmit.

Refs #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 19:58:48 +02:00
sjgandClaude Opus 5 a19633e81f [fix](trx-frontend-http): rebuild the background decode panel around one list
CI / frontend (push) Successful in 4m45s
CI / reuse (push) Successful in 5s
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 8m37s
The panel listed the same bookmarks twice: a status card at the top naming
the selected ones and what each was doing, and a checklist below naming all
of them with the checkboxes.  Neither list said which row in the other it
meant, so choosing what to decode and reading what was being decoded were two
passes over the same names.  The checklist itself sat squeezed against the
right-hand edge, because the row it lives in sets `align-items: flex-end` and
an inline `flex-direction: column` turned that into "push everything right".

It is one list now.  A row carries its checkbox, its name, its frequency,
mode and decoder, and its own state, so picking and watching happen in the
same place.  What the rig can hear moves up beside the switch, where it
explains why a selected bookmark reads out of span, and the selection adds
itself up under the list.  The states lose the ✓/△/✗ they carried next to a
dot that already said as much, and say what they mean: "Out of span",
"Scheduler has it", "Nobody listening", each with the reason on hover.  The
empty list now says which of the two reasons it is empty for, and what to do.

Save was live from the moment the panel opened, with nothing to save; it now
offers itself only when something has changed.

Two races behind it, both of which left the panel useless rather than ugly:

  * The rig was whatever the panel was handed at load.  Loading before the rig
    list arrived handed it null, and the next telling only came when the
    operator switched rigs — so the panel stayed empty and silent.  Every rig
    list refresh now passes the rig on, and both panels ignore one they have.

  * The settings panels are wired once the session is up, but their modules
    import asynchronously and the wiring skipped whichever had not arrived.
    A panel that missed it kept dead buttons for the rest of the session: no
    filter, no Select All, no Save.  Wiring runs again when the modules land.

Closes #52

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 19:54:36 +02:00
sjgandClaude Opus 5 c8b6f2d536 [fix](trx-frontend-http): stop freezing the page in the browser cache
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m37s
CI / frontend (pull_request) Successful in 4m33s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Failing after 14m3s
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 3m39s
CI / reuse (push) Successful in 6s
index.html, the stylesheets and the entry bundles are all served from fixed
URLs and answered with `public, max-age=31536000, immutable`.  Nothing in
those URLs changes when the bytes behind them do, and immutable tells the
browser not to ask, so a client that visited once could go on running the
page it downloaded then — for a year, with the build-stamped ETag never
consulted.  That is how a layout fix ships and one browser still shows the
old behaviour while every other one has it: not a rendering difference, a
copy of last week's stylesheet.

Only the shared chunks are content-addressed — esbuild hashes their names —
so only they can be kept forever.  Everything served from a stable URL now
answers `no-cache`, which asks and gets a 304 in the ordinary case, at the
cost of one conditional request per asset per load.  Vendored files with a
version in the URL stay immutable; Leaflet, whose URL does not name its
version, revalidates with the rest.

Covered both ways: a unit test on the policy each asset gets, and endpoint
tests that the page, the stylesheet and app.js come back revalidating with an
ETag, and that an unchanged one answers 304 under the same policy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 10:50:26 +02:00
sjgandClaude Opus 5 17300170cc [test](trx-frontend-http): read the statistics counters past their formatting
CI / test (pull_request) Successful in 7m52s
CI / lint (push) Successful in 2m19s
CI / lint (pull_request) Successful in 2m23s
CI / frontend (pull_request) Successful in 4m31s
CI / reuse (pull_request) Successful in 5s
CI / test (push) Successful in 7m43s
CI / frontend (push) Successful in 3m41s
CI / reuse (push) Successful in 6s
The new assertion parsed the decode counter with Number() on its text.  The
counters are written with toLocaleString(), so the 1220 records the history
fixture serves arrive as "1,220" and the parse gave NaN.  It passed here and
failed on CI because the count in the local repro was 18 — below the point
where grouping appears — and the runner's locale groups where mine did not.

Read the digits and ignore the separator, whichever one the locale picks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 09:43:43 +02:00
sjgandClaude Opus 5 e41c13917d [fix](trx-frontend-http): put HF APRS on the map, under its own source
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m39s
CI / frontend (pull_request) Failing after 1m24s
CI / reuse (pull_request) Successful in 5s
The HF APRS list never plotted anything.  Its plugin ships in the map plugin
group and its packets carry positions, but nothing ever handed one to the
map, so a station heard on 30 m appeared in the panel and nowhere else — and
unlike the replay gaps around it, no reload brought it back.

Plot it, and not as more VHF APRS.  HF is a different band and a different
path, and lumping the two together would leave no way to tell them apart or
to look at one without the other, so it goes on as a source of its own: its
own colour, its own chip in the map's Show filter, its own entry in the
source legend, and its own clear.

Station entries are keyed by source and callsign rather than callsign alone,
so a station worked on both bands keeps a marker for each while the popups,
the search text and the tracks still show the callsign as heard.

decode-flow feeds an HF beacon alongside the VHF one and checks all of it:
both reach the map under their own sources, the Show row offers HF APRS next
to APRS, and turning that chip off takes the HF station off the map while the
VHF one stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 09:05:26 +02:00
sjgandClaude Opus 5 ae7df31d91 [fix](trx-frontend-http): replay what arrived before a lazy view loaded
CI / lint (pull_request) Successful in 2m23s
CI / test (pull_request) Successful in 8m36s
CI / frontend (pull_request) Failing after 1m23s
CI / reuse (pull_request) Successful in 5s
Opening the Map or Statistics page showed only what had been decoded since
the moment it was opened, and a reload — landing straight on the tab, so its
module loads at startup ahead of the history — was the only way to see the
rest.  Two things were being thrown away.

The decode log the Statistics page counts lives in the map module, which is
lazy.  Recording into a module that is not loaded yet is a no-op, and unlike
the map markers nothing replayed the log when it finally arrived, so every
decode heard before the first visit was simply never counted.  Hold those
records in the client and hand them over when the module attaches.

The map's own replay covered APRS, AIS and VDES, whose plugins implement
syncMap, but not the grid squares: the FTx family and WSPR plotted locators
as decodes arrived and had no replay at all, so everything they heard before
the map loaded was lost, and the unique-grid counter with it.  Both plot
through a helper now, which their syncMap replays oldest first.  A replayed
WSPR spot carries the frequency it was heard on rather than one worked out
against wherever the dial has moved to since.

Pinned in decode-flow, whose history fixture gains FT8 and WSPR spots: after
a first visit the statistics count every stored decode and every grid square,
which before this change were 0 and 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 08:56:06 +02:00
25 changed files with 1456 additions and 345 deletions
+252
View File
@@ -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.
+1 -1
View File
@@ -464,7 +464,7 @@ and each page answers that differently:
|------|-------| |------|-------|
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. | | Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. | | Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. | | Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. HF APRS is a source of its own there, filtered apart from VHF APRS. |
| Statistics | The whole station, including the per-rig comparison. | | Statistics | The whole station, including the per-rig comparison. |
Switching rigs repaints the radio and digital modes pages for the rig now Switching rigs repaints the radio and digital modes pages for the rig now
@@ -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 || "");
} }
@@ -5889,6 +5889,7 @@ function navigateToTab(name, options = {}) {
if (leavingSatellites) window.clearSatPredictionDom?.(); if (leavingSatellites) window.clearSatPredictionDom?.();
void loadPluginsForTab(name).then(() => { void loadPluginsForTab(name).then(() => {
if (name === "satellites") window.refreshSatPredictions?.(); if (name === "satellites") window.refreshSatPredictions?.();
flushPendingDecodeStats();
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
}); });
@@ -6000,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) {
@@ -6271,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();
@@ -7619,11 +7624,35 @@ var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
"sstv", "sstv",
"sstv_progress" "sstv_progress"
]); ]);
var pendingDecodeStats = [];
var PENDING_DECODE_STATS_MAX = 5e4;
function recordDecodeStat(kind, rig, tsMs) {
const stats = window.trx.modules.map;
if (stats) {
stats.statsRecordDecode(kind, rig, tsMs);
return;
}
pendingDecodeStats.push({ kind, rig, tsMs });
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
}
}
function flushPendingDecodeStats() {
const stats = window.trx.modules.map;
if (!stats || pendingDecodeStats.length === 0) return;
for (const entry of pendingDecodeStats.splice(0)) {
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
}
stats.scheduleStatsRender();
}
function scheduleStatsRenderIfLoaded() {
window.trx.modules.map?.scheduleStatsRender();
}
function dispatchDecodeMessage(msg, skipStats = false) { function dispatchDecodeMessage(msg, skipStats = false) {
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg); if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) { if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null); recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender(); scheduleStatsRenderIfLoaded();
} }
} }
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512; var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
@@ -7669,9 +7698,9 @@ function restoreDecodeHistoryGroup(kind, messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (!IMAGE_DECODE_KINDS.has(kind)) { if (!IMAGE_DECODE_KINDS.has(kind)) {
for (const msg of messages) { for (const msg of messages) {
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0); recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
} }
window.trx.modules.map?.scheduleStatsRender(); scheduleStatsRenderIfLoaded();
} }
window.trxPluginRuntime.restore(kind, messages); window.trxPluginRuntime.restore(kind, messages);
} }
@@ -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");
@@ -168,19 +168,22 @@ function initializeFtxDecoder(config) {
} }
messagesElement.replaceChildren(fragment); messagesElement.replaceChildren(fragment);
}; };
const normalize = (message) => { const plotLocator = (message) => {
const raw = message.message ?? ""; const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw); const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
if (grids.length === 0) return;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
const frequency = displayFrequency(message.freq_hz); const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) { bridge.mapAddLocator?.(raw, grids, id, station, {
bridge.mapAddLocator?.(raw, grids, id, station, { ...message,
...message, freq_hz: frequency ?? message.freq_hz,
freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails
locator_details: locatorDetails });
}); };
} const normalize = (message) => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
return { return {
// The rig that heard it, kept so the mini view can tell a decode of the // The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band. // rig on screen from one a background rig made on another band.
@@ -240,6 +243,10 @@ function initializeFtxDecoder(config) {
rerender: () => { rerender: () => {
bridge.updateFt8Bar?.(); bridge.updateFt8Bar?.();
render(); render();
},
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => {
for (const message of [...history].reverse()) plotLocator(message);
} }
}); });
bridge.registerFt8FamilyBarRenderer?.(id, barFrames); bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
@@ -1,6 +1,6 @@
import { import {
initializeFtxDecoder initializeFtxDecoder
} from "./chunk-PAKFJPA2.js"; } from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js"; import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
@@ -1,6 +1,6 @@
import { import {
initializeFtxDecoder initializeFtxDecoder
} from "./chunk-PAKFJPA2.js"; } from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js"; import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
@@ -2,7 +2,7 @@ import {
initializeFt8FamilyBar, initializeFt8FamilyBar,
initializeFtxDecoder, initializeFtxDecoder,
installFtxCompatibilityHelpers installFtxCompatibilityHelpers
} from "./chunk-PAKFJPA2.js"; } from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js"; import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
@@ -149,17 +149,32 @@ function resetHfAprsHistoryView() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = []; hfAprsPacketHistory = [];
renderHfAprsHistory(); renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
} }
function pruneHfAprsHistoryView() { function pruneHfAprsHistoryView() {
pruneHfAprsPacketHistory(); pruneHfAprsPacketHistory();
renderHfAprsHistory(); renderHfAprsHistory();
} }
function plotHfAprsPacket(pkt) {
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
hfAprsWindow.aprsMapAddStation(
pkt.srcCall ?? "",
pkt.lat,
pkt.lon,
pkt.info ?? "",
pkt.symbolTable,
pkt.symbolCode,
pkt,
"hf_aprs"
);
}
function addHfAprsPacket(pkt) { function addHfAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs; pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
hfAprsPacketHistory.unshift(pkt); hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory(); pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender(); scheduleHfAprsHistoryRender();
} }
function normalizeServerHfAprsPacket(pkt) { function normalizeServerHfAprsPacket(pkt) {
@@ -174,6 +189,7 @@ function onServerHfAprsBatch(packets) {
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotHfAprsPacket(next);
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -248,5 +264,9 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerHfAprsBatch, restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView, reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView, prune: pruneHfAprsHistoryView,
rerender: renderHfAprsHistory rerender: renderHfAprsHistory,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry);
}
}); });
@@ -60,7 +60,7 @@ var mapWindow = window;
const decodeContactPaths = /* @__PURE__ */ new Map(); const decodeContactPaths = /* @__PURE__ */ new Map();
let selectedMapQsoKey = null; let selectedMapQsoKey = null;
const mapMarkers = /* @__PURE__ */ new Set(); const mapMarkers = /* @__PURE__ */ new Set();
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
const MAP_FILTER_ALL_KEY = "__all"; const MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() }; const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
@@ -128,6 +128,12 @@ var mapWindow = window;
} }
return trimmed; return trimmed;
} }
function aprsEntrySource(entry) {
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
}
function aprsStationKey(call, source) {
return source === "aprs" ? call : `${source}:${call}`;
}
function refreshAprsTrack(call, entry) { function refreshAprsTrack(call, entry) {
if (!entry) return; if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
@@ -149,7 +155,7 @@ var mapWindow = window;
lineJoin: "round", lineJoin: "round",
interactive: false interactive: false
}); });
track.__trxType = "aprs"; track.__trxType = aprsEntrySource(entry);
track._aprsCall = call; track._aprsCall = call;
entry.track = track; entry.track = track;
} }
@@ -381,6 +387,7 @@ var mapWindow = window;
} }
function mapSourceLabel(type) { function mapSourceLabel(type) {
if (type === "bookmark") return "Bookmarks"; if (type === "bookmark") return "Bookmarks";
if (type === "hf_aprs") return "HF APRS";
return String(type || "").toUpperCase(); return String(type || "").toUpperCase();
} }
function locatorFilterColor(type) { function locatorFilterColor(type) {
@@ -396,6 +403,7 @@ var mapWindow = window;
if (type === "vdes") return "#a78bfa"; if (type === "vdes") return "#a78bfa";
if (type === "sat") return "#f59e0b"; if (type === "sat") return "#f59e0b";
if (type === "aprs") return "#00d17f"; if (type === "aprs") return "#00d17f";
if (type === "hf_aprs") return "#fb7185";
return locatorFilterColor(type); return locatorFilterColor(type);
} }
function bandForHz(hz) { function bandForHz(hz) {
@@ -931,10 +939,8 @@ var mapWindow = window;
} }
} }
for (const entry of stationMarkers.values()) { for (const entry of stationMarkers.values()) {
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) { if (!entry?.visibleInHistoryWindow) continue;
availableSources.add("aprs"); availableSources.add(aprsEntrySource(entry));
break;
}
} }
const bandMap = /* @__PURE__ */ new Map(); const bandMap = /* @__PURE__ */ new Map();
for (const entry of locatorMarkers.values()) { for (const entry of locatorMarkers.values()) {
@@ -966,7 +972,7 @@ var mapWindow = window;
for (const key of Array.from(mapLocatorFilter.bands)) { for (const key of Array.from(mapLocatorFilter.bands)) {
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key); if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
} }
const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({ const sourceItems = ["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
key, key,
label: mapSourceLabel(key), label: mapSourceLabel(key),
color: mapSourceColor(key), color: mapSourceColor(key),
@@ -1028,9 +1034,10 @@ var mapWindow = window;
} }
return parts.join(" ").toLowerCase(); return parts.join(" ").toLowerCase();
} }
if (type === "aprs") { if (type === "aprs" || type === "hf_aprs") {
const call = marker?._aprsCall ? String(marker._aprsCall) : ""; const key = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(call); const entry = stationMarkers.get(key);
const call = entry?.call ?? key;
const info = entry?.info ? String(entry.info) : ""; const info = entry?.info ? String(entry.info) : "";
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : ""; const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
return `${call} ${info} ${pktRaw}`.toLowerCase(); return `${call} ${info} ${pktRaw}`.toLowerCase();
@@ -1200,9 +1207,10 @@ var mapWindow = window;
} }
}; };
mapWindow.clearMapMarkersByType = function(type) { mapWindow.clearMapMarkersByType = function(type) {
if (type === "aprs") { if (type === "aprs" || type === "hf_aprs") {
selectedAprsTrackCall = null; selectedAprsTrackCall = null;
stationMarkers.forEach((entry) => { stationMarkers.forEach((entry, key) => {
if (aprsEntrySource(entry) !== type) return;
if (entry && entry.marker) { if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker); mapMarkers.delete(entry.marker);
@@ -1211,8 +1219,8 @@ var mapWindow = window;
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
mapMarkers.delete(entry.track); mapMarkers.delete(entry.track);
} }
stationMarkers.delete(key);
}); });
stationMarkers.clear();
return; return;
} }
if (type === "ais") { if (type === "ais") {
@@ -1448,13 +1456,15 @@ var mapWindow = window;
if (!ll) return; if (!ll) return;
const entry = stationMarkers.get(marker._aprsCall); const entry = stationMarkers.get(marker._aprsCall);
if (!entry) return; if (!entry) return;
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt)); const source = aprsEntrySource(entry);
const call = entry.call ?? String(marker._aprsCall);
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
refreshAprsTrack(String(marker._aprsCall), entry); refreshAprsTrack(String(marker._aprsCall), entry);
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) { if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
entry.track.addTo(aprsMap); entry.track.addTo(aprsMap);
} }
selectedAprsTrackCall = String(marker._aprsCall); selectedAprsTrackCall = String(marker._aprsCall);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds); setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
return; return;
} }
if (marker._aisMmsi) { if (marker._aisMmsi) {
@@ -1848,28 +1858,33 @@ var mapWindow = window;
} }
return null; return null;
} }
function _aprsAddMarkerToMap(call, entry) { function _aprsAddMarkerToMap(key, entry) {
if (!aprsMap || entry.lat == null || entry.lon == null) return; if (!aprsMap || entry.lat == null || entry.lon == null) return;
refreshAprsTrack(call, entry); refreshAprsTrack(key, entry);
const source = aprsEntrySource(entry);
const call = entry.call ?? key;
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? ""); const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
const color = mapSourceColor(source);
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], { const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
radius: 6, radius: 6,
color: "#00d17f", color,
fillColor: "#00d17f", fillColor: color,
fillOpacity: 0.8 fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(popupContent); }).addTo(aprsMap).bindPopup(popupContent);
marker.__trxType = "aprs"; marker.__trxType = source;
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
marker._aprsCall = call; marker._aprsCall = key;
entry.marker = marker; entry.marker = marker;
mapMarkers.add(marker); mapMarkers.add(marker);
} }
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
const nextPoint = [lat, lon]; const nextPoint = [lat, lon];
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
const msgRigId = pkt?.rig_id || T.lastActiveRigId; const msgRigId = pkt?.rig_id || T.lastActiveRigId;
const existing = stationMarkers.get(call); const stationSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
const key = aprsStationKey(call, stationSource);
const existing = stationMarkers.get(key);
if (existing) { if (existing) {
existing.pkt = pkt; existing.pkt = pkt;
existing.lat = lat; existing.lat = lat;
@@ -1888,7 +1903,8 @@ var mapWindow = window;
} else if (prevPoint) { } else if (prevPoint) {
prevPoint.tsMs = tsMs; prevPoint.tsMs = tsMs;
} }
pruneAprsEntry(call, existing, mapHistoryCutoffMs()); existing.call = call;
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([lat, lon]); existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -1899,7 +1915,8 @@ var mapWindow = window;
track: null, track: null,
trackHistory: [{ lat, lon, tsMs }], trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint], trackPoints: [nextPoint],
type: "aprs", type: stationSource,
call,
pkt, pkt,
lat, lat,
lon, lon,
@@ -1908,9 +1925,9 @@ var mapWindow = window;
symbolCode, symbolCode,
rigIds: new Set(msgRigId ? [msgRigId] : []) rigIds: new Set(msgRigId ? [msgRigId] : [])
}; };
stationMarkers.set(call, entry); stationMarkers.set(key, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs()); pruneAprsEntry(key, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry); if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
if (aprsMap) scheduleDecodeMapMaintenance(); if (aprsMap) scheduleDecodeMapMaintenance();
} }
}; };
@@ -95,6 +95,7 @@ function normalizeServerWsprMessage(msg) {
rfHz, rfHz,
history: { history: {
rig_id: msg.rig_id ?? null, rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, ts_ms: msg.ts_ms,
snr_db: msg.snr_db, snr_db: msg.snr_db,
@@ -110,12 +111,7 @@ function onServerWsprBatch(messages) {
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving"; if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { plotWsprLocator(msg);
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
});
}
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now(); next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history); normalized.push(next.history);
} }
@@ -255,15 +251,19 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
} }
})(); })();
}); });
function plotWsprLocator(msg) {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...rfHz === null ? {} : { freq_hz: rfHz }
});
}
function onServerWspr(msg) { function onServerWspr(msg) {
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving"; if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { plotWsprLocator(msg);
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
});
}
addWsprMessage(next.history); addWsprMessage(next.history);
} }
wsprWindow.trxPluginRuntime.registerDecoder({ wsprWindow.trxPluginRuntime.registerDecoder({
@@ -273,5 +273,9 @@ wsprWindow.trxPluginRuntime.registerDecoder({
restore: onServerWsprBatch, restore: onServerWsprBatch,
prune: pruneWsprHistoryView, prune: pruneWsprHistoryView,
reset: resetWsprHistoryView, reset: resetWsprHistoryView,
rerender: renderWsprHistory rerender: renderWsprHistory,
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => {
for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message);
}
}); });
@@ -1467,30 +1467,31 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;"> <div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
<div id="background-decode-panel" class="sch-panel"> <div id="background-decode-panel" class="sch-panel">
<div class="sch-toast" id="background-decode-toast" role="alert" aria-live="polite" style="display:none;"></div> <div class="sch-toast" id="background-decode-toast" role="alert" aria-live="polite" style="display:none;"></div>
<!-- Now Playing status card (moved to top) -->
<div class="now-playing-card">
<div id="background-decode-status-card" class="sch-status-card">No background decode bookmarks configured.</div>
</div>
<div class="sch-section"> <div class="sch-section">
<div class="sch-section-title">Configuration</div> <div class="sch-section-title">Background Decode</div>
<div class="sch-row"> <p class="bgd-intro">
<label class="sch-label bgd-toggle-wrap">Background decode Decodes saved bookmarks on hidden channels while you work another band.
<span class="bgd-toggle-row"> Needs an SDR rig, and a bookmark is only decoded while it falls inside
<input type="checkbox" id="background-decode-enabled" /> the span the rig is tuned across.
<span>Enable hidden background decoder channels</span> </p>
</span> <div class="bgd-controls">
<label class="bgd-enable">
<input type="checkbox" id="background-decode-enabled" />
<span>Enabled</span>
</label> </label>
<span id="bgd-span-summary" class="bgd-span" aria-live="polite"></span>
</div> </div>
<div class="sch-row" style="flex-direction:column;gap:0.5rem;"> <div class="bgd-list-toolbar">
<label class="sch-label" style="min-width:100%;">Bookmarks <input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter"
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter" placeholder="Filter bookmarks..." /> placeholder="Filter bookmarks&hellip;" 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 || "");
} }
@@ -4912,6 +4916,8 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
// Passes go stale while the page is closed, so each visit reloads them. // Passes go stale while the page is closed, so each visit reloads them.
// The first visit is what imports the module, which renders on its own. // The first visit is what imports the module, which renders on its own.
if (name === "satellites") window.refreshSatPredictions?.(); if (name === "satellites") window.refreshSatPredictions?.();
// The map module owns the decode log; this is the moment it can exist.
flushPendingDecodeStats();
}).catch((error: unknown) => { console.error(error); }); }).catch((error: unknown) => { console.error(error); });
if (name === "map") { if (name === "map") {
_initMapWhenReady(); _initMapWhenReady();
@@ -5034,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) {
@@ -5187,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();
@@ -6597,11 +6616,49 @@ const IMAGE_DECODE_KINDS = new Set([
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress", "lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
]); ]);
// The decode log the Statistics page counts lives in the map module, which is
// lazy: it arrives when the Map or Statistics tab is first opened, long after
// the history replayed and the live decodes started coming. Recording into a
// module that is not there yet dropped every one of them, and nothing replayed
// them afterwards -- so the page opened empty and only filled in from decodes
// heard after it, which is why it took a reload (landing on the tab, so the
// module loads at startup) to show the whole picture. Hold them here until
// the module arrives, then hand them over.
interface PendingDecodeStat { kind: string; rig: string | null; tsMs: number | undefined }
const pendingDecodeStats: PendingDecodeStat[] = [];
// The module's own log keeps 50k entries; there is no point holding more here.
const PENDING_DECODE_STATS_MAX = 50_000;
function recordDecodeStat(kind: string, rig: string | null, tsMs?: number) {
const stats = window.trx.modules.map;
if (stats) {
stats.statsRecordDecode(kind, rig, tsMs);
return;
}
pendingDecodeStats.push({ kind, rig, tsMs });
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
}
}
function flushPendingDecodeStats() {
const stats = window.trx.modules.map;
if (!stats || pendingDecodeStats.length === 0) return;
for (const entry of pendingDecodeStats.splice(0)) {
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
}
stats.scheduleStatsRender();
}
function scheduleStatsRenderIfLoaded() {
window.trx.modules.map?.scheduleStatsRender();
}
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) { function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg); if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) { if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null); recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender(); scheduleStatsRenderIfLoaded();
} }
} }
@@ -6651,9 +6708,9 @@ function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
// Record statistics for restored history messages. // Record statistics for restored history messages.
if (!IMAGE_DECODE_KINDS.has(kind)) { if (!IMAGE_DECODE_KINDS.has(kind)) {
for (const msg of messages) { for (const msg of messages) {
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined); recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
} }
window.trx.modules.map?.scheduleStatsRender(); scheduleStatsRenderIfLoaded();
} }
window.trxPluginRuntime.restore(kind, messages); window.trxPluginRuntime.restore(kind, messages);
} }
@@ -31,7 +31,7 @@ function mapEl(id: string): MapElement {
return element as MapElement; return element as MapElement;
} }
type DecoderSource = "ais" | "vdes" | "aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat"; type DecoderSource = "ais" | "vdes" | "aprs" | "hf_aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
interface TrackPoint { lat: number; lon: number; tsMs: number } interface TrackPoint { lat: number; lon: number; tsMs: number }
interface DecodeDetail { interface DecodeDetail {
station?: string | null; source?: string | null; target?: string | null; station?: string | null; source?: string | null; target?: string | null;
@@ -83,6 +83,9 @@ interface MapEntry {
bookmarks?: Array<Record<string, unknown>>; bookmarks?: Array<Record<string, unknown>>;
bounds?: Leaflet.LatLngBoundsExpression; bounds?: Leaflet.LatLngBoundsExpression;
symbolTable?: string; symbolCode?: string; bandLabel?: string | null; symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
/** Callsign as heard. The map key qualifies it with the source, so that a
* station worked on both 144 MHz and 30 m keeps one marker per band. */
call?: string;
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null; overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
pathKey?: string; sourceGrid?: string; targetGrid?: string; pathKey?: string; sourceGrid?: string; targetGrid?: string;
from?: LatLon; to?: LatLon; from?: LatLon; to?: LatLon;
@@ -171,7 +174,7 @@ interface MapWindow {
clearMapMarkersByType?(type: DecoderSource): void; clearMapMarkersByType?(type: DecoderSource): void;
navigateToAprsMap?(lat: number, lon: number): void; navigateToAprsMap?(lat: number, lon: number): void;
navigateToMapLocator?(grid: string, preferredType?: string | null): void; navigateToMapLocator?(grid: string, preferredType?: string | null): void;
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage): void; aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage, source?: "aprs" | "hf_aprs"): void;
aisMapAddVessel?(message: MapMessage): void; aisMapAddVessel?(message: MapMessage): void;
vdesMapAddPoint?(message: MapMessage): void; vdesMapAddPoint?(message: MapMessage): void;
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void; syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
@@ -228,7 +231,7 @@ const mapWindow = window as unknown as MapWindow;
const decodeContactPaths = new Map<string, MapEntry>(); const decodeContactPaths = new Map<string, MapEntry>();
let selectedMapQsoKey: string | null = null; let selectedMapQsoKey: string | null = null;
const mapMarkers = new Set<TrxLayer>(); const mapMarkers = new Set<TrxLayer>();
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER }; const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
/** Chip key that clears a selection rather than naming a band or a source. */ /** Chip key that clears a selection rather than naming a band or a source. */
const MAP_FILTER_ALL_KEY = "__all"; const MAP_FILTER_ALL_KEY = "__all";
@@ -302,6 +305,18 @@ const mapWindow = window as unknown as MapWindow;
return trimmed; return trimmed;
} }
/** The source a station marker belongs to, defaulting to VHF APRS for
* entries stored before HF APRS had a source of its own. */
function aprsEntrySource(entry: MapEntry | null | undefined): DecoderSource {
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
}
/** Map key for a station: the callsign alone on VHF, source-qualified on HF,
* so one station heard on both bands keeps a marker for each. */
function aprsStationKey(call: string, source: DecoderSource): string {
return source === "aprs" ? call : `${source}:${call}`;
}
function refreshAprsTrack(call: string, entry: MapEntry): void { function refreshAprsTrack(call: string, entry: MapEntry): void {
if (!entry) return; if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
@@ -323,7 +338,7 @@ const mapWindow = window as unknown as MapWindow;
lineJoin: "round", lineJoin: "round",
interactive: false, interactive: false,
}) as unknown as TrxLayer; }) as unknown as TrxLayer;
track.__trxType = "aprs"; track.__trxType = aprsEntrySource(entry);
track._aprsCall = call; track._aprsCall = call;
entry.track = track; entry.track = track;
} }
@@ -575,6 +590,7 @@ const mapWindow = window as unknown as MapWindow;
function mapSourceLabel(type: DecoderSource): string { function mapSourceLabel(type: DecoderSource): string {
if (type === "bookmark") return "Bookmarks"; if (type === "bookmark") return "Bookmarks";
if (type === "hf_aprs") return "HF APRS";
return String(type || "").toUpperCase(); return String(type || "").toUpperCase();
} }
@@ -594,6 +610,8 @@ const mapWindow = window as unknown as MapWindow;
if (type === "vdes") return "#a78bfa"; if (type === "vdes") return "#a78bfa";
if (type === "sat") return "#f59e0b"; if (type === "sat") return "#f59e0b";
if (type === "aprs") return "#00d17f"; if (type === "aprs") return "#00d17f";
// Far enough from the VHF green to tell the two apart at a glance.
if (type === "hf_aprs") return "#fb7185";
return locatorFilterColor(type); return locatorFilterColor(type);
} }
@@ -1188,10 +1206,8 @@ const mapWindow = window as unknown as MapWindow;
} }
} }
for (const entry of stationMarkers.values()) { for (const entry of stationMarkers.values()) {
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) { if (!entry?.visibleInHistoryWindow) continue;
availableSources.add("aprs"); availableSources.add(aprsEntrySource(entry));
break;
}
} }
const bandMap = new Map<string, FilterChip>(); const bandMap = new Map<string, FilterChip>();
for (const entry of locatorMarkers.values()) { for (const entry of locatorMarkers.values()) {
@@ -1225,7 +1241,7 @@ const mapWindow = window as unknown as MapWindow;
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key); if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
} }
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[]) const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
.filter((key) => availableSources.has(key)) .filter((key) => availableSources.has(key))
.map((key) => ({ .map((key) => ({
key, key,
@@ -1296,9 +1312,10 @@ const mapWindow = window as unknown as MapWindow;
} }
return parts.join(" ").toLowerCase(); return parts.join(" ").toLowerCase();
} }
if (type === "aprs") { if (type === "aprs" || type === "hf_aprs") {
const call = marker?._aprsCall ? String(marker._aprsCall) : ""; const key = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(call); const entry = stationMarkers.get(key);
const call = entry?.call ?? key;
const info = entry?.info ? String(entry.info) : ""; const info = entry?.info ? String(entry.info) : "";
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : ""; const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
return `${call} ${info} ${pktRaw}`.toLowerCase(); return `${call} ${info} ${pktRaw}`.toLowerCase();
@@ -1496,9 +1513,10 @@ const mapWindow = window as unknown as MapWindow;
}; };
mapWindow.clearMapMarkersByType = function(type) { mapWindow.clearMapMarkersByType = function(type) {
if (type === "aprs") { if (type === "aprs" || type === "hf_aprs") {
selectedAprsTrackCall = null; selectedAprsTrackCall = null;
stationMarkers.forEach((entry) => { stationMarkers.forEach((entry, key) => {
if (aprsEntrySource(entry) !== type) return;
if (entry && entry.marker) { if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker); mapMarkers.delete(entry.marker);
@@ -1507,8 +1525,8 @@ const mapWindow = window as unknown as MapWindow;
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
mapMarkers.delete(entry.track); mapMarkers.delete(entry.track);
} }
stationMarkers.delete(key);
}); });
stationMarkers.clear();
return; return;
} }
@@ -1781,13 +1799,15 @@ const mapWindow = window as unknown as MapWindow;
if (!ll) return; if (!ll) return;
const entry = stationMarkers.get(marker._aprsCall); const entry = stationMarkers.get(marker._aprsCall);
if (!entry) return; if (!entry) return;
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt)); const source = aprsEntrySource(entry);
const call = entry.call ?? String(marker._aprsCall);
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
refreshAprsTrack(String(marker._aprsCall), entry); refreshAprsTrack(String(marker._aprsCall), entry);
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) { if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
entry.track.addTo(aprsMap); entry.track.addTo(aprsMap);
} }
selectedAprsTrackCall = String(marker._aprsCall); selectedAprsTrackCall = String(marker._aprsCall);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds); setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
return; return;
} }
@@ -2270,28 +2290,33 @@ const mapWindow = window as unknown as MapWindow;
return null; return null;
} }
function _aprsAddMarkerToMap(call: string, entry: MapEntry): void { function _aprsAddMarkerToMap(key: string, entry: MapEntry): void {
if (!aprsMap || entry.lat == null || entry.lon == null) return; if (!aprsMap || entry.lat == null || entry.lon == null) return;
refreshAprsTrack(call, entry); refreshAprsTrack(key, entry);
const source = aprsEntrySource(entry);
const call = entry.call ?? key;
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? ""); const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
const color = mapSourceColor(source);
const marker = (icon const marker = (icon
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
: L.circleMarker([entry.lat, entry.lon], { : L.circleMarker([entry.lat, entry.lon], {
radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8 radius: 6, color, fillColor: color, fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer; }).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
marker.__trxType = "aprs"; marker.__trxType = source;
marker.__trxRigIds = entry.rigIds || new Set(); marker.__trxRigIds = entry.rigIds || new Set();
marker._aprsCall = call; marker._aprsCall = key;
entry.marker = marker; entry.marker = marker;
mapMarkers.add(marker); mapMarkers.add(marker);
} }
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
const nextPoint: Leaflet.LatLngTuple = [lat, lon]; const nextPoint: Leaflet.LatLngTuple = [lat, lon];
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
const msgRigId = pkt?.rig_id || T.lastActiveRigId; const msgRigId = pkt?.rig_id || T.lastActiveRigId;
const existing = stationMarkers.get(call); const stationSource: DecoderSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
const key = aprsStationKey(call, stationSource);
const existing = stationMarkers.get(key);
if (existing) { if (existing) {
existing.pkt = pkt; existing.pkt = pkt;
existing.lat = lat; existing.lat = lat;
@@ -2310,7 +2335,8 @@ const mapWindow = window as unknown as MapWindow;
} else if (prevPoint) { } else if (prevPoint) {
prevPoint.tsMs = tsMs; prevPoint.tsMs = tsMs;
} }
pruneAprsEntry(call, existing, mapHistoryCutoffMs()); existing.call = call;
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([lat, lon]); existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -2321,7 +2347,8 @@ const mapWindow = window as unknown as MapWindow;
track: null, track: null,
trackHistory: [{ lat, lon, tsMs }], trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint], trackPoints: [nextPoint],
type: "aprs", type: stationSource,
call,
pkt, pkt,
lat, lat,
lon, lon,
@@ -2330,9 +2357,9 @@ const mapWindow = window as unknown as MapWindow;
symbolCode, symbolCode,
rigIds: new Set(msgRigId ? [msgRigId] : []), rigIds: new Set(msgRigId ? [msgRigId] : []),
}; };
stationMarkers.set(call, entry); stationMarkers.set(key, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs()); pruneAprsEntry(key, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry); if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
if (aprsMap) scheduleDecodeMapMaintenance(); if (aprsMap) scheduleDecodeMapMaintenance();
} }
}; };
@@ -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;
@@ -242,19 +242,28 @@ export function initializeFtxDecoder(config: FtxConfig): void {
} }
messagesElement.replaceChildren(fragment); messagesElement.replaceChildren(fragment);
}; };
const normalize = (message: FtxMessage): FtxMessage => { /** Hands a decode's grid squares to the map, if the map module is loaded yet.
* Split out of normalize so a decode can be replayed onto a map that
* arrived later: the module is lazy, and everything decoded before it
* loaded had nowhere to go. */
const plotLocator = (message: FtxMessage): void => {
const raw = message.message ?? ""; const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0 const grids = locatorDetails.length > 0
? locatorDetails.map(({ grid }) => grid) ? locatorDetails.map(({ grid }) => grid)
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw); : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
if (grids.length === 0) return;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
// Already an RF frequency on a replay, an audio offset on arrival; the
// conversion only fires below 100 kHz, so it is right either way.
const frequency = displayFrequency(message.freq_hz); const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) { bridge.mapAddLocator?.(raw, grids, id, station, {
bridge.mapAddLocator?.(raw, grids, id, station, { ...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails, });
}); };
} const normalize = (message: FtxMessage): FtxMessage => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
return { return {
// The rig that heard it, kept so the mini view can tell a decode of the // The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band. // rig on screen from one a background rig made on another band.
@@ -312,6 +321,8 @@ export function initializeFtxDecoder(config: FtxConfig): void {
prune: () => { prune(); render(); }, prune: () => { prune(); render(); },
reset, reset,
rerender: () => { bridge.updateFt8Bar?.(); render(); }, rerender: () => { bridge.updateFt8Bar?.(); render(); },
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => { for (const message of [...history].reverse()) plotLocator(message); },
}); });
bridge.registerFt8FamilyBarRenderer?.(id, barFrames); bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
@@ -20,6 +20,12 @@ interface HfAprsBridge {
getDecodeHistoryRetentionMs?: () => number; getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void; trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
navigateToAprsMap?: (lat: number, lon: number) => void; navigateToAprsMap?: (lat: number, lon: number) => void;
aprsMapAddStation?: (
call: string, lat: number, lon: number, info: string,
symbolTable: string | null | undefined, symbolCode: string | null | undefined,
packet: AprsPacket, source: "aprs" | "hf_aprs",
) => void;
clearMapMarkersByType?: (type: string) => void;
getDecodeRigMeta?: () => unknown; getDecodeRigMeta?: () => unknown;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
@@ -177,6 +183,7 @@ function resetHfAprsHistoryView(): void {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = []; hfAprsPacketHistory = [];
renderHfAprsHistory(); renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
} }
function pruneHfAprsHistoryView(): void { function pruneHfAprsHistoryView(): void {
@@ -184,6 +191,17 @@ function pruneHfAprsHistoryView(): void {
renderHfAprsHistory(); renderHfAprsHistory();
} }
/** Hands a positioned packet to the map, if the map module is loaded yet.
* HF traffic goes on as its own source: it is a different band and a
* different path, and the map filter offers it separately. */
function plotHfAprsPacket(pkt: AprsPacket): void {
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
hfAprsWindow.aprsMapAddStation(
pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "",
pkt.symbolTable, pkt.symbolCode, pkt, "hf_aprs",
);
}
function addHfAprsPacket(pkt: AprsPacket): void { function addHfAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs; pkt._tsMs = tsMs;
@@ -192,6 +210,8 @@ function addHfAprsPacket(pkt: AprsPacket): void {
hfAprsPacketHistory.unshift(pkt); hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory(); pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender(); scheduleHfAprsHistoryRender();
} }
@@ -209,6 +229,7 @@ function onServerHfAprsBatch(packets: AprsPacket[]): void {
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotHfAprsPacket(next);
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -290,4 +311,6 @@ renderHfAprsHistory();
reset: resetHfAprsHistoryView, reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView, prune: pruneHfAprsHistoryView,
rerender: renderHfAprsHistory, rerender: renderHfAprsHistory,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry); },
}); });
@@ -17,6 +17,9 @@ interface WsprMessage {
dt_s?: number | undefined; dt_s?: number | undefined;
freq_hz?: number | undefined; freq_hz?: number | undefined;
rig_id?: string | null | undefined; rig_id?: string | null | undefined;
/** RF frequency the spot was heard on, kept so a map replay does not
* recompute it against wherever the dial has moved to since. */
_rfHz?: number | null | undefined;
receiver?: unknown; receiver?: unknown;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -135,6 +138,7 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
rfHz, rfHz,
history: { history: {
rig_id: msg.rig_id ?? null, rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, ts_ms: msg.ts_ms,
snr_db: msg.snr_db, snr_db: msg.snr_db,
@@ -152,12 +156,7 @@ function onServerWsprBatch(messages: WsprMessage[]): void {
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's. // "Receiving" is the panel's own status, and the panel is the rig's.
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving"; if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { plotWsprLocator(msg);
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
});
}
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now(); next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history); normalized.push(next.history);
} }
@@ -317,15 +316,24 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
})(); })();
}); });
/** Hands a spot's grid squares to the map, if the map module is loaded yet.
* The module is lazy, so a spot heard before it arrived has to be replayable. */
function plotWsprLocator(msg: WsprMessage): void {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
// A replayed spot carries the frequency it was heard on; a fresh one has it
// worked out from the dial it just arrived against.
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(rfHz === null ? {} : { freq_hz: rfHz }),
});
}
function onServerWspr(msg: WsprMessage): void { function onServerWspr(msg: WsprMessage): void {
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving"; if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { plotWsprLocator(msg);
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
});
}
addWsprMessage(next.history); addWsprMessage(next.history);
} }
@@ -337,4 +345,6 @@ wsprWindow.trxPluginRuntime.registerDecoder({
prune: pruneWsprHistoryView, prune: pruneWsprHistoryView,
reset: resetWsprHistoryView, reset: resetWsprHistoryView,
rerender: renderWsprHistory, rerender: renderWsprHistory,
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => { for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message); },
}); });
@@ -0,0 +1,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");
@@ -25,6 +25,13 @@ const BEACON = {
// A second rig listening in the background, on its own band. Both pages // A second rig listening in the background, on its own band. Both pages
// describe the rig on screen, so its traffic belongs in neither the panel nor // describe the rig on screen, so its traffic belongs in neither the panel nor
// the mini view — only on the map, which shows the whole station. // the mini view — only on the map, which shows the whole station.
// HF APRS travels a different band and a different path from the VHF list, so
// it goes on the map under a source of its own that the filter can hide.
const HF_BEACON = {
type: "hf_aprs", src_call: "SP5HF-7", dest_call: "APRS", path: "WIDE2-2", info: "HF beacon",
packet_type: "position", crc_ok: true, lat: 50.06, lon: 19.94,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
const OTHER_RIG_VESSEL = { const OTHER_RIG_VESSEL = {
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY", ...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
lat: 51.92, lon: 4.48, rig_id: "rig-b", lat: 51.92, lon: 4.48, rig_id: "rig-b",
@@ -32,7 +39,7 @@ const OTHER_RIG_VESSEL = {
// AIS is what the mini view for vessels is gated on; the rig has to be on it. // AIS is what the mini view for vessels is gated on; the rig has to be on it.
const fixture = await startWebFixture({ const fixture = await startWebFixture({
spectrum: true, decodes: [VESSEL, BEACON, OTHER_RIG_VESSEL], mode: "AIS", spectrum: true, decodes: [VESSEL, BEACON, HF_BEACON, OTHER_RIG_VESSEL], mode: "AIS",
}); });
const { browser, page, runtimeErrors } = await startBrowser(chromium); const { browser, page, runtimeErrors } = await startBrowser(chromium);
@@ -104,6 +111,36 @@ try {
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) }; return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
}); });
assert.ok(markers.stations > 0, "the APRS station never reached the map"); assert.ok(markers.stations > 0, "the APRS station never reached the map");
// Both APRS stations are on it, each under its own source: the HF one used
// to be dropped entirely, since the HF list never plotted anything.
const aprsSources = await page.evaluate(() => {
const entries = [...(window.trx.modules.map?.stationMarkers ?? new Map()).entries()];
return entries.map(([key, entry]) => `${entry?.type ?? "?"}:${entry?.call ?? key}`).sort();
});
assert.deepEqual(aprsSources, ["aprs:SP2SJG-9", "hf_aprs:SP5HF-7"],
`the map holds ${JSON.stringify(aprsSources)}`);
// And the filter offers HF APRS on a chip of its own, next to the VHF one.
await page.locator('#map-locator-phase .map-locator-phase-btn[data-phase="type"]').click();
await page.waitForTimeout(400);
const chips = await page.evaluate(() => [...document.querySelectorAll("#map-locator-choice-filter .map-locator-chip")]
.map((chip) => chip.dataset.filterKey));
assert.ok(chips.includes("aprs") && chips.includes("hf_aprs"),
`the Show chips are ${JSON.stringify(chips)}`);
// Turning that chip off takes the HF station off the map and leaves the VHF
// one on: the two are filtered apart, which is the whole point of the split.
await page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="hf_aprs"]').click();
await page.waitForTimeout(500);
const shownAfterFilter = await page.evaluate(() => {
const map = window.trx.modules.map;
const onMap = [];
(map?.stationMarkers ?? new Map()).forEach((entry, key) => {
if (entry?.marker && map.aprsMap?.hasLayer(entry.marker)) onMap.push(entry?.call ?? key);
});
return onMap.sort();
});
assert.deepEqual(shownAfterFilter, ["SP2SJG-9"],
`hiding HF APRS left ${JSON.stringify(shownAfterFilter)} on the map`);
// The map is the whole station's view — the one place a background rig's // The map is the whole station's view — the one place a background rig's
// traffic belongs — so both vessels are on it even though the panel and the // traffic belongs — so both vessels are on it even though the panel and the
// mini view show only the selected rig's. // mini view show only the selected rig's.
@@ -120,6 +157,8 @@ try {
// left the client on its retry path and the history path untested. // left the client on its retry path and the history path untested.
const HISTORY_AIS = 900; const HISTORY_AIS = 900;
const HISTORY_APRS = 300; const HISTORY_APRS = 300;
const HISTORY_FT8 = 12;
const HISTORY_WSPR = 8;
const historyFixture = await startWebFixture({ const historyFixture = await startWebFixture({
spectrum: true, spectrum: true,
mode: "AIS", mode: "AIS",
@@ -129,6 +168,14 @@ const historyFixture = await startWebFixture({
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1, vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})), })),
ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({
message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2,
freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({
message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5,
freq_hz: 1500 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({ aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1", src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
info: `history ${index}`, packet_type: "position", crc_ok: true, info: `history ${index}`, packet_type: "position", crc_ok: true,
@@ -216,6 +263,27 @@ try {
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`); assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`); assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
// The Statistics page counts the same history. Its decode log lives in the
// map module, which is lazy, so every decode that arrived before the module
// did used to be recorded into nothing at all — the page opened empty and
// only filled in from decodes heard afterwards, and it took a reload landing
// on the tab (module loaded at startup, before the history) to show the lot.
await replay.page.evaluate(() => window.navigateToTab("statistics"));
await replay.page.waitForTimeout(1500);
// The counters are written with toLocaleString(), so a four-figure count
// arrives as "1,220" — whichever separator the runner's locale picks. Read
// the digits rather than the formatting.
const counted = await replay.page.evaluate(() => {
const count = (id) => Number((document.getElementById(id)?.textContent ?? "").replace(/\D/g, ""));
return { decodes: count("stats-total-decodes"), grids: count("stats-unique-grids") };
});
assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR,
`the statistics counted ${counted.decodes} decodes`);
// Grid squares come from the FT8 and WSPR spots, which had no map replay of
// their own: the locators of everything heard before the map loaded were lost.
assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR,
`the statistics counted ${counted.grids} grid squares`);
assert.deepEqual(replay.runtimeErrors, []); assert.deepEqual(replay.runtimeErrors, []);
} finally { } finally {
await replay.browser.close(); await replay.browser.close();
@@ -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() {