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
sjgandClaude Opus 5 bf3bcc8a84 [fix](trx-frontend-http): show one rig at a time on the digital modes page
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m34s
CI / test (push) Successful in 7m47s
CI / frontend (pull_request) Successful in 4m28s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m21s
CI / frontend (push) Successful in 3m37s
CI / reuse (push) Successful in 6s
A client connected to several rigs decodes all of them at once, and the
decode stream carries every rig's traffic to every browser.  The decoder
panels listed all of it: a station a background rig copied on another band
appeared in the APRS list next to the selected rig's, the vessel counts and
the "latest seen" lines counted both, the status lines said "Receiving"
because some other rig was, and the CW pane interleaved two rigs into one
stream of text that read as neither.

The page is about the rig the operator selected — the one whose spectrum is
on screen and whose audio is playing — so each panel now shows what that rig
heard: rows, counts, latest-seen, status, the live picture a WEFAX or SSTV
frame is painting, and the CW pane.

Nothing is dropped on the way in.  The map is the whole station's view, has
its own rig filter, and would empty out if the plugins stopped feeding it, so
the histories still hold every rig and the map still plots them.  That also
means a switch loses nothing: the runtime gained a rerender hook, which the
rig switch calls, and switching back brings the other rig's traffic up again.
The CW pane is the exception — a running stream of text cannot be unpicked
after the fact — so it starts empty on the rig switched to.

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:11:13 +02:00
sjgandClaude Opus 5 86dd36312e [test](trx-frontend-http): type the frequency instead of filling it
CI / lint (pull_request) Successful in 2m20s
CI / test (pull_request) Successful in 8m32s
CI / frontend (pull_request) Successful in 4m27s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m26s
CI / test (push) Successful in 7m42s
CI / frontend (push) Successful in 3m35s
CI / reuse (push) Successful in 5s
tune-links drove the dial with Playwright's fill(), which writes a value
into the field without a keystroke.  The app arms its guard against its own
refreshes on the first keydown, so a filled field stays unguarded: any state
update landing between the fill and the Enter rewrites the field with the
frequency the radio is already on, and the Enter then re-applies that.  The
window is a few milliseconds wide on a developer's machine and wide enough
to lose on a loaded CI runner, where the test failed claiming the tuning had
landed on the frequency it started from.

Type it the way an operator does: select the field, then send the characters
as keystrokes.  The select arms the guard before a single character changes.

Under CPU throttling that reproduced the failure — 1 in 6 runs with fill(),
on this branch and on main alike — typing came through 8 runs clean.

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 02:47:04 +02:00
sjgandClaude Opus 5 15ff686542 [fix](trx-frontend-http): keep the mini views to the rig on screen
CI / lint (pull_request) Successful in 2m23s
CI / test (pull_request) Successful in 8m34s
CI / frontend (pull_request) Failing after 1m32s
CI / reuse (pull_request) Successful in 6s
The decode SSE stream and the history behind it are not rig-scoped: every
rig's decodes reach the browser, each carrying the rig that heard it.  The
panels on the decoder tabs want that — they aggregate the whole station —
but the mini views over the waterfall caption the spectrum underneath, and
they were reading the same unfiltered histories.  A background rig copying
APRS on another band put its frames over the active rig's waterfall.  The
mode gate did not help: it reads the mode of the rig on screen, so those
frames appeared whenever that rig happened to be in PKT.

Filter each overlay on the rig it belongs to, through one shared predicate
that compares a decode's rig_id with the per-tab active rig already driving
the spectrum and the audio.  A decode that names no rig, and a session that
has not learnt its rig list yet, still show everything.

The FTx normalizer was dropping rig_id on the floor, so it now keeps it.
CW needed more than a filter: its lines accumulate character by character,
so two rigs copying at once braided their text into one unreadable line.
Lines in progress are now kept per rig.

The bar repaints in render() move into refreshDecodeBars(), which the rig
switch calls as well — otherwise the outgoing rig's frames stayed on screen
until the next state update — and which finally includes the CW bar.

Closes #49

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 00:46:39 +02:00
sjgandClaude Opus 5 b78c4a4dd4 [feat](trx-rs): make spectrum affordable over a slow link
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m36s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 6s
CI / frontend (pull_request) Successful in 4m27s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m48s
Spectrum dominates the server↔client connection, and all three things that
govern its cost were working against a poor link.

**It was polled, one round trip per frame.** The client asked for a frame every
50 ms on a dedicated connection and waited for the reply, so the frame rate was
capped at 1/RTT — on a 200 ms link, five frames a second no matter what was
configured.  Add SubscribeSpectrum alongside the existing SubscribeMeter: the
server pushes frames from a per-rig broadcast that rig_task fills only while
somebody is subscribed.  A server too old to know the command answers with an
error and leaves the connection usable, so the client falls back to polling on
the same connection without reconnecting.

**Bins were JSON floats.** 1024 bins spelled out as decimal text is around
10 KB a frame, ~200 KB/s at full rate — while the very next hop, client to
browser, already sends the same information as base64 i8 in about 1.4 KB.  Bins
now travel base64-encoded whole dBFS, the resolution the display draws at
anyway.  Decoding still accepts the old array form.

**Nothing was tunable.** [sdr].spectrum_fft_size and [sdr].spectrum_interval_ms
replace the compile-time FFT size and cadence; [[remotes]].spectrum_interval_ms
lets the client ask for less.  512 bins at 5 frames/s is roughly 3.5 KB/s
against roughly 200 KB/s before.

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 00:15:50 +02:00
sjgandClaude Opus 5 f396e8f235 [fix](trx-frontend-http): give satellite passes their own page
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m50s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 6s
Pass predictions were a third view inside the Weather Satellite Decoder card,
under Digital modes — a planning tool filed behind a decoder toggle, beside the
FT8 and WEFAX panels it has nothing to do with.  Nothing about knowing when a
bird comes over belongs there.

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

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

Closes #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 00:06:00 +02:00
74 changed files with 3964 additions and 1074 deletions
Generated
+1
View File
@@ -3165,6 +3165,7 @@ dependencies = [
name = "trx-core" name = "trx-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"flate2", "flate2",
"reqwest", "reqwest",
"serde", "serde",
+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.
+66
View File
@@ -153,6 +153,13 @@ When audio is enabled, at least one of `rx_enabled` or `tx_enabled` must be true
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz | | `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz | | `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur | | `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
| `spectrum_fft_size` | usize | `1024` | Spectrum FFT bins; power of two, 1288192 |
| `spectrum_interval_ms` | u64 | `50` | How often a spectrum frame is pushed to subscribed clients |
Spectrum is the largest thing on the client connection. On a slow or
high-latency link, halving `spectrum_fft_size` halves the bytes per frame (at
half the frequency resolution) and raising `spectrum_interval_ms` sends fewer of
them; see [Spectrum over a slow link](#spectrum-over-a-slow-link).
#### `[sdr.gain]` #### `[sdr.gain]`
@@ -295,6 +302,7 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `url` | string | — | Server address (e.g. `localhost:4530`) | | `url` | string | — | Server address (e.g. `localhost:4530`) |
| `poll_interval_ms` | u64 | `750` | State poll interval | | `poll_interval_ms` | u64 | `750` | State poll interval |
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
#### `[remote.auth]` #### `[remote.auth]`
@@ -394,6 +402,41 @@ older single `port` key and `--rigctl-port` are ignored.
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
loopback on Linux, BlackHole on macOS). loopback on Linux, BlackHole on macOS).
### Spectrum over a slow link
Spectrum dominates the server↔client connection: everything else is a few
hundred bytes, a frame is a few kilobytes. Three things govern what it costs.
**Frames are pushed, not polled.** The client subscribes and the server sends
frames at `[sdr].spectrum_interval_ms`. Polling cost a round trip per frame, so
the rate was capped at 1/RTT — on a 200 ms link you could not exceed 5 frames a
second however often the client asked. Clients fall back to polling
automatically against a server too old to stream.
**Bins travel as whole dBFS.** They are base64-encoded `i8` on the wire, about
an eighth of the JSON array of floats they used to be, at the resolution the
display draws anyway.
**Both ends have a rate, and the slower one wins.** The server pushes no faster
than `[sdr].spectrum_interval_ms`; the client asks for no more than
`[[remotes]].spectrum_interval_ms`.
For a link that struggles, start here:
```toml
[trx-server.sdr]
spectrum_fft_size = 512 # half the bins, half the bytes
spectrum_interval_ms = 200 # 5 frames/s instead of 20
[[trx-client.remotes]]
name = "remote-site"
url = "radio.example.com:4530"
spectrum_interval_ms = 200
```
That is roughly 0.7 KB per frame at 5 frames/s — about 3.5 KB/s, against
roughly 200 KB/s for 1024 float bins at 20 frames/s.
### CLI Override Summary ### CLI Override Summary
**trx-server:** **trx-server:**
@@ -411,6 +454,29 @@ listener and every rig's audio listener.
--- ---
## Multiple Rigs in the Web UI
A client connected to several rigs decodes all of them at once, whichever one
is on screen. The rig picker in the header decides which rig the UI is about,
and each page answers that differently:
| Page | Shows |
|------|-------|
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. HF APRS is a source of its own there, filtered apart from VHF APRS. |
| Statistics | The whole station, including the per-rig comparison. |
Switching rigs repaints the radio and digital modes pages for the rig now
selected. Nothing is lost by switching: the traffic other rigs heard is still
held, and switching back brings it up again. The one exception is the CW pane,
which is a single running stream of copied text rather than a list of frames,
so it starts empty on the rig you switch to.
Each browser tab keeps its own selection, so two tabs can watch two rigs.
---
## Tune Links ## Tune Links
Every page of the web UI carries what the radio is doing in its address, so the Every page of the web UI carries what the radio is doing in its address, so the
+10
View File
@@ -288,6 +288,7 @@ async fn async_init() -> DynResult<AppState> {
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone()); let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms); let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
vec![RemoteEntry { vec![RemoteEntry {
spectrum_interval_ms: cfg.remote.spectrum_interval_ms,
name, name,
url: url.clone(), url: url.clone(),
rig_id, rig_id,
@@ -483,6 +484,13 @@ async fn async_init() -> DynResult<AppState> {
.map(|e| e.poll_interval_ms) .map(|e| e.poll_interval_ms)
.min() .min()
.unwrap_or(750); .unwrap_or(750);
// Entries sharing a server share its connections, so the most frequent
// request wins: whoever wants spectrum fastest sets the rate.
let spectrum_interval = entries
.iter()
.map(|e| e.spectrum_interval_ms)
.min()
.unwrap_or_else(|| remote_client::DEFAULT_SPECTRUM_INTERVAL.as_millis() as u64);
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER); let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
for entry in entries { for entry in entries {
@@ -496,6 +504,8 @@ async fn async_init() -> DynResult<AppState> {
known_rigs: frontend_runtime.routing.remote_rigs.clone(), known_rigs: frontend_runtime.routing.remote_rigs.clone(),
rig_states: frontend_runtime.routing.rig_states.clone(), rig_states: frontend_runtime.routing.rig_states.clone(),
poll_interval: Duration::from_millis(poll_interval), poll_interval: Duration::from_millis(poll_interval),
spectrum_interval: Duration::from_millis(spectrum_interval),
spectrum_stream_unsupported: Arc::new(std::sync::atomic::AtomicBool::new(false)),
spectrum: frontend_runtime.spectrum.sender.clone(), spectrum: frontend_runtime.spectrum.sender.clone(),
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(), rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
server_connected: frontend_runtime.routing.server_connected.clone(), server_connected: frontend_runtime.routing.server_connected.clone(),
+271 -5
View File
@@ -20,7 +20,7 @@ use trx_core::{RigError, RigResult};
use trx_frontend::{RemoteRigEntry, SharedSpectrum}; use trx_frontend::{RemoteRigEntry, SharedSpectrum};
use trx_protocol::rig_command_to_client; use trx_protocol::rig_command_to_client;
use trx_protocol::types::RigEntry; use trx_protocol::types::RigEntry;
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate}; use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, SpectrumFrame};
// Endpoint parsing lives in `trx-config` so config validation and the // Endpoint parsing lives in `trx-config` so config validation and the
// connection code agree on what a URL means. // connection code agree on what a URL means.
@@ -32,9 +32,10 @@ const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
const MAX_JSON_LINE_BYTES: usize = 256 * 1024; const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3; const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
// Keep remote spectrum reasonably responsive without returning to the old // Default spectrum cadence when a config does not specify one. Both the push
// timeout churn caused by a much tighter request cadence. // stream and the poll fallback run at the configured rate; see
const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(50); // `[[remotes]].spectrum_interval_ms`.
pub const DEFAULT_SPECTRUM_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Clone)] #[derive(Clone)]
pub struct RemoteClientConfig { pub struct RemoteClientConfig {
@@ -43,6 +44,12 @@ pub struct RemoteClientConfig {
pub selected_rig_id: Arc<Mutex<Option<String>>>, pub selected_rig_id: Arc<Mutex<Option<String>>>,
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>, pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
pub poll_interval: Duration, pub poll_interval: Duration,
/// How often spectrum frames are wanted. Drives the poll fallback and is
/// the rate the client asks the server to push at.
pub spectrum_interval: Duration,
/// Set once a server has rejected `SubscribeSpectrum`, so later
/// connections to it go straight to polling instead of asking again.
pub spectrum_stream_unsupported: Arc<AtomicBool>,
/// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe. /// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe.
pub spectrum: Arc<watch::Sender<SharedSpectrum>>, pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
/// Shared flag: `true` while a TCP connection to trx-server is active. /// Shared flag: `true` while a TCP connection to trx-server is active.
@@ -491,6 +498,100 @@ async fn send_get_sat_passes_on(
)) ))
} }
/// What ended a spectrum stream attempt.
enum SpectrumStreamOutcome {
/// The stream ran and is over; the connection is spent.
Finished,
/// The server rejected the subscription. The connection is still usable,
/// so the caller can poll on it.
Unsupported,
}
/// Subscribe to the server's spectrum push for one rig and publish frames as
/// they arrive.
///
/// The server answers either with frames or, when it is too old to know the
/// command, with an error response — which leaves the connection usable for
/// polling, so falling back costs no reconnect.
async fn run_spectrum_stream(
config: &RemoteClientConfig,
writer: &mut (impl AsyncWriteExt + Unpin),
reader: &mut (impl AsyncBufRead + Unpin),
short_name: &str,
shutdown_rx: &mut watch::Receiver<bool>,
) -> RigResult<SpectrumStreamOutcome> {
let envelope = build_envelope(
config,
ClientCommand::SubscribeSpectrum,
Some(short_name.to_string()),
);
let mut payload = serde_json::to_string(&envelope)
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
payload.push('\n');
time::timeout(SPECTRUM_IO_TIMEOUT, writer.write_all(payload.as_bytes()))
.await
.map_err(|_| RigError::communication("spectrum subscribe write timed out".to_string()))?
.map_err(|e| RigError::communication(format!("spectrum subscribe write failed: {e}")))?;
time::timeout(SPECTRUM_IO_TIMEOUT, writer.flush())
.await
.map_err(|_| RigError::communication("spectrum subscribe flush timed out".to_string()))?
.map_err(|e| RigError::communication(format!("spectrum subscribe flush failed: {e}")))?;
// Re-check what the UI wants: switching rigs has to end this stream so the
// connection can be rebuilt for the new one.
let mut supervisor = time::interval(Duration::from_millis(500));
supervisor.tick().await;
loop {
tokio::select! {
changed = shutdown_rx.changed() => {
match changed {
Ok(()) if *shutdown_rx.borrow() => return Ok(SpectrumStreamOutcome::Finished),
Ok(()) => {}
Err(_) => return Ok(SpectrumStreamOutcome::Finished),
}
}
_ = supervisor.tick() => {
let wanted = active_spectrum_rig_ids(config);
if wanted.len() != 1 || wanted[0] != short_name {
return Ok(SpectrumStreamOutcome::Finished);
}
}
line = read_limited_line(reader, MAX_JSON_LINE_BYTES) => {
let line = line
.map_err(|e| RigError::communication(format!("spectrum read failed: {e}")))?
.ok_or_else(|| {
RigError::communication("spectrum connection closed".to_string())
})?;
let trimmed = line.trim_end();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<SpectrumFrame>(trimmed) {
Ok(frame) => publish_spectrum_frame(config, short_name, frame),
// Anything that is not a frame means the server would rather
// answer than stream: an older build rejecting the command.
Err(_) => return Ok(SpectrumStreamOutcome::Unsupported),
}
}
}
}
}
/// Publish one pushed frame to the per-rig and selected-rig watch channels.
fn publish_spectrum_frame(config: &RemoteClientConfig, short_name: &str, frame: SpectrumFrame) {
if let Ok(map) = config.rig_spectrums.read() {
if let Some(tx) = map.get(short_name) {
tx.send_modify(|s| s.set(Some(frame.spectrum.clone()), frame.vchan_rds.clone()));
}
}
if selected_rig_id(config).as_deref() == Some(short_name) {
config
.spectrum
.send_modify(|s| s.set(Some(frame.spectrum), frame.vchan_rds));
}
}
async fn handle_spectrum_connection( async fn handle_spectrum_connection(
config: &RemoteClientConfig, config: &RemoteClientConfig,
stream: TcpStream, stream: TcpStream,
@@ -498,7 +599,33 @@ async fn handle_spectrum_connection(
) -> RigResult<()> { ) -> RigResult<()> {
let (reader, mut writer) = stream.into_split(); let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader); let mut reader = BufReader::new(reader);
let mut interval = time::interval(SPECTRUM_POLL_INTERVAL);
// Prefer the push stream: polling costs a round trip per frame, so on a
// high-latency link the frame rate is 1/RTT no matter what interval is
// configured. It only works for one rig per connection, and only against
// a server new enough to understand the command.
let streamable = active_spectrum_rig_ids(config);
if streamable.len() == 1 && !config.spectrum_stream_unsupported.load(Ordering::Relaxed) {
match run_spectrum_stream(
config,
&mut writer,
&mut reader,
&streamable[0],
shutdown_rx,
)
.await?
{
SpectrumStreamOutcome::Finished => return Ok(()),
SpectrumStreamOutcome::Unsupported => {
info!("Server does not support spectrum streaming; falling back to polling");
config
.spectrum_stream_unsupported
.store(true, Ordering::Relaxed);
}
}
}
let mut interval = time::interval(config.spectrum_interval);
// Cache the token outside the poll loop to avoid cloning it every 50ms. // Cache the token outside the poll loop to avoid cloning it every 50ms.
let cached_token = config.token.clone(); let cached_token = config.token.clone();
@@ -1187,6 +1314,7 @@ mod tests {
use super::{has_short_names, resolve_server_rig_id, resolve_short_name}; use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
use super::{ use super::{
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum, parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
DEFAULT_SPECTRUM_INTERVAL,
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
@@ -1381,6 +1509,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)), selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())), known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(100), poll_interval: Duration::from_millis(100),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx), spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)), server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())), rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1426,6 +1556,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))), selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
known_rigs: Arc::new(Mutex::new(Vec::new())), known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500), poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx), spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)), server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())), rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1441,6 +1573,130 @@ mod tests {
assert_eq!(envelope.rig_id.as_deref(), Some("sdr")); assert_eq!(envelope.rig_id.as_deref(), Some("sdr"));
} }
fn stream_test_config(spectrum_tx: watch::Sender<SharedSpectrum>) -> super::RemoteClientConfig {
super::RemoteClientConfig {
addr: "127.0.0.1:4530".to_string(),
token: None,
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
rig_states: Arc::new(RwLock::new(HashMap::new())),
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
rig_id_to_short_name: HashMap::new(),
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
sat_passes: Arc::new(RwLock::new(None)),
rig_meters: Arc::new(RwLock::new(HashMap::new())),
}
}
/// A pushed frame reaches the watch channel the UI reads, with no request
/// from the client beyond the initial subscribe.
#[tokio::test]
async fn spectrum_stream_publishes_pushed_frames() {
use tokio::io::{AsyncBufReadExt, BufReader};
let (spectrum_tx, mut spectrum_rx) = watch::channel(SharedSpectrum::default());
let config = stream_test_config(spectrum_tx);
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
let (client_read, mut client_write) = tokio::io::split(client_io);
let mut client_read = BufReader::new(client_read);
let task = tokio::spawn(async move {
super::run_spectrum_stream(
&config,
&mut client_write,
&mut client_read,
"sdr",
&mut shutdown_rx,
)
.await
});
// The server sees the subscribe, then pushes without being asked.
let mut server = BufReader::new(&mut server_io);
let mut subscribe = String::new();
server.read_line(&mut subscribe).await.expect("subscribe");
assert!(
subscribe.contains("subscribe_spectrum"),
"unexpected command: {subscribe}"
);
let frame = trx_protocol::SpectrumFrame {
rig_id: "sdr".to_string(),
spectrum: trx_core::rig::state::SpectrumData {
bins: vec![-70.0, -30.0],
center_hz: 14_200_000,
sample_rate: 1_920_000,
rds: None,
},
vchan_rds: None,
};
let mut line = serde_json::to_string(&frame).unwrap();
line.push('\n');
server_io.write_all(line.as_bytes()).await.expect("push");
server_io.flush().await.expect("flush");
spectrum_rx.changed().await.expect("spectrum published");
let published = spectrum_rx.borrow().clone();
let spectrum = published.frame.expect("spectrum present");
assert_eq!(spectrum.center_hz, 14_200_000);
assert_eq!(spectrum.bins, vec![-70.0, -30.0]);
let _ = shutdown_tx.send(true);
let _ = task.await;
}
/// An older server answers the unknown command with an error instead of
/// frames. That has to read as "poll instead", not as a dead connection.
#[tokio::test]
async fn spectrum_stream_falls_back_when_unsupported() {
use tokio::io::{AsyncBufReadExt, BufReader};
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
let config = stream_test_config(spectrum_tx);
let (_shutdown_tx, mut shutdown_rx) = watch::channel(false);
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
let (client_read, mut client_write) = tokio::io::split(client_io);
let mut client_read = BufReader::new(client_read);
let task = tokio::spawn(async move {
super::run_spectrum_stream(
&config,
&mut client_write,
&mut client_read,
"sdr",
&mut shutdown_rx,
)
.await
});
let mut server = BufReader::new(&mut server_io);
let mut subscribe = String::new();
server.read_line(&mut subscribe).await.expect("subscribe");
server_io
.write_all(
b"{\"success\":false,\"state\":null,\"error\":\"Invalid JSON: unknown variant\"}\n",
)
.await
.expect("error response");
server_io.flush().await.expect("flush");
let outcome = task.await.expect("join").expect("stream result");
assert!(
matches!(outcome, super::SpectrumStreamOutcome::Unsupported),
"an error response should fall back to polling"
);
}
#[test] #[test]
fn build_envelope_translates_short_name_to_server_rig_id() { fn build_envelope_translates_short_name_to_server_rig_id() {
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default()); let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
@@ -1454,6 +1710,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))), selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
known_rigs: Arc::new(Mutex::new(Vec::new())), known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500), poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx), spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)), server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())), rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1486,6 +1744,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)), selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())), known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500), poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx), spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)), server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())), rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1510,6 +1770,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)), selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())), known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500), poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx), spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)), server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())), rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1550,6 +1812,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)), selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: known_rigs.clone(), known_rigs: known_rigs.clone(),
poll_interval: Duration::from_millis(500), poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx), spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)), server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())), rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1623,6 +1887,8 @@ mod tests {
selected_rig_id, selected_rig_id,
known_rigs, known_rigs,
poll_interval: Duration::from_millis(500), poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx), spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)), server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())), rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -140,18 +144,22 @@ function aisLatestByVessel(messages) {
} }
return Array.from(byMmsi.values()); return Array.from(byMmsi.values());
} }
function aisRigMessages() {
return forActiveRig(aisMessageHistory);
}
function updateAisSummary() { function updateAisSummary() {
const plan = currentAisChannelPlan(); const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) { if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`; aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
} }
const vessels = aisLatestByVessel(aisMessageHistory); const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
if (aisVesselCountEl) { if (aisVesselCountEl) {
const count = vessels.length; const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`; aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
} }
if (aisLatestSeenEl) { if (aisLatestSeenEl) {
const latest = aisMessageHistory[0]; const latest = rigMessages[0];
if (!latest) { if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet"; aisLatestSeenEl.textContent = "No traffic yet";
} else { } else {
@@ -222,7 +230,9 @@ function updateAisBar() {
updateAisSummary(); updateAisSummary();
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS"; const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS; const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs); const recent = aisMessageHistory.filter(
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)
);
const messages = aisLatestByVessel(recent).slice(0, 8); const messages = aisLatestByVessel(recent).slice(0, 8);
if (!isAis || messages.length === 0) { if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none"; aisBarOverlay.style.display = "none";
@@ -267,7 +277,7 @@ function renderAisHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const message of aisMessageHistory) { for (const message of aisRigMessages()) {
fragment.appendChild(renderAisRow(message)); fragment.appendChild(renderAisRow(message));
} }
aisMessagesEl.replaceChildren(fragment); aisMessagesEl.replaceChildren(fragment);
@@ -299,10 +309,10 @@ function normalizeServerAisMessage(msg) {
} }
function onServerAisBatch(messages) { function onServerAisBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerAisMessage(msg); const next = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -342,8 +352,9 @@ if (aisFilterInput) {
}); });
} }
function onServerAis(msg) { function onServerAis(msg) {
if (aisStatus) aisStatus.textContent = "Receiving"; const message = normalizeServerAisMessage(msg);
addAisMessage(normalizeServerAisMessage(msg)); if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
} }
updateAisSummary(); updateAisSummary();
window.trxPluginRuntime.registerDecoder({ window.trxPluginRuntime.registerDecoder({
@@ -353,6 +364,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAisBatch, restore: onServerAisBatch,
reset: resetAisHistoryView, reset: resetAisHistoryView,
prune: pruneAisHistoryView, prune: pruneAisHistoryView,
rerender: () => {
updateAisBar();
renderAisHistory();
},
// Oldest first, so vessel tracks are rebuilt in the order they happened. // Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { syncMap: () => {
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
@@ -885,7 +885,7 @@ function elementById(id) {
if (restoreFocus) more.focus(); if (restoreFocus) more.focus();
}; };
api.closeMobileOverlays = closeMore; api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => { ["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`); const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return; if (!source) return;
const item = document.createElement("button"); const item = document.createElement("button");
@@ -1065,6 +1065,9 @@ var runtime = {
plugin.prune(); plugin.prune();
return true; return true;
}, },
rerenderAll() {
for (const plugin of decoders.values()) plugin.rerender?.();
},
syncMapAll() { syncMapAll() {
for (const plugin of decoders.values()) plugin.syncMap?.(); for (const plugin of decoders.values()) plugin.syncMap?.();
}, },
@@ -1529,6 +1532,7 @@ var TAB_ORDER = [
"bookmarks", "bookmarks",
"digital-modes", "digital-modes",
"map", "map",
"satellites",
"statistics", "statistics",
"recorder", "recorder",
"settings", "settings",
@@ -1539,6 +1543,7 @@ var TAB_PATHS = {
bookmarks: "/bookmarks", bookmarks: "/bookmarks",
"digital-modes": "/digital-modes", "digital-modes": "/digital-modes",
map: "/map", map: "/map",
satellites: "/satellites",
statistics: "/statistics", statistics: "/statistics",
recorder: "/recorder", recorder: "/recorder",
settings: "/settings", settings: "/settings",
@@ -1756,6 +1761,7 @@ var pluginGroups = {
], ],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"], "map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"], map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"], statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"], bookmarks: ["/bookmarks.js"],
recorder: [], recorder: [],
@@ -2899,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 || "");
} }
@@ -3552,6 +3558,13 @@ function positionRdsPsOverlay() {
function resetRdsDisplay() { function resetRdsDisplay() {
updateRdsPsOverlay(primaryRds); updateRdsPsOverlay(primaryRds);
} }
function refreshDecodeBars() {
window.updateAisBar?.();
window.updateVdesBar?.();
window.updateAprsBar?.();
window.updateFt8Bar?.();
window.updateCwBar?.();
}
function resetDecoderStateOnRigSwitch() { function resetDecoderStateOnRigSwitch() {
primaryRds = null; primaryRds = null;
vchanRdsById = /* @__PURE__ */ new Map(); vchanRdsById = /* @__PURE__ */ new Map();
@@ -3566,6 +3579,8 @@ function resetDecoderStateOnRigSwitch() {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.textContent = "--"; if (el) el.textContent = "--";
}); });
refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
} }
function resetWfmStereoIndicator() { function resetWfmStereoIndicator() {
if (!wfmStFlagEl) return; if (!wfmStFlagEl) return;
@@ -4785,10 +4800,7 @@ function render(update) {
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets"; const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText); setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
} }
if (window.updateAisBar) window.updateAisBar(); refreshDecodeBars();
if (window.updateVdesBar) window.updateVdesBar();
if (window.updateAprsBar) window.updateAprsBar();
if (window.updateFt8Bar) window.updateFt8Bar();
for (const d of decoderRegistry) { for (const d of decoderRegistry) {
if (d.activation !== "toggle") continue; if (d.activation !== "toggle") continue;
const key = d.id.replace(/-/g, "_") + "_decode_enabled"; const key = d.id.replace(/-/g, "_") + "_decode_enabled";
@@ -5844,6 +5856,7 @@ function _initMapWhenReady() {
} }
function navigateToTab(name, options = {}) { function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.(); window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options; const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") { if (authEnabled && !authRole && name !== "main") {
showAuthGate(false); showAuthGate(false);
@@ -5873,7 +5886,11 @@ function navigateToTab(name, options = {}) {
updateTabHistory(name, replaceHistory); updateTabHistory(name, replaceHistory);
} }
scheduleSpectrumLayout(); scheduleSpectrumLayout();
void loadPluginsForTab(name).catch((error) => { if (leavingSatellites) window.clearSatPredictionDom?.();
void loadPluginsForTab(name).then(() => {
if (name === "satellites") window.refreshSatPredictions?.();
flushPendingDecodeStats();
}).catch((error) => {
console.error(error); console.error(error);
}); });
if (name === "map") { if (name === "map") {
@@ -5984,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) {
@@ -6255,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();
@@ -6320,9 +6341,6 @@ function _wireSubTabBar(bar) {
if (window.refreshCwTonePicker) window.refreshCwTonePicker(); if (window.refreshCwTonePicker) window.refreshCwTonePicker();
}); });
} }
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
window.clearSatPredictionDom();
}
}); });
} }
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar); document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
@@ -7606,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;
@@ -7656,9 +7698,9 @@ function restoreDecodeHistoryGroup(kind, messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (!IMAGE_DECODE_KINDS.has(kind)) { if (!IMAGE_DECODE_KINDS.has(kind)) {
for (const msg of messages) { for (const msg of messages) {
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0); recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
} }
window.trx.modules.map?.scheduleStatsRender(); scheduleStatsRenderIfLoaded();
} }
window.trxPluginRuntime.restore(kind, messages); window.trxPluginRuntime.restore(kind, messages);
} }
@@ -5,6 +5,10 @@ import {
normalizeAprsPacket, normalizeAprsPacket,
renderAprsPacketRow renderAprsPacketRow
} from "./chunk-OPEIVJGD.js"; } from "./chunk-OPEIVJGD.js";
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -82,20 +86,25 @@ function aprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase(); ].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(aprsFilterText); return haystack.includes(aprsFilterText);
} }
function aprsRigPackets() {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets() { function aprsVisiblePackets() {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory; const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(aprsFilterMatch); return packets.filter(aprsFilterMatch);
} }
function updateAprsSummary() { function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets(); const visible = aprsVisiblePackets();
if (aprsTotalCountEl) { if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`; aprsTotalCountEl.textContent = `${rigPackets.length} total`;
} }
if (aprsVisibleCountEl) { if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`; aprsVisibleCountEl.textContent = `${visible.length} shown`;
} }
if (aprsLatestSeenEl) { if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0]; const latest = rigPackets[0];
if (!latest) { if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet"; aprsLatestSeenEl.textContent = "No packets yet";
} else { } else {
@@ -153,7 +162,9 @@ function updateAprsBar() {
if (!aprsBarOverlay) return; if (!aprsBarOverlay) return;
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT"; const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS; const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs); const okFrames = aprsPacketHistory.filter(
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id)
);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8); const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0); const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) { if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
@@ -215,11 +226,11 @@ function normalizeServerAprsPacket(pkt) {
} }
function onServerAprsBatch(packets) { function onServerAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
let hasCrcOk = false; let hasCrcOk = false;
for (const pkt of packets) { for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt); const next = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -277,8 +288,9 @@ if (aprsFilterInput) {
}); });
} }
function onServerAprs(pkt) { function onServerAprs(pkt) {
if (aprsStatus) aprsStatus.textContent = "Receiving"; const packet = normalizeServerAprsPacket(pkt);
addAprsPacket(normalizeServerAprsPacket(pkt)); if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
} }
renderAprsHistory(); renderAprsHistory();
window.trxPluginRuntime.registerDecoder({ window.trxPluginRuntime.registerDecoder({
@@ -288,6 +300,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAprsBatch, restore: onServerAprsBatch,
reset: resetAprsHistoryView, reset: resetAprsHistoryView,
prune: pruneAprsHistoryView, prune: pruneAprsHistoryView,
rerender: () => {
updateAprsBar();
renderAprsHistory();
},
// Oldest first, so station tracks are rebuilt in the order they happened. // Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { syncMap: () => {
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
@@ -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");
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -153,9 +157,10 @@ function initializeFtxDecoder(config) {
const render = () => { const render = () => {
prune(); prune();
if (!messagesElement) return; if (!messagesElement) return;
const rigMessages = forActiveRig(history);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
let count = 0; let count = 0;
for (const message of history) { for (const message of rigMessages) {
if (count >= 200) break; if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue; if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message)); fragment.appendChild(renderRow(message));
@@ -163,20 +168,26 @@ function initializeFtxDecoder(config) {
} }
messagesElement.replaceChildren(fragment); messagesElement.replaceChildren(fragment);
}; };
const normalize = (message) => { const plotLocator = (message) => {
const raw = message.message ?? ""; const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw); const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
if (grids.length === 0) return;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
const frequency = displayFrequency(message.freq_hz); const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) { bridge.mapAddLocator?.(raw, grids, id, station, {
bridge.mapAddLocator?.(raw, grids, id, station, { ...message,
...message, freq_hz: frequency ?? message.freq_hz,
freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails
locator_details: locatorDetails });
}); };
} const normalize = (message) => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
return { return {
// The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band.
rig_id: message.rig_id ?? null,
receiver: bridge.getDecodeRigMeta?.() ?? null, receiver: bridge.getDecodeRigMeta?.() ?? null,
ts_ms: message.ts_ms, ts_ms: message.ts_ms,
snr_db: message.snr_db, snr_db: message.snr_db,
@@ -188,7 +199,9 @@ function initializeFtxDecoder(config) {
}; };
const receiveBatch = (messages) => { const receiveBatch = (messages) => {
if (messages.length === 0) return; if (messages.length === 0) return;
if (status) status.textContent = "Receiving"; if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
status.textContent = "Receiving";
}
history = messages.map(normalize).reverse().concat(history); history = messages.map(normalize).reverse().concat(history);
prune(); prune();
bridge.setFt8FamilyBarDecoder?.(id); bridge.setFt8FamilyBarDecoder?.(id);
@@ -202,7 +215,7 @@ function initializeFtxDecoder(config) {
bridge.clearMapMarkersByType?.(id); bridge.clearMapMarkersByType?.(id);
}; };
const barFrames = () => { const barFrames = () => {
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8); const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5 && isActiveRigDecode(message.rig_id)).slice(0, 8);
let html = ""; let html = "";
for (const message of recent) { for (const message of recent) {
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms); const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
@@ -226,7 +239,15 @@ function initializeFtxDecoder(config) {
prune(); prune();
render(); render();
}, },
reset reset,
rerender: () => {
bridge.updateFt8Bar?.();
render();
},
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => {
for (const message of [...history].reverse()) plotLocator(message);
}
}); });
bridge.registerFt8FamilyBarRenderer?.(id, barFrames); bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
const updatePeriod = () => { const updatePeriod = () => {
@@ -0,0 +1,18 @@
import {
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/active-rig.ts
function isActiveRigDecode(rigId) {
const activeRigId = hostState.lastActiveRigId;
if (!activeRigId || !rigId) return true;
return rigId === activeRigId;
}
function forActiveRig(items) {
return items.filter((item) => isActiveRigDecode(item.rig_id));
}
export {
isActiveRigDecode,
forActiveRig
};
@@ -1,3 +1,6 @@
import {
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -25,7 +28,7 @@ var CW_BAR_LINE_GAP_MS = 5e3;
var cwLastAppendTime = 0; var cwLastAppendTime = 0;
var cwTonePickerRaf = null; var cwTonePickerRaf = null;
var cwBarHistory = []; var cwBarHistory = [];
var cwBarCurrentLine = null; var cwBarCurrentLines = /* @__PURE__ */ new Map();
var cwBarDismissedAtMs = 0; var cwBarDismissedAtMs = 0;
var cwAutoLocalOverride = null; var cwAutoLocalOverride = null;
function escapeCwHtml(input) { function escapeCwHtml(input) {
@@ -50,20 +53,22 @@ cwWindow.applyCwAutoUiFromServer = function(enabled) {
if (cwAutoLocalOverride !== null) return; if (cwAutoLocalOverride !== null) return;
applyCwAutoUi(enabled); applyCwAutoUi(enabled);
}; };
function cwBarFlushCurrentLine() { function cwBarFlushCurrentLine(key) {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) { const line = cwBarCurrentLines.get(key);
cwBarHistory.unshift(cwBarCurrentLine); cwBarCurrentLines.delete(key);
if (line?.text.trim()) {
cwBarHistory.unshift(line);
if (cwBarHistory.length > 50) cwBarHistory.length = 50; if (cwBarHistory.length > 50) cwBarHistory.length = 50;
} }
cwBarCurrentLine = null;
} }
function updateCwBar() { function updateCwBar() {
if (!cwBarOverlay) return; if (!cwBarOverlay) return;
const mode = (document.getElementById("mode")?.value || "").toUpperCase(); const mode = (document.getElementById("mode")?.value || "").toUpperCase();
const isCw = mode === "CW" || mode === "CWR"; const isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS; const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs); const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent; const inProgress = [...cwBarCurrentLines.values()].filter((l) => l.text && isActiveRigDecode(l.rigId)).sort((a, b) => b.tsMs - a.tsMs);
const liveLines = [...inProgress, ...recent];
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0); const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) { if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none"; cwBarOverlay.style.display = "none";
@@ -308,7 +313,7 @@ function resetCwHistoryView() {
if (cwOutputEl) cwOutputEl.innerHTML = ""; if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0; cwLastAppendTime = 0;
cwBarHistory = []; cwBarHistory = [];
cwBarCurrentLine = null; cwBarCurrentLines.clear();
updateCwBar(); updateCwBar();
drawCwTonePicker(); drawCwTonePicker();
} }
@@ -324,8 +329,9 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
})(); })();
}); });
function onServerCw(evt) { function onServerCw(evt) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving"; const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
if (evt.text && cwOutputEl) { if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl && forSelectedRig) {
const now = Date.now(); const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") { if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
const line = document.createElement("div"); const line = document.createElement("div");
@@ -346,25 +352,29 @@ function onServerCw(evt) {
} }
if (evt.text) { if (evt.text) {
const now = Date.now(); const now = Date.now();
const rigId = evt.rig_id ?? null;
const key = rigId ?? "";
if (evt.text === "\n") { if (evt.text === "\n") {
cwBarFlushCurrentLine(); cwBarFlushCurrentLine(key);
} else { } else {
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) { let line = cwBarCurrentLines.get(key);
cwBarFlushCurrentLine(); if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine(key);
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
cwBarCurrentLines.set(key, line);
} }
cwBarCurrentLine.text += evt.text; line.text += evt.text;
cwBarCurrentLine.lastMs = now; line.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm); if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz)); if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
} }
updateCwBar(); updateCwBar();
} }
if (cwSignalIndicator) { if (cwSignalIndicator && forSelectedRig) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off"; cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
} }
if (!cwAutoInput || cwAutoInput.checked) { if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) { if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm)); cwWpmInput.value = String(clampCwWpm(evt.wpm));
} }
@@ -389,7 +399,14 @@ cwWindow.trxPluginRuntime.registerDecoder({
id: "cw", id: "cw",
onMessage: onServerCw, onMessage: onServerCw,
restore: restoreCwHistory, restore: restoreCwHistory,
reset: resetCwHistoryView reset: resetCwHistoryView,
// The copied text of a rig that is no longer on screen cannot be unpicked
// from the pane, so the switch starts the new rig's stream from empty.
rerender: () => {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
updateCwBar();
}
}); });
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() { cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution(); ensureCwToneCanvasResolution();
@@ -1,6 +1,7 @@
import { import {
initializeFtxDecoder initializeFtxDecoder
} from "./chunk-O2Y7YEVQ.js"; } from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
// src/plugins/ft2.ts // src/plugins/ft2.ts
@@ -1,6 +1,7 @@
import { import {
initializeFtxDecoder initializeFtxDecoder
} from "./chunk-O2Y7YEVQ.js"; } from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
// src/plugins/ft4.ts // src/plugins/ft4.ts
@@ -2,7 +2,8 @@ import {
initializeFt8FamilyBar, initializeFt8FamilyBar,
initializeFtxDecoder, initializeFtxDecoder,
installFtxCompatibilityHelpers installFtxCompatibilityHelpers
} from "./chunk-O2Y7YEVQ.js"; } from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
// src/plugins/ft8.ts // src/plugins/ft8.ts
@@ -5,6 +5,10 @@ import {
normalizeAprsPacket, normalizeAprsPacket,
renderAprsPacketRow renderAprsPacketRow
} from "./chunk-OPEIVJGD.js"; } from "./chunk-OPEIVJGD.js";
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -67,21 +71,26 @@ function hfAprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase(); ].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(hfAprsFilterText); return haystack.includes(hfAprsFilterText);
} }
function hfAprsRigPackets() {
return forActiveRig(hfAprsPacketHistory);
}
function hfAprsVisiblePackets() { function hfAprsVisiblePackets() {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory; const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(hfAprsFilterMatch); return packets.filter(hfAprsFilterMatch);
} }
var collapseHfAprsDuplicates = collapseAprsDuplicates; var collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() { function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets(); const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) { if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`; hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
} }
if (hfAprsVisibleCountEl) { if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`; hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
} }
if (hfAprsLatestSeenEl) { if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0]; const latest = rigPackets[0];
if (!latest) { if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet"; hfAprsLatestSeenEl.textContent = "No packets yet";
} else { } else {
@@ -140,17 +149,32 @@ function resetHfAprsHistoryView() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = []; hfAprsPacketHistory = [];
renderHfAprsHistory(); renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
} }
function pruneHfAprsHistoryView() { function pruneHfAprsHistoryView() {
pruneHfAprsPacketHistory(); pruneHfAprsPacketHistory();
renderHfAprsHistory(); renderHfAprsHistory();
} }
function plotHfAprsPacket(pkt) {
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
hfAprsWindow.aprsMapAddStation(
pkt.srcCall ?? "",
pkt.lat,
pkt.lon,
pkt.info ?? "",
pkt.symbolTable,
pkt.symbolCode,
pkt,
"hf_aprs"
);
}
function addHfAprsPacket(pkt) { function addHfAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs; pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
hfAprsPacketHistory.unshift(pkt); hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory(); pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender(); scheduleHfAprsHistoryRender();
} }
function normalizeServerHfAprsPacket(pkt) { function normalizeServerHfAprsPacket(pkt) {
@@ -158,13 +182,14 @@ function normalizeServerHfAprsPacket(pkt) {
} }
function onServerHfAprsBatch(packets) { function onServerHfAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
for (const pkt of packets) { for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt); const next = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotHfAprsPacket(next);
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -227,8 +252,9 @@ if (hfAprsFilterInput) {
}); });
} }
function onServerHfAprs(pkt) { function onServerHfAprs(pkt) {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; const packet = normalizeServerHfAprsPacket(pkt);
addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
} }
renderHfAprsHistory(); renderHfAprsHistory();
window.trxPluginRuntime.registerDecoder({ window.trxPluginRuntime.registerDecoder({
@@ -237,5 +263,10 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerHfAprsBatch, onBatch: onServerHfAprsBatch,
restore: onServerHfAprsBatch, restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView, reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView prune: pruneHfAprsHistoryView,
rerender: renderHfAprsHistory,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry);
}
}); });
@@ -60,7 +60,7 @@ var mapWindow = window;
const decodeContactPaths = /* @__PURE__ */ new Map(); const decodeContactPaths = /* @__PURE__ */ new Map();
let selectedMapQsoKey = null; let selectedMapQsoKey = null;
const mapMarkers = /* @__PURE__ */ new Set(); const mapMarkers = /* @__PURE__ */ new Set();
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
const MAP_FILTER_ALL_KEY = "__all"; const MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() }; const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
@@ -128,6 +128,12 @@ var mapWindow = window;
} }
return trimmed; return trimmed;
} }
function aprsEntrySource(entry) {
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
}
function aprsStationKey(call, source) {
return source === "aprs" ? call : `${source}:${call}`;
}
function refreshAprsTrack(call, entry) { function refreshAprsTrack(call, entry) {
if (!entry) return; if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
@@ -149,7 +155,7 @@ var mapWindow = window;
lineJoin: "round", lineJoin: "round",
interactive: false interactive: false
}); });
track.__trxType = "aprs"; track.__trxType = aprsEntrySource(entry);
track._aprsCall = call; track._aprsCall = call;
entry.track = track; entry.track = track;
} }
@@ -381,6 +387,7 @@ var mapWindow = window;
} }
function mapSourceLabel(type) { function mapSourceLabel(type) {
if (type === "bookmark") return "Bookmarks"; if (type === "bookmark") return "Bookmarks";
if (type === "hf_aprs") return "HF APRS";
return String(type || "").toUpperCase(); return String(type || "").toUpperCase();
} }
function locatorFilterColor(type) { function locatorFilterColor(type) {
@@ -396,6 +403,7 @@ var mapWindow = window;
if (type === "vdes") return "#a78bfa"; if (type === "vdes") return "#a78bfa";
if (type === "sat") return "#f59e0b"; if (type === "sat") return "#f59e0b";
if (type === "aprs") return "#00d17f"; if (type === "aprs") return "#00d17f";
if (type === "hf_aprs") return "#fb7185";
return locatorFilterColor(type); return locatorFilterColor(type);
} }
function bandForHz(hz) { function bandForHz(hz) {
@@ -931,10 +939,8 @@ var mapWindow = window;
} }
} }
for (const entry of stationMarkers.values()) { for (const entry of stationMarkers.values()) {
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) { if (!entry?.visibleInHistoryWindow) continue;
availableSources.add("aprs"); availableSources.add(aprsEntrySource(entry));
break;
}
} }
const bandMap = /* @__PURE__ */ new Map(); const bandMap = /* @__PURE__ */ new Map();
for (const entry of locatorMarkers.values()) { for (const entry of locatorMarkers.values()) {
@@ -966,7 +972,7 @@ var mapWindow = window;
for (const key of Array.from(mapLocatorFilter.bands)) { for (const key of Array.from(mapLocatorFilter.bands)) {
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key); if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
} }
const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({ const sourceItems = ["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
key, key,
label: mapSourceLabel(key), label: mapSourceLabel(key),
color: mapSourceColor(key), color: mapSourceColor(key),
@@ -1028,9 +1034,10 @@ var mapWindow = window;
} }
return parts.join(" ").toLowerCase(); return parts.join(" ").toLowerCase();
} }
if (type === "aprs") { if (type === "aprs" || type === "hf_aprs") {
const call = marker?._aprsCall ? String(marker._aprsCall) : ""; const key = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(call); const entry = stationMarkers.get(key);
const call = entry?.call ?? key;
const info = entry?.info ? String(entry.info) : ""; const info = entry?.info ? String(entry.info) : "";
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : ""; const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
return `${call} ${info} ${pktRaw}`.toLowerCase(); return `${call} ${info} ${pktRaw}`.toLowerCase();
@@ -1200,9 +1207,10 @@ var mapWindow = window;
} }
}; };
mapWindow.clearMapMarkersByType = function(type) { mapWindow.clearMapMarkersByType = function(type) {
if (type === "aprs") { if (type === "aprs" || type === "hf_aprs") {
selectedAprsTrackCall = null; selectedAprsTrackCall = null;
stationMarkers.forEach((entry) => { stationMarkers.forEach((entry, key) => {
if (aprsEntrySource(entry) !== type) return;
if (entry && entry.marker) { if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker); mapMarkers.delete(entry.marker);
@@ -1211,8 +1219,8 @@ var mapWindow = window;
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
mapMarkers.delete(entry.track); mapMarkers.delete(entry.track);
} }
stationMarkers.delete(key);
}); });
stationMarkers.clear();
return; return;
} }
if (type === "ais") { if (type === "ais") {
@@ -1448,13 +1456,15 @@ var mapWindow = window;
if (!ll) return; if (!ll) return;
const entry = stationMarkers.get(marker._aprsCall); const entry = stationMarkers.get(marker._aprsCall);
if (!entry) return; if (!entry) return;
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt)); const source = aprsEntrySource(entry);
const call = entry.call ?? String(marker._aprsCall);
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
refreshAprsTrack(String(marker._aprsCall), entry); refreshAprsTrack(String(marker._aprsCall), entry);
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) { if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
entry.track.addTo(aprsMap); entry.track.addTo(aprsMap);
} }
selectedAprsTrackCall = String(marker._aprsCall); selectedAprsTrackCall = String(marker._aprsCall);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds); setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
return; return;
} }
if (marker._aisMmsi) { if (marker._aisMmsi) {
@@ -1848,28 +1858,33 @@ var mapWindow = window;
} }
return null; return null;
} }
function _aprsAddMarkerToMap(call, entry) { function _aprsAddMarkerToMap(key, entry) {
if (!aprsMap || entry.lat == null || entry.lon == null) return; if (!aprsMap || entry.lat == null || entry.lon == null) return;
refreshAprsTrack(call, entry); refreshAprsTrack(key, entry);
const source = aprsEntrySource(entry);
const call = entry.call ?? key;
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? ""); const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
const color = mapSourceColor(source);
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], { const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
radius: 6, radius: 6,
color: "#00d17f", color,
fillColor: "#00d17f", fillColor: color,
fillOpacity: 0.8 fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(popupContent); }).addTo(aprsMap).bindPopup(popupContent);
marker.__trxType = "aprs"; marker.__trxType = source;
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
marker._aprsCall = call; marker._aprsCall = key;
entry.marker = marker; entry.marker = marker;
mapMarkers.add(marker); mapMarkers.add(marker);
} }
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
const nextPoint = [lat, lon]; const nextPoint = [lat, lon];
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
const msgRigId = pkt?.rig_id || T.lastActiveRigId; const msgRigId = pkt?.rig_id || T.lastActiveRigId;
const existing = stationMarkers.get(call); const stationSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
const key = aprsStationKey(call, stationSource);
const existing = stationMarkers.get(key);
if (existing) { if (existing) {
existing.pkt = pkt; existing.pkt = pkt;
existing.lat = lat; existing.lat = lat;
@@ -1888,7 +1903,8 @@ var mapWindow = window;
} else if (prevPoint) { } else if (prevPoint) {
prevPoint.tsMs = tsMs; prevPoint.tsMs = tsMs;
} }
pruneAprsEntry(call, existing, mapHistoryCutoffMs()); existing.call = call;
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([lat, lon]); existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -1899,7 +1915,8 @@ var mapWindow = window;
track: null, track: null,
trackHistory: [{ lat, lon, tsMs }], trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint], trackPoints: [nextPoint],
type: "aprs", type: stationSource,
call,
pkt, pkt,
lat, lat,
lon, lon,
@@ -1908,9 +1925,9 @@ var mapWindow = window;
symbolCode, symbolCode,
rigIds: new Set(msgRigId ? [msgRigId] : []) rigIds: new Set(msgRigId ? [msgRigId] : [])
}; };
stationMarkers.set(call, entry); stationMarkers.set(key, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs()); pruneAprsEntry(key, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry); if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
if (aprsMap) scheduleDecodeMapMaintenance(); if (aprsMap) scheduleDecodeMapMaintenance();
} }
}; };
@@ -8,7 +8,6 @@ var satDom = {
status: document.getElementById("sat-status"), status: document.getElementById("sat-status"),
liveView: document.getElementById("sat-live-view"), liveView: document.getElementById("sat-live-view"),
historyView: document.getElementById("sat-history-view"), historyView: document.getElementById("sat-history-view"),
predictionsView: document.getElementById("sat-predictions-view"),
liveLatest: document.getElementById("sat-live-latest"), liveLatest: document.getElementById("sat-live-latest"),
historyList: document.getElementById("sat-history-list"), historyList: document.getElementById("sat-history-list"),
historyCount: document.getElementById("sat-history-count"), historyCount: document.getElementById("sat-history-count"),
@@ -17,29 +16,12 @@ var satDom = {
typeFilter: document.getElementById("sat-type-filter"), typeFilter: document.getElementById("sat-type-filter"),
lrptState: document.getElementById("sat-lrpt-state"), lrptState: document.getElementById("sat-lrpt-state"),
viewLiveBtn: document.getElementById("sat-view-live"), viewLiveBtn: document.getElementById("sat-view-live"),
viewHistoryBtn: document.getElementById("sat-view-history"), viewHistoryBtn: document.getElementById("sat-view-history")
viewPredBtn: document.getElementById("sat-view-predictions"),
predFilter: document.getElementById("sat-pred-filter"),
predMinEl: document.getElementById("sat-pred-min-el"),
predCategory: document.getElementById("sat-pred-category"),
predCurrentList: document.getElementById("sat-pred-current-list"),
predUpcomingList: document.getElementById("sat-pred-list"),
predCurrentSec: document.getElementById("sat-pred-current-section"),
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
predStatus: document.getElementById("sat-pred-status")
}; };
var satImageHistory = []; var satImageHistory = [];
var SAT_MAX_IMAGES = 100; var SAT_MAX_IMAGES = 100;
var SAT_PRED_PAGE_SIZE = 50;
var satPredShowAll = false;
var satFilterText = ""; var satFilterText = "";
var satActiveView = "live"; var satActiveView = "live";
var satPredData = [];
var satPredFilterText = "";
var satPredMinEl = 0;
var satPredCategory = "all";
var satPredSatCount = 0;
var satPredCountdownTimer = null;
function scheduleSatUi(key, job) { function scheduleSatUi(key, job) {
if (typeof satWindow.trxScheduleUiFrameJob === "function") { if (typeof satWindow.trxScheduleUiFrameJob === "function") {
satWindow.trxScheduleUiFrameJob(key, job); satWindow.trxScheduleUiFrameJob(key, job);
@@ -48,37 +30,19 @@ function scheduleSatUi(key, job) {
job(); job();
} }
function switchSatView(view) { function switchSatView(view) {
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
satActiveView = view; satActiveView = view;
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none"; if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none"; if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live"); if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history"); if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions"); if (view === "history") renderSatHistoryTable();
if (leavingPredictions) clearPredictionDom();
if (view === "history") {
renderSatHistoryTable();
} else if (view === "predictions") {
satPredShowAll = false;
void loadSatPredictions();
}
} }
function clearPredictionDom() {
stopCountdownTimer();
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
}
satWindow.clearSatPredictionDom = clearPredictionDom;
satDom.viewLiveBtn?.addEventListener("click", () => { satDom.viewLiveBtn?.addEventListener("click", () => {
switchSatView("live"); switchSatView("live");
}); });
satDom.viewHistoryBtn?.addEventListener("click", () => { satDom.viewHistoryBtn?.addEventListener("click", () => {
switchSatView("history"); switchSatView("history");
}); });
satDom.viewPredBtn?.addEventListener("click", () => {
switchSatView("predictions");
});
var lastSatLrptOn = null; var lastSatLrptOn = null;
satWindow.updateSatLiveState = function(update) { satWindow.updateSatLiveState = function(update) {
if (!satDom.lrptState) return; if (!satDom.lrptState) return;
@@ -275,200 +239,6 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click",
} }
})(); })();
}); });
function azToCardinal(deg) {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms) {
const d = new Date(ms);
const now = /* @__PURE__ */ new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s) {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms) {
const totalSec = Math.max(0, Math.floor(ms / 1e3));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg) {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
function stopCountdownTimer() {
if (satPredCountdownTimer) {
clearInterval(satPredCountdownTimer);
satPredCountdownTimer = null;
}
}
function startCountdownTimer(container) {
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
satPredCountdownTimer = setInterval(() => {
if (satActiveView !== "predictions") {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
renderSatPredictions(getFilteredPredictions());
}
}, 1e3);
}
function buildCurrentPassRow(pass, now) {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function buildUpcomingPassRow(pass) {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function getFilteredPredictions() {
let items = satPredData;
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
return items;
}
function applyPredFilters() {
renderSatPredictions(getFilteredPredictions());
}
var satPredictionFilter = satDom.predFilter;
satPredictionFilter?.addEventListener("input", () => {
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
applyPredFilters();
});
var satPredictionMinElevation = satDom.predMinEl;
satPredictionMinElevation?.addEventListener("change", () => {
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
applyPredFilters();
});
var satPredictionCategory = satDom.predCategory;
satPredictionCategory?.addEventListener("change", () => {
satPredCategory = satPredictionCategory.value;
applyPredFilters();
});
function renderSatPredictions(passes, error) {
stopCountdownTimer();
if (error) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
if (satDom.predCurrentList) {
if (current.length === 0) {
satDom.predCurrentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
satDom.predCurrentList.replaceChildren(frag);
}
}
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
if (satDom.predUpcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
moreRow.addEventListener("click", () => {
satPredShowAll = true;
renderSatPredictions(getFilteredPredictions());
});
frag.appendChild(moreRow);
}
satDom.predUpcomingList.replaceChildren(frag);
}
if (satDom.predStatus) {
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
satDom.predStatus.textContent = text;
}
if (current.length > 0 && satActiveView === "predictions") {
startCountdownTimer(satDom.predCurrentList);
}
}
async function loadSatPredictions() {
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…";
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
satPredSatCount = data.satellite_count || 0;
if (data.error) {
satPredData = [];
renderSatPredictions([], data.error);
} else {
satPredData = data.passes || [];
renderSatPredictions(getFilteredPredictions());
}
} catch (error) {
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
satWindow.satShowOnMap = function(south, west, north, east) { satWindow.satShowOnMap = function(south, west, north, east) {
if (typeof satWindow.enableMapSourceFilter === "function") { if (typeof satWindow.enableMapSourceFilter === "function") {
satWindow.enableMapSourceFilter("sat"); satWindow.enableMapSourceFilter("sat");
@@ -0,0 +1,228 @@
// src/plugins/satellite-predictions.ts
var predWindow = window;
var dom = {
page: document.getElementById("tab-satellites"),
filter: document.getElementById("sat-pred-filter"),
minElevation: document.getElementById("sat-pred-min-el"),
category: document.getElementById("sat-pred-category"),
currentList: document.getElementById("sat-pred-current-list"),
upcomingList: document.getElementById("sat-pred-list"),
currentSection: document.getElementById("sat-pred-current-section"),
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
status: document.getElementById("sat-pred-status")
};
var PAGE_SIZE = 50;
var predShowAll = false;
var predData = [];
var predFilterText = "";
var predMinEl = 0;
var predCategory = "all";
var predSatCount = 0;
var predCountdownTimer = null;
function isPageVisible() {
return !!dom.page && dom.page.style.display !== "none";
}
function azToCardinal(deg) {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms) {
const d = new Date(ms);
const now = /* @__PURE__ */ new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s) {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms) {
const totalSec = Math.max(0, Math.floor(ms / 1e3));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg) {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
function stopCountdownTimer() {
if (predCountdownTimer) {
clearInterval(predCountdownTimer);
predCountdownTimer = null;
}
}
function startCountdownTimer(container) {
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
predCountdownTimer = setInterval(() => {
if (!isPageVisible()) {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
render(filtered());
}
}, 1e3);
}
function buildCurrentPassRow(pass, now) {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function buildUpcomingPassRow(pass) {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function filtered() {
let items = predData;
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
return items;
}
function applyFilters() {
render(filtered());
}
var filterInput = dom.filter;
filterInput?.addEventListener("input", () => {
predFilterText = filterInput.value.trim().toUpperCase();
applyFilters();
});
var minElevationSelect = dom.minElevation;
minElevationSelect?.addEventListener("change", () => {
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
applyFilters();
});
var categorySelect = dom.category;
categorySelect?.addEventListener("change", () => {
predCategory = categorySelect.value;
applyFilters();
});
function render(passes, error) {
stopCountdownTimer();
if (error) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
if (dom.currentList) {
if (current.length === 0) {
dom.currentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
dom.currentList.replaceChildren(frag);
}
}
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
if (dom.upcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
moreRow.addEventListener("click", () => {
predShowAll = true;
render(filtered());
});
frag.appendChild(moreRow);
}
dom.upcomingList.replaceChildren(frag);
}
if (dom.status) {
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
if (predSatCount > 0) text += ` · ${predSatCount} satellites tracked`;
dom.status.textContent = text;
}
if (current.length > 0 && isPageVisible()) {
startCountdownTimer(dom.currentList);
}
}
async function load() {
if (dom.status) dom.status.textContent = "Loading predictions…";
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
predSatCount = data.satellite_count || 0;
if (data.error) {
predData = [];
render([], data.error);
} else {
predData = data.passes || [];
render(filtered());
}
} catch (error) {
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
function clearPredictionDom() {
stopCountdownTimer();
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
}
predWindow.clearSatPredictionDom = clearPredictionDom;
predWindow.refreshSatPredictions = function() {
predShowAll = false;
void load();
};
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -111,6 +115,7 @@ function paintRow(line, rgb) {
liveRows = Math.max(liveRows, line + 1); liveRows = Math.max(liveRows, line + 1);
} }
function onProgress(msg) { function onProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state) { if (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state; if (sstvDom.status) sstvDom.status.textContent = msg.state;
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0); beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
@@ -131,15 +136,20 @@ function onImage(msg) {
sstvHistory.push(image); sstvHistory.push(image);
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift(); if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory(); pruneHistory();
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) { if (sstvDom.status) {
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"}${image.lines ?? 0} lines`; sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"}${image.lines ?? 0} lines`;
} }
scheduleUi("sstv-latest", renderLatestCard); scheduleUi("sstv-latest", renderLatestCard);
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable); if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
} }
function sstvRigImages() {
return forActiveRig(sstvHistory);
}
function renderLatestCard() { function renderLatestCard() {
if (!sstvDom.liveLatest) return; if (!sstvDom.liveLatest) return;
const latest = sstvHistory[sstvHistory.length - 1]; const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
if (!latest) { if (!latest) {
sstvDom.liveLatest.innerHTML = ""; sstvDom.liveLatest.innerHTML = "";
return; return;
@@ -160,8 +170,9 @@ function renderLatestCard() {
</div>`; </div>`;
} }
function filteredHistory() { function filteredHistory() {
const rigImages = sstvRigImages();
const text = filterText.trim().toLowerCase(); const text = filterText.trim().toLowerCase();
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice(); const matching = text ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : rigImages;
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest"; const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0))); matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching; return matching;
@@ -241,7 +252,11 @@ sstvWindow.trxPluginRuntime.registerDecoder({
onMessage: onImage, onMessage: onImage,
restore: restoreHistory, restore: restoreHistory,
prune: renderHistoryTable, prune: renderHistoryTable,
reset: resetHistoryView reset: resetHistoryView,
rerender: () => {
renderLatestCard();
renderHistoryTable();
}
}); });
sstvWindow.trxPluginRuntime.registerDecoder({ sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress", id: "sstv_progress",
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -60,17 +64,21 @@ function vdesHexPreview(rawBytes) {
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--"; if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase(); return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
} }
function vdesRigMessages() {
return forActiveRig(vdesMessageHistory);
}
function updateVdesSummary() { function updateVdesSummary() {
pruneVdesMessageHistory(); pruneVdesMessageHistory();
if (vdesChannelSummaryEl) { if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText(); vdesChannelSummaryEl.textContent = currentVdesCenterText();
} }
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) { if (vdesFrameCountEl) {
const count = vdesMessageHistory.length; const count = rigMessages.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`; vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
} }
if (vdesLatestSeenEl) { if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0]; const latest = rigMessages[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet"; vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
} }
} }
@@ -137,7 +145,7 @@ function updateVdesBar() {
updateVdesSummary(); updateVdesSummary();
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES"; const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS; const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6); const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)).slice(0, 6);
if (!isVdes || messages.length === 0) { if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none"; vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = ""; vdesBarOverlay.innerHTML = "";
@@ -182,7 +190,7 @@ function renderVdesHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) { for (const message of vdesRigMessages()) {
fragment.appendChild(renderVdesRow(message)); fragment.appendChild(renderVdesRow(message));
} }
vdesMessagesEl.replaceChildren(fragment); vdesMessagesEl.replaceChildren(fragment);
@@ -209,10 +217,10 @@ function normalizeServerVdesMessage(msg) {
} }
function onServerVdesBatch(messages) { function onServerVdesBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -251,8 +259,8 @@ function plotVdesMessage(msg) {
vdesWindow.vdesMapAddPoint(msg); vdesWindow.vdesMapAddPoint(msg);
} }
function onServerVdes(msg) { function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next); addVdesMessage(next);
plotVdesMessage(next); plotVdesMessage(next);
} }
@@ -269,6 +277,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerVdesBatch, restore: onServerVdesBatch,
reset: resetVdesHistoryView, reset: resetVdesHistoryView,
prune: pruneVdesHistoryView, prune: pruneVdesHistoryView,
rerender: () => {
updateVdesBar();
renderVdesHistory();
},
// Oldest first, so tracks are rebuilt in the order they happened. // Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { syncMap: () => {
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -102,13 +106,17 @@ function paintLine(lineBytes) {
wefaxLiveCtx.putImageData(imgData, 0, y); wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++; wefaxLiveLineCount++;
} }
function wefaxRigImages() {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() { function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return; if (!wefaxDom.liveLatest) return;
if (wefaxImageHistory.length === 0) { const rigImages = wefaxRigImages();
if (rigImages.length === 0) {
wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>'; wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
return; return;
} }
const img = wefaxImageHistory[0]; const img = rigImages[0];
if (!img) return; if (!img) return;
const ts = img._ts || "--"; const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : ""; const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
@@ -129,7 +137,7 @@ function renderWefaxLatestCard() {
wefaxDom.liveLatest.innerHTML = html; wefaxDom.liveLatest.innerHTML = html;
} }
function getWefaxFilteredHistory() { function getWefaxFilteredHistory() {
let items = wefaxImageHistory; let items = wefaxRigImages();
if (wefaxFilterText) { if (wefaxFilterText) {
items = items.filter(function(i) { items = items.filter(function(i) {
const haystack = [ const haystack = [
@@ -173,7 +181,7 @@ function renderWefaxHistoryTable() {
} }
wefaxDom.historyList.replaceChildren(fragment); wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) { if (wefaxDom.historyCount) {
const total = wefaxImageHistory.length; const total = wefaxRigImages().length;
const shown = items.length; const shown = items.length;
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`; wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
} }
@@ -208,6 +216,7 @@ function addWefaxImage(msg) {
} }
} }
function onServerWefaxProgress(msg) { function onServerWefaxProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state && !msg.line_data) { if (msg.state && !msg.line_data) {
if (wefaxDom.status) { if (wefaxDom.status) {
wefaxDom.status.textContent = msg.state; wefaxDom.status.textContent = msg.state;
@@ -234,6 +243,7 @@ function onServerWefaxProgress(msg) {
} }
function onServerWefax(msg) { function onServerWefax(msg) {
addWefaxImage(msg); addWefaxImage(msg);
if (!isActiveRigDecode(msg.rig_id)) return;
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none"; if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
if (wefaxDom.status) { if (wefaxDom.status) {
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`; wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
@@ -328,7 +338,11 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerWefax, onMessage: onServerWefax,
restore: restoreWefaxHistory, restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView, prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView reset: resetWefaxHistoryView,
rerender: () => {
renderWefaxLatestCard();
renderWefaxHistoryTable();
}
}); });
wefaxWindow.trxPluginRuntime.registerDecoder({ wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress", id: "wefax_progress",
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -64,10 +68,10 @@ function renderWsprRow(msg) {
function renderWsprHistory() { function renderWsprHistory() {
pruneWsprMessageHistory(); pruneWsprMessageHistory();
if (!wsprMessagesEl) return; if (!wsprMessagesEl) return;
const rigMessages = forActiveRig(wsprMessageHistory);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) { for (const message of rigMessages) {
const message = wsprMessageHistory[i]; fragment.appendChild(renderWsprRow(message));
if (message) fragment.appendChild(renderWsprRow(message));
} }
wsprMessagesEl.replaceChildren(fragment); wsprMessagesEl.replaceChildren(fragment);
} }
@@ -90,6 +94,8 @@ function normalizeServerWsprMessage(msg) {
station, station,
rfHz, rfHz,
history: { history: {
rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, ts_ms: msg.ts_ms,
snr_db: msg.snr_db, snr_db: msg.snr_db,
@@ -101,16 +107,11 @@ function normalizeServerWsprMessage(msg) {
} }
function onServerWsprBatch(messages) { function onServerWsprBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { plotWsprLocator(msg);
...msg,
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
});
}
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now(); next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history); normalized.push(next.history);
} }
@@ -250,15 +251,19 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
} }
})(); })();
}); });
function onServerWspr(msg) { function plotWsprLocator(msg) {
if (wsprStatus) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
...msg, wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...next.rfHz === null ? {} : { freq_hz: next.rfHz } ...msg,
}); ...rfHz === null ? {} : { freq_hz: rfHz }
} });
}
function onServerWspr(msg) {
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg);
plotWsprLocator(msg);
addWsprMessage(next.history); addWsprMessage(next.history);
} }
wsprWindow.trxPluginRuntime.registerDecoder({ wsprWindow.trxPluginRuntime.registerDecoder({
@@ -267,5 +272,10 @@ wsprWindow.trxPluginRuntime.registerDecoder({
onBatch: onServerWsprBatch, onBatch: onServerWsprBatch,
restore: onServerWsprBatch, restore: onServerWsprBatch,
prune: pruneWsprHistoryView, prune: pruneWsprHistoryView,
reset: resetWsprHistoryView reset: resetWsprHistoryView,
rerender: renderWsprHistory,
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => {
for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message);
}
}); });
@@ -24,6 +24,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol> <symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol> <symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol> <symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol> <symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol> <symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol> <symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
@@ -63,6 +64,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg> <svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
<span class="tab-label">Map</span> <span class="tab-label">Map</span>
</button> </button>
<button class="tab" data-tab="satellites">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-satellite"/></svg>
<span class="tab-label">Satellites</span>
</button>
<button class="tab" data-tab="statistics"> <button class="tab" data-tab="statistics">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg> <svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
<span class="tab-label">Statistics</span> <span class="tab-label">Statistics</span>
@@ -918,7 +923,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="sat-view-bar"> <div class="sat-view-bar">
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button> <button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
<button id="sat-view-history" class="sat-view-btn" type="button">History</button> <button id="sat-view-history" class="sat-view-btn" type="button">History</button>
<button id="sat-view-predictions" class="sat-view-btn" type="button">Predictions</button>
</div> </div>
<!-- Live view --> <!-- Live view -->
<div id="sat-live-view"> <div id="sat-live-view">
@@ -960,50 +964,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="sat-history-list"></div> <div id="sat-history-list"></div>
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small> <small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
</div> </div>
<!-- Predictions view -->
<div id="sat-predictions-view" style="display:none;">
<div class="ft8-controls">
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
<select id="sat-pred-category" class="sat-sort-select">
<option value="all">All</option>
<option value="weather">Weather</option>
<option value="amateur">Ham Radio</option>
<option value="other">Other</option>
</select>
<select id="sat-pred-min-el" class="sat-sort-select">
<option value="0">All passes</option>
<option value="10">Min 10°</option>
<option value="20">Min 20°</option>
<option value="45">Min 45°</option>
</select>
</div>
<!-- Current passes -->
<div id="sat-pred-current-section">
<div class="sat-pred-section-title">Currently receivable</div>
<div class="sat-pred-header sat-pred-header-current">
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-time">AOS Start</span>
<span class="sat-pred-col-time">AOS End</span>
<span class="sat-pred-col-countdown">Time left</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-current-list"></div>
</div>
<!-- Upcoming passes -->
<div id="sat-pred-upcoming-section">
<div class="sat-pred-section-title">Upcoming passes</div>
<div class="sat-pred-header">
<span class="sat-pred-col-time">AOS (UTC)</span>
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-dur">Duration</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-list"></div>
</div>
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions&hellip;</small>
</div>
</div> </div>
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;"> <div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls"> <div class="ft8-controls">
@@ -1117,6 +1077,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</template> </template>
</div> </div>
<div id="tab-satellites" class="tab-panel" style="display:none;">
<h2 class="section-heading">Satellites</h2>
<div class="ft8-controls">
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
<select id="sat-pred-category" class="sat-sort-select">
<option value="all">All</option>
<option value="weather">Weather</option>
<option value="amateur">Ham Radio</option>
<option value="other">Other</option>
</select>
<select id="sat-pred-min-el" class="sat-sort-select">
<option value="0">All passes</option>
<option value="10">Min 10°</option>
<option value="20">Min 20°</option>
<option value="45">Min 45°</option>
</select>
</div>
<!-- Current passes -->
<div id="sat-pred-current-section">
<div class="sat-pred-section-title">Currently receivable</div>
<div class="sat-pred-header sat-pred-header-current">
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-time">AOS Start</span>
<span class="sat-pred-col-time">AOS End</span>
<span class="sat-pred-col-countdown">Time left</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-current-list"></div>
</div>
<!-- Upcoming passes -->
<div id="sat-pred-upcoming-section">
<div class="sat-pred-section-title">Upcoming passes</div>
<div class="sat-pred-header">
<span class="sat-pred-col-time">AOS (UTC)</span>
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-dur">Duration</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-list"></div>
</div>
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions&hellip;</small>
</div>
<div id="tab-statistics" class="tab-panel" style="display:none;"> <div id="tab-statistics" class="tab-panel" style="display:none;">
<h2 class="section-heading">Statistics</h2> <h2 class="section-heading">Statistics</h2>
<template id="tmpl-statistics"> <template id="tmpl-statistics">
@@ -1462,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">
@@ -3816,11 +3816,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
@media (max-width: 760px) { @media (max-width: 760px) {
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; } body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
} }
/* One navigation model at every width. Statistics, Recorder, Settings and /* One navigation model at every width. Satellites, Statistics, Recorder,
* About are occasional destinations: they live behind More rather than * Settings and About are occasional destinations: they live behind More rather
* competing with the operating tabs for the row and then scrolling out of * than competing with the operating tabs for the row and then scrolling out of
* reach. The mobile layout already grouped them this way; the desktop strip * reach. The mobile layout already grouped them this way; the desktop strip
* now matches it, which is why the tab strip no longer needs to scroll. */ * now matches it, which is why the tab strip no longer needs to scroll. */
.tab-bar-nav .tab[data-tab="satellites"],
.tab-bar-nav .tab[data-tab="statistics"], .tab-bar-nav .tab[data-tab="statistics"],
.tab-bar-nav .tab[data-tab="recorder"], .tab-bar-nav .tab[data-tab="recorder"],
.tab-bar-nav .tab[data-tab="settings"], .tab-bar-nav .tab[data-tab="settings"],
@@ -4030,8 +4031,9 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent); box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
} }
.tab-icon, .tab-more-icon { display: block; } .tab-icon, .tab-more-icon { display: block; }
.tab[data-tab="statistics"], .tab[data-tab="recorder"], .tab[data-tab="satellites"], .tab[data-tab="statistics"],
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; } .tab[data-tab="recorder"], .tab[data-tab="settings"],
.tab[data-tab="about"] { display: none; }
.mobile-more-btn { display: flex; } .mobile-more-btn { display: flex; }
.mobile-more-menu { .mobile-more-menu {
position: fixed; position: fixed;
@@ -5587,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;
@@ -5622,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);
@@ -5879,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;
@@ -5902,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 {
@@ -5941,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;
@@ -5990,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;
} }
@@ -6001,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;
} }
} }
@@ -30,6 +30,7 @@ await build({
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"), "hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
sat: path.join(sourceDir, "plugins", "sat.ts"), sat: path.join(sourceDir, "plugins", "sat.ts"),
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"), "sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
vchan: path.join(sourceDir, "plugins", "vchan.ts"), vchan: path.join(sourceDir, "plugins", "vchan.ts"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"), scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
@@ -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", "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts" "verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
}, },
"devDependencies": { "devDependencies": {
@@ -78,6 +78,10 @@ export type RdsData = { pi?: number | null, program_service?: string | null, rad
export type SpectrumData = { export type SpectrumData = {
/** /**
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2. * FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
*
* On the wire these are base64-encoded `i8` whole dBFS (see
* `spectrum_wire`), which is what the display draws anyway; the TypeScript
* type describes the decoded array the browser receives over SSE.
*/ */
bins: Array<number>, bins: Array<number>,
/** /**
@@ -385,12 +385,14 @@ declare global {
refreshCwTonePicker?(): void; refreshCwTonePicker?(): void;
updateFt8RfDisplay?(): void; updateFt8RfDisplay?(): void;
clearSatPredictionDom?(): void; clearSatPredictionDom?(): void;
refreshSatPredictions?(): void;
syncWefaxToggle?(enabled: boolean): void; syncWefaxToggle?(enabled: boolean): void;
syncSstvToggle?(enabled: boolean): void; syncSstvToggle?(enabled: boolean): void;
updateAisBar?(value?: number): void; updateAisBar?(value?: number): void;
updateVdesBar?(value?: number): void; updateVdesBar?(value?: number): void;
updateAprsBar?(value?: number): void; updateAprsBar?(value?: number): void;
updateFt8Bar?(value?: number): void; updateFt8Bar?(value?: number): void;
updateCwBar?(value?: number): void;
updateSatLiveState?(value: unknown): void; updateSatLiveState?(value: unknown): void;
applyCwAutoUi?(enabled: boolean): void; applyCwAutoUi?(enabled: boolean): void;
applyCwAutoUiFromServer?(enabled: boolean): void; applyCwAutoUiFromServer?(enabled: boolean): void;
@@ -1466,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 || "");
} }
@@ -2206,6 +2212,17 @@ function resetRdsDisplay() {
updateRdsPsOverlay(primaryRds); updateRdsPsOverlay(primaryRds);
} }
// The mini views over the waterfall show only what the rig on screen heard, so
// they have to be repainted whenever that rig — or its mode — changes, not just
// when the next decode happens to arrive.
function refreshDecodeBars() {
window.updateAisBar?.();
window.updateVdesBar?.();
window.updateAprsBar?.();
window.updateFt8Bar?.();
window.updateCwBar?.();
}
function resetDecoderStateOnRigSwitch() { function resetDecoderStateOnRigSwitch() {
// RDS // RDS
primaryRds = null; primaryRds = null;
@@ -2225,6 +2242,12 @@ function resetDecoderStateOnRigSwitch() {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.textContent = "--"; if (el) el.textContent = "--";
}); });
// The decode stream is not rig-scoped, so the histories behind the mini views
// and the decoder panels survive the switch: repaint both for the rig now
// selected instead of leaving the outgoing rig's traffic on screen.
refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
} }
function resetWfmStereoIndicator() { function resetWfmStereoIndicator() {
@@ -3675,10 +3698,7 @@ function render(update: AppUpdate) {
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets"; const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText); setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
} }
if (window.updateAisBar) window.updateAisBar(); refreshDecodeBars();
if (window.updateVdesBar) window.updateVdesBar();
if (window.updateAprsBar) window.updateAprsBar();
if (window.updateFt8Bar) window.updateFt8Bar();
// Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong. // Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong.
for (const d of decoderRegistry) { for (const d of decoderRegistry) {
if (d.activation !== "toggle") continue; if (d.activation !== "toggle") continue;
@@ -4849,6 +4869,7 @@ function _initMapWhenReady() {
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) { function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
window.trxUi?.closeMobileOverlays?.(); window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options; const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") { if (authEnabled && !authRole && name !== "main") {
showAuthGate(false); showAuthGate(false);
@@ -4889,7 +4910,15 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
updateTabHistory(name, replaceHistory); updateTabHistory(name, replaceHistory);
} }
scheduleSpectrumLayout(); scheduleSpectrumLayout();
void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); }); // Its countdowns tick every second, so the page stops when it is not on screen.
if (leavingSatellites) window.clearSatPredictionDom?.();
void loadPluginsForTab(name).then(() => {
// Passes go stale while the page is closed, so each visit reloads them.
// The first visit is what imports the module, which renders on its own.
if (name === "satellites") window.refreshSatPredictions?.();
// The map module owns the decode log; this is the moment it can exist.
flushPendingDecodeStats();
}).catch((error: unknown) => { console.error(error); });
if (name === "map") { if (name === "map") {
_initMapWhenReady(); _initMapWhenReady();
} }
@@ -5011,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) {
@@ -5164,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();
@@ -5241,10 +5283,6 @@ function _wireSubTabBar(bar: WiredElement) {
if (window.refreshCwTonePicker) window.refreshCwTonePicker(); if (window.refreshCwTonePicker) window.refreshCwTonePicker();
}); });
} }
// Clear SAT prediction DOM when leaving the SAT tab to reduce node count.
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
window.clearSatPredictionDom();
}
}); });
} }
document.querySelectorAll<WiredElement>(".sub-tab-bar").forEach(_wireSubTabBar); document.querySelectorAll<WiredElement>(".sub-tab-bar").forEach(_wireSubTabBar);
@@ -6578,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();
} }
} }
@@ -6632,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);
} }
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
export const TAB_ORDER = [ export const TAB_ORDER = [
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about", "main", "bookmarks", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
] as const; ] as const;
export type TabName = typeof TAB_ORDER[number]; export type TabName = typeof TAB_ORDER[number];
@@ -13,6 +13,7 @@ export const TAB_PATHS: Readonly<Record<TabName, string>> = {
bookmarks: "/bookmarks", bookmarks: "/bookmarks",
"digital-modes": "/digital-modes", "digital-modes": "/digital-modes",
map: "/map", map: "/map",
satellites: "/satellites",
statistics: "/statistics", statistics: "/statistics",
recorder: "/recorder", recorder: "/recorder",
settings: "/settings", settings: "/settings",
@@ -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();
} }
}; };
@@ -2,7 +2,9 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings"; type PluginGroup =
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
| "bookmarks" | "recorder" | "settings";
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = { const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
// AIS, VDES and the two APRS decoders have panels on this tab, so they load // AIS, VDES and the two APRS decoders have panels on this tab, so they load
@@ -15,6 +17,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
], ],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"], "map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"], map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"], statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"], bookmarks: ["/bookmarks.js"],
recorder: [], recorder: [],
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
plugin.prune(); plugin.prune();
return true; return true;
}, },
rerenderAll() { for (const plugin of decoders.values()) plugin.rerender?.(); },
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); }, syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
clearQueued() { queued.clear(); }, clearQueued() { queued.clear(); },
hasDecoder: (id) => decoders.has(id), hasDecoder: (id) => decoders.has(id),
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
// The decode stream is not rig-scoped: every rig a client is connected to sends
// its decodes to every browser, each frame naming the rig that heard it (the
// client stamps `rig_id` as it leaves the audio connection). The map wants all
// of them — it has its own filter, and plotting only one rig would empty it —
// but the radio page and the decoder panels describe one rig at a time: the one
// the operator selected, whose spectrum is on screen and whose audio is
// playing. A frame a background rig copied on another band belongs to neither
// picture, so both filter on this.
export function isActiveRigDecode(rigId: string | null | undefined): boolean {
const activeRigId = hostState.lastActiveRigId;
// Before the rig list has arrived, and for a decode that reached the browser
// without a rig of its own, there is nothing to disagree with.
if (!activeRigId || !rigId) return true;
return rigId === activeRigId;
}
/** The decodes of `items` that the selected rig heard, in their original order. */
export function forActiveRig<T extends { rig_id?: string | null | undefined }>(items: T[]): T[] {
return items.filter((item) => isActiveRigDecode(item.rig_id));
}
@@ -6,6 +6,7 @@ import { hostCore, hostState } from "./host.js";
export {}; export {};
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract"; import type { PluginRuntimeWindow } from "./runtime-contract";
interface AisMessage { interface AisMessage {
@@ -190,20 +191,27 @@ function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
return Array.from(byMmsi.values()); return Array.from(byMmsi.values());
} }
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function aisRigMessages(): AisMessage[] {
return forActiveRig(aisMessageHistory);
}
function updateAisSummary() { function updateAisSummary() {
const plan = currentAisChannelPlan(); const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) { if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`; aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
} }
const vessels = aisLatestByVessel(aisMessageHistory); const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
if (aisVesselCountEl) { if (aisVesselCountEl) {
const count = vessels.length; const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`; aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
} }
if (aisLatestSeenEl) { if (aisLatestSeenEl) {
const latest = aisMessageHistory[0]; const latest = rigMessages[0];
if (!latest) { if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet"; aisLatestSeenEl.textContent = "No traffic yet";
} else { } else {
@@ -314,7 +322,9 @@ function updateAisBar() {
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS"; const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS; const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs); const recent = aisMessageHistory.filter(
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id),
);
const messages = aisLatestByVessel(recent).slice(0, 8); const messages = aisLatestByVessel(recent).slice(0, 8);
if (!isAis || messages.length === 0) { if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none"; aisBarOverlay.style.display = "none";
@@ -368,7 +378,7 @@ function renderAisHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const message of aisMessageHistory) { for (const message of aisRigMessages()) {
fragment.appendChild(renderAisRow(message)); fragment.appendChild(renderAisRow(message));
} }
aisMessagesEl.replaceChildren(fragment); aisMessagesEl.replaceChildren(fragment);
@@ -407,10 +417,11 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage {
function onServerAisBatch(messages: AisMessage[]): void { function onServerAisBatch(messages: AisMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized: AisMessage[] = []; const normalized: AisMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerAisMessage(msg); const next = normalizeServerAisMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -452,8 +463,9 @@ if (aisFilterInput) {
} }
function onServerAis(msg: AisMessage): void { function onServerAis(msg: AisMessage): void {
if (aisStatus) aisStatus.textContent = "Receiving"; const message = normalizeServerAisMessage(msg);
addAisMessage(normalizeServerAisMessage(msg)); if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
} }
updateAisSummary(); updateAisSummary();
@@ -464,6 +476,7 @@ updateAisSummary();
restore: onServerAisBatch, restore: onServerAisBatch,
reset: resetAisHistoryView, reset: resetAisHistoryView,
prune: pruneAisHistoryView, prune: pruneAisHistoryView,
rerender: () => { updateAisBar(); renderAisHistory(); },
// Oldest first, so vessel tracks are rebuilt in the order they happened. // Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); }, syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
}); });
@@ -4,6 +4,7 @@
import { hostCore, hostState } from "./host.js"; import { hostCore, hostState } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import { import {
aprsAgeText, aprsAgeText,
aprsPacketCategory, aprsPacketCategory,
@@ -106,21 +107,29 @@ function aprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(aprsFilterText); return haystack.includes(aprsFilterText);
} }
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function aprsRigPackets(): AprsPacket[] {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets(): AprsPacket[] { function aprsVisiblePackets(): AprsPacket[] {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory; const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(aprsFilterMatch); return packets.filter(aprsFilterMatch);
} }
function updateAprsSummary() { function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets(); const visible = aprsVisiblePackets();
if (aprsTotalCountEl) { if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`; aprsTotalCountEl.textContent = `${rigPackets.length} total`;
} }
if (aprsVisibleCountEl) { if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`; aprsVisibleCountEl.textContent = `${visible.length} shown`;
} }
if (aprsLatestSeenEl) { if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0]; const latest = rigPackets[0];
if (!latest) { if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet"; aprsLatestSeenEl.textContent = "No packets yet";
} else { } else {
@@ -179,7 +188,9 @@ function updateAprsBar() {
if (!aprsBarOverlay) return; if (!aprsBarOverlay) return;
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT"; const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS; const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs); const okFrames = aprsPacketHistory.filter(
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id),
);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8); const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0); const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) { if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
@@ -256,11 +267,12 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerAprsBatch(packets: AprsPacket[]): void { function onServerAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = []; const normalized: AprsPacket[] = [];
let hasCrcOk = false; let hasCrcOk = false;
for (const pkt of packets) { for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt); const next = normalizeServerAprsPacket(pkt);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -324,8 +336,9 @@ if (aprsFilterInput) {
// --- Server-side APRS decode handler --- // --- Server-side APRS decode handler ---
function onServerAprs(pkt: AprsPacket): void { function onServerAprs(pkt: AprsPacket): void {
if (aprsStatus) aprsStatus.textContent = "Receiving"; const packet = normalizeServerAprsPacket(pkt);
addAprsPacket(normalizeServerAprsPacket(pkt)); if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
} }
renderAprsHistory(); renderAprsHistory();
@@ -336,6 +349,7 @@ renderAprsHistory();
restore: onServerAprsBatch, restore: onServerAprsBatch,
reset: resetAprsHistoryView, reset: resetAprsHistoryView,
prune: pruneAprsHistoryView, prune: pruneAprsHistoryView,
rerender: () => { updateAprsBar(); renderAprsHistory(); },
// Oldest first, so station tracks are rebuilt in the order they happened. // Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); }, syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
}); });
@@ -66,10 +66,17 @@ const bgdWindow = window as unknown as BackgroundBridge;
let bookmarkList: Bookmark[] = []; let bookmarkList: Bookmark[] = [];
let statusInterval: ReturnType<typeof setInterval> | null = null; let statusInterval: ReturnType<typeof setInterval> | null = null;
let bgdDirty = false; let bgdDirty = false;
/** Last polled state per bookmark, so a row can say what it is doing. */
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
let lastStatus: BackgroundDecodeStatus | null = null;
function initBackgroundDecode(rigId: string | null, role: string | null): void { function initBackgroundDecode(rigId: string | null, role: string | null): void {
backgroundDecodeRole = role; backgroundDecodeRole = role;
currentRigId = rigId || null; // The panel used to take whatever rig it was handed at load and wait to be
// told again. Loading before the rig list arrives handed it null, and the
// next telling only came when the operator switched rigs, so the panel sat
// empty and silent. The host knows the rig; ask it.
currentRigId = rigId || hostState.lastActiveRigId || null;
if (currentRigId) loadBackgroundDecode(); if (currentRigId) loadBackgroundDecode();
startStatusPolling(); startStatusPolling();
} }
@@ -171,7 +178,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
setCheckbox("background-decode-enabled", currentConfig.enabled); setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist(); renderBookmarkChecklist();
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false; const isControl = isControlRole();
const panel = document.getElementById("background-decode-panel"); const panel = document.getElementById("background-decode-panel");
if (panel) { if (panel) {
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) { panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
@@ -182,6 +189,11 @@ const bgdWindow = window as unknown as BackgroundBridge;
const resetBtn = document.getElementById("background-decode-reset-btn"); const resetBtn = document.getElementById("background-decode-reset-btn");
if (saveBtn) saveBtn.style.display = isControl ? "" : "none"; if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
if (resetBtn) resetBtn.style.display = isControl ? "" : "none"; if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
syncSaveButton();
}
function currentFilterText(): string {
return (document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value ?? "";
} }
function renderBookmarkChecklist(filterText = ""): void { function renderBookmarkChecklist(filterText = ""): void {
@@ -203,26 +215,65 @@ const bgdWindow = window as unknown as BackgroundBridge;
: all; : all;
if (filtered.length === 0) { if (filtered.length === 0) {
container.innerHTML = '<div class="bgd-checklist-empty">' + container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + renderSelectionSummary();
'</div>';
return; return;
} }
filtered.forEach(function (bookmark) { filtered.forEach(function (bookmark) {
const row = document.createElement("label"); const row = document.createElement("label");
row.className = "bgd-checklist-row"; row.className = "bgd-checklist-row";
row.dataset.bmId = bookmark.id;
const decoders = bookmarkDecoderKinds(bookmark); const decoders = bookmarkDecoderKinds(bookmark);
const checked = selectedIds.has(bookmark.id) ? " checked" : ""; const selected = selectedIds.has(bookmark.id);
if (selected) row.classList.add("is-selected");
row.innerHTML = row.innerHTML =
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' + '<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' + '<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>'; '<span class="bgd-checklist-meta">'
+ escHtml(formatFreq(bookmark.freq_hz)) + '<span class="bgd-checklist-mode">'
+ escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span></span>'
+ stateBadgeHtml(bookmark.id, selected);
row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) { row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) {
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked); onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
}); });
container.appendChild(row); container.appendChild(row);
}); });
renderSelectionSummary();
}
/** Why the list is empty, in the terms the operator can act on. */
function emptyListText(supportedCount: number): string {
if (supportedCount > 0) return "No bookmark matches that filter.";
if (bookmarkList.length > 0) {
return "None of your bookmarks name a decoder that can run in the background."
+ " Give one a decoder on the Bookmarks tab to list it here.";
}
return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
}
/** The live state of a selected bookmark, as the row's own badge. */
function stateBadgeHtml(bookmarkId: string, selected: boolean): string {
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
const entry = statusByBookmark.get(bookmarkId);
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '">'
+ '<span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
}
function renderSelectionSummary(): void {
const el = document.getElementById("bgd-selection-summary");
if (!el) return;
const selected = currentConfig?.bookmark_ids.length ?? 0;
if (selected === 0) {
el.textContent = "Nothing selected — background decoding is idle.";
return;
}
const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
el.textContent = currentConfig?.enabled
? `${noun}, ${String(active)} decoding now.`
: `${noun}. Switch Enabled on to start decoding them.`;
} }
function onChecklistToggle(bookmarkId: string, checked: boolean): void { function onChecklistToggle(bookmarkId: string, checked: boolean): void {
@@ -260,7 +311,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
showToast(`Save failed: ${errorMessage(err)}`, true); showToast(`Save failed: ${errorMessage(err)}`, true);
}) })
.finally(function () { .finally(function () {
if (btn) btn.disabled = false; syncSaveButton();
}); });
} }
@@ -295,58 +346,77 @@ const bgdWindow = window as unknown as BackgroundBridge;
} }
function renderStatus(status: BackgroundDecodeStatus): void { function renderStatus(status: BackgroundDecodeStatus): void {
const card = document.getElementById("background-decode-status-card"); lastStatus = status;
if (!card) return; statusByBookmark = new Map(
const entries = status.entries ?? []; (status.entries ?? [])
if (!entries.length) { .filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0)
card.textContent = "No background decode bookmarks configured."; .map((entry) => [entry.bookmark_id as string, entry]),
return; );
} renderSpanSummary();
const summary = []; // Rows carry the state, so the list repaints — keeping the filter the
if (status.active_rig) { // operator typed and the selection they have not saved yet.
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz)); renderBookmarkChecklist(currentFilterText());
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
} else {
summary.push("This rig is not currently selected for audio.");
}
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
html += '<div class="bgd-status-list">';
entries.forEach(function (entry) {
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
const parts = [];
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
if (entry.mode) parts.push(entry.mode);
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
parts.push(entry.decoder_kinds.join("/").toUpperCase());
}
html +=
'<div class="bgd-status-row">' +
'<div>' +
'<div class="bgd-status-name">' + escHtml(name) + '</div>' +
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
'</div>' +
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' +
'<svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' +
escHtml(prettyState(entry.state)) + '</div>' +
'</div>';
});
html += "</div>";
card.innerHTML = html;
} }
/** What the rig is listening across, which is what decides whether a
* selected bookmark can be decoded at all. */
function renderSpanSummary(): void {
const el = document.getElementById("bgd-span-summary");
if (!el) return;
const status = lastStatus;
if (!status) {
el.textContent = "";
return;
}
if (!status.active_rig) {
el.textContent = "This rig is not the one playing audio.";
el.dataset.tone = "warn";
return;
}
const centre = typeof status.center_hz === "number" && Number.isFinite(status.center_hz)
? formatFreq(status.center_hz)
: null;
const half = typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0
? formatFreq(status.sample_rate / 2)
: null;
el.dataset.tone = "";
el.textContent = centre && half ? `Span ${centre} ±${half}` : centre ? `Centre ${centre}` : "";
}
/** The one-line reason behind a state, for the row's tooltip. */
function stateHelp(state: string | undefined): string {
switch (state) {
case "active": return "Decoding on a hidden channel.";
case "out_of_span": return "Outside the span the rig is tuned across, so it cannot be heard from here.";
case "waiting_for_spectrum": return "Waiting for the first spectrum frame from the rig.";
case "waiting_for_user": return "Nobody is listening to this rig, so no audio is being pulled.";
case "missing_bookmark": return "The bookmark this was selected from is gone.";
case "no_supported_decoders": return "No decoder that runs in the background can decode this bookmark.";
case "disabled": return "Background decoding is switched off.";
case "handled_by_scheduler":
case "scheduler_has_control": return "The scheduler is running this bookmark instead.";
case "handled_by_virtual_channel": return "A virtual channel is already on this frequency.";
case "pending": return "Selected, and not started yet — save to apply.";
default: return "Selected, but not decoding.";
}
}
// The dot beside the word already says whether this is running, so the
// words no longer carry a tick or a triangle of their own.
function prettyState(state: string | undefined): string { function prettyState(state: string | undefined): string {
switch (state) { switch (state) {
case "active": return "\u2713 Active"; case "active": return "Decoding";
case "out_of_span": return "\u25B3 Out of span"; case "out_of_span": return "Out of span";
case "waiting_for_spectrum": return "\u25B3 Waiting"; case "waiting_for_spectrum": return "Waiting for spectrum";
case "waiting_for_user": return "\u25B3 No user"; case "waiting_for_user": return "Nobody listening";
case "missing_bookmark": return "\u2717 Missing"; case "missing_bookmark": return "Bookmark gone";
case "no_supported_decoders": return "\u2717 Unsupported"; case "no_supported_decoders": return "No decoder";
case "disabled": return "\u25B3 Disabled"; case "disabled": return "Off";
case "handled_by_scheduler": return "\u25B3 Scheduler"; case "handled_by_scheduler":
case "scheduler_has_control": return "\u25B3 Scheduler"; case "scheduler_has_control": return "Scheduler has it";
case "handled_by_virtual_channel": return "\u25B3 VChan"; case "handled_by_virtual_channel": return "On a channel";
default: return "\u25B3 Inactive"; case "pending": return "Not saved";
default: return "Idle";
} }
} }
@@ -380,14 +450,25 @@ const bgdWindow = window as unknown as BackgroundBridge;
function markBgdDirty() { function markBgdDirty() {
if (bgdDirty) return; if (bgdDirty) return;
bgdDirty = true; bgdDirty = true;
const btn = document.getElementById("background-decode-save-btn"); syncSaveButton();
if (btn) btn.classList.add("sch-dirty");
} }
function clearBgdDirty() { function clearBgdDirty() {
bgdDirty = false; bgdDirty = false;
const btn = document.getElementById("background-decode-save-btn"); syncSaveButton();
if (btn) btn.classList.remove("sch-dirty"); }
/** Save offers itself only when there is something to save. */
function syncSaveButton() {
const btn = document.getElementById("background-decode-save-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.classList.toggle("sch-dirty", bgdDirty);
btn.disabled = !bgdDirty || !isControlRole();
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
}
function isControlRole(): boolean {
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
} }
function showToast(msg: string, isError: boolean): void { function showToast(msg: string, isError: boolean): void {
@@ -407,7 +488,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
} }
const ids = supportedBookmarks().map(function (bm) { return bm.id; }); const ids = supportedBookmarks().map(function (bm) { return bm.id; });
currentConfig.bookmark_ids = ids; currentConfig.bookmark_ids = ids;
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value); renderBookmarkChecklist(currentFilterText());
markBgdDirty(); markBgdDirty();
} }
@@ -416,7 +497,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
} }
currentConfig.bookmark_ids = []; currentConfig.bookmark_ids = [];
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value); renderBookmarkChecklist(currentFilterText());
markBgdDirty(); markBgdDirty();
} }
@@ -432,7 +513,11 @@ const bgdWindow = window as unknown as BackgroundBridge;
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null; const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
if (enabledCb && !enabledCb._wired) { if (enabledCb && !enabledCb._wired) {
enabledCb._wired = true; enabledCb._wired = true;
enabledCb.addEventListener("change", function () { markBgdDirty(); }); enabledCb.addEventListener("change", function () {
if (currentConfig) currentConfig.enabled = enabledCb.checked;
markBgdDirty();
renderBookmarkChecklist(currentFilterText());
});
} }
const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null; const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js"; import { hostCore } from "./host.js";
import { isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {}; export {};
@@ -20,8 +21,11 @@ interface CwRenderer {
drawPoints(points: number[], size: number, color: Rgba): void; drawPoints(points: number[], size: number, color: Rgba): void;
} }
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number } interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean } interface CwEvent { rig_id?: string | null; text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number } interface CwLine {
rigId: string | null; tsMs: number; ts: string; text: string;
wpm: number | null; tone_hz: number | null; lastMs: number;
}
interface CwToneRange { interface CwToneRange {
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number; tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
toneSpanHz: number; lowerSideband: boolean; mode: string; toneSpanHz: number; lowerSideband: boolean; mode: string;
@@ -66,7 +70,10 @@ const CW_BAR_LINE_GAP_MS = 5000;
let cwLastAppendTime = 0; let cwLastAppendTime = 0;
let cwTonePickerRaf: number | null = null; let cwTonePickerRaf: number | null = null;
let cwBarHistory: CwLine[] = []; let cwBarHistory: CwLine[] = [];
let cwBarCurrentLine: CwLine | null = null; // One line in progress per rig, keyed by rig id ("" for a decode that names no
// rig). Two rigs copying at the same time each fill their own line instead of
// braiding their characters into one.
const cwBarCurrentLines = new Map<string, CwLine>();
let cwBarDismissedAtMs = 0; let cwBarDismissedAtMs = 0;
// Tracks a user-initiated auto toggle that is in-flight (POST not yet // Tracks a user-initiated auto toggle that is in-flight (POST not yet
// acknowledged). While set, server-state updates must not override the // acknowledged). While set, server-state updates must not override the
@@ -101,12 +108,13 @@ cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
applyCwAutoUi(enabled); applyCwAutoUi(enabled);
}; };
function cwBarFlushCurrentLine(): void { function cwBarFlushCurrentLine(key: string): void {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) { const line = cwBarCurrentLines.get(key);
cwBarHistory.unshift(cwBarCurrentLine); cwBarCurrentLines.delete(key);
if (line?.text.trim()) {
cwBarHistory.unshift(line);
if (cwBarHistory.length > 50) cwBarHistory.length = 50; if (cwBarHistory.length > 50) cwBarHistory.length = 50;
} }
cwBarCurrentLine = null;
} }
function updateCwBar(): void { function updateCwBar(): void {
@@ -114,9 +122,12 @@ function updateCwBar(): void {
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase(); const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
const isCw = mode === "CW" || mode === "CWR"; const isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS; const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs); const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
// Prepend the in-progress line so characters appear immediately // Prepend the in-progress lines so characters appear immediately
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent; const inProgress = [...cwBarCurrentLines.values()]
.filter((l) => l.text && isActiveRigDecode(l.rigId))
.sort((a, b) => b.tsMs - a.tsMs);
const liveLines = [...inProgress, ...recent];
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0); const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) { if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none"; cwBarOverlay.style.display = "none";
@@ -402,7 +413,7 @@ function resetCwHistoryView(): void {
if (cwOutputEl) cwOutputEl.innerHTML = ""; if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0; cwLastAppendTime = 0;
cwBarHistory = []; cwBarHistory = [];
cwBarCurrentLine = null; cwBarCurrentLines.clear();
updateCwBar(); updateCwBar();
drawCwTonePicker(); drawCwTonePicker();
} }
@@ -421,8 +432,13 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
// --- Server-side CW decode handler --- // --- Server-side CW decode handler ---
function onServerCw(evt: CwEvent): void { function onServerCw(evt: CwEvent): void {
if (cwStatusEl) cwStatusEl.textContent = "Receiving"; // The panel is a single stream of copied text: characters from a background
if (evt.text && cwOutputEl) { // rig do not belong in it at all, and interleaving them would make both
// rigs unreadable. The bar histories below keep every rig, each on its own
// line, and pick out the selected rig when they paint.
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl && forSelectedRig) {
// Append decoded text to output // Append decoded text to output
const now = Date.now(); const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") { if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
@@ -445,25 +461,29 @@ function onServerCw(evt: CwEvent): void {
// Bar history accumulation (regardless of pause state) // Bar history accumulation (regardless of pause state)
if (evt.text) { if (evt.text) {
const now = Date.now(); const now = Date.now();
const rigId = evt.rig_id ?? null;
const key = rigId ?? "";
if (evt.text === "\n") { if (evt.text === "\n") {
cwBarFlushCurrentLine(); cwBarFlushCurrentLine(key);
} else { } else {
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) { let line = cwBarCurrentLines.get(key);
cwBarFlushCurrentLine(); if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine(key);
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
cwBarCurrentLines.set(key, line);
} }
cwBarCurrentLine.text += evt.text; line.text += evt.text;
cwBarCurrentLine.lastMs = now; line.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm); if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz)); if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
} }
updateCwBar(); updateCwBar();
} }
if (cwSignalIndicator) { if (cwSignalIndicator && forSelectedRig) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off"; cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
} }
if (!cwAutoInput || cwAutoInput.checked) { if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) { if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm)); cwWpmInput.value = String(clampCwWpm(evt.wpm));
} }
@@ -491,6 +511,13 @@ cwWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerCw, onMessage: onServerCw,
restore: restoreCwHistory, restore: restoreCwHistory,
reset: resetCwHistoryView, reset: resetCwHistoryView,
// The copied text of a rig that is no longer on screen cannot be unpicked
// from the pane, so the switch starts the new rig's stream from empty.
rerender: () => {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
updateCwBar();
},
}); });
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() { cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js"; import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export type FtxDecoderId = "ft2" | "ft4" | "ft8"; export type FtxDecoderId = "ft2" | "ft4" | "ft8";
@@ -15,6 +16,7 @@ export interface FtxMessage {
snr_db?: number | undefined; snr_db?: number | undefined;
dt_s?: number | undefined; dt_s?: number | undefined;
freq_hz?: number | undefined; freq_hz?: number | undefined;
rig_id?: string | null | undefined;
receiver?: unknown; receiver?: unknown;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -227,9 +229,12 @@ export function initializeFtxDecoder(config: FtxConfig): void {
const render = (): void => { const render = (): void => {
prune(); prune();
if (!messagesElement) return; if (!messagesElement) return;
// The history holds every rig's decodes, for the map; the panel is about
// the rig the operator is working.
const rigMessages = forActiveRig(history);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
let count = 0; let count = 0;
for (const message of history) { for (const message of rigMessages) {
if (count >= 200) break; if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue; if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message)); fragment.appendChild(renderRow(message));
@@ -237,20 +242,32 @@ export function initializeFtxDecoder(config: FtxConfig): void {
} }
messagesElement.replaceChildren(fragment); messagesElement.replaceChildren(fragment);
}; };
const normalize = (message: FtxMessage): FtxMessage => { /** Hands a decode's grid squares to the map, if the map module is loaded yet.
* Split out of normalize so a decode can be replayed onto a map that
* arrived later: the module is lazy, and everything decoded before it
* loaded had nowhere to go. */
const plotLocator = (message: FtxMessage): void => {
const raw = message.message ?? ""; const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0 const grids = locatorDetails.length > 0
? locatorDetails.map(({ grid }) => grid) ? locatorDetails.map(({ grid }) => grid)
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw); : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
if (grids.length === 0) return;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
// Already an RF frequency on a replay, an audio offset on arrival; the
// conversion only fires below 100 kHz, so it is right either way.
const frequency = displayFrequency(message.freq_hz); const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) { bridge.mapAddLocator?.(raw, grids, id, station, {
bridge.mapAddLocator?.(raw, grids, id, station, { ...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails, });
}); };
} const normalize = (message: FtxMessage): FtxMessage => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
return { return {
// The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band.
rig_id: message.rig_id ?? null,
receiver: bridge.getDecodeRigMeta?.() ?? null, receiver: bridge.getDecodeRigMeta?.() ?? null,
ts_ms: message.ts_ms, ts_ms: message.ts_ms,
snr_db: message.snr_db, snr_db: message.snr_db,
@@ -262,7 +279,10 @@ export function initializeFtxDecoder(config: FtxConfig): void {
}; };
const receiveBatch = (messages: FtxMessage[]): void => { const receiveBatch = (messages: FtxMessage[]): void => {
if (messages.length === 0) return; if (messages.length === 0) return;
if (status) status.textContent = "Receiving"; // "Receiving" is the panel's own status, and the panel is the rig's.
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
status.textContent = "Receiving";
}
history = messages.map(normalize).reverse().concat(history); history = messages.map(normalize).reverse().concat(history);
prune(); prune();
bridge.setFt8FamilyBarDecoder?.(id); bridge.setFt8FamilyBarDecoder?.(id);
@@ -277,7 +297,8 @@ export function initializeFtxDecoder(config: FtxConfig): void {
}; };
const barFrames = (): BarFrames => { const barFrames = (): BarFrames => {
const recent = history const recent = history
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000) .filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000
&& isActiveRigDecode(message.rig_id))
.slice(0, 8); .slice(0, 8);
let html = ""; let html = "";
for (const message of recent) { for (const message of recent) {
@@ -299,6 +320,9 @@ export function initializeFtxDecoder(config: FtxConfig): void {
restore: receiveBatch, restore: receiveBatch,
prune: () => { prune(); render(); }, prune: () => { prune(); render(); },
reset, reset,
rerender: () => { bridge.updateFt8Bar?.(); render(); },
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => { for (const message of [...history].reverse()) plotLocator(message); },
}); });
bridge.registerFt8FamilyBarRenderer?.(id, barFrames); bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
@@ -4,6 +4,7 @@
import { hostCore, hostState } from "./host.js"; import { hostCore, hostState } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import { import {
aprsAgeText, aprsAgeText,
aprsPacketCategory, aprsPacketCategory,
@@ -19,6 +20,12 @@ interface HfAprsBridge {
getDecodeHistoryRetentionMs?: () => number; getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void; trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
navigateToAprsMap?: (lat: number, lon: number) => void; navigateToAprsMap?: (lat: number, lon: number) => void;
aprsMapAddStation?: (
call: string, lat: number, lon: number, info: string,
symbolTable: string | null | undefined, symbolCode: string | null | undefined,
packet: AprsPacket, source: "aprs" | "hf_aprs",
) => void;
clearMapMarkersByType?: (type: string) => void;
getDecodeRigMeta?: () => unknown; getDecodeRigMeta?: () => unknown;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
@@ -90,23 +97,31 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(hfAprsFilterText); return haystack.includes(hfAprsFilterText);
} }
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function hfAprsRigPackets(): AprsPacket[] {
return forActiveRig(hfAprsPacketHistory);
}
function hfAprsVisiblePackets(): AprsPacket[] { function hfAprsVisiblePackets(): AprsPacket[] {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory; const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(hfAprsFilterMatch); return packets.filter(hfAprsFilterMatch);
} }
const collapseHfAprsDuplicates = collapseAprsDuplicates; const collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() { function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets(); const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) { if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`; hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
} }
if (hfAprsVisibleCountEl) { if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`; hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
} }
if (hfAprsLatestSeenEl) { if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0]; const latest = rigPackets[0];
if (!latest) { if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet"; hfAprsLatestSeenEl.textContent = "No packets yet";
} else { } else {
@@ -168,6 +183,7 @@ function resetHfAprsHistoryView(): void {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = []; hfAprsPacketHistory = [];
renderHfAprsHistory(); renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
} }
function pruneHfAprsHistoryView(): void { function pruneHfAprsHistoryView(): void {
@@ -175,6 +191,17 @@ function pruneHfAprsHistoryView(): void {
renderHfAprsHistory(); renderHfAprsHistory();
} }
/** Hands a positioned packet to the map, if the map module is loaded yet.
* HF traffic goes on as its own source: it is a different band and a
* different path, and the map filter offers it separately. */
function plotHfAprsPacket(pkt: AprsPacket): void {
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
hfAprsWindow.aprsMapAddStation(
pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "",
pkt.symbolTable, pkt.symbolCode, pkt, "hf_aprs",
);
}
function addHfAprsPacket(pkt: AprsPacket): void { function addHfAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs; pkt._tsMs = tsMs;
@@ -183,6 +210,8 @@ function addHfAprsPacket(pkt: AprsPacket): void {
hfAprsPacketHistory.unshift(pkt); hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory(); pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender(); scheduleHfAprsHistoryRender();
} }
@@ -192,13 +221,15 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerHfAprsBatch(packets: AprsPacket[]): void { function onServerHfAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = []; const normalized: AprsPacket[] = [];
for (const pkt of packets) { for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt); const next = normalizeServerHfAprsPacket(pkt);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotHfAprsPacket(next);
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -266,8 +297,9 @@ if (hfAprsFilterInput) {
// --- Server-side HF APRS decode handler --- // --- Server-side HF APRS decode handler ---
function onServerHfAprs(pkt: AprsPacket): void { function onServerHfAprs(pkt: AprsPacket): void {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; const packet = normalizeServerHfAprsPacket(pkt);
addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
} }
renderHfAprsHistory(); renderHfAprsHistory();
@@ -278,4 +310,7 @@ renderHfAprsHistory();
restore: onServerHfAprsBatch, restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView, reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView, prune: pruneHfAprsHistoryView,
rerender: renderHfAprsHistory,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry); },
}); });
@@ -9,6 +9,10 @@ export interface DecoderPlugin<TMessage = unknown> {
restore?(messages: TMessage[]): void; restore?(messages: TMessage[]): void;
reset?(): void; reset?(): void;
prune?(): void; prune?(): void;
/** Repaint the plugin's panel from what it already holds. Called when the
* selected rig changes: the panels show one rig's traffic, and the decodes
* behind them arrived long before the switch. */
rerender?(): void;
/** Replay everything the plugin is holding onto the map. Called when the map /** Replay everything the plugin is holding onto the map. Called when the map
* module attaches, which can happen long after the decodes arrived. */ * module attaches, which can happen long after the decodes arrived. */
syncMap?(): void; syncMap?(): void;
@@ -22,6 +26,7 @@ export interface TrxPluginRuntime {
reset(id: string): boolean; reset(id: string): boolean;
resetAll(): void; resetAll(): void;
prune(id: string): boolean; prune(id: string): boolean;
rerenderAll(): void;
syncMapAll(): void; syncMapAll(): void;
clearQueued(): void; clearQueued(): void;
hasDecoder(id: string): boolean; hasDecoder(id: string): boolean;
@@ -4,13 +4,12 @@
import { hostCore } from "./host.js"; import { hostCore } from "./host.js";
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types"; import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate } from "./satellite-types";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
type SatelliteView = "live" | "history" | "predictions"; type SatelliteView = "live" | "history";
interface SatelliteBridge { interface SatelliteBridge {
trxScheduleUiFrameJob?: (key: string, job: () => void) => void; trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
clearSatPredictionDom?: () => void;
updateSatLiveState?: (update: SatelliteLiveUpdate) => void; updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
addSatMapOverlay?: (image: SatelliteImage) => void; addSatMapOverlay?: (image: SatelliteImage) => void;
clearSatMapOverlays?: () => void; clearSatMapOverlays?: () => void;
@@ -25,14 +24,14 @@ const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow;
// --- SAT Plugin --- // --- SAT Plugin ---
// Live view: decoder state, latest image card // Live view: decoder state, latest image card
// History view: filterable table of all decoded images // History view: filterable table of all decoded images
// Predictions view: next 24 h passes for ham satellites //
// Pass predictions live on their own page; see satellite-predictions.ts.
// ── DOM references (cached once) ─────────────────────────────────── // ── DOM references (cached once) ───────────────────────────────────
const satDom = { const satDom = {
status: document.getElementById("sat-status"), status: document.getElementById("sat-status"),
liveView: document.getElementById("sat-live-view"), liveView: document.getElementById("sat-live-view"),
historyView: document.getElementById("sat-history-view"), historyView: document.getElementById("sat-history-view"),
predictionsView: document.getElementById("sat-predictions-view"),
liveLatest: document.getElementById("sat-live-latest"), liveLatest: document.getElementById("sat-live-latest"),
historyList: document.getElementById("sat-history-list"), historyList: document.getElementById("sat-history-list"),
historyCount: document.getElementById("sat-history-count"), historyCount: document.getElementById("sat-history-count"),
@@ -42,30 +41,13 @@ const satDom = {
lrptState: document.getElementById("sat-lrpt-state"), lrptState: document.getElementById("sat-lrpt-state"),
viewLiveBtn: document.getElementById("sat-view-live"), viewLiveBtn: document.getElementById("sat-view-live"),
viewHistoryBtn: document.getElementById("sat-view-history"), viewHistoryBtn: document.getElementById("sat-view-history"),
viewPredBtn: document.getElementById("sat-view-predictions"),
predFilter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
predMinEl: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
predCategory: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
predCurrentList: document.getElementById("sat-pred-current-list"),
predUpcomingList: document.getElementById("sat-pred-list"),
predCurrentSec: document.getElementById("sat-pred-current-section"),
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
predStatus: document.getElementById("sat-pred-status"),
}; };
// ── State ─────────────────────────────────────────────────────────── // ── State ───────────────────────────────────────────────────────────
let satImageHistory: SatelliteImage[] = []; let satImageHistory: SatelliteImage[] = [];
const SAT_MAX_IMAGES = 100; const SAT_MAX_IMAGES = 100;
const SAT_PRED_PAGE_SIZE = 50;
let satPredShowAll = false;
let satFilterText = ""; let satFilterText = "";
let satActiveView: SatelliteView = "live"; let satActiveView: SatelliteView = "live";
let satPredData: SatellitePass[] = [];
let satPredFilterText = "";
let satPredMinEl = 0;
let satPredCategory = "all";
let satPredSatCount = 0;
let satPredCountdownTimer: ReturnType<typeof setInterval> | null = null;
// ── UI scheduler helper ───────────────────────────────────────────── // ── UI scheduler helper ─────────────────────────────────────────────
function scheduleSatUi(key: string, job: () => void): void { function scheduleSatUi(key: string, job: () => void): void {
@@ -78,33 +60,16 @@ function scheduleSatUi(key: string, job: () => void): void {
// ── View switching ────────────────────────────────────────────────── // ── View switching ──────────────────────────────────────────────────
function switchSatView(view: SatelliteView): void { function switchSatView(view: SatelliteView): void {
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
satActiveView = view; satActiveView = view;
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none"; if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none"; if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none"; if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live"); if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history"); if (view === "history") renderSatHistoryTable();
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
if (leavingPredictions) clearPredictionDom();
if (view === "history") {
renderSatHistoryTable();
} else if (view === "predictions") {
satPredShowAll = false;
void loadSatPredictions();
}
} }
function clearPredictionDom() {
stopCountdownTimer();
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
}
satWindow.clearSatPredictionDom = clearPredictionDom;
satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); }); satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); }); satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); });
// ── Live view: decoder state ──────────────────────────────────────── // ── Live view: decoder state ────────────────────────────────────────
let lastSatLrptOn: boolean | null = null; let lastSatLrptOn: boolean | null = null;
@@ -335,234 +300,6 @@ document
} }
})(); }); })(); });
// ── Predictions: helpers ────────────────────────────────────────────
function azToCardinal(deg: number): string {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms: number): string {
const d = new Date(ms);
const now = new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s: number): string {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms: number): string {
const totalSec = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg: number): string {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
// ── Predictions: countdown timer management ─────────────────────────
function stopCountdownTimer() {
if (satPredCountdownTimer) {
clearInterval(satPredCountdownTimer);
satPredCountdownTimer = null;
}
}
function startCountdownTimer(container: HTMLElement | null): void {
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
satPredCountdownTimer = setInterval(() => {
if (satActiveView !== "predictions") {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
renderSatPredictions(getFilteredPredictions());
}
}, 1000);
}
// ── Predictions: row builders ───────────────────────────────────────
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
// ── Predictions: filter state ───────────────────────────────────────
function getFilteredPredictions(): SatellitePass[] {
let items = satPredData;
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
return items;
}
function applyPredFilters() {
renderSatPredictions(getFilteredPredictions());
}
const satPredictionFilter = satDom.predFilter;
satPredictionFilter?.addEventListener("input", () => {
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
applyPredFilters();
});
const satPredictionMinElevation = satDom.predMinEl;
satPredictionMinElevation?.addEventListener("change", () => {
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
applyPredFilters();
});
const satPredictionCategory = satDom.predCategory;
satPredictionCategory?.addEventListener("change", () => {
satPredCategory = satPredictionCategory.value;
applyPredFilters();
});
// ── Predictions: main render ────────────────────────────────────────
function renderSatPredictions(passes: SatellitePass[], error?: string): void {
stopCountdownTimer();
if (error) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
// ── Current passes ──
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
if (satDom.predCurrentList) {
if (current.length === 0) {
satDom.predCurrentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
satDom.predCurrentList.replaceChildren(frag);
}
}
// ── Upcoming passes ──
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
if (satDom.predUpcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
moreRow.addEventListener("click", () => {
satPredShowAll = true;
renderSatPredictions(getFilteredPredictions());
});
frag.appendChild(moreRow);
}
satDom.predUpcomingList.replaceChildren(frag);
}
// ── Status ──
if (satDom.predStatus) {
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`;
satDom.predStatus.textContent = text;
}
// ── Countdown timer ──
if (current.length > 0 && satActiveView === "predictions") {
startCountdownTimer(satDom.predCurrentList);
}
}
// ── Predictions: data loading ───────────────────────────────────────
async function loadSatPredictions(): Promise<void> {
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json() as SatellitePassResponse;
satPredSatCount = data.satellite_count || 0;
if (data.error) {
satPredData = [];
renderSatPredictions([], data.error);
} else {
satPredData = data.passes || [];
renderSatPredictions(getFilteredPredictions());
}
} catch (error: unknown) {
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
// ── Navigate to map centered on satellite image bounds ────────────── // ── Navigate to map centered on satellite image bounds ──────────────
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) { satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
if (typeof satWindow.enableMapSourceFilter === "function") { if (typeof satWindow.enableMapSourceFilter === "function") {
@@ -0,0 +1,290 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import type { SatellitePass, SatellitePassResponse } from "./satellite-types";
// --- Satellite pass predictions ---
//
// Its own page rather than a third view inside the weather-satellite decoder:
// when a bird comes over is a planning question, and it was buried under
// Digital modes beside decoders it has nothing to do with.
interface PredictionsBridge {
clearSatPredictionDom?: () => void;
refreshSatPredictions?: () => void;
}
const predWindow = window as unknown as PredictionsBridge;
// ── DOM references (cached once) ───────────────────────────────────
const dom = {
page: document.getElementById("tab-satellites"),
filter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
minElevation: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
category: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
currentList: document.getElementById("sat-pred-current-list"),
upcomingList: document.getElementById("sat-pred-list"),
currentSection: document.getElementById("sat-pred-current-section"),
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
status: document.getElementById("sat-pred-status"),
};
// ── State ───────────────────────────────────────────────────────────
const PAGE_SIZE = 50;
let predShowAll = false;
let predData: SatellitePass[] = [];
let predFilterText = "";
let predMinEl = 0;
let predCategory = "all";
let predSatCount = 0;
let predCountdownTimer: ReturnType<typeof setInterval> | null = null;
/** The countdowns tick once a second, so they stop when nobody is looking. */
function isPageVisible(): boolean {
return !!dom.page && dom.page.style.display !== "none";
}
// ── Predictions: helpers ────────────────────────────────────────────
function azToCardinal(deg: number): string {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms: number): string {
const d = new Date(ms);
const now = new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s: number): string {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms: number): string {
const totalSec = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg: number): string {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
// ── Predictions: countdown timer management ─────────────────────────
function stopCountdownTimer() {
if (predCountdownTimer) {
clearInterval(predCountdownTimer);
predCountdownTimer = null;
}
}
function startCountdownTimer(container: HTMLElement | null): void {
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
predCountdownTimer = setInterval(() => {
if (!isPageVisible()) {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
render(filtered());
}
}, 1000);
}
// ── Predictions: row builders ───────────────────────────────────────
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
// ── Predictions: filter state ───────────────────────────────────────
function filtered(): SatellitePass[] {
let items = predData;
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
return items;
}
function applyFilters() {
render(filtered());
}
const filterInput = dom.filter;
filterInput?.addEventListener("input", () => {
predFilterText = filterInput.value.trim().toUpperCase();
applyFilters();
});
const minElevationSelect = dom.minElevation;
minElevationSelect?.addEventListener("change", () => {
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
applyFilters();
});
const categorySelect = dom.category;
categorySelect?.addEventListener("change", () => {
predCategory = categorySelect.value;
applyFilters();
});
// ── Predictions: main render ────────────────────────────────────────
function render(passes: SatellitePass[], error?: string): void {
stopCountdownTimer();
if (error) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
// ── Current passes ──
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
if (dom.currentList) {
if (current.length === 0) {
dom.currentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
dom.currentList.replaceChildren(frag);
}
}
// ── Upcoming passes ──
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
if (dom.upcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
moreRow.addEventListener("click", () => {
predShowAll = true;
render(filtered());
});
frag.appendChild(moreRow);
}
dom.upcomingList.replaceChildren(frag);
}
// ── Status ──
if (dom.status) {
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
if (predSatCount > 0) text += ` \u00B7 ${predSatCount} satellites tracked`;
dom.status.textContent = text;
}
// ── Countdown timer ──
if (current.length > 0 && isPageVisible()) {
startCountdownTimer(dom.currentList);
}
}
// ── Predictions: data loading ───────────────────────────────────────
async function load(): Promise<void> {
if (dom.status) dom.status.textContent = "Loading predictions\u2026";
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json() as SatellitePassResponse;
predSatCount = data.satellite_count || 0;
if (data.error) {
predData = [];
render([], data.error);
} else {
predData = data.passes || [];
render(filtered());
}
} catch (error: unknown) {
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
// ── Page lifecycle ──────────────────────────────────────────────────
function clearPredictionDom(): void {
stopCountdownTimer();
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
}
predWindow.clearSatPredictionDom = clearPredictionDom;
/**
* Called by the router each time the Satellites page is opened, including the
* visit that imports this module so loading is driven from one place only.
*/
predWindow.refreshSatPredictions = function () {
predShowAll = false;
void load();
};
@@ -14,11 +14,13 @@
import { hostCore } from "./host.js"; import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {}; export {};
interface SstvImage { interface SstvImage {
rig_id?: string | null;
ts_ms?: number; ts_ms?: number;
vis?: number; vis?: number;
mode?: string; mode?: string;
@@ -32,6 +34,7 @@ interface SstvImage {
} }
interface SstvProgress { interface SstvProgress {
rig_id?: string | null;
state?: string; state?: string;
mode?: string; mode?: string;
width?: number; width?: number;
@@ -185,6 +188,9 @@ function paintRow(line: number, rgb: Uint8Array): void {
// ── Server messages ───────────────────────────────────────────────── // ── Server messages ─────────────────────────────────────────────────
function onProgress(msg: SstvProgress): void { function onProgress(msg: SstvProgress): void {
// The live canvas draws one picture at a time: a background rig's rows would
// paint straight over the one being watched.
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state) { if (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state; if (sstvDom.status) sstvDom.status.textContent = msg.state;
// A state update carries the geometry; a row update carries only the row. // A state update carries the geometry; a row update carries only the row.
@@ -208,6 +214,9 @@ function onImage(msg: SstvImage): void {
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift(); if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory(); pruneHistory();
// Kept in the history either way, so switching to that rig finds it there.
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) { if (sstvDom.status) {
sstvDom.status.textContent = image.complete sstvDom.status.textContent = image.complete
? `Received ${image.mode ?? "picture"}` ? `Received ${image.mode ?? "picture"}`
@@ -219,9 +228,15 @@ function onImage(msg: SstvImage): void {
// ── Rendering ─────────────────────────────────────────────────────── // ── Rendering ───────────────────────────────────────────────────────
/** The pictures the selected rig decoded, oldest first, as the history is kept. */
function sstvRigImages(): SstvImage[] {
return forActiveRig(sstvHistory);
}
function renderLatestCard(): void { function renderLatestCard(): void {
if (!sstvDom.liveLatest) return; if (!sstvDom.liveLatest) return;
const latest = sstvHistory[sstvHistory.length - 1]; const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
if (!latest) { if (!latest) {
sstvDom.liveLatest.innerHTML = ""; sstvDom.liveLatest.innerHTML = "";
return; return;
@@ -245,10 +260,12 @@ function renderLatestCard(): void {
} }
function filteredHistory(): SstvImage[] { function filteredHistory(): SstvImage[] {
// Every rig's pictures are kept; the panel shows the selected rig's.
const rigImages = sstvRigImages();
const text = filterText.trim().toLowerCase(); const text = filterText.trim().toLowerCase();
const matching = text const matching = text
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
: sstvHistory.slice(); : rigImages;
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest"; const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0))); matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching; return matching;
@@ -348,6 +365,7 @@ sstvWindow.trxPluginRuntime.registerDecoder({
restore: restoreHistory, restore: restoreHistory,
prune: renderHistoryTable, prune: renderHistoryTable,
reset: resetHistoryView, reset: resetHistoryView,
rerender: () => { renderLatestCard(); renderHistoryTable(); },
}); });
sstvWindow.trxPluginRuntime.registerDecoder({ sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress", id: "sstv_progress",
@@ -6,6 +6,7 @@ import { hostCore } from "./host.js";
export {}; export {};
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract"; import type { PluginRuntimeWindow } from "./runtime-contract";
interface VdesMessage { interface VdesMessage {
@@ -116,17 +117,24 @@ function vdesHexPreview(rawBytes: number[] | undefined): string {
.toUpperCase(); .toUpperCase();
} }
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function vdesRigMessages(): VdesMessage[] {
return forActiveRig(vdesMessageHistory);
}
function updateVdesSummary() { function updateVdesSummary() {
pruneVdesMessageHistory(); pruneVdesMessageHistory();
if (vdesChannelSummaryEl) { if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText(); vdesChannelSummaryEl.textContent = currentVdesCenterText();
} }
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) { if (vdesFrameCountEl) {
const count = vdesMessageHistory.length; const count = rigMessages.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`; vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
} }
if (vdesLatestSeenEl) { if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0]; const latest = rigMessages[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet"; vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
} }
} }
@@ -228,7 +236,9 @@ function updateVdesBar() {
updateVdesSummary(); updateVdesSummary();
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES"; const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS; const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6); const messages = vdesMessageHistory
.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id))
.slice(0, 6);
if (!isVdes || messages.length === 0) { if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none"; vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = ""; vdesBarOverlay.innerHTML = "";
@@ -278,7 +288,7 @@ function renderVdesHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) { for (const message of vdesRigMessages()) {
fragment.appendChild(renderVdesRow(message)); fragment.appendChild(renderVdesRow(message));
} }
vdesMessagesEl.replaceChildren(fragment); vdesMessagesEl.replaceChildren(fragment);
@@ -309,10 +319,11 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
function onServerVdesBatch(messages: VdesMessage[]): void { function onServerVdesBatch(messages: VdesMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized: VdesMessage[] = []; const normalized: VdesMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -354,8 +365,8 @@ function plotVdesMessage(msg: VdesMessage): void {
} }
function onServerVdes(msg: VdesMessage): void { function onServerVdes(msg: VdesMessage): void {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next); addVdesMessage(next);
plotVdesMessage(next); plotVdesMessage(next);
} }
@@ -374,6 +385,7 @@ updateVdesSummary();
restore: onServerVdesBatch, restore: onServerVdesBatch,
reset: resetVdesHistoryView, reset: resetVdesHistoryView,
prune: pruneVdesHistoryView, prune: pruneVdesHistoryView,
rerender: () => { updateVdesBar(); renderVdesHistory(); },
// Oldest first, so tracks are rebuilt in the order they happened. // Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); }, syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
}); });
@@ -4,11 +4,13 @@
import { hostCore } from "./host.js"; import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {}; export {};
interface WefaxImage { interface WefaxImage {
rig_id?: string | null;
ts_ms?: number; ts_ms?: number;
ioc?: number; ioc?: number;
lpm?: number; lpm?: number;
@@ -20,6 +22,7 @@ interface WefaxImage {
} }
interface WefaxProgress { interface WefaxProgress {
rig_id?: string | null;
state?: string; state?: string;
line_data?: string; line_data?: string;
line_count?: number; line_count?: number;
@@ -156,15 +159,21 @@ function paintLine(lineBytes: Uint8Array): void {
} }
// ── Live view: latest image card ──────────────────────────────────── // ── Live view: latest image card ────────────────────────────────────
/** The pictures the selected rig decoded, newest first. */
function wefaxRigImages(): WefaxImage[] {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() { function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return; if (!wefaxDom.liveLatest) return;
if (wefaxImageHistory.length === 0) { const rigImages = wefaxRigImages();
if (rigImages.length === 0) {
wefaxDom.liveLatest.innerHTML = wefaxDom.liveLatest.innerHTML =
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>'; '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
return; return;
} }
const img = wefaxImageHistory[0]; const img = rigImages[0];
if (!img) return; if (!img) return;
const ts = img._ts || '--'; const ts = img._ts || '--';
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : ''; const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
@@ -193,7 +202,8 @@ function renderWefaxLatestCard() {
// ── History view: table ───────────────────────────────────────────── // ── History view: table ─────────────────────────────────────────────
function getWefaxFilteredHistory() { function getWefaxFilteredHistory() {
let items = wefaxImageHistory; // Every rig's pictures are kept; the panel shows the selected rig's.
let items = wefaxRigImages();
if (wefaxFilterText) { if (wefaxFilterText) {
items = items.filter(function (i) { items = items.filter(function (i) {
@@ -253,7 +263,7 @@ function renderWefaxHistoryTable() {
wefaxDom.historyList.replaceChildren(fragment); wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) { if (wefaxDom.historyCount) {
const total = wefaxImageHistory.length; const total = wefaxRigImages().length;
const shown = items.length; const shown = items.length;
wefaxDom.historyCount.textContent = wefaxDom.historyCount.textContent =
total === 0 total === 0
@@ -298,6 +308,9 @@ function addWefaxImage(msg: WefaxImage): void {
// ── SSE event handlers (public API) ───────────────────────────────── // ── SSE event handlers (public API) ─────────────────────────────────
function onServerWefaxProgress(msg: WefaxProgress): void { function onServerWefaxProgress(msg: WefaxProgress): void {
// The live canvas draws one picture at a time: a background rig's lines
// would paint straight over the one being watched.
if (!isActiveRigDecode(msg.rig_id)) return;
// State-only update (no image data): show decoder state in status. // State-only update (no image data): show decoder state in status.
if (msg.state && !msg.line_data) { if (msg.state && !msg.line_data) {
if (wefaxDom.status) { if (wefaxDom.status) {
@@ -331,6 +344,8 @@ function onServerWefaxProgress(msg: WefaxProgress): void {
function onServerWefax(msg: WefaxImage): void { function onServerWefax(msg: WefaxImage): void {
addWefaxImage(msg); addWefaxImage(msg);
// Kept in the history either way, so switching to that rig finds it there.
if (!isActiveRigDecode(msg.rig_id)) return;
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none'; if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
if (wefaxDom.status) { if (wefaxDom.status) {
@@ -438,6 +453,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
restore: restoreWefaxHistory, restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView, prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView, reset: resetWefaxHistoryView,
rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); },
}); });
wefaxWindow.trxPluginRuntime.registerDecoder({ wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress", id: "wefax_progress",
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js"; import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {}; export {};
@@ -15,6 +16,10 @@ interface WsprMessage {
snr_db?: number | undefined; snr_db?: number | undefined;
dt_s?: number | undefined; dt_s?: number | undefined;
freq_hz?: number | undefined; freq_hz?: number | undefined;
rig_id?: string | null | undefined;
/** RF frequency the spot was heard on, kept so a map replay does not
* recompute it against wherever the dial has moved to since. */
_rfHz?: number | null | undefined;
receiver?: unknown; receiver?: unknown;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -102,10 +107,12 @@ function renderWsprRow(msg: WsprMessage): HTMLDivElement {
function renderWsprHistory(): void { function renderWsprHistory(): void {
pruneWsprMessageHistory(); pruneWsprMessageHistory();
if (!wsprMessagesEl) return; if (!wsprMessagesEl) return;
// The history holds every rig's decodes, for the map; the panel is about the
// rig the operator is working.
const rigMessages = forActiveRig(wsprMessageHistory);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) { for (const message of rigMessages) {
const message = wsprMessageHistory[i]; fragment.appendChild(renderWsprRow(message));
if (message) fragment.appendChild(renderWsprRow(message));
} }
wsprMessagesEl.replaceChildren(fragment); wsprMessagesEl.replaceChildren(fragment);
} }
@@ -130,6 +137,8 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
station, station,
rfHz, rfHz,
history: { history: {
rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, ts_ms: msg.ts_ms,
snr_db: msg.snr_db, snr_db: msg.snr_db,
@@ -142,16 +151,12 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
function onServerWsprBatch(messages: WsprMessage[]): void { function onServerWsprBatch(messages: WsprMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized: WsprMessage[] = []; const normalized: WsprMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { // "Receiving" is the panel's own status, and the panel is the rig's.
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
...msg, plotWsprLocator(msg);
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
});
}
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now(); next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history); normalized.push(next.history);
} }
@@ -311,15 +316,24 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
})(); })();
}); });
function onServerWspr(msg: WsprMessage): void { /** Hands a spot's grid squares to the map, if the map module is loaded yet.
if (wsprStatus) wsprStatus.textContent = "Receiving"; * The module is lazy, so a spot heard before it arrived has to be replayable. */
function plotWsprLocator(msg: WsprMessage): void {
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { // A replayed spot carries the frequency it was heard on; a fresh one has it
...msg, // worked out from the dial it just arrived against.
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }), const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
}); wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
} ...msg,
...(rfHz === null ? {} : { freq_hz: rfHz }),
});
}
function onServerWspr(msg: WsprMessage): void {
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg);
plotWsprLocator(msg);
addWsprMessage(next.history); addWsprMessage(next.history);
} }
@@ -330,4 +344,7 @@ wsprWindow.trxPluginRuntime.registerDecoder({
restore: onServerWsprBatch, restore: onServerWsprBatch,
prune: pruneWsprHistoryView, prune: pruneWsprHistoryView,
reset: resetWsprHistoryView, reset: resetWsprHistoryView,
rerender: renderWsprHistory,
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => { for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message); },
}); });
@@ -499,7 +499,7 @@ function elementById<T extends HTMLElement>(id: string): T {
if (restoreFocus) more.focus(); if (restoreFocus) more.focus();
}; };
api.closeMobileOverlays = closeMore; api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => { ["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`); const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return; if (!source) return;
const item = document.createElement("button"); const item = document.createElement("button");
@@ -0,0 +1,348 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The radio page and the digital modes page both describe one rig: the one the
// operator selected. Every rig a client is connected to feeds the same decode
// stream, so both the mini views over the waterfall and the panels behind them
// filter on the rig that heard each decode. The map is the exception and keeps
// all of them — it has its own rig filter — which is why the histories these
// views read from are never pruned by rig.
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
class ElementFixture {
constructor(value = "") {
this.children = [];
this.innerHTML = "";
this.textContent = "";
this.value = value;
this.style = {};
this.dataset = {};
this.classList = { add() {}, remove() {}, toggle() {} };
}
appendChild(child) { this.children.push(child); return child; }
removeChild(child) { this.children.splice(this.children.indexOf(child), 1); }
replaceChildren(...nodes) { this.children = nodes.flatMap((node) => node.children ?? [node]); }
addEventListener() {}
setAttribute() {}
querySelector() { return null; }
querySelectorAll() { return []; }
get firstChild() { return this.children[0] ?? null; }
get lastElementChild() { return this.children.at(-1) ?? null; }
get scrollHeight() { return this.children.length; }
}
/** A document whose named elements exist and whose unknown ones do not. */
function createDocument(elements) {
return {
documentElement: {},
getElementById: (id) => elements.get(id) ?? null,
querySelector: () => null,
querySelectorAll: () => [],
createElement: () => new ElementFixture(),
createDocumentFragment: () => new ElementFixture(),
};
}
async function runPlugin(entry, { window, document: doc, extras = {} }) {
const context = vm.createContext({
window,
document: doc,
navigator: {},
requestAnimationFrame(callback) { callback(); return 1; },
getComputedStyle: () => ({ getPropertyValue: () => "" }),
Date, Number, String, Math, Array, Map, Set, Reflect, console,
...extras,
});
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL(`../src/plugins/${entry}.ts`, import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
return context;
}
test("the APRS mini view and panel both keep to the active rig", async () => {
const overlay = new ElementFixture();
const packets = new ElementFixture();
const totals = new ElementFixture();
const elements = new Map([
["aprs-bar-overlay", overlay],
["aprs-packets", packets],
["aprs-total-count", totals],
["mode", new ElementFixture("PKT")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
aprsMapAddStation: () => {},
};
await runPlugin("aprs", { window, document: createDocument(elements) });
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
assert.match(overlay.innerHTML, /SP1AAA/, "the active rig's frame is missing from the mini view");
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's frame reached the mini view");
assert.equal(packets.children.length, 1, "the APRS panel listed a background rig's frame");
assert.equal(totals.textContent, "1 total", `the APRS panel counted "${totals.textContent}"`);
});
test("the APRS mini view shows every frame until a rig is known", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["aprs-bar-overlay", overlay],
["mode", new ElementFixture("PKT")],
]);
const window = {
...createHost(),
trxUi: { confirm: async () => true },
aprsMapAddStation: () => {},
};
await runPlugin("aprs", { window, document: createDocument(elements) });
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB" });
assert.match(overlay.innerHTML, /SP1AAA/);
assert.match(overlay.innerHTML, /SP2BBB/);
});
test("the AIS mini view keeps to the active rig", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["ais-bar-overlay", overlay],
["mode", new ElementFixture("AIS")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
aisMapAddVessel: () => {},
};
await runPlugin("ais", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
assert.match(overlay.innerHTML, /NEARBY/);
assert.doesNotMatch(overlay.innerHTML, /ELSEWHERE/, "a background rig's vessel reached the mini view");
});
test("the VDES mini view keeps to the active rig", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["vdes-bar-overlay", overlay],
["mode", new ElementFixture("VDES")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
vdesMapAddPoint: () => {},
};
await runPlugin("vdes", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
assert.match(overlay.innerHTML, /SP1AAA/);
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's burst reached the mini view");
});
test("the CW mini view keeps to the active rig and does not braid two rigs into a line", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["cw-bar-overlay", overlay],
["cw-output", new ElementFixture()],
["mode", new ElementFixture("CW")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
addEventListener() {},
};
await runPlugin("cw", { window, document: createDocument(elements) });
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("cw", { text: "DX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
assert.match(overlay.innerHTML, /CQ SP1AAA/, "the active rig's line was broken up or lost");
assert.doesNotMatch(overlay.innerHTML, /DX/, "a background rig's characters reached the mini view");
});
test("the FT8 mini view keeps to the active rig", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["ft8-bar-overlay", overlay],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 7_074_000,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
setInterval() { return 1; },
};
await runPlugin("ft8", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
assert.match(overlay.innerHTML, /SP1AAA/);
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view");
});
// ── The digital modes panels ───────────────────────────────────────────────
// The same rule one page over: a panel lists the selected rig's decodes only.
test("the AIS panel lists the active rig's vessels only", async () => {
const messages = new ElementFixture();
const count = new ElementFixture();
const elements = new Map([
["ais-messages", messages],
["ais-vessel-count", count],
["mode", new ElementFixture("AIS")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
aisMapAddVessel: () => {},
};
await runPlugin("ais", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the AIS panel listed a background rig's vessel");
assert.equal(count.textContent, "1 vessel", `the panel counted "${count.textContent}"`);
});
test("the VDES panel lists the active rig's bursts only", async () => {
const messages = new ElementFixture();
const count = new ElementFixture();
const elements = new Map([
["vdes-messages", messages],
["vdes-frame-count", count],
["mode", new ElementFixture("VDES")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
vdesMapAddPoint: () => {},
};
await runPlugin("vdes", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the VDES panel listed a background rig's burst");
assert.equal(count.textContent, "1 burst", `the panel counted "${count.textContent}"`);
});
test("the FT8 panel lists the active rig's decodes only", async () => {
const messages = new ElementFixture();
const elements = new Map([
["ft8-messages", messages],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 7_074_000,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
setInterval() { return 1; },
};
await runPlugin("ft8", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the FT8 panel listed a background rig's decode");
});
test("the WSPR panel lists the active rig's spots only", async () => {
const messages = new ElementFixture();
const elements = new Map([
["wspr-messages", messages],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 14_095_600,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
};
await runPlugin("wspr", {
window,
document: createDocument(elements),
extras: { setInterval: () => 1 },
});
const now = Date.now();
window.trxPluginRuntime.dispatch("wspr", { message: "SP1AAA JO91 30", freq_hz: 1_500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("wspr", { message: "SP2BBB JO94 27", freq_hz: 1_520, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the WSPR panel listed a background rig's spot");
});
test("the CW pane copies the active rig only", async () => {
const output = new ElementFixture();
const elements = new Map([
["cw-output", output],
["cw-bar-overlay", new ElementFixture()],
["mode", new ElementFixture("CW")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
addEventListener() {},
};
await runPlugin("cw", { window, document: createDocument(elements) });
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("cw", { text: "XX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
const copied = output.children.map((line) => line.textContent).join("");
assert.equal(copied, "CQ SP1AAA", `the pane copied "${copied}"`);
});
test("switching rigs repaints the panels through the runtime", async () => {
const packets = new ElementFixture();
const elements = new Map([
["aprs-packets", packets],
["aprs-bar-overlay", new ElementFixture()],
["mode", new ElementFixture("PKT")],
]);
// A mutable host: the rig picker writes lastActiveRigId, and the panels read
// it as they paint.
const host = createHost({ state: { lastActiveRigId: "rig-a" } });
let activeRigId = "rig-a";
Object.defineProperty(host.trx.state, "lastActiveRigId", { get: () => activeRigId });
const window = { ...host, trxUi: { confirm: async () => true }, aprsMapAddStation: () => {} };
await runPlugin("aprs", { window, document: createDocument(elements) });
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
assert.equal(packets.children.length, 1);
activeRigId = "rig-b";
window.trxPluginRuntime.rerenderAll();
assert.equal(packets.children.length, 1, "the panel did not follow the switch");
const shown = packets.children.map((row) => row.innerHTML).join("");
assert.match(shown, /SP2BBB/, "the panel still shows the rig that was switched away from");
assert.doesNotMatch(shown, /SP1AAA/);
});
@@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The background decode panel: one list that both picks the bookmarks and says
// what each one is doing. It used to be two lists of the same bookmarks — one
// to choose from, one to read state off — and it filled neither until the
// operator switched rigs, because it took whatever rig it was handed at load
// and that was nothing yet.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document */
const BOOKMARKS = [
{ id: "bm-ft8-20", name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoders: ["ft8"] },
{ id: "bm-ft8-40", name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoders: ["ft8"] },
{ id: "bm-ft4-20", name: "FT4 20 m", freq_hz: 14_080_000, mode: "USB", decoders: ["ft4"] },
{ id: "bm-aprs", name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoders: ["aprs"] },
// Nothing that runs in the background can decode CW, so it must not be offered.
{ id: "bm-cw", name: "CW practice", freq_hz: 7_030_000, mode: "CW", decoders: ["cw"] },
];
const backgroundDecode = {
config: { remote: "rig-a", enabled: true, bookmark_ids: ["bm-ft8-20", "bm-ft8-40", "bm-aprs"] },
status: {
active_rig: true,
center_hz: 14_100_000,
sample_rate: 2_400_000,
entries: [
{ bookmark_id: "bm-ft8-20", bookmark_name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "active" },
{ bookmark_id: "bm-ft8-40", bookmark_name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "out_of_span" },
{ bookmark_id: "bm-aprs", bookmark_name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoder_kinds: ["aprs"], state: "handled_by_scheduler" },
],
},
};
const readRows = () => page.evaluate(() => {
const rows = [...document.querySelectorAll("#bgd-bookmark-checklist .bgd-checklist-row")];
return rows.map((row) => ({
name: row.querySelector(".bgd-checklist-name")?.textContent ?? "",
checked: row.querySelector("input[type=checkbox]")?.checked ?? false,
state: row.querySelector(".bgd-state")?.dataset.state ?? null,
stateText: row.querySelector(".bgd-state")?.textContent?.trim() ?? "",
}));
});
const openPanel = async () => {
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-settings").waitFor({ state: "visible" });
await page.waitForTimeout(2000);
await page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
await page.waitForTimeout(1200);
};
const fixture = await startWebFixture({ spectrum: true, bookmarks: BOOKMARKS, backgroundDecode });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.setViewportSize({ width: 1400, height: 950 });
await openPanel();
// Nothing was switched, clicked or reloaded: the panel asked the host which
// rig it is on and filled itself.
const rows = await readRows();
assert.deepEqual(rows.map((row) => row.name), ["FT8 20 m", "FT8 40 m", "FT4 20 m", "APRS 2 m"],
"the list does not hold the bookmarks a background channel can decode");
assert.deepEqual(rows.map((row) => row.checked), [true, true, false, true],
"the saved selection is not reflected in the list");
// Each row carries its own state, so there is no second list to consult.
assert.deepEqual(rows.map((row) => row.state),
["active", "out_of_span", "unselected", "handled_by_scheduler"],
"the rows do not carry the state of the bookmark they name");
assert.match(rows[0].stateText, /Decoding/);
assert.match(rows[1].stateText, /Out of span/);
// What the rig can hear, and what the selection adds up to.
const context = await page.evaluate(() => ({
span: document.getElementById("bgd-span-summary")?.textContent?.trim(),
summary: document.getElementById("bgd-selection-summary")?.textContent?.trim(),
}));
assert.match(context.span, /14\.1 MHz/, `the span reads "${context.span}"`);
assert.match(context.summary, /3 bookmarks selected, 1 decoding now\./, `the summary reads "${context.summary}"`);
// Save has nothing to do until something changes, and says so again once it
// has been done.
const saveState = () => page.evaluate(() => {
const btn = document.getElementById("background-decode-save-btn");
return { disabled: btn.disabled, dirty: btn.classList.contains("sch-dirty") };
});
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save offers itself with nothing to save");
await page.locator('#bgd-bookmark-checklist .bgd-checklist-row:nth-child(3) input').click();
await page.waitForTimeout(300);
assert.deepEqual(await saveState(), { disabled: false, dirty: true }, "Save stayed inert after a change");
await page.locator("#background-decode-save-btn").click();
await page.waitForTimeout(1000);
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save stayed live after saving");
// The selection reached the server and comes back on the next load.
await openPanel();
const saved = await readRows();
assert.deepEqual(saved.map((row) => row.checked), [true, true, true, true],
"the newly selected bookmark did not survive a reload");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
// With no bookmarks at all the panel says what to do about it rather than
// showing an empty box.
const emptyFixture = await startWebFixture({ spectrum: true, bookmarks: [] });
const empty = await startBrowser(chromium);
try {
await empty.page.setViewportSize({ width: 1400, height: 950 });
await empty.page.goto(`${emptyFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await empty.page.locator("#tab-settings").waitFor({ state: "visible" });
await empty.page.waitForTimeout(2000);
await empty.page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
await empty.page.waitForTimeout(1000);
const text = await empty.page.evaluate(() =>
document.getElementById("bgd-bookmark-checklist")?.textContent?.trim() ?? "");
assert.match(text, /No bookmarks yet/, `the empty list reads "${text}"`);
const summary = await empty.page.evaluate(() =>
document.getElementById("bgd-selection-summary")?.textContent?.trim() ?? "");
assert.match(summary, /Nothing selected/, `the summary reads "${summary}"`);
} finally {
await empty.browser.close();
await emptyFixture.close();
}
console.log("background decode panel tests passed");
@@ -22,9 +22,25 @@ const BEACON = {
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65, packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a", symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
}; };
// A second rig listening in the background, on its own band. Both pages
// describe the rig on screen, so its traffic belongs in neither the panel nor
// the mini view — only on the map, which shows the whole station.
// HF APRS travels a different band and a different path from the VHF list, so
// it goes on the map under a source of its own that the filter can hide.
const HF_BEACON = {
type: "hf_aprs", src_call: "SP5HF-7", dest_call: "APRS", path: "WIDE2-2", info: "HF beacon",
packet_type: "position", crc_ok: true, lat: 50.06, lon: 19.94,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
const OTHER_RIG_VESSEL = {
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
lat: 51.92, lon: 4.48, rig_id: "rig-b",
};
// AIS is what the mini view for vessels is gated on; the rig has to be on it. // AIS is what the mini view for vessels is gated on; the rig has to be on it.
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" }); const fixture = await startWebFixture({
spectrum: true, decodes: [VESSEL, BEACON, HF_BEACON, OTHER_RIG_VESSEL], mode: "AIS",
});
const { browser, page, runtimeErrors } = await startBrowser(chromium); const { browser, page, runtimeErrors } = await startBrowser(chromium);
try { try {
@@ -41,11 +57,14 @@ try {
aprs: document.getElementById("aprs-packets")?.children.length ?? 0, aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
aisStatus: document.getElementById("ais-status")?.textContent ?? "", aisStatus: document.getElementById("ais-status")?.textContent ?? "",
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "", aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false,
aisRows: document.getElementById("ais-messages")?.children.length ?? 0,
mapLoaded: !!window.trx.modules.map, mapLoaded: !!window.trx.modules.map,
})); }));
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing"); assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`); assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`); assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
assert.equal(panels.otherRig, false, "the AIS panel listed a background rig's vessel");
// The mini view rides over the waterfall on the radio page. // The mini view rides over the waterfall on the radio page.
await page.locator('.tab[data-tab="main"]').click(); await page.locator('.tab[data-tab="main"]').click();
@@ -56,11 +75,13 @@ try {
shown: getComputedStyle(bar).display !== "none", shown: getComputedStyle(bar).display !== "none",
pins: bar.querySelectorAll(".aprs-bar-pin").length, pins: bar.querySelectorAll(".aprs-bar-pin").length,
names: bar.textContent.includes("NEDERLAND"), names: bar.textContent.includes("NEDERLAND"),
otherRig: bar.textContent.includes("ELDERBERRY"),
}; };
}); });
assert.equal(miniView.shown, true, "the AIS mini view did not appear"); assert.equal(miniView.shown, true, "the AIS mini view did not appear");
assert.ok(miniView.pins > 0, "the mini view has no pin to follow"); assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
assert.equal(miniView.names, true, "the mini view does not name the vessel"); assert.equal(miniView.names, true, "the mini view does not name the vessel");
assert.equal(miniView.otherRig, false, "the mini view shows a background rig's vessel");
// Following the pin: the map opens, on the vessel. This is the path that was // Following the pin: the map opens, on the vessel. This is the path that was
// broken for every decoder — the module that owned the navigation had not // broken for every decoder — the module that owned the navigation had not
@@ -89,8 +110,41 @@ try {
: Object.keys(collection ?? {}).length); : Object.keys(collection ?? {}).length);
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) }; return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
}); });
assert.ok(markers.ais > 0, "the vessel never reached the map");
assert.ok(markers.stations > 0, "the APRS station never reached the map"); assert.ok(markers.stations > 0, "the APRS station never reached the map");
// Both APRS stations are on it, each under its own source: the HF one used
// to be dropped entirely, since the HF list never plotted anything.
const aprsSources = await page.evaluate(() => {
const entries = [...(window.trx.modules.map?.stationMarkers ?? new Map()).entries()];
return entries.map(([key, entry]) => `${entry?.type ?? "?"}:${entry?.call ?? key}`).sort();
});
assert.deepEqual(aprsSources, ["aprs:SP2SJG-9", "hf_aprs:SP5HF-7"],
`the map holds ${JSON.stringify(aprsSources)}`);
// And the filter offers HF APRS on a chip of its own, next to the VHF one.
await page.locator('#map-locator-phase .map-locator-phase-btn[data-phase="type"]').click();
await page.waitForTimeout(400);
const chips = await page.evaluate(() => [...document.querySelectorAll("#map-locator-choice-filter .map-locator-chip")]
.map((chip) => chip.dataset.filterKey));
assert.ok(chips.includes("aprs") && chips.includes("hf_aprs"),
`the Show chips are ${JSON.stringify(chips)}`);
// Turning that chip off takes the HF station off the map and leaves the VHF
// one on: the two are filtered apart, which is the whole point of the split.
await page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="hf_aprs"]').click();
await page.waitForTimeout(500);
const shownAfterFilter = await page.evaluate(() => {
const map = window.trx.modules.map;
const onMap = [];
(map?.stationMarkers ?? new Map()).forEach((entry, key) => {
if (entry?.marker && map.aprsMap?.hasLayer(entry.marker)) onMap.push(entry?.call ?? key);
});
return onMap.sort();
});
assert.deepEqual(shownAfterFilter, ["SP2SJG-9"],
`hiding HF APRS left ${JSON.stringify(shownAfterFilter)} on the map`);
// The map is the whole station's view — the one place a background rig's
// traffic belongs — so both vessels are on it even though the panel and the
// mini view show only the selected rig's.
assert.equal(markers.ais, 2, `${markers.ais} of 2 vessels reached the map`);
assert.deepEqual(runtimeErrors, []); assert.deepEqual(runtimeErrors, []);
} finally { } finally {
@@ -103,6 +157,8 @@ try {
// left the client on its retry path and the history path untested. // left the client on its retry path and the history path untested.
const HISTORY_AIS = 900; const HISTORY_AIS = 900;
const HISTORY_APRS = 300; const HISTORY_APRS = 300;
const HISTORY_FT8 = 12;
const HISTORY_WSPR = 8;
const historyFixture = await startWebFixture({ const historyFixture = await startWebFixture({
spectrum: true, spectrum: true,
mode: "AIS", mode: "AIS",
@@ -112,6 +168,14 @@ const historyFixture = await startWebFixture({
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1, vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})), })),
ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({
message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2,
freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({
message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5,
freq_hz: 1500 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({ aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1", src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
info: `history ${index}`, packet_type: "position", crc_ok: true, info: `history ${index}`, packet_type: "position", crc_ok: true,
@@ -199,6 +263,27 @@ try {
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`); assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`); assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
// The Statistics page counts the same history. Its decode log lives in the
// map module, which is lazy, so every decode that arrived before the module
// did used to be recorded into nothing at all — the page opened empty and
// only filled in from decodes heard afterwards, and it took a reload landing
// on the tab (module loaded at startup, before the history) to show the lot.
await replay.page.evaluate(() => window.navigateToTab("statistics"));
await replay.page.waitForTimeout(1500);
// The counters are written with toLocaleString(), so a four-figure count
// arrives as "1,220" — whichever separator the runner's locale picks. Read
// the digits rather than the formatting.
const counted = await replay.page.evaluate(() => {
const count = (id) => Number((document.getElementById(id)?.textContent ?? "").replace(/\D/g, ""));
return { decodes: count("stats-total-decodes"), grids: count("stats-unique-grids") };
});
assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR,
`the statistics counted ${counted.decodes} decodes`);
// Grid squares come from the FT8 and WSPR spots, which had no map replay of
// their own: the locators of everything heard before the map loaded were lost.
assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR,
`the statistics counted ${counted.grids} grid squares`);
assert.deepEqual(replay.runtimeErrors, []); assert.deepEqual(replay.runtimeErrors, []);
} finally { } finally {
await replay.browser.close(); await replay.browser.close();
@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// Pass predictions used to be a third view inside the weather-satellite
// decoder, under Digital modes — a planning tool filed with the decoders it has
// nothing to do with. It is its own page now, so what this guards is that the
// page exists at its own address, renders passes, and that the decoder card no
// longer offers the view it gave up.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, window, getComputedStyle */
const HOUR = 3_600_000;
const now = Date.now();
const satPasses = {
satellite_count: 2,
passes: [
// In progress right now, so the page has a countdown to run.
{
satellite: "NOAA 19", category: "weather",
aos_ms: now - 4 * 60_000, los_ms: now + 6 * 60_000,
max_elevation_deg: 62.4, duration_s: 600,
azimuth_aos_deg: 10, azimuth_los_deg: 190,
},
{
satellite: "ISS", category: "amateur",
aos_ms: now + 2 * HOUR, los_ms: now + 2 * HOUR + 480_000,
max_elevation_deg: 18.2, duration_s: 480,
azimuth_aos_deg: 200, azimuth_los_deg: 20,
},
],
};
const fixture = await startWebFixture({ satPasses });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.setViewportSize({ width: 1500, height: 950 });
// The page is reachable at its own address, not through a decoder sub-view.
// #content is the radio panel on the main tab, so the readiness signal for a
// deep link is the destination panel itself.
await page.goto(`${fixture.origin}/satellites`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-satellites").waitFor({ state: "visible" });
await page.waitForFunction(
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
null,
{ timeout: 5000 },
);
const rendered = await page.evaluate(() => ({
current: document.getElementById("sat-pred-current-list").textContent,
upcoming: document.getElementById("sat-pred-list").textContent,
status: document.getElementById("sat-pred-status").textContent,
activeTab: document.querySelector(".tab-bar .tab.active")?.dataset.tab,
}));
assert.match(rendered.current, /NOAA 19/, `current passes read "${rendered.current}"`);
assert.match(rendered.upcoming, /ISS/, `upcoming passes read "${rendered.upcoming}"`);
assert.match(rendered.status, /1 active/, `status reads "${rendered.status}"`);
assert.equal(rendered.activeTab, "satellites", "the Satellites tab is the active one");
// A pass in progress counts down, so the seconds have to move on their own.
const firstTick = await page.evaluate(
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
await page.waitForTimeout(1600);
const secondTick = await page.evaluate(
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
assert.notEqual(firstTick, secondTick, `the countdown sat at ${firstTick}`);
// Leaving stops the countdown rather than leaving a timer running behind a
// hidden page.
await page.evaluate(() => { window.navigateToTab("main"); });
await page.waitForTimeout(300);
const afterLeaving = await page.evaluate(() => ({
visible: document.getElementById("tab-satellites").style.display,
rows: document.getElementById("sat-pred-current-list").childElementCount,
}));
assert.equal(afterLeaving.visible, "none", "the page stayed on screen after navigating away");
assert.equal(afterLeaving.rows, 0, "the countdown rows outlived the page");
// Coming back reloads rather than showing whatever was there before.
await page.evaluate(() => { window.navigateToTab("satellites"); });
await page.waitForFunction(
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
null,
{ timeout: 5000 },
);
// The page is an occasional destination, so it lives behind Tools rather than
// taking a slot in the operating strip — the same treatment Statistics,
// Recorder, Settings and About get.
const nav = await page.evaluate(() => ({
inStrip: getComputedStyle(document.querySelector('.tab-bar-nav .tab[data-tab="satellites"]')).display,
inTools: !!document.querySelector('#mobile-more-menu [data-navigate-tab="satellites"]'),
}));
assert.equal(nav.inStrip, "none", "Satellites took a slot in the operating strip");
assert.ok(nav.inTools, "Satellites is not reachable from Tools");
// The decoder card keeps Live and History, and no longer offers Predictions.
await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
const satCard = await page.evaluate(() => ({
predictionsButton: !!document.getElementById("sat-view-predictions"),
predictionsView: !!document.getElementById("sat-predictions-view"),
live: !!document.getElementById("sat-view-live"),
history: !!document.getElementById("sat-view-history"),
}));
assert.equal(satCard.predictionsButton, false, "the decoder card still offers Predictions");
assert.equal(satCard.predictionsView, false, "the old predictions view is still in the page");
assert.ok(satCard.live && satCard.history, "the decoder card lost Live/History");
assert.deepEqual(runtimeErrors, [], "the page threw while showing predictions");
console.log("satellite predictions page tests passed");
} finally {
await browser.close();
await fixture.close();
}
@@ -27,6 +27,21 @@ const dial = () => page.evaluate(() => ({
path: window.location.pathname, path: window.location.pathname,
})); }));
// Typed, not filled. The app holds back its own refreshes of the frequency
// field from the first keystroke until Enter, so that a state update arriving
// mid-edit does not rewrite what is being typed. `fill()` sets the value
// without a keystroke, leaving the field unguarded: on a slow machine a state
// update could land between the fill and the Enter and put the old frequency
// back, and the Enter would then re-apply the frequency the radio was already
// on. Selecting first arms the guard before a single character changes.
async function tuneByHand(text) {
const field = page.locator("#freq");
await field.click();
await field.press("ControlOrMeta+a");
await field.pressSequentially(text);
await field.press("Enter");
}
try { try {
await page.setViewportSize({ width: 1500, height: 950 }); await page.setViewportSize({ width: 1500, height: 950 });
@@ -49,8 +64,7 @@ try {
// Tuning by hand rewrites the link. This is the part that makes the address // Tuning by hand rewrites the link. This is the part that makes the address
// bar shareable at any moment rather than only at load. // bar shareable at any moment rather than only at load.
await page.locator("#freq").fill("7.040M"); await tuneByHand("7.040M");
await page.locator("#freq").press("Enter");
await page.waitForTimeout(1500); await page.waitForTimeout(1500);
const tuned = await dial(); const tuned = await dial();
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`); assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
@@ -59,8 +73,7 @@ try {
// Tuning is not navigation: a swept dial must not bury the back button. // Tuning is not navigation: a swept dial must not bury the back button.
const historyLength = await page.evaluate(() => window.history.length); const historyLength = await page.evaluate(() => window.history.length);
await page.locator("#freq").fill("7.100M"); await tuneByHand("7.100M");
await page.locator("#freq").press("Enter");
await page.waitForTimeout(1200); await page.waitForTimeout(1200);
assert.equal(await page.evaluate(() => window.history.length), historyLength, assert.equal(await page.evaluate(() => window.history.length), historyLength,
"tuning pushed a history entry instead of replacing one"); "tuning pushed a history entry instead of replacing one");
@@ -34,7 +34,14 @@ const DECODER_REGISTRY = [
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] }, { id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] }, { id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] }, { id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true })); ].map((decoder) => ({
...decoder,
// Which decoders a background channel can run, as the server's own registry
// has it: the background decode panel offers a bookmark only if one of these
// can decode it, so marking them all false left that panel with nothing.
background_decode: ["ft8", "ft4", "ft2", "wspr", "ais", "aprs", "hf-aprs"].includes(decoder.id),
bookmark_selectable: true,
}));
const CONTENT_TYPES = new Map([ const CONTENT_TYPES = new Map([
[".css", "text/css; charset=utf-8"], [".css", "text/css; charset=utf-8"],
@@ -132,9 +139,11 @@ export async function startWebFixture({
mode = "FM", mode = "FM",
history = {}, history = {},
bookmarks = [], bookmarks = [],
backgroundDecode = null,
bandplan = {}, bandplan = {},
bandplanEnabled = false, bandplanEnabled = false,
bandplanUnauthorizedFirst = false, bandplanUnauthorizedFirst = false,
satPasses = null,
} = {}) { } = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({ const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote, remote,
@@ -230,6 +239,7 @@ export async function startWebFixture({
["/bandplan.json", bandplan], ["/bandplan.json", bandplan],
["/api/recorder/status", []], ["/api/recorder/status", []],
["/api/recorder/files", []], ["/api/recorder/files", []],
["/sat_passes", satPasses ?? { satellite_count: 0, passes: [] }],
]); ]);
// Flat i8 bins: the shape does not matter, only that frames arrive so the // Flat i8 bins: the shape does not matter, only that frames arrive so the
@@ -269,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,49 +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")]
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
} }
#[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,
)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -142,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
@@ -172,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.
@@ -215,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,
)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -233,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,
)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -243,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")]
@@ -349,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))
} }
@@ -639,6 +664,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::recorder_index) .service(assets::recorder_index)
.service(assets::settings_index) .service(assets::settings_index)
.service(assets::about_index) .service(assets::about_index)
.service(assets::satellites_index)
.service(assets::statistics_index) .service(assets::statistics_index)
.service(assets::bookmarks_index) .service(assets::bookmarks_index)
.service(assets::favicon) .service(assets::favicon)
@@ -839,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() {
+76
View File
@@ -76,6 +76,8 @@ pub struct RemoteConfig {
pub auth: RemoteAuthConfig, pub auth: RemoteAuthConfig,
/// Poll interval in milliseconds. /// Poll interval in milliseconds.
pub poll_interval_ms: u64, pub poll_interval_ms: u64,
/// Spectrum frame interval in milliseconds.
pub spectrum_interval_ms: u64,
} }
impl Default for RemoteConfig { impl Default for RemoteConfig {
@@ -85,6 +87,7 @@ impl Default for RemoteConfig {
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 750, poll_interval_ms: 750,
spectrum_interval_ms: default_spectrum_interval_ms(),
} }
} }
} }
@@ -121,12 +124,23 @@ pub struct RemoteEntry {
/// Poll interval in milliseconds. Defaults to 750. /// Poll interval in milliseconds. Defaults to 750.
#[serde(default = "default_poll_interval_ms")] #[serde(default = "default_poll_interval_ms")]
pub poll_interval_ms: u64, pub poll_interval_ms: u64,
/// How often spectrum frames are wanted from this server, in milliseconds.
/// Defaults to 50 (20 frames/s).
///
/// Raise it on a slow or high-latency link: spectrum is by far the largest
/// thing on the connection, and the server pushes no faster than this.
#[serde(default = "default_spectrum_interval_ms")]
pub spectrum_interval_ms: u64,
} }
fn default_poll_interval_ms() -> u64 { fn default_poll_interval_ms() -> u64 {
750 750
} }
fn default_spectrum_interval_ms() -> u64 {
50
}
/// Frontend configurations. /// Frontend configurations.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
@@ -421,6 +435,7 @@ impl ClientConfig {
rig_id: self.remote.rig_id.clone(), rig_id: self.remote.rig_id.clone(),
auth: self.remote.auth.clone(), auth: self.remote.auth.clone(),
poll_interval_ms: self.remote.poll_interval_ms, poll_interval_ms: self.remote.poll_interval_ms,
spectrum_interval_ms: self.remote.spectrum_interval_ms,
}] }]
} else { } else {
Vec::new() Vec::new()
@@ -603,12 +618,21 @@ impl ClientConfig {
i, entry.name i, entry.name
)); ));
} }
if entry.spectrum_interval_ms == 0 {
return Err(format!(
"[[remotes]][{}].spectrum_interval_ms must be > 0 (name \"{}\")",
i, entry.name
));
}
} }
// Legacy [remote], kept for backward compatibility. // Legacy [remote], kept for backward compatibility.
if self.remote.poll_interval_ms == 0 { if self.remote.poll_interval_ms == 0 {
return Err("[remote].poll_interval_ms must be > 0".to_string()); return Err("[remote].poll_interval_ms must be > 0".to_string());
} }
if self.remote.spectrum_interval_ms == 0 {
return Err("[remote].spectrum_interval_ms must be > 0".to_string());
}
if let Some(url) = &self.remote.url { if let Some(url) = &self.remote.url {
if url.trim().is_empty() { if url.trim().is_empty() {
return Err("[remote].url must not be empty when set".to_string()); return Err("[remote].url must not be empty when set".to_string());
@@ -834,6 +858,7 @@ impl ClientConfig {
token_file: None, token_file: None,
}, },
poll_interval_ms: 750, poll_interval_ms: 750,
spectrum_interval_ms: 50,
}, },
RemoteEntry { RemoteEntry {
name: "home-vhf".to_string(), name: "home-vhf".to_string(),
@@ -844,6 +869,7 @@ impl ClientConfig {
token_file: None, token_file: None,
}, },
poll_interval_ms: 750, poll_interval_ms: 750,
spectrum_interval_ms: 50,
}, },
], ],
frontends: FrontendsConfig { frontends: FrontendsConfig {
@@ -994,6 +1020,7 @@ impl ConfigFile for ClientConfig {
rig_id: Some("hf".to_string()), rig_id: Some("hf".to_string()),
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: default_poll_interval_ms(), poll_interval_ms: default_poll_interval_ms(),
spectrum_interval_ms: 50,
}], }],
..Default::default() ..Default::default()
}; };
@@ -1311,6 +1338,7 @@ url = "remote.example.com:4530"
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 750, poll_interval_ms: 750,
spectrum_interval_ms: 50,
}], }],
..Default::default() ..Default::default()
}; };
@@ -1330,6 +1358,7 @@ url = "remote.example.com:4530"
token_file: None, token_file: None,
}, },
poll_interval_ms: 750, poll_interval_ms: 750,
spectrum_interval_ms: 50,
}, },
..Default::default() ..Default::default()
}; };
@@ -1368,6 +1397,7 @@ url = "remote.example.com:4530"
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 750, poll_interval_ms: 750,
spectrum_interval_ms: 50,
}], }],
..Default::default() ..Default::default()
}; };
@@ -1386,6 +1416,7 @@ url = "remote.example.com:4530"
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 750, poll_interval_ms: 750,
spectrum_interval_ms: 50,
}, },
RemoteEntry { RemoteEntry {
name: "dup".to_string(), name: "dup".to_string(),
@@ -1393,6 +1424,7 @@ url = "remote.example.com:4530"
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 750, poll_interval_ms: 750,
spectrum_interval_ms: 50,
}, },
], ],
..Default::default() ..Default::default()
@@ -1409,6 +1441,7 @@ url = "remote.example.com:4530"
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 750, poll_interval_ms: 750,
spectrum_interval_ms: 50,
}], }],
..Default::default() ..Default::default()
}; };
@@ -1427,6 +1460,7 @@ url = "remote.example.com:4530"
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 750, poll_interval_ms: 750,
spectrum_interval_ms: 50,
}], }],
..Default::default() ..Default::default()
}; };
@@ -1445,6 +1479,7 @@ url = "remote.example.com:4530"
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 0, poll_interval_ms: 0,
spectrum_interval_ms: 50,
}], }],
..Default::default() ..Default::default()
}; };
@@ -1499,6 +1534,45 @@ url = "remote.example.com:4530"
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
} }
// --- Spectrum cadence ---
#[test]
fn test_spectrum_interval_defaults_and_parses() {
let config: ClientConfig = toml::from_str(
r#"
[[remotes]]
name = "slow-link"
url = "host:4530"
spectrum_interval_ms = 500
[[remotes]]
name = "lan"
url = "host2:4530"
"#,
)
.unwrap();
assert_eq!(config.remotes[0].spectrum_interval_ms, 500);
assert_eq!(config.remotes[1].spectrum_interval_ms, 50);
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_zero_spectrum_interval() {
let config: ClientConfig = toml::from_str(
r#"
[[remotes]]
name = "hf"
url = "host:4530"
spectrum_interval_ms = 0
"#,
)
.unwrap();
assert!(config
.validate()
.unwrap_err()
.contains("spectrum_interval_ms"));
}
// --- Secret indirection --- // --- Secret indirection ---
fn secret_file(content: &str) -> tempfile::NamedTempFile { fn secret_file(content: &str) -> tempfile::NamedTempFile {
@@ -1537,6 +1611,7 @@ url = "remote.example.com:4530"
token_file: Some(f.path().to_str().unwrap().to_string()), token_file: Some(f.path().to_str().unwrap().to_string()),
}, },
poll_interval_ms: 750, poll_interval_ms: 750,
spectrum_interval_ms: 50,
}], }],
..Default::default() ..Default::default()
}; };
@@ -1570,6 +1645,7 @@ url = "remote.example.com:4530"
rig_id: None, rig_id: None,
auth: RemoteAuthConfig::default(), auth: RemoteAuthConfig::default(),
poll_interval_ms: 750, poll_interval_ms: 750,
spectrum_interval_ms: 50,
}) })
.collect() .collect()
} }
+130
View File
@@ -399,12 +399,30 @@ pub struct SdrConfig {
/// Default: 4. /// Default: 4.
#[serde(default = "default_max_virtual_channels")] #[serde(default = "default_max_virtual_channels")]
pub max_virtual_channels: usize, pub max_virtual_channels: usize,
/// FFT bin count for the spectrum display. Must be a power of two.
///
/// Halving it halves both the DSP cost and the bytes each frame puts on
/// the network, at half the frequency resolution — worth it on a slow link.
#[serde(default = "default_spectrum_fft_size")]
pub spectrum_fft_size: usize,
/// How often the server pushes a spectrum frame to a subscribed client, in
/// milliseconds. Raise it on a slow or metered link.
#[serde(default = "default_spectrum_interval_ms")]
pub spectrum_interval_ms: u64,
} }
fn default_max_virtual_channels() -> usize { fn default_max_virtual_channels() -> usize {
4 4
} }
fn default_spectrum_fft_size() -> usize {
1024
}
fn default_spectrum_interval_ms() -> u64 {
50
}
impl Default for SdrConfig { impl Default for SdrConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -417,6 +435,8 @@ impl Default for SdrConfig {
noise_blanker: SdrNoiseBlankerConfig::default(), noise_blanker: SdrNoiseBlankerConfig::default(),
channels: Vec::new(), channels: Vec::new(),
max_virtual_channels: default_max_virtual_channels(), max_virtual_channels: default_max_virtual_channels(),
spectrum_fft_size: default_spectrum_fft_size(),
spectrum_interval_ms: default_spectrum_interval_ms(),
} }
} }
} }
@@ -913,6 +933,9 @@ fn validate_rig_instance(
errors.push(format!("{prefix}[sdr.gain].max_value must be >= 0")); errors.push(format!("{prefix}[sdr.gain].max_value must be >= 0"));
} }
} }
if let Err(e) = validate_spectrum_config(prefix, &rig.sdr) {
errors.push(e);
}
if let Err(e) = validate_sdr_squelch_config(&format!("{prefix}[sdr.squelch]"), &rig.sdr.squelch) if let Err(e) = validate_sdr_squelch_config(&format!("{prefix}[sdr.squelch]"), &rig.sdr.squelch)
{ {
errors.push(e); errors.push(e);
@@ -1070,6 +1093,26 @@ fn validate_access(prefix: &str, access: &AccessConfig) -> Result<(), String> {
Ok(()) Ok(())
} }
/// A non-power-of-two FFT size still plans, but costs far more per frame; the
/// bounds keep the display useful without letting a typo allocate a huge FFT.
fn validate_spectrum_config(prefix: &str, sdr: &SdrConfig) -> Result<(), String> {
let size = sdr.spectrum_fft_size;
if !(128..=8192).contains(&size) {
return Err(format!(
"{prefix}[sdr].spectrum_fft_size must be in range 128..=8192"
));
}
if !size.is_power_of_two() {
return Err(format!(
"{prefix}[sdr].spectrum_fft_size must be a power of two (got {size})"
));
}
if sdr.spectrum_interval_ms == 0 {
return Err(format!("{prefix}[sdr].spectrum_interval_ms must be > 0"));
}
Ok(())
}
fn validate_sdr_squelch_config(path: &str, squelch: &SdrSquelchConfig) -> Result<(), String> { fn validate_sdr_squelch_config(path: &str, squelch: &SdrSquelchConfig) -> Result<(), String> {
if !squelch.threshold_db.is_finite() { if !squelch.threshold_db.is_finite() {
return Err(format!("{path}.threshold_db must be finite")); return Err(format!("{path}.threshold_db must be finite"));
@@ -1813,6 +1856,93 @@ port = 4531
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
} }
// --- Spectrum transport knobs ---
#[test]
fn test_spectrum_defaults() {
let cfg = SdrConfig::default();
assert_eq!(cfg.spectrum_fft_size, 1024);
assert_eq!(cfg.spectrum_interval_ms, 50);
}
fn sdr_cfg_with_spectrum(fft_size: usize, interval_ms: u64) -> ServerConfig {
let mut cfg = ServerConfig::default();
cfg.rig.access.port = Some("/dev/ttyUSB0".to_string());
cfg.rig.access.baud = Some(9600);
cfg.sdr.spectrum_fft_size = fft_size;
cfg.sdr.spectrum_interval_ms = interval_ms;
cfg
}
#[test]
fn test_validate_accepts_smaller_power_of_two_fft() {
assert!(sdr_cfg_with_spectrum(256, 200).validate().is_ok());
}
#[test]
fn test_validate_rejects_non_power_of_two_fft() {
let err = sdr_cfg_with_spectrum(1000, 50)
.validate()
.expect_err("expected a power-of-two error");
assert!(err.contains("power of two"), "unexpected error: {err}");
}
#[test]
fn test_validate_rejects_out_of_range_fft() {
assert!(sdr_cfg_with_spectrum(64, 50).validate().is_err());
assert!(sdr_cfg_with_spectrum(16384, 50).validate().is_err());
}
#[test]
fn test_validate_rejects_zero_spectrum_interval() {
let err = sdr_cfg_with_spectrum(1024, 0)
.validate()
.expect_err("expected an interval error");
assert!(
err.contains("spectrum_interval_ms"),
"unexpected error: {err}"
);
}
/// Per-rig validation covers the spectrum knobs too, and names the rig.
#[test]
fn test_validate_rejects_bad_spectrum_size_in_rig_entry() {
let toml_str = r#"
[[rigs]]
id = "sdr"
[rigs.rig]
model = "soapysdr"
[rigs.rig.access]
type = "sdr"
args = "driver=rtlsdr"
[rigs.audio]
port = 4532
tx_enabled = false
[rigs.sdr]
spectrum_fft_size = 999
"#;
let cfg: ServerConfig = toml::from_str(toml_str).unwrap();
let err = cfg.validate().expect_err("expected a power-of-two error");
assert!(
err.contains("power of two") && err.contains("sdr"),
"unexpected error: {err}"
);
}
#[test]
fn test_parse_spectrum_config_from_toml() {
let cfg: ServerConfig = toml::from_str(
r#"
[sdr]
spectrum_fft_size = 512
spectrum_interval_ms = 250
"#,
)
.unwrap();
assert_eq!(cfg.sdr.spectrum_fft_size, 512);
assert_eq!(cfg.sdr.spectrum_interval_ms, 250);
}
// --- Secret indirection --- // --- Secret indirection ---
#[test] #[test]
+1
View File
@@ -17,3 +17,4 @@ uuid = { workspace = true }
ts-rs = { version = "12.0.1", features = ["uuid-impl"] } ts-rs = { version = "12.0.1", features = ["uuid-impl"] }
sgp4 = "2" sgp4 = "2"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
base64 = "0.22"
+1
View File
@@ -19,6 +19,7 @@ pub mod command;
pub mod controller; pub mod controller;
pub mod request; pub mod request;
pub mod response; pub mod response;
pub mod spectrum_wire;
pub mod state; pub mod state;
/// How this backend communicates with the rig. /// How this backend communicates with the rig.
+124
View File
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Compact wire encoding for spectrum bins.
//!
//! Bins are dBFS magnitudes, and the web UI has always drawn them from `i8`
//! values — the SSE hop to the browser quantizes and base64-encodes them. The
//! server→client hop, which is the one that crosses the operator's network,
//! used to send the same information as a JSON array of `f32`: around ten bytes
//! per bin instead of one, or roughly 10 KB per 1024-bin frame.
//!
//! Bins therefore travel as base64-encoded `i8` dBFS, about an eighth of the
//! size, at a resolution the display already rounds to. Decoding still accepts
//! the old array form, so a new client can read an older server.
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use serde::de::{SeqAccess, Visitor};
use serde::{Deserializer, Serializer};
use std::fmt;
/// Quantize to whole dBFS and encode as base64.
pub fn serialize<S: Serializer>(bins: &[f32], serializer: S) -> Result<S::Ok, S::Error> {
let quantized: Vec<u8> = bins
.iter()
.map(|&db| {
let clamped = if db.is_finite() { db } else { -128.0 };
clamped.round().clamp(-128.0, 127.0) as i8 as u8
})
.collect();
serializer.serialize_str(&BASE64.encode(quantized))
}
/// Decode base64 bins, or a plain array of numbers from an older server.
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<f32>, D::Error> {
deserializer.deserialize_any(BinsVisitor)
}
struct BinsVisitor;
impl<'de> Visitor<'de> for BinsVisitor {
type Value = Vec<f32>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("base64-encoded i8 dBFS bins, or an array of numbers")
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
let bytes = BASE64
.decode(value)
.map_err(|e| E::custom(format!("invalid base64 spectrum bins: {e}")))?;
Ok(bytes.into_iter().map(|byte| byte as i8 as f32).collect())
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut bins = Vec::with_capacity(seq.size_hint().unwrap_or(1024));
while let Some(value) = seq.next_element::<f32>()? {
bins.push(value);
}
Ok(bins)
}
}
#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Frame {
#[serde(with = "super")]
bins: Vec<f32>,
}
#[test]
fn test_round_trip_quantizes_to_whole_db() {
let frame = Frame {
bins: vec![-73.4, -20.6, 0.0, -120.2],
};
let json = serde_json::to_string(&frame).unwrap();
let back: Frame = serde_json::from_str(&json).unwrap();
assert_eq!(back.bins, vec![-73.0, -21.0, 0.0, -120.0]);
}
#[test]
fn test_serializes_as_a_base64_string() {
let json = serde_json::to_string(&Frame {
bins: vec![-1.0, 0.0],
})
.unwrap();
assert!(json.contains('"'), "bins should be a string: {json}");
assert!(!json.contains('['), "bins should not be an array: {json}");
}
#[test]
fn test_clamps_out_of_range_and_non_finite() {
let frame = Frame {
bins: vec![-400.0, 400.0, f32::NAN, f32::NEG_INFINITY],
};
let json = serde_json::to_string(&frame).unwrap();
let back: Frame = serde_json::from_str(&json).unwrap();
assert_eq!(back.bins, vec![-128.0, 127.0, -128.0, -128.0]);
}
#[test]
fn test_reads_the_old_array_form() {
let back: Frame = serde_json::from_str(r#"{"bins":[-73.25,-20.5]}"#).unwrap();
assert_eq!(back.bins, vec![-73.25, -20.5]);
}
/// The point of the change: an ordinary frame gets much smaller.
#[test]
fn test_frame_is_far_smaller_than_the_array_form() {
let bins: Vec<f32> = (0..1024).map(|i| -60.0 - (i % 40) as f32 * 0.37).collect();
let compact = serde_json::to_string(&Frame { bins: bins.clone() }).unwrap();
let verbose = serde_json::to_string(&bins).unwrap();
assert!(
compact.len() * 5 < verbose.len(),
"compact {} bytes vs array {} bytes",
compact.len(),
verbose.len()
);
}
}
+6
View File
@@ -394,6 +394,12 @@ fn default_wfm_denoise_level() -> WfmDenoiseLevel {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
pub struct SpectrumData { pub struct SpectrumData {
/// FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2. /// FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
///
/// On the wire these are base64-encoded `i8` whole dBFS (see
/// `spectrum_wire`), which is what the display draws anyway; the TypeScript
/// type describes the decoded array the browser receives over SSE.
#[serde(with = "crate::rig::spectrum_wire")]
#[ts(type = "Array<number>")]
pub bins: Vec<f32>, pub bins: Vec<f32>,
/// Centre frequency of the SDR capture in Hz. /// Centre frequency of the SDR capture in Hz.
#[ts(type = "number")] #[ts(type = "number")]
+3 -1
View File
@@ -18,4 +18,6 @@ pub use auth::{NoAuthValidator, SimpleTokenValidator, TokenValidator};
pub use codec::{mode_to_string, parse_envelope, parse_mode}; pub use codec::{mode_to_string, parse_envelope, parse_mode};
pub use decoders::{DecoderActivation, DecoderDescriptor, DECODER_REGISTRY}; pub use decoders::{DecoderActivation, DecoderDescriptor, DECODER_REGISTRY};
pub use mapping::{client_command_to_rig, rig_command_to_client}; pub use mapping::{client_command_to_rig, rig_command_to_client};
pub use types::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry}; pub use types::{
ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry, SpectrumFrame,
};
+1 -1
View File
@@ -102,7 +102,7 @@ macro_rules! define_command_mapping {
define_command_mapping! { define_command_mapping! {
// ── Client-only variants (no RigCommand counterpart) ───────────── // ── Client-only variants (no RigCommand counterpart) ─────────────
client_only: GetRigs, GetSatPasses, SubscribeMeter; client_only: GetRigs, GetSatPasses, SubscribeMeter, SubscribeSpectrum;
// ── Unit variants (no payload) ─────────────────────────────────── // ── Unit variants (no payload) ───────────────────────────────────
unit: unit:
+22
View File
@@ -130,6 +130,28 @@ pub enum ClientCommand {
/// newline-delimited `MeterUpdate` JSON frames and no further commands or /// newline-delimited `MeterUpdate` JSON frames and no further commands or
/// regular responses are sent. Intended for a dedicated TCP connection. /// regular responses are sent. Intended for a dedicated TCP connection.
SubscribeMeter, SubscribeMeter,
/// Subscribe to a per-rig spectrum stream on this connection. Like
/// `SubscribeMeter`, the connection becomes a one-way flow of
/// newline-delimited `SpectrumFrame` JSON and no further commands or
/// regular responses are sent.
///
/// Polling `GetSpectrum` costs a round trip per frame, which caps the frame
/// rate at 1/RTT no matter how often the client asks; the server pushes at
/// its own cadence instead. Clients fall back to polling when the server
/// rejects this command.
SubscribeSpectrum,
}
/// One spectrum frame pushed by the server on a dedicated spectrum stream.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SpectrumFrame {
/// Rig identifier this frame belongs to.
pub rig_id: String,
/// The frame itself; bins travel base64-encoded (see `spectrum_wire`).
pub spectrum: trx_core::rig::state::SpectrumData,
/// Virtual-channel RDS state, mirroring what `GetSpectrum` returned.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vchan_rds: Option<Vec<trx_core::rig::state::VchanRdsEntry>>,
} }
/// Fast meter sample pushed by the server on a dedicated meter stream. /// Fast meter sample pushed by the server on a dedicated meter stream.
+117
View File
@@ -494,6 +494,58 @@ where
} }
}; };
// SubscribeSpectrum: turns this connection into a one-way spectrum
// stream. Polling GetSpectrum costs a round trip per frame, so a
// client on a slow link could never reach the frame rate it asked for;
// pushing decouples the rate from the latency.
if matches!(envelope.cmd, ClientCommand::SubscribeSpectrum) {
let mut spectrum_rx = handle.spectrum_tx.subscribe();
let io_timeout = timeouts.io_timeout;
info!(
"Client {} subscribed to spectrum stream for rig '{}'",
addr, target_rig_id
);
loop {
tokio::select! {
frame = spectrum_rx.recv() => {
match frame {
Ok(frame) => {
let Ok(mut line) = serde_json::to_string(&frame) else { continue };
line.push('\n');
let write = time::timeout(
io_timeout,
writer.write_all(line.as_bytes()),
).await;
match write {
Ok(Ok(())) => {}
Ok(Err(e)) => {
info!("Client {} spectrum write failed: {}", addr, e);
break;
}
Err(_) => {
info!("Client {} spectrum write timed out", addr);
break;
}
}
}
// A client that cannot keep up skips to the newest
// frame; stale spectrum is not worth drawing.
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
changed = shutdown_rx.changed() => {
match changed {
Ok(()) if *shutdown_rx.borrow() => break,
Ok(()) => {}
Err(_) => break,
}
}
}
}
break;
}
// SubscribeMeter: turns this connection into a one-way meter stream. // SubscribeMeter: turns this connection into a one-way meter stream.
// No regular responses are produced; the connection lives until the // No regular responses are produced; the connection lives until the
// client disconnects or shutdown fires. // client disconnects or shutdown fires.
@@ -732,6 +784,7 @@ mod tests {
let (state_tx, state_rx) = watch::channel(state); let (state_tx, state_rx) = watch::channel(state);
let _state_tx = state_tx; let _state_tx = state_tx;
let (meter_tx, _) = tokio::sync::broadcast::channel(8); let (meter_tx, _) = tokio::sync::broadcast::channel(8);
let (spectrum_tx, _) = tokio::sync::broadcast::channel(4);
let handle = RigHandle { let handle = RigHandle {
rig_id: "default".to_string(), rig_id: "default".to_string(),
display_name: "Default Rig".to_string(), display_name: "Default Rig".to_string(),
@@ -739,6 +792,7 @@ mod tests {
state_rx, state_rx,
audio_port: 4531, audio_port: 4531,
meter_tx, meter_tx,
spectrum_tx,
}; };
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert("default".to_string(), handle); map.insert("default".to_string(), handle);
@@ -933,6 +987,7 @@ mod tests {
state_rx: state_rx_a, state_rx: state_rx_a,
audio_port: 4531, audio_port: 4531,
meter_tx: meter_tx_a, meter_tx: meter_tx_a,
spectrum_tx: tokio::sync::broadcast::channel(4).0,
}; };
let (tx_b, rx_b) = mpsc::channel::<RigRequest>(8); let (tx_b, rx_b) = mpsc::channel::<RigRequest>(8);
@@ -945,6 +1000,7 @@ mod tests {
state_rx: state_rx_b, state_rx: state_rx_b,
audio_port: 4532, audio_port: 4532,
meter_tx: meter_tx_b, meter_tx: meter_tx_b,
spectrum_tx: tokio::sync::broadcast::channel(4).0,
}; };
let mut map = HashMap::new(); let mut map = HashMap::new();
@@ -953,6 +1009,67 @@ mod tests {
(Arc::new(map), "rig_hf".to_string(), rx_a, rx_b) (Arc::new(map), "rig_hf".to_string(), rx_a, rx_b)
} }
/// Polling spectrum costs a round trip per frame, so the rate a client can
/// reach is capped by latency rather than by what it asked for. Subscribed
/// clients get frames pushed instead; this is that path end to end.
#[tokio::test]
async fn subscribe_spectrum_pushes_frames() {
use trx_core::rig::state::SpectrumData;
let (rigs, default_id) = make_rigs(sample_state());
let spectrum_tx = rigs.get("default").expect("rig").spectrum_tx.clone();
let ctx = make_ctx(rigs, default_id, HashSet::new());
let (mut reader, mut writer, handle, shutdown_tx) = spawn_client_io(ctx);
writer
.write_all(br#"{"cmd":"subscribe_spectrum"}"#)
.await
.expect("write");
writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush");
// The subscription is registered asynchronously; publish until it takes.
let frame = trx_protocol::SpectrumFrame {
rig_id: "default".to_string(),
spectrum: SpectrumData {
bins: vec![-73.4, -20.6, 0.0],
center_hz: 14_200_000,
sample_rate: 1_920_000,
rds: None,
},
vchan_rds: None,
};
let mut line = String::new();
for _ in 0..50 {
let _ = spectrum_tx.send(frame.clone());
tokio::select! {
read = reader.read_line(&mut line) => {
if read.expect("read") > 0 && !line.trim().is_empty() {
break;
}
line.clear();
}
_ = tokio::time::sleep(Duration::from_millis(20)) => {}
}
}
let received: trx_protocol::SpectrumFrame =
serde_json::from_str(line.trim()).unwrap_or_else(|e| panic!("frame {line:?}: {e}"));
assert_eq!(received.rig_id, "default");
assert_eq!(received.spectrum.center_hz, 14_200_000);
// Bins survive the trip quantized to whole dBFS, which is the
// resolution the display draws at anyway.
assert_eq!(received.spectrum.bins, vec![-73.0, -21.0, 0.0]);
// And they travel as base64 rather than a JSON array of floats.
assert!(
!line.contains("-73"),
"bins should not be spelled out on the wire: {line}"
);
let _ = shutdown_tx.send(true);
let _ = handle.await;
}
#[tokio::test] #[tokio::test]
async fn multi_rig_state_isolation() { async fn multi_rig_state_isolation() {
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB); let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
+8
View File
@@ -360,6 +360,7 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
max_virtual_channels: rig_cfg.sdr.max_virtual_channels, max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
nb_enabled: rig_cfg.sdr.noise_blanker.enabled, nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
nb_threshold: rig_cfg.sdr.noise_blanker.threshold, nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
})?; })?;
let pcm_rx = sdr_rig.subscribe_pcm(); let pcm_rx = sdr_rig.subscribe_pcm();
@@ -460,6 +461,7 @@ fn build_rig_task_config(
prebuilt_rig: None, prebuilt_rig: None,
command_exec_timeout: Duration::from_millis(timeouts.command_exec_timeout_ms), command_exec_timeout: Duration::from_millis(timeouts.command_exec_timeout_ms),
poll_refresh_timeout: Duration::from_millis(timeouts.poll_refresh_timeout_ms), poll_refresh_timeout: Duration::from_millis(timeouts.poll_refresh_timeout_ms),
spectrum_interval_ms: rig_cfg.sdr.spectrum_interval_ms,
} }
} }
@@ -1215,6 +1217,9 @@ async fn main() -> DynResult<()> {
let (state_tx, state_rx) = watch::channel(initial_state); let (state_tx, state_rx) = watch::channel(initial_state);
let (meter_tx, _) = let (meter_tx, _) =
broadcast::channel::<trx_protocol::MeterUpdate>(rig_handle::METER_BROADCAST_CAPACITY); broadcast::channel::<trx_protocol::MeterUpdate>(rig_handle::METER_BROADCAST_CAPACITY);
let (spectrum_tx, _) = broadcast::channel::<trx_protocol::SpectrumFrame>(
rig_handle::SPECTRUM_BROADCAST_CAPACITY,
);
let mut task_config = build_rig_task_config( let mut task_config = build_rig_task_config(
rig_cfg, rig_cfg,
@@ -1242,12 +1247,14 @@ async fn main() -> DynResult<()> {
let rig_shutdown_rx = shutdown_rx.clone(); let rig_shutdown_rx = shutdown_rx.clone();
let rig_id_supervisor = rig_cfg.id.clone(); let rig_id_supervisor = rig_cfg.id.clone();
let meter_tx_task = meter_tx.clone(); let meter_tx_task = meter_tx.clone();
let spectrum_tx_task = spectrum_tx.clone();
task_handles.push(tokio::spawn(async move { task_handles.push(tokio::spawn(async move {
let result = rig_task::run_rig_task( let result = rig_task::run_rig_task(
task_config, task_config,
rig_rx, rig_rx,
state_tx.clone(), state_tx.clone(),
meter_tx_task, meter_tx_task,
spectrum_tx_task,
rig_shutdown_rx, rig_shutdown_rx,
) )
.await; .await;
@@ -1294,6 +1301,7 @@ async fn main() -> DynResult<()> {
rig_handles.insert( rig_handles.insert(
rig_cfg.id.clone(), rig_cfg.id.clone(),
RigHandle { RigHandle {
spectrum_tx: spectrum_tx.clone(),
rig_id: rig_cfg.id.clone(), rig_id: rig_cfg.id.clone(),
display_name: rig_cfg.display_name().to_string(), display_name: rig_cfg.display_name().to_string(),
rig_tx, rig_tx,
+10 -1
View File
@@ -8,13 +8,18 @@ use tokio::sync::{broadcast, mpsc, watch};
use trx_core::rig::request::RigRequest; use trx_core::rig::request::RigRequest;
use trx_core::rig::state::RigState; use trx_core::rig::state::RigState;
use trx_protocol::MeterUpdate; use trx_protocol::{MeterUpdate, SpectrumFrame};
/// Bounded broadcast capacity for the meter stream. Keeps ~0.5 s of buffered /// Bounded broadcast capacity for the meter stream. Keeps ~0.5 s of buffered
/// samples at 30 Hz — more than enough slack to tolerate a scheduling blip /// samples at 30 Hz — more than enough slack to tolerate a scheduling blip
/// without forcing the producer to block or drop silently. /// without forcing the producer to block or drop silently.
pub const METER_BROADCAST_CAPACITY: usize = 16; pub const METER_BROADCAST_CAPACITY: usize = 16;
/// Bounded broadcast capacity for the spectrum stream. Frames are large and
/// only the newest one is worth drawing, so the buffer stays shallow: a slow
/// client lags and skips rather than making the server hold stale frames.
pub const SPECTRUM_BROADCAST_CAPACITY: usize = 4;
/// A handle to a single running rig backend. /// A handle to a single running rig backend.
/// ///
/// One `RigHandle` is created per rig in `main.rs` and stored in the shared /// One `RigHandle` is created per rig in `main.rs` and stored in the shared
@@ -34,4 +39,8 @@ pub struct RigHandle {
/// ~67 Hz (CAT). Consumed by `SubscribeMeter` clients; independent of /// ~67 Hz (CAT). Consumed by `SubscribeMeter` clients; independent of
/// the slower `state_rx` snapshot path. /// the slower `state_rx` snapshot path.
pub meter_tx: broadcast::Sender<MeterUpdate>, pub meter_tx: broadcast::Sender<MeterUpdate>,
/// Per-rig spectrum frames published by `rig_task` while at least one
/// client is subscribed. Consumed by `SubscribeSpectrum` clients; the
/// producer skips the work entirely when nobody is listening.
pub spectrum_tx: broadcast::Sender<SpectrumFrame>,
} }
+27 -1
View File
@@ -23,7 +23,7 @@ use trx_core::rig::request::RigRequest;
use trx_core::rig::state::{RigMode, RigSnapshot, RigState}; use trx_core::rig::state::{RigMode, RigSnapshot, RigState};
use trx_core::rig::{RigCat, RigRxStatus, RigTxStatus}; use trx_core::rig::{RigCat, RigRxStatus, RigTxStatus};
use trx_core::{DynResult, RigError, RigResult}; use trx_core::{DynResult, RigError, RigResult};
use trx_protocol::MeterUpdate; use trx_protocol::{MeterUpdate, SpectrumFrame};
use crate::audio::DecoderHistories; use crate::audio::DecoderHistories;
use crate::error::is_invalid_bcd_error; use crate::error::is_invalid_bcd_error;
@@ -64,6 +64,8 @@ pub struct RigTaskConfig {
pub command_exec_timeout: Duration, pub command_exec_timeout: Duration,
/// Maximum time for a CAT poll refresh cycle. /// Maximum time for a CAT poll refresh cycle.
pub poll_refresh_timeout: Duration, pub poll_refresh_timeout: Duration,
/// How often to push a spectrum frame to subscribed clients, in ms.
pub spectrum_interval_ms: u64,
} }
impl Default for RigTaskConfig { impl Default for RigTaskConfig {
@@ -94,6 +96,7 @@ impl Default for RigTaskConfig {
prebuilt_rig: None, prebuilt_rig: None,
command_exec_timeout: DEFAULT_COMMAND_EXEC_TIMEOUT, command_exec_timeout: DEFAULT_COMMAND_EXEC_TIMEOUT,
poll_refresh_timeout: DEFAULT_POLL_REFRESH_TIMEOUT, poll_refresh_timeout: DEFAULT_POLL_REFRESH_TIMEOUT,
spectrum_interval_ms: 50,
} }
} }
} }
@@ -115,6 +118,7 @@ pub async fn run_rig_task(
mut rx: mpsc::Receiver<RigRequest>, mut rx: mpsc::Receiver<RigRequest>,
state_tx: watch::Sender<RigState>, state_tx: watch::Sender<RigState>,
meter_tx: broadcast::Sender<MeterUpdate>, meter_tx: broadcast::Sender<MeterUpdate>,
spectrum_tx: broadcast::Sender<SpectrumFrame>,
mut shutdown_rx: watch::Receiver<bool>, mut shutdown_rx: watch::Receiver<bool>,
) -> DynResult<()> { ) -> DynResult<()> {
let histories = config.histories.clone(); let histories = config.histories.clone();
@@ -273,6 +277,13 @@ pub async fn run_rig_task(
} else { } else {
Duration::from_millis(150) Duration::from_millis(150)
}; };
// Spectrum frames get their own tick so a subscribed client never pays a
// round trip per frame. The FFT is computed by the SDR thread either way;
// this only reads the latest result, and only while somebody is subscribed.
let spectrum_tick_duration = Duration::from_millis(config.spectrum_interval_ms.max(1));
let mut spectrum_tick: std::pin::Pin<Box<tokio::time::Sleep>> =
Box::pin(tokio::time::sleep(spectrum_tick_duration));
let meter_task_start = Instant::now(); let meter_task_start = Instant::now();
let meter_state_delta_db: f64 = 0.25; let meter_state_delta_db: f64 = 0.25;
let rig_id = config.rig_id.clone(); let rig_id = config.rig_id.clone();
@@ -302,6 +313,21 @@ pub async fn run_rig_task(
Err(_) => break, Err(_) => break,
} }
} }
// Push the latest spectrum frame to subscribed clients.
_ = &mut spectrum_tick => {
spectrum_tick = Box::pin(tokio::time::sleep(spectrum_tick_duration));
// `send` fails only when nobody is listening, but building the
// frame clones a few KB of bins, so check before doing the work.
if spectrum_tx.receiver_count() > 0 {
if let Some(spectrum) = rig.as_sdr_ref().and_then(|s| s.get_spectrum()) {
let _ = spectrum_tx.send(SpectrumFrame {
rig_id: rig_id.clone(),
spectrum,
vchan_rds: rig.as_sdr_ref().and_then(|s| s.get_vchan_rds()),
});
}
}
}
// Fast meter-only refresh between full polls. // Fast meter-only refresh between full polls.
_ = &mut meter_tick => { _ = &mut meter_tick => {
meter_tick = Box::pin(tokio::time::sleep(meter_tick_duration)); meter_tick = Box::pin(tokio::time::sleep(meter_tick_duration));
@@ -154,6 +154,7 @@ impl SdrPipeline {
squelch_cfg: VirtualSquelchConfig, squelch_cfg: VirtualSquelchConfig,
nb_cfg: NoiseBlankerConfig, nb_cfg: NoiseBlankerConfig,
channels: &[(f64, RigMode, u32)], channels: &[(f64, RigMode, u32)],
spectrum_fft_size: usize,
) -> Self { ) -> Self {
const IQ_BROADCAST_CAPACITY: usize = 64; const IQ_BROADCAST_CAPACITY: usize = 64;
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY); let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
@@ -219,6 +220,7 @@ impl SdrPipeline {
.name("sdr-iq-read".to_string()) .name("sdr-iq-read".to_string())
.spawn(move || { .spawn(move || {
iq_read_loop( iq_read_loop(
spectrum_fft_size,
source, source,
sdr_sample_rate, sdr_sample_rate,
thread_dsps, thread_dsps,
@@ -317,6 +319,7 @@ pub const IQ_BLOCK_SIZE: usize = 4096;
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn iq_read_loop( fn iq_read_loop(
spectrum_fft_size: usize,
mut source: Box<dyn IqSource>, mut source: Box<dyn IqSource>,
sdr_sample_rate: u32, sdr_sample_rate: u32,
channel_dsps: Arc<RwLock<Vec<Arc<Mutex<ChannelDsp>>>>>, channel_dsps: Arc<RwLock<Vec<Arc<Mutex<ChannelDsp>>>>>,
@@ -335,7 +338,7 @@ fn iq_read_loop(
}; };
let throttle = !source.is_blocking(); let throttle = !source.is_blocking();
let mut spectrum = SpectrumSnapshotter::new(); let mut spectrum = SpectrumSnapshotter::new(spectrum_fft_size);
let mut read_error_streak: u32 = 0; let mut read_error_streak: u32 = 0;
let mut zero_read_streak: u32 = 0; let mut zero_read_streak: u32 = 0;
let mut overflow_log_window_start: Option<Instant> = None; let mut overflow_log_window_start: Option<Instant> = None;
@@ -572,6 +575,7 @@ mod tests {
VirtualSquelchConfig::default(), VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(), NoiseBlankerConfig::default(),
&[(200_000.0, RigMode::USB, 3000)], &[(200_000.0, RigMode::USB, 3000)],
1024,
); );
assert_eq!(pipeline.pcm_senders.len(), 1); assert_eq!(pipeline.pcm_senders.len(), 1);
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1); assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1);
@@ -590,6 +594,7 @@ mod tests {
VirtualSquelchConfig::default(), VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(), NoiseBlankerConfig::default(),
&[], &[],
1024,
); );
assert_eq!(pipeline.pcm_senders.len(), 0); assert_eq!(pipeline.pcm_senders.len(), 0);
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 0); assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 0);
@@ -9,28 +9,36 @@ use num_complex::Complex;
use rustfft::num_complex::Complex as FftComplex; use rustfft::num_complex::Complex as FftComplex;
use rustfft::FftPlanner; use rustfft::FftPlanner;
/// Number of FFT bins for the spectrum display. /// Default number of FFT bins for the spectrum display, used when the config
pub(super) const SPECTRUM_FFT_SIZE: usize = 1024; /// does not say otherwise.
pub(super) const DEFAULT_SPECTRUM_FFT_SIZE: usize = 1024;
/// Update the spectrum buffer every this many IQ blocks (~10 Hz at 1.92 MHz / 4096 block). /// Update the spectrum buffer every this many IQ blocks (~10 Hz at 1.92 MHz / 4096 block).
pub(super) const SPECTRUM_UPDATE_BLOCKS: usize = 4; pub(super) const SPECTRUM_UPDATE_BLOCKS: usize = 4;
pub(super) struct SpectrumSnapshotter { pub(super) struct SpectrumSnapshotter {
fft_size: usize,
hann_window: Vec<f32>, hann_window: Vec<f32>,
fft: std::sync::Arc<dyn rustfft::Fft<f32>>, fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
counter: usize, counter: usize,
} }
impl SpectrumSnapshotter { impl SpectrumSnapshotter {
pub(super) fn new() -> Self { pub(super) fn new(fft_size: usize) -> Self {
let hann_window: Vec<f32> = (0..SPECTRUM_FFT_SIZE) let fft_size = if fft_size >= 2 {
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (SPECTRUM_FFT_SIZE - 1) as f32).cos())) fft_size
} else {
DEFAULT_SPECTRUM_FFT_SIZE
};
let hann_window: Vec<f32> = (0..fft_size)
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (fft_size - 1) as f32).cos()))
.collect(); .collect();
let mut planner = FftPlanner::<f32>::new(); let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(SPECTRUM_FFT_SIZE); let fft = planner.plan_fft_forward(fft_size);
Self { Self {
fft_size,
hann_window, hann_window,
fft, fft,
counter: 0, counter: 0,
@@ -48,7 +56,7 @@ impl SpectrumSnapshotter {
} }
self.counter = 0; self.counter = 0;
let take = samples.len().min(SPECTRUM_FFT_SIZE); let take = samples.len().min(self.fft_size);
let mut buf: Vec<FftComplex<f32>> = samples[..take] let mut buf: Vec<FftComplex<f32>> = samples[..take]
.iter() .iter()
.enumerate() .enumerate()
@@ -59,16 +67,15 @@ impl SpectrumSnapshotter {
) )
}) })
.collect(); .collect();
buf.resize(SPECTRUM_FFT_SIZE, FftComplex::new(0.0, 0.0)); buf.resize(self.fft_size, FftComplex::new(0.0, 0.0));
self.fft.process(&mut buf); self.fft.process(&mut buf);
let half = SPECTRUM_FFT_SIZE / 2; let half = self.fft_size / 2;
let bins: Vec<f32> = buf[half..] let bins: Vec<f32> = buf[half..]
.iter() .iter()
.chain(buf[..half].iter()) .chain(buf[..half].iter())
.map(|value| { .map(|value| {
let mag = let mag = (value.re * value.re + value.im * value.im).sqrt() / self.fft_size as f32;
(value.re * value.re + value.im * value.im).sqrt() / SPECTRUM_FFT_SIZE as f32;
20.0 * mag.max(1e-10_f32).log10() 20.0 * mag.max(1e-10_f32).log10()
}) })
.collect(); .collect();
@@ -73,6 +73,11 @@ pub struct SoapySdrConfig {
pub nb_enabled: bool, pub nb_enabled: bool,
/// Noise blanker impulse threshold multiplier. /// Noise blanker impulse threshold multiplier.
pub nb_threshold: f64, pub nb_threshold: f64,
/// FFT bin count for the spectrum display; a power of two.
///
/// Fewer bins cost less DSP and put fewer bytes on the network per frame,
/// which is what a slow server↔client link cares about.
pub spectrum_fft_size: usize,
} }
impl Default for SoapySdrConfig { impl Default for SoapySdrConfig {
@@ -99,6 +104,7 @@ impl Default for SoapySdrConfig {
max_virtual_channels: 4, max_virtual_channels: 4,
nb_enabled: false, nb_enabled: false,
nb_threshold: 10.0, nb_threshold: 10.0,
spectrum_fft_size: 1024,
} }
} }
} }
@@ -194,6 +200,7 @@ impl SoapySdrRig {
let max_virtual_channels = config.max_virtual_channels; let max_virtual_channels = config.max_virtual_channels;
let nb_enabled = config.nb_enabled; let nb_enabled = config.nb_enabled;
let nb_threshold = config.nb_threshold; let nb_threshold = config.nb_threshold;
let spectrum_fft_size = config.spectrum_fft_size;
tracing::info!( tracing::info!(
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})", "initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
args, args,
@@ -293,6 +300,7 @@ impl SoapySdrRig {
threshold: nb_threshold as f32, threshold: nb_threshold as f32,
}, },
&all_channels, &all_channels,
spectrum_fft_size,
)); ));
let info = RigInfo { let info = RigInfo {
@@ -418,6 +426,7 @@ impl SoapySdrRig {
nb_threshold: f64, nb_threshold: f64,
) -> DynResult<Self> { ) -> DynResult<Self> {
Self::new_from_config(SoapySdrConfig { Self::new_from_config(SoapySdrConfig {
spectrum_fft_size: 1024,
args: args.to_string(), args: args.to_string(),
channels: channels.to_vec(), channels: channels.to_vec(),
gain_mode: gain_mode.to_string(), gain_mode: gain_mode.to_string(),
@@ -439,6 +439,7 @@ mod tests {
VirtualSquelchConfig::default(), VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(), NoiseBlankerConfig::default(),
&[(0.0, RigMode::USB, 3_000)], &[(0.0, RigMode::USB, 3_000)],
1024,
)) ))
} }
+5
View File
@@ -120,6 +120,8 @@ wfm_deemphasis_us = 50
center_offset_hz = 100000 center_offset_hz = 100000
channels = [] channels = []
max_virtual_channels = 4 max_virtual_channels = 4
spectrum_fft_size = 1024
spectrum_interval_ms = 50
# "auto" for hardware AGC, or "manual". # "auto" for hardware AGC, or "manual".
[trx-server.sdr.gain] [trx-server.sdr.gain]
@@ -160,6 +162,7 @@ log_level = "info"
# Legacy single-remote form; prefer [[remotes]] below. # Legacy single-remote form; prefer [[remotes]] below.
[trx-client.remote] [trx-client.remote]
poll_interval_ms = 750 poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remote.auth] [trx-client.remote.auth]
@@ -168,6 +171,7 @@ name = "home-hf"
url = "192.168.1.100:4530" url = "192.168.1.100:4530"
rig_id = "hf" rig_id = "hf"
poll_interval_ms = 750 poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remotes.auth] [trx-client.remotes.auth]
token = "my-token" token = "my-token"
@@ -177,6 +181,7 @@ name = "home-vhf"
url = "192.168.1.100:4530" url = "192.168.1.100:4530"
rig_id = "vhf" rig_id = "vhf"
poll_interval_ms = 750 poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remotes.auth] [trx-client.remotes.auth]
token = "my-token" token = "my-token"