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
sjgandClaude Opus 5 46c9827e8a [fix](trx-config): keep the generated example off the machine that made it
CI / lint (pull_request) Successful in 2m17s
CI / frontend (pull_request) Successful in 3m28s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m16s
CI / test (pull_request) Successful in 7m50s
CI / frontend (push) Successful in 4m11s
CI / reuse (push) Successful in 9s
CI / test (push) Failing after 17m15s
The example is generated from the config defaults, and [decode_logs].dir
defaults to the running user's cache directory.  So the file rendered
/Users/sjg/Library/Caches/trx-rs/decoders on the machine that generated it and
/root/.cache/trx-rs/decoders in CI, and the up-to-date test failed for everyone
but its author.

Pin dir to an illustrative /var/lib/trx-rs/decoders in the example config;
omitting the key still falls back to the per-user directory.  A new test asserts
the rendered example contains none of this machine's home, cache or config
directories, so the next environment-derived default cannot slip through the
same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 22:08:06 +02:00
sjgandClaude Opus 5 0fc977f19f [style](trx-config): apply rustfmt
CI / test (pull_request) Failing after 6m0s
CI / lint (pull_request) Successful in 2m18s
CI / frontend (pull_request) Successful in 3m28s
CI / reuse (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:44:17 +02:00
sjgandClaude Opus 5 084f629b5b [docs](trx-rs): record the trx-config crate and its commands
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 6m6s
CI / reuse (pull_request) Has been cancelled
CI / frontend (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:39:09 +02:00
sjgandClaude Opus 5 bc63ded583 [feat](trx-config): warn about deprecated configuration keys
Several keys quietly stopped doing what they look like they do, and nothing
said so: [remote] and the flat per-rig sections are ignored outright once
[[remotes]] / [[rigs]] exist, [frontends.rigctl].port and --rigctl-port have
been dead since rig_ports replaced them, [frontends.audio].rig_ports is
superseded by rig_urls, and default_rig_id was renamed to default_rig_name.

Warn once at load, naming the replacement.  Defaults are indistinguishable from
explicit values after deserialization, so the loader now records which key paths
the file actually set and the checks work off that — no warning for a setting
the user never wrote.

The single-rig flat layout is not deprecated: it is the documented simple form,
and only draws a warning when [[rigs]] is silently shadowing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:35:52 +02:00
sjgandClaude Opus 5 cfaeb6ee15 [docs](trx-rs): generate the example config and correct the manual
trx-rs.toml.example was maintained by hand and had fallen well behind: no
[[rigs]], no [[remotes]], no [timeouts], no bandplan or decode-history
settings, and a [frontends.http].default_rig_id that had been renamed.

Generate it from the config structs instead, so a new field shows up the moment
it exists, and add a test that fails when the checked-in copy drifts:

    cargo run -p trx-config --example generate_example

Section comments come from a small table; a section without an entry is still
emitted, so forgetting a comment can never drop a setting from the example.

The manual was wrong about the basics.  It listed five config search paths, none
of which the loader has ever looked at (the real order is ./trx-rs.toml → XDG →
/etc), called --print-config output "fully commented" when it carries no
comments at all, and documented a TRX_PLUGIN_DIRS variable no code reads.  It
also still described [frontends.rigctl].port as the bind port years after
rig_ports replaced it.  Fixed, and the new configuration features are written
up alongside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:32:46 +02:00
sjgandClaude Opus 5 76bcce8c54 [feat](trx-config): let secrets live outside the config file
Tokens and passphrases had exactly one representation: plain text in
trx-rs.toml.  That is awkward for config-management tools, for a config kept in
a private repo, and for anything shared between machines.

Two alternatives:

- ${VAR} anywhere in a config string, expanded from the environment at load.
  An unset variable is an error rather than an empty string — a silently blank
  passphrase is how authentication gets disabled by accident.
- A *_file sibling for every credential: [listen.auth].tokens_file,
  [[remotes]].auth.token_file, [frontends.http.auth].rx_passphrase_file and
  .control_passphrase_file, [frontends.http_json.auth].tokens_file.  Setting
  both forms is an error rather than a guess about which wins.

Plus a nudge: a config file that holds credentials inline and is readable by
group or others gets a warning naming the chmod that fixes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:27:18 +02:00
sjgandClaude Opus 5 88ed3da6cc [feat](trx-server): make the decoder set configurable per rig
Every rig started nine decoders — APRS, HF APRS, CW, FT8, FT4, WSPR, LRPT,
WEFAX, SSTV — whether or not anyone ever looked at the results.  Two rigs on a
Pi meant eighteen decoder tasks chewing CPU for modes the operator does not
run.  Only the SDR virtual channels had a decoder list; the analog path had no
say at all.

Add [decoders] per rig:

    [decoders]
    enabled = ["cw", "ft8", "wspr"]
    output_dir = "/var/lib/trx-rs"

using the same decoder names as [sdr.channels].decoders, so there is one
vocabulary.  enabled defaults to every decoder, so upgrading changes nothing.
An unknown name is a config error rather than a silently ignored entry.

output_dir also replaces the hard-coded cache paths for the decoders that write
images, so SSTV, WEFAX and LRPT output can live somewhere the operator chooses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:22:03 +02:00
sjgandClaude Opus 5 7c69e0de08 [feat](trx-rs): add --check-config to the server and client
Validating a config meant starting the daemon and reading the first error it
died on, fixing that, and repeating.  Add --check-config, which loads the
config through the real loader, reports every problem at once and exits 0/1:

    $ trx-server --check-config --config trx-rs.toml
    trx-rs.toml
      warning: unknown config key 'listen.prot' (did you mean 'listen.port'?)
      error: [general].log_level 'verbose' is invalid (expected one of: ...)
      error: [rig.access].baud must be > 0 for serial access
      error: [audio].frame_duration_ms must be one of: 3, 5, 10, 20, 40, 60
      error: [listen] and rig "default" [audio] would both bind 127.0.0.1:4530

Validation grows validate_all()/validate_resolved_all() alongside the existing
first-error entry points; validate() is now the first element of validate_all().
Sockets are built by one helper shared by startup and the check, so the two
cannot disagree about what --listen overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:08:20 +02:00
sjgandClaude Opus 5 fbc4f6e398 [feat](trx-config): add a resolved-config validation phase
Some things can only be checked once CLI overrides have been folded in and the
rig/remote lists are final, so nothing checked them at all:

- The client's per-rig maps (rigctl.rig_ports, audio.rig_urls, audio.rig_ports,
  decode_history_retention_min_by_rig, http.default_rig_name) are keyed by a
  remote's short name.  A typo used to spawn a rigctl listener that injected a
  rig_id no remote answered to, without a word in the log.
- Nothing noticed two listeners claiming one socket.  [listen].port and a rig's
  [audio].port could both be 4530; on the client, http, http_json and each
  rigctl rig port could collide freely.

Add validate_resolved() to both configs, run after argument parsing, plus a
shared socket-conflict check that treats a wildcard address as conflicting with
any address on the same port and ignores port 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-06 21:02:26 +02:00
sjgandClaude Opus 5 d42ca4f030 [fix](trx-config): validate every rig, not just the legacy flat one
ServerConfig::validate() checked the flat [rig]/[audio]/[behavior] fields and
gave [[rigs]] entries only an id/audio-port uniqueness pass, and
validate_sdr() returned early unless the *flat* access type was "sdr".  A
multi-rig SDR station therefore got no Nyquist, stream_opus, duplicate-decoder
or tx_enabled checking at all, and a rig entry with frame_duration_ms = 7 or a
missing baud rate started and failed at runtime.

Move the per-rig rules into validate_rig_instance() and validate_sdr_instance()
and run them over resolved_rigs(), which already synthesises the flat layout as
a single entry.  Both layouts now go through the same code, and multi-rig
messages name the rig they came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 20:57:59 +02:00
sjgandClaude Opus 5 335922fecc [feat](trx-config): report unknown configuration keys
Every config struct is #[serde(default)], so a misspelled key was dropped in
silence and the setting kept its default.  Writing `prot = 9999` under
[listen] started the server on 4530 without a word.

Collect the ignored key paths with serde_ignored and pair each with the
closest known key at the same level:

    WARN unknown config key 'listen.prot' (did you mean 'listen.port'?)

Warnings by default, so a config written for a newer version still runs on an
older binary; --strict-config makes them fatal for CI.  Logging now starts
before validation so these warnings are actually visible.

trx-configurator --check drops its hand-maintained key lists and re-implemented
range checks in favour of the real loader and validators, so it no longer
passes configs the binaries reject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 20:55:01 +02:00
sjgandClaude Opus 5 bede2e34fe [fix](trx-config): accept both sectioned and bare config files
trx-configurator wrote standalone configs with [general]/[rig] at the root
while the loader required a [trx-server] section header, so every config the
wizard generated with --type server or --type client was rejected by the
binary it was generated for:

    $ trx-server --config trx-server.toml
    Error: ParseError("trx-server.toml", "missing [trx-server] section")

Teach the loader to fall back to the document root when no section header is
present, so hand-written standalone files keep working, and have the wizard
emit the same sectioned shape --print-config does.  A file carrying only the
*other* component's section still reports the missing section rather than
silently loading defaults.

Round-trip tests now load every document the wizard can generate through the
real loader and validator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 20:46:59 +02:00
sjgandClaude Opus 5 da58a004fe [refactor](trx-config): extract client/server config into a shared crate
The setup wizard, the server and the client each carried their own idea of
what a valid config looks like: trx-configurator validated with hand-written
toml_edit key lists while the binaries validated with serde plus their own
validate().  Nothing kept the three in sync.

Move ServerConfig, ClientConfig, the section loader, the shared validators and
the endpoint-URL parsing into a new trx-config crate that all three depend on,
so there is one definition of the config to drift from.  The binaries keep a
thin crate::config re-export so their internal paths are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 20:44:42 +02:00
sjg 77b283cb78 [fix](trx-frontend-http): size the nav's labels against the screen, not the font
CI / lint (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 8m23s
CI / frontend (pull_request) Successful in 4m20s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m35s
CI / frontend (push) Successful in 3m29s
CI / reuse (push) Successful in 2s
CI cut "Bookmarks" off at 360 px where this machine had ten pixels to
spare.  How wide a platform draws a word varies by more than ten per
cent, so a fixed 0.6rem label is a bet on the machine it was measured
on -- the same bet ui-core's own comment warns about where it explains
why the tab strip reflows by measurement rather than at a width.

The long labels are sized with `clamp(0.46rem, 2.2vw, 0.62rem)` now.  A
tab is a fifth of the viewport, so what fits in it follows the viewport;
tying the label to the same thing leaves better than a fifth of the tab
spare at every phone width, and holds with the text drawn 30% wider than
it is here.

The test asked the wrong question too.  "Does the label fit" is a
question about font metrics, and it will keep answering differently on
different machines.  It now asserts what actually matters: a label stays
inside its own tab, and if it is too long for it, it ends in an ellipsis
rather than being cut through a letter.  Both hold whatever width the
platform draws the words at.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 19:41:45 +02:00
sjg 6284747339 [fix](trx-frontend-http): let the bottom nav's labels fit inside its tabs
CI / lint (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 8m22s
CI / frontend (pull_request) Failing after 1m46s
CI / reuse (pull_request) Successful in 2s
The bottom nav keeps its labels under the icons -- that is what makes it
navigation rather than five glyphs -- but the labels did not fit the
tabs.  On a 360 px screen "Bookmarks" and "Digital modes" were cut off
mid-word and ran into each other: "Bookmarks igital mode".

The stylesheet already meant to handle it.  Three rules shrink the long
labels, and a rule twenty lines further down sets the size for all of
them; identical specificity, later in the file, so the blanket rule won
and nothing was ever shortened.  Those rules now come after it.

Beyond that, tabs sized themselves to their own labels, so "Map" and
"Digital modes" were given the same room.  They divide the bar evenly
now, which is the shape of every bottom nav, and a label that still runs
long ellipsises rather than being cut through a letter.

"Digital modes" does not fit at any size worth reading, so the nav shows
"Digital" and the button carries the full name as its accessible label;
the short form is hidden from assistive tech, which reads the button's
name instead.

The phone layout test now checks that no label in the nav is cut off at
430, 390 or 360 px, and that the shortened tab still says what it is to
something that reads the page rather than looks at it.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 19:14:31 +02:00
sjg 14ad6e241b [fix](trx-frontend-http): stop the radio controls running off the side of a phone
CI / lint (pull_request) Successful in 2m19s
CI / test (pull_request) Successful in 8m23s
CI / frontend (pull_request) Failing after 1m28s
CI / reuse (pull_request) Successful in 2s
On a 390 px screen the transmit controls were laid out at x=400, off the
side of a tray 354 px wide: present, invisible, and reachable only by a
horizontal scroll with nothing to say it was there.  The page scrolled
sideways by a dozen pixels as well.

Three causes, each in a different place.

A container query at the end of the stylesheet re-imposes `flex-wrap:
wrap` on a narrow tray's rows.  That is right while a row runs left to
right; below the phone breakpoint the row is a column, and wrapping a
column starts a *second column* — which is what put the transmit
controls beside the tray rather than under it.  The rule outranks the
phone one, two classes to its one, and sits later in the file, so it now
excludes itself below that breakpoint rather than being overridden.

The tray is a grid, and a grid column sizes to its content.  One row
wider than the screen — the mode picker, six buttons across — dragged
the whole tray out with it.  `minmax(0, 1fr)` lets the column be as
narrow as the phone and the rows wrap inside it.

The wavelength and signal-strength readouts are given the width of their
column on narrow screens, but with padding on a content box that is the
column's width plus the padding.  Those dozen pixels were the page's
sideways scroll.  They are border-box now.

Also: the rig picker was taking 139 px of a 338 px bar, pushing the rest
of the top bar into the overflow menu.  It is capped and ellipsised on
phones, with the full name still in the menu it opens.

The map's filter bar, collapsed, keeps both its anchors and shrinks
inside them rather than dropping `left` to be sized by shrink-to-fit,
and no longer asks for a compositing layer it does not need.  An
absolutely positioned, backdrop-filtered, composited box sizing itself
from its content is the shape of thing that renders as nothing on an
engine other than the one it was written against — which is what Edge
does with it.  Unverified there: this machine has no Edge to test with.

tests/mobile-layout.mjs holds the page to it at 430, 390 and 360 px: no
sideways scroll, nothing laid out past the right edge, the transmit
controls inside the screen, the rig picker within its cap — and the
collapsed filter bar still on screen with a button to bring the filters
back.

The SSTV panel test stamped its picture with a fixed date, which the
panel's own retention window dropped once that date was a day old.  It
uses a recent stamp now.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 19:06:15 +02:00
sjg 06971ff65c [style](trx-frontend-http): make the spectrum control strip one strip
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m28s
CI / frontend (push) Successful in 4m10s
CI / reuse (push) Successful in 3s
The row of controls under the plot held four different control heights,
units as loose text beside the field they belonged to, and a quarter of
its width as a hole in the middle.  Between about 1100 and 1400 px it
came apart: the bandwidth cluster wrapped to two lines while the level
cluster stayed on one, so the two sat at heights that matched neither
each other nor anything else on the page.

Every control stays, in its order, with its name and its behaviour.
This is the styling and the layout.

A field is now one box -- name, value and unit inside a single border --
so a number cannot be read apart from the unit it is in.  Fields,
buttons, the peak-hold select and the contrast slider are one height,
border-box so a button's own border cannot add two pixels to it, and
2.4rem under a coarse pointer where a fingertip needs the room.  The
contrast readout holds a fixed, tabular slot, so the row no longer
twitches between 1.0 and 0.9.

The container wraps and a cluster does not: a cluster that will not fit
drops whole to the next line and starts it left-aligned.  The slack
goes to a spacer rather than to `space-between`, which is what opened
the hole.

Two things this turned up.  The select carries `status-input` for other
layouts' sake, which drew a box inside the field's box.  And the narrow
-screen rules lived in a media query earlier in the file than the rules
they override -- identical specificity, so the later one won and the
phone layout had been overflowing sideways rather than stacking.  The
narrow rules now sit directly after what they override.

The layout test measures the strip at three widths: one height across
every control, no overflow, inside the plot, and clusters either
sharing a line or each having one -- never one floating against the
middle of the other.

docs/Spectrum-Controls-Rework.md records what was wrong and what was
deliberately left alone: the two different Autos, the settings that do
not persist, the one-shot buttons, and Sweet-spot's silence while it
retunes the SDR.  Those are behaviour, and are for another day.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 01:10:26 +02:00
sjg 18107ce07e [feat](trx-rs): receive SSTV pictures end to end
CI / lint (pull_request) Successful in 2m16s
CI / frontend (pull_request) Successful in 4m12s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m15s
CI / test (pull_request) Successful in 9m37s
CI / test (push) Successful in 7m36s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
Wires the SSTV decoder into the stack, from the audio the server already
has to a panel in the browser that shows the picture arriving.

Server: a decoder task alongside the WEFAX one, running whenever the
decoder is enabled and the rig is in a mode SSTV is sent in.  A finished
picture is written to the cache as a PNG and sent on as a message; the
rows are sent as they decode, so a client can watch two minutes of
Martin M1 fill in rather than waiting for it.  Pictures join the decode
history, are replayed to a client that connects later, and survive a
restart.

Protocol: SetSstvDecodeEnabled and ResetSstvDecoder, a sstv_decode
_enabled flag in the rig state, two audio message types, and Sstv and
SstvProgress on DecodedMessage.  The history stores the message without
its base64 payload -- the picture is already on disk, and a megabyte per
entry is not what a history is for.

Client: pictures land in their own history, and the PNG the server sent
is written to the local cache so /sstv-images/ can serve it back.  That
endpoint and the WEFAX one now share their filename checks rather than
each carrying a copy: no separators, no parent references, .png only.

Web UI: an SSTV sub-tab beside WEFAX, with a live canvas the rows paint
into at the line number they carry, a card for the last picture, and a
filterable history with links to the files.  Rows below the one arriving
are grey rather than black -- not yet received is a different thing from
received as black.  A picture is not a spot, so neither pictures nor
their progress updates reach the decode statistics; that exclusion list
had grown by hand for LRPT and WEFAX and is now one named set.

The decoder crate gains what the server needed to hand a picture on:
to_png, to_png_base64 and save_png, with file names stamped in UTC so
they sort.

Panel behaviour is tested with the plugin runtime: rows painting at
their own line numbers rather than in arrival order, a completed picture
linked by file name alone with no server path in the page, a cut-off
picture reported as partial, clearing, and the toggle following the rig
state.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 00:14:59 +02:00
sjg a0b0c0ed81 [feat](trx-sstv): decode SSTV pictures
CI / test (pull_request) Successful in 7m40s
CI / lint (pull_request) Failing after 14m36s
CI / frontend (pull_request) Successful in 3m12s
CI / reuse (pull_request) Successful in 3s
CI / test (push) Successful in 7m32s
CI / frontend (push) Failing after 1m21s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m16s
A new decoder crate covering the modes SSTV is actually sent in: Martin
M1/M2, Scottie S1/S2/DX, Robot 36/72, PD50 through PD290, and Wraase
SC2-180.  The mode comes from the VIS header every transmission opens
with, so nothing has to be told what is arriving.

Modes are a table rather than code: a list of segments -- sync, gaps,
and one scan per colour channel -- plus a colour model and a geometry.
The decoder reads the offset of each scan straight off that list, which
is what makes fifteen modes cost about as much as one, and a new mode a
table entry.  The segment lists are checked against the published line
durations in a test, because both are transcribed by hand from the same
specification and a digit wrong in one is unlikely to be wrong
identically in the other.

Signal path: band-pass over the SSTV band, Hilbert FIR, instantaneous
frequency by phase difference, then a state machine that walks the
transmission a line at a time.  Each line is looked for where the mode
says it should be and nudged into place by the sync pulse found near
it -- two sound cards never agree exactly, and over the two minutes of a
Martin M1 frame an uncorrected error of a few parts per million shears
the picture visibly.  Rows are emitted as they decode, so a picture can
be watched arriving, which is most of the appeal of the mode.

Four things this cost, each now the reason a piece of it is shaped the
way it is:

The per-sample frequency estimate ripples by ±95 Hz at 1200 Hz, where
the Hilbert approximation is weakest, though its mean is exact.  Pixels
average over their own window and were always right; the VIS bits and
the sync detector classify individual samples and were reading the
ripple.  Both now read short means.  Pixels deliberately still do not,
so edges stay where they are.

Broadband noise cost the whole picture, not part of it: a
phase-difference detector answers whatever is loudest, and there was no
input filter.  Hence the band-pass, which is what every real decoder
does first.

A sync search window shorter than a sync pulse rejected every pulse
arriving late in it, for being short.

The first line's sync search locked onto the VIS stop bit -- 30 ms at
exactly the sync frequency, immediately before the picture starts.  The
header already says where the picture begins, so the first line no
longer searches.

Tests: nine modes are encoded from a test card and decoded back,
compared pixel by pixel, alongside silence around the signal, a
transmission cut off part way, two transmissions back to back, 20 dB of
noise, and a transmitter clock 0.1% fast.  The encoder that produces
those signals reads the same table as the decoder, so a round trip
tests the decoder and not the timings; the timings are held to the
published line durations separately.

Nothing is wired into the server or the web UI yet: this is the decoder
alone.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 23:29:52 +02:00
122 changed files with 12603 additions and 4796 deletions
+8 -1
View File
@@ -24,6 +24,12 @@ cargo test -p trx-core
./target/release/trx-server --print-config > trx-server.toml
./target/release/trx-client --print-config > trx-client.toml
# Validate a config without starting anything (reports every problem)
./target/release/trx-server --check-config --config trx-rs.toml
# Regenerate trx-rs.toml.example after changing a config struct
cargo run -p trx-config --example generate_example
# Run server
./target/release/trx-server --config trx-server.toml
# or via CLI args:
@@ -41,7 +47,8 @@ This is a Cargo workspace. All crates live under `src/`:
src/
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
trx-app/ # Shared application helpers (config paths, logging init)
trx-app/ # Shared application helpers (logging init, name normalization)
trx-config/ # Client + server config structs, loader, validators (~2,500 LOC)
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
trx-backend/ # Backend abstraction trait + factory + dummy
Generated
+33 -4
View File
@@ -2412,6 +2412,16 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.149"
@@ -3031,10 +3041,6 @@ dependencies = [
name = "trx-app"
version = "0.1.0"
dependencies = [
"dirs",
"serde",
"thiserror 2.0.18",
"toml",
"tracing",
"tracing-subscriber",
]
@@ -3115,6 +3121,7 @@ dependencies = [
"toml",
"tracing",
"trx-app",
"trx-config",
"trx-core",
"trx-frontend",
"trx-frontend-http",
@@ -3124,6 +3131,23 @@ dependencies = [
"uuid",
]
[[package]]
name = "trx-config"
version = "0.1.0"
dependencies = [
"dirs",
"serde",
"serde_ignored",
"tempfile",
"thiserror 2.0.18",
"toml",
"toml_edit 0.22.27",
"tracing",
"trx-core",
"trx-decode-log",
"trx-reporting",
]
[[package]]
name = "trx-configurator"
version = "0.1.0"
@@ -3132,13 +3156,16 @@ dependencies = [
"dialoguer",
"tempfile",
"tokio-serial",
"toml",
"toml_edit 0.22.27",
"trx-config",
]
[[package]]
name = "trx-core"
version = "0.1.0"
dependencies = [
"base64",
"flate2",
"reqwest",
"serde",
@@ -3294,12 +3321,14 @@ dependencies = [
"trx-app",
"trx-aprs",
"trx-backend",
"trx-config",
"trx-core",
"trx-cw",
"trx-decode-log",
"trx-ftx",
"trx-protocol",
"trx-reporting",
"trx-sstv",
"trx-vdes",
"trx-wefax",
"trx-wspr",
+1
View File
@@ -18,6 +18,7 @@ members = [
"src/trx-core",
"src/trx-protocol",
"src/trx-app",
"src/trx-config",
"src/trx-reporting",
"src/trx-server",
"src/trx-server/trx-backend",
+10 -3
View File
@@ -93,13 +93,17 @@ The wizard walks you through rig selection, serial port detection, audio
settings, and frontend options, then writes `trx-server.toml` and
`trx-client.toml`.
Alternatively, generate example configs and edit them by hand:
Alternatively, copy `trx-rs.toml.example` — a commented example covering every
setting — and edit it by hand:
```bash
./target/release/trx-server --print-config > trx-server.toml
./target/release/trx-client --print-config > trx-client.toml
cp trx-rs.toml.example trx-rs.toml
./target/release/trx-server --check-config --config trx-rs.toml
```
`--check-config` reports everything wrong with a config without starting
anything. `--print-config` prints the same settings without comments.
### 4. Run
```bash
@@ -107,6 +111,9 @@ Alternatively, generate example configs and edit them by hand:
./target/release/trx-client --config trx-client.toml
```
A single `trx-rs.toml` can configure both: the server reads its `[trx-server]`
section and the client reads `[trx-client]`.
Open the configured HTTP frontend address in a browser (default `http://localhost:8080`).
## How It Works
+252
View File
@@ -322,3 +322,255 @@ trx-configurator
| `trx-app` | Config types and validation | Yes |
| `serialport` | Serial port enumeration | Yes (transitive) |
| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) |
---
## Logbook and Ham Radio Layout
Two halves of one feature ([#54](https://git.haxx.space/sjg/trx-rs/issues/54)): a station
logbook in a panel of its own, and an operator layout that puts a transceiver's controls
around it.
Nothing in the application records a QSO today. The map's "QSO summary" cards describe
contacts *between other stations*, reconstructed from decoded traffic; bookmarks are
frequencies, not contacts. Neither is the operator's own log.
### Requirements
| ID | Description |
|----|-------------|
| REQ-LOG-001 | The system shall record QSOs the operator makes, each holding at minimum callsign, date, time, band, frequency, mode and both signal reports. |
| REQ-LOG-002 | When starting a log entry, the system shall pre-fill exactly six fields: frequency, mode, rig name, time, callsign and locator. |
| REQ-LOG-003 | The system shall leave every other field of a log entry empty for the operator to fill. |
| REQ-LOG-004 | The system shall allow a logged QSO to be edited and deleted. |
| REQ-LOG-005 | The system shall survive a crash without losing a QSO that was recorded before it. |
| REQ-LOG-006 | The system shall list, search and filter the log by callsign, band, mode and date range. |
| REQ-LOG-007 | Where a decoded station is on screen, the system shall offer to start a log entry from it, pre-filled, without logging it unattended. |
| REQ-LOG-008 | The system shall show whether a callsign has been worked before, and on which bands. |
| REQ-FMT-001 | The system shall export the log as an ADIF 3.1.x `.adi` file. |
| REQ-FMT-002 | The system shall import ADIF `.adi` files produced by other logging software, preserving fields it does not itself use. |
| REQ-FMT-003 | When importing, the system shall identify QSOs already held and shall not duplicate them. |
| REQ-FMT-004 | The system shall export a filtered selection of the log as a Cabrillo 3.0 file for contest submission. |
| REQ-LOG-009 | The system shall stamp QSO times from the server's clock in UTC, and shall tell the operator when the browser's clock disagrees with it by more than one second. |
| REQ-LAY-001 | The system shall present the logbook in a panel of its own, reachable whatever layout is selected. |
| REQ-LAY-002 | The system shall offer a "Ham radio" operator layout presenting the transceiver controls and that panel together. |
| REQ-LAY-003 | Where the selected rig cannot transmit, the system shall not offer the ham layout. |
### A decode is not a QSO
The decoders are receive-only: FT8, CW, APRS and the rest report what was *heard*. A heard
callsign is the beginning of a log entry, not a contact, and the logbook must never write one
by itself — REQ-LOG-007 says offer and pre-fill, never auto-log. Digital QSOs made in
WSJT-X or similar arrive the way every other logger takes them: through ADIF import.
### What is pre-filled, and what is not
Six fields, and no more (REQ-LOG-002, REQ-LOG-003):
| Field | From | ADIF |
|-------|------|------|
| Frequency | the selected rig's dial | `FREQ`, with `BAND` derived from it |
| Mode | the selected rig | `MODE`, and `SUBMODE` where the mode implies one |
| Rig name | the rig's display name | `MY_RIG` |
| Time | the server's clock, UTC, when the entry opens | `QSO_DATE`, `TIME_ON` |
| Callsign | the decode row or map station the entry was started from, else empty | `CALL` |
| Locator | that station's grid, where the decode carried one, else empty | `GRIDSQUARE` |
Signal reports, power, name, QTH and the rest stay empty. A report in particular is the
operator's to give: an FT8 SNR is not what was sent, and pre-filling one would put a number in
the log that nobody exchanged.
The station's own callsign and locator are not pre-filled per entry either — they are station
identity, taken from configuration when the QSO is written (`STATION_CALLSIGN`, `OPERATOR`,
`MY_GRIDSQUARE`), and shown once at the top of the panel rather than typed into every row.
### Architecture
#### New crate: `trx-logbook`
```
src/trx-client/
trx-logbook/
src/
lib.rs # LogbookHandle: open, add, edit, delete, query, import, export
qso.rs # Qso: the record, its ADIF field mapping, band/mode helpers
adif.rs # ADI reader and writer (pure Rust, no new dependencies)
store.rs # Append-only JSON Lines file, in-memory index, compaction
dedupe.rs # Worked-before and import-collision rules
```
A library crate under `src/trx-client/`, beside `trx-frontend`, consumed by
`trx-frontend-http`. The log belongs to the station rather than to a rig or a frontend, so it
sits where any frontend can reach it.
#### Storage: append-only, not a rewritten blob
Bookmarks use `PickleDb` with `AutoDump`, which rewrites the whole file on every write. That
is right for a few dozen bookmarks and wrong for a log: a station with 40 000 QSOs would
rewrite several megabytes to log one contact, and lose the lot if the power went during the
dump.
The log is instead a JSON Lines file — the shape `trx-decode-log` already uses — appended one
record per write and read into an in-memory index at startup. An edit or a delete appends a new revision of that record's id; the load
keeps the last one, and a compaction pass rewrites the file when superseded records exceed a
threshold. Appending is O(1) and atomic per line, so a crash costs at most the line being
written.
#### Two formats, for the two things a log is asked for
The file formats were left open ("pick a well-known ham format"), so: **ADIF for interchange,
Cabrillo for contest submission.** Both are implemented in-repo, in the way this project
already implements its decoders, and neither adds a dependency.
**ADIF has to stay.** It is not one option among several — it is the only thing the ecosystem
reads. LoTW, eQSL, Club Log, QRZ.com and every other logger take ADIF and nothing else, so a
log that cannot write `.adi` cannot be uploaded, confirmed, or moved to another program. That
is a one-way door, and the interoperability is most of the point of keeping a log at all.
Nothing on disk is ADI regardless: the store is JSON Lines, and ADIF is what comes out of an
export.
ADI is a tagged text format — `<FIELD:length>value`, records ended by `<EOR>`, a header ended
by `<EOH>`, everything outside a tag ignored — small enough to implement exactly. The reader
must be lenient in the ways real files are irregular (lowercase tags, CRLF, missing header,
unknown fields, type indicators) and the writer strict. Unknown fields are carried through
import to export unchanged, so a round trip through trx-rs does not quietly strip what another
logger wrote. ADX, the XML serialisation of the same data model, is out of scope: it is part
of the standard but almost nothing reads it.
**Cabrillo is the second format, because ADIF cannot do its job.** Contest logs are submitted
to sponsors in Cabrillo 3.0 and are rejected in anything else — a header of `CALLSIGN:`,
`CONTEST:`, `CATEGORY-*` and `CLAIMED-SCORE:` lines, then one fixed-column `QSO:` line per
contact carrying frequency in kHz, a mode code (`CW`, `PH`, `FM`, `RY`, `DG`), the UTC date and
time, and both stations' calls, reports and exchanges. It is export-only and drops everything
outside the contest's exchange, which is why it complements ADIF rather than replacing it.
It arrives with the contest exchange fields in phase 5, since without a serial or a zone to
put in the exchange there is nothing for it to write.
#### Integration points
| Source | What it gives the log | How |
|--------|----------------------|-----|
| `RigState` | `FREQ`, `BAND`, `MODE`/`SUBMODE`, and the rig id a QSO was made on | watch channel already in the frontend context |
| Client config `general.callsign` | `STATION_CALLSIGN`, and the default `OPERATOR` | already surfaced as `owner_callsign` in frontend meta |
| The QSO's own rig, and its position | `MY_GRIDSQUARE` | per-rig latitude and longitude already carried in the rig list |
| Server clock | `QSO_DATE`, `TIME_ON` in UTC | new `GET /logbook/now`, which also feeds the browser-clock check |
| Decoder panels and map | a pre-filled entry: callsign, grid, and the report to offer | existing decode history; no new plumbing |
| `bandForHz` | `BAND` from a frequency | exists in `map-core.ts`; move to a shared module |
#### HTTP API
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/logbook` | Query: filters, paging |
| `POST` | `/logbook` | Add a QSO |
| `PUT` | `/logbook/{id}` | Edit |
| `DELETE` | `/logbook/{id}` | Delete |
| `GET` | `/logbook/export.adi` | ADIF export, honouring the current filter |
| `GET` | `/logbook/export.cbr` | Cabrillo export of a contest selection |
| `POST` | `/logbook/import` | Import, answering with counts: added, duplicate, rejected |
| `GET` | `/logbook/worked/{call}` | Worked-before: bands and modes |
| `GET` | `/logbook/now` | The server's UTC clock, for stamping entries and checking the browser's |
Writes require the control role, as the rig endpoints do.
### Frontend
The logbook is **its own panel**, not a strip bolted to the radio page: a `logbook` entry in
the tab order beside Bookmarks, holding the entry form, the table with the filters of
REQ-LOG-006, and import and export. It stands on its own in every layout, so a log can be kept
without adopting the ham layout, and read while another layout is selected (REQ-LAY-001).
The panel is three parts: the station line at the top (own callsign, locator, the rig a QSO
would be logged against), the entry form beneath it opening with the six pre-filled fields,
and the log itself under that, filtered as REQ-LOG-006 asks. Worked-before shows against
the callsign as it is typed.
The **ham layout** is a fifth entry in the operator layouts (`compact`, `broadcast`, `digital`,
`full`), which already gate on capability, seed the disclosure sections and persist per rig:
```ts
ham: {
label: "Ham radio",
unavailable: "Ham radio needs a rig that can transmit",
advanced: true, audio: true, scheduler: false,
preferredTab: "logbook", capability: "ham",
}
```
with the `ham` capability set from `RigCapabilities.tx`. It keeps frequency, VFO, mode, filter,
PTT, power and the meters, and hides the broadcast furniture. What it adds over `full` is where
it starts: the logbook panel, with the radio controls a keystroke away rather than the other
way round — the layout an operator working the bands wants, where logging the contact is the
task and the rig is the instrument.
### Phases
| Phase | Lands |
|-------|-------|
| 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers |
| 2 | Store, dedupe, and the HTTP API behind the control role |
| 3 | Logbook tab: entry, table, filters, import, export |
| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before |
| 5 | Contest exchange fields and Cabrillo export; QSL and LoTW/eQSL fields; per-band worked/confirmed statistics |
### Decisions
**One station log, not one per rig.** Awards and uploads are per station callsign — DXCC, WAS
and LoTW all count the callsign, not the radio — and a station worked on the second rig is
still worked. The rig is recorded on the QSO (`MY_RIG`) rather than dividing the log by it.
The station *location* fields do follow the rig, though: trx-rs rigs can be in different
places, so `MY_GRIDSQUARE` is taken from the rig that made the QSO rather than from one global
setting, which is also what LoTW's station locations expect.
**The operator is a per-QSO field, set once per session.** ADIF separates `STATION_CALLSIGN`
(the call used on the air) from `OPERATOR` (the person at the key); multi-operator stations
rotate operators through one station callsign, which is why contest loggers record it per QSO.
It is stored per QSO, defaulted from the configured callsign so a single operator never touches
it, and changed on the station line at the top of the panel where it sticks for the session.
It cannot be taken from the session's identity: the auth roles are `control` and `rx`, with no
notion of who is logged in.
**Server clock, and the log says so.** The server is the machine at the radio; the browser may
be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the
server, and when a browser's clock disagrees by more than a second the panel says so rather
than silently logging a time the operator did not expect.
**The log file is configurable, and defaults to the user's data directory.** Bookmarks live in
the config directory because they are settings; decode logs live in the cache directory because
they are disposable. A QSO log is neither — it is irreplaceable, and cache directories are
swept by cleaners. `[logbook].path` in the client config, defaulting to
`dirs::data_dir()/trx-rs/logbook.jsonl`, so a station that keeps its log on a synced or
backed-up volume can say so.
**Import collisions: callsign, band, mode and a two-minute window.** Two loggers rarely agree
to the second on the same QSO — one records the time the contact started, another the time it
was entered — so an exact-minute key duplicates half of what it is asked to merge. Two minutes
absorbs that. It does not swallow legitimate re-works: contest rules forbid a second contact
with the same station on the same band and mode, so a repeat inside two minutes is the same
QSO. Times are compared as instants rather than date and time strings, so a QSO either side of
midnight matches. Modes are normalised before comparison, or a log that stored `SSB` would fail
to match ours that stored `USB`.
### Rig modes to ADIF modes
The rig reports what it is demodulating; ADIF wants what the contact was made on, which is not
always the same word:
| Rig mode | ADIF `MODE` | ADIF `SUBMODE` |
|----------|-------------|----------------|
| `USB`, `LSB` | `SSB` | `USB` / `LSB` |
| `CW`, `CWR` | `CW` | — |
| `AM`, `SAM` | `AM` | — |
| `FM`, `WFM` | `FM` | — |
| `PKT` | `PKT` | — |
| `DIG` | decided by the decoder in use, not by the rig | |
| `AIS`, `VDES` | none — not amateur modes, and these rigs do not log | |
| `Other(..)` | passed through when it names an ADIF mode, else left for the operator | |
`DIG` is the one that cannot come from the rig: a rig in `DIG` is in FT8, FT4 or something else
depending on which decoder is running, and an entry started from an FT8 row logs `FT8` rather
than the rig's word for it. WSPR never opens an entry at all — it is a beacon mode, and hearing
a beacon is not a contact.
The table is data in `qso.rs`, checked against the ADIF enumeration when it is written, with
anything unrecognised left to the operator rather than guessed into the log.
+116
View File
@@ -0,0 +1,116 @@
# Spectrum Controls — Visual Rework
The strip between the spectrum and the radio controls (`#spectrum-controls`)
holds eight controls in two groups. This proposes how it should *look*.
Every control stays, in its current order, with its current name and its
current behaviour. Nothing here changes what a button does, what commits when,
or what is stored. It is a styling and layout change.
*Status: implemented. Kept as the record of what was changed and why.*
---
## What it looks like now
```
Bandwidth [ 12 ] kHz [Set] [Auto BW] [Sweet-spot] Peak Hold [2 s] Floor [-115] dB Range [90] dB [Auto] Contrast [——●——] 1.0
```
Four problems, all of them visual:
**1. Four different control heights on one line.** The bare number inputs, the
buttons, the `select` and the range slider are each sized by their own rule, so
nothing shares a baseline and the row reads as a pile rather than a strip.
**2. Units are loose text.** `kHz`, `dB`, `dB` and the contrast value `1.0` are
text nodes sitting outside the control they belong to, separated from it by a
gap the same size as the gap between unrelated controls. The eye has to work
out which number owns which unit.
**3. A quarter of the strip is a hole.** `justify-content: space-between` puts
about 250 px of nothing in the middle at 1600 px, and the two groups read as
two unrelated things because the only thing between them is emptiness.
**4. The groups stagger between 1100 and 1400 px.** The bandwidth group wraps
to two lines while the level group stays on one, so the level group floats at a
height of its own, aligned with neither line of the group beside it. This is
the worst of it, and it happens at a common window width.
Two smaller things: 24 px controls are below any touch-target guideline, and
the contrast readout has no fixed width, so the row twitches as the value
changes between `1.0` and `0.9`.
## Proposed
**One control height, units inside their field, and a rule where the clusters
meet.**
- **A field is one box.** Label, value and unit share a single bordered box —
`Bandwidth │ 12.0 │ kHz` — so a number and its unit can never be read apart.
Fields, buttons, the peak-hold select and the contrast slider are all 1.7 rem
tall, on 44 px targets under a coarse pointer.
- **A rule, not a hole.** The two clusters are separated by a thin vertical
rule with normal spacing either side. The slack goes to a flexible spacer, so
the strip is left-aligned rather than pushed apart.
- **A cluster never splits.** Each cluster is `nowrap`; the container wraps. If
a cluster does not fit on the line it drops whole to the next one,
left-aligned with the one above. No staggering, at any width.
- **Rules fall away at line starts.** A cluster that begins a line has no rule
hanging off its left edge.
- **The contrast readout gets a fixed, tabular slot**, so the row is still.
Below the existing mobile breakpoint the strip already stacks; the same field
component applies there, which is most of what makes it look deliberate.
## What this does not change
`Set` stays. `Auto BW` and `Auto` keep their names, even though they mean
different things — that is a naming question, not a styling one. Sweet-spot
stays where it is and keeps its behaviour. Nothing gains or loses persistence.
Nothing moves into a popover, and no control is hidden behind a click.
Those are all worth arguing about separately; a note of them is at the end of
this file so the arguments are not lost.
## Implementation
One pass, no behaviour touched:
1. `.spectrum-field` and `.spectrum-btn` in `style.css`, replacing the six
per-id rules (`#spectrum-bw-input`, `#spectrum-floor-input`,
`#spectrum-range-input`, `#spectrum-bw-label`, `#spectrum-floor-label`,
`#spectrum-range-label`) that currently repeat the same declarations.
2. Markup in `index.html`: the loose `kHz` / `dB` text nodes move inside their
label, which keeps every id and every event handler exactly where it is.
3. `#spectrum-controls` becomes a wrapping flex row with a spacer;
`#spectrum-bw-row` and `#spectrum-level-row` become `nowrap` clusters with a
left rule.
4. The mobile block in the media query drops the rules it no longer needs.
`app.ts` is not touched. Every id survives, so the existing handlers, the
`spectrum-layout.mjs` geometry test and the broadcast-layout highlight all keep
working.
### Tests
Extend `spectrum-layout.mjs`, which already measures this area:
- Every control in the strip shares one height, at 1600, 1200 and 900 px.
- No two clusters sit at different vertical offsets on the same line — the
staggering bug, asserted directly.
- The strip never overflows its container and never overlaps the hint line.
---
## Noted for later, not proposed here
Behavioural observations from reading the code, kept so they are not lost:
- `Auto BW` (filter) and `Auto` (display scaling) are both called Auto, 600 px
apart.
- `Floor`, `Range` and `Contrast` are not persisted; `Peak Hold` is.
- `Auto` and `Auto BW` are one-shot: no state, nothing to turn off.
- `Sweet-spot` retunes the SDR and waits up to 1.4 s per candidate centre, with
no busy indication.
- Contrast resets on a double-click that nothing advertises.
+199 -22
View File
@@ -17,30 +17,61 @@ frontends.
## Configuration
Both `trx-server` and `trx-client` use TOML configuration files. Use
`--print-config` to generate a fully commented example.
Both `trx-server` and `trx-client` read TOML. The server takes its settings
from the `[trx-server]` section and the client from `[trx-client]`, so one
`trx-rs.toml` can configure both — or each may live in its own file with the
section header left off.
`trx-rs.toml.example` in the repository root is a complete, commented example
generated from the config definitions themselves. `--print-config` prints the
same settings without the comments.
### File Locations
**trx-server** lookup order:
1. `--config <FILE>`
2. `./trx-server.toml`
3. `~/.trx-server.toml`
4. `~/.config/trx-rs/server.toml`
5. `/etc/trx-rs/server.toml`
Both binaries use the same lookup order:
**trx-client** lookup order:
1. `--config <FILE>`
2. `./trx-client.toml`
3. `~/.config/trx-rs/client.toml`
4. `/etc/trx-rs/client.toml`
2. `./trx-rs.toml`
3. `~/.config/trx-rs/trx-rs.toml`
4. `/etc/trx-rs/trx-rs.toml`
CLI arguments override config file values.
### Environment Variables
### Checking a Config
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by
both server and client.
`--check-config` loads the file, reports every problem it finds — unknown keys,
invalid values, listeners fighting over a port — and exits without starting
anything:
```bash
trx-server --check-config --config trx-rs.toml
trx-client --check-config --config trx-rs.toml
```
Unknown keys are warnings by default, so a config written for a newer version
still runs on an older binary. `--strict-config` makes them fatal.
`trx-configurator --check <FILE>` runs the same checks.
### Environment Variables and Secrets
Any string in the config may reference an environment variable as `${VAR}`;
an unset variable is an error rather than an empty value.
Credentials can be kept out of the config entirely by pointing at a file
instead. Every secret has a `*_file` sibling — set one or the other, never
both:
| Inline key | File key | Contents |
|------------|----------|----------|
| `[listen.auth].tokens` | `tokens_file` | one token per line |
| `[[remotes]].auth.token` | `token_file` | the token |
| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase |
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
Blank lines and `#` comments are ignored in the list files. A config that holds
credentials inline and is readable by group or others is flagged at startup.
### Server Options
@@ -96,6 +127,7 @@ CLI arguments override config file values.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
| `tokens_file` | string | — | Read tokens from this file, one per line |
#### `[audio]`
@@ -121,6 +153,13 @@ When audio is enabled, at least one of `rx_enabled` or `tx_enabled` must be true
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
| `spectrum_fft_size` | usize | `1024` | Spectrum FFT bins; power of two, 1288192 |
| `spectrum_interval_ms` | u64 | `50` | How often a spectrum frame is pushed to subscribed clients |
Spectrum is the largest thing on the client connection. On a slow or
high-latency link, halving `spectrum_fft_size` halves the bytes per frame (at
half the frequency resolution) and raising `spectrum_interval_ms` sends fewer of
them; see [Spectrum over a slow link](#spectrum-over-a-slow-link).
#### `[sdr.gain]`
@@ -197,6 +236,29 @@ Notes:
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
`%MM%`, `%DD%` (UTC).
#### `[decoders]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | string[] | all decoders | Decoders to run for this rig |
| `output_dir` | string | `"$XDG_CACHE_HOME/trx-rs"` | Base directory for decoders that write images |
Valid decoder names: `aprs`, `aprs_hf`, `ais`, `cw`, `ft2`, `ft4`, `ft8`,
`lrpt`, `sstv`, `vdes`, `wefax`, `wspr` — the same names `[[sdr.channels]]`
uses. An unrecognised name is a config error.
Every decoder runs by default, which costs real CPU on a small machine. On a
station that only works digital modes, listing just what you use is worth it:
```toml
[decoders]
enabled = ["ft8", "ft4", "wspr"]
```
`sstv`, `wefax` and `lrpt` write images into a subdirectory of `output_dir`
named after the decoder. `ais` and `vdes` additionally require an SDR channel
configured to feed them.
#### Multi-Rig Configuration
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
@@ -240,12 +302,32 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|-------|------|---------|-------------|
| `url` | string | — | Server address (e.g. `localhost:4530`) |
| `poll_interval_ms` | u64 | `750` | State poll interval |
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
#### `[remote.auth]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `token` | string | — | Auth token (must not be empty if set) |
| `token_file` | string | — | Read the token from this file instead |
#### `[[remotes]]`
Preferred over the single `[remote]` section: one entry per rig, each mapping a
short name to a server and an optional server-side rig id.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `name` | string | — | Short name used everywhere in the client |
| `url` | string | — | Server address (`host:port`) |
| `rig_id` | string | — | Rig id on a multi-rig server |
| `auth.token` | string | — | Auth token |
| `auth.token_file` | string | — | Read the token from this file instead |
| `poll_interval_ms` | u64 | `750` | State poll interval |
The `name` is the key used by `default_rig_name`, `rigctl.rig_ports`,
`audio.rig_urls`, `audio.rig_ports` and `decode_history_retention_min_by_rig`.
A name in any of those maps that no remote answers to is a config error.
#### `[frontends.http]`
@@ -254,6 +336,31 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
| `enabled` | bool | `true` | Enable web UI |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `8080` | Bind port |
| `default_rig_name` | string | — | Remote selected on startup |
| `initial_map_zoom` | u8 | `10` | Starting zoom for the APRS map |
| `show_sdr_gain_control` | bool | `true` | Expose the RF gain control |
| `bandplan_enabled` | bool | `true` | Show the bandplan strip |
| `bandplan_region` | string | `"iaru_r1"` | `iaru_r1`, `iaru_r2`, or `iaru_r3` |
| `decode_history_retention_min` | u64 | `1440` | Decode history retention |
| `decode_history_retention_min_by_rig` | table | `{}` | Per-remote retention override |
| `spectrum_coverage_margin_hz` | u32 | `50000` | Centre-retune guard margin |
| `spectrum_usable_span_ratio` | f32 | `0.92` | Usable fraction of the sampled span |
#### `[frontends.http.auth]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Require a passphrase |
| `rx_passphrase` | string | — | Passphrase granting receive-only access |
| `rx_passphrase_file` | string | — | Read it from this file instead |
| `control_passphrase` | string | — | Passphrase granting full control |
| `control_passphrase_file` | string | — | Read it from this file instead |
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users |
| `session_ttl_min` | u64 | `480` | Session lifetime |
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
With `enabled = true`, at least one passphrase must be set.
#### `[frontends.rigctl]`
@@ -261,7 +368,11 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable Hamlib rigctl |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `4532` | Bind port |
| `rig_ports` | table | `{}` | Remote name → local port; one listener each |
One listener is started per `rig_ports` entry, each routing to its rig, so
`rig_ports` must name at least one remote when the frontend is enabled. The
older single `port` key and `--rigctl-port` are ignored.
#### `[frontends.http_json]`
@@ -271,13 +382,17 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
| `auth.tokens_file` | string | — | Read tokens from this file, one per line |
#### `[frontends.audio]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable audio client |
| `server_port` | u16 | `4531` | Server audio port |
| `server_url` | string | — | Audio endpoint for every remote |
| `rig_urls` | table | `{}` | Remote name → audio URL (wins over `server_url`) |
| `server_port` | u16 | `4531` | Fallback port when no URL is configured |
| `rig_ports` | table | `{}` | Remote name → port; superseded by `rig_urls` |
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
| `bridge.rx_output_device` | string | — | Local playback device |
| `bridge.tx_input_device` | string | — | Local capture device |
@@ -287,16 +402,78 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
loopback on Linux, BlackHole on macOS).
### Spectrum over a slow link
Spectrum dominates the server↔client connection: everything else is a few
hundred bytes, a frame is a few kilobytes. Three things govern what it costs.
**Frames are pushed, not polled.** The client subscribes and the server sends
frames at `[sdr].spectrum_interval_ms`. Polling cost a round trip per frame, so
the rate was capped at 1/RTT — on a 200 ms link you could not exceed 5 frames a
second however often the client asked. Clients fall back to polling
automatically against a server too old to stream.
**Bins travel as whole dBFS.** They are base64-encoded `i8` on the wire, about
an eighth of the JSON array of floats they used to be, at the resolution the
display draws anyway.
**Both ends have a rate, and the slower one wins.** The server pushes no faster
than `[sdr].spectrum_interval_ms`; the client asks for no more than
`[[remotes]].spectrum_interval_ms`.
For a link that struggles, start here:
```toml
[trx-server.sdr]
spectrum_fft_size = 512 # half the bins, half the bytes
spectrum_interval_ms = 200 # 5 frames/s instead of 20
[[trx-client.remotes]]
name = "remote-site"
url = "radio.example.com:4530"
spectrum_interval_ms = 200
```
That is roughly 0.7 KB per frame at 5 frames/s — about 3.5 KB/s, against
roughly 200 KB/s for 1024 float bins at 20 frames/s.
### CLI Override Summary
**trx-server:**
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
`--port`. SDR options are file-only.
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`,
`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only.
**trx-client:**
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`,
`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`,
`--http-port`, `--rigctl-listen`, `--http-json-listen`, `--http-json-port`,
`--callsign`.
`--listen` on the server overrides the bind address of both the control
listener and every rig's audio listener.
---
## Multiple Rigs in the Web UI
A client connected to several rigs decodes all of them at once, whichever one
is on screen. The rig picker in the header decides which rig the UI is about,
and each page answers that differently:
| Page | Shows |
|------|-------|
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. HF APRS is a source of its own there, filtered apart from VHF APRS. |
| Statistics | The whole station, including the per-rig comparison. |
Switching rigs repaints the radio and digital modes pages for the rig now
selected. Nothing is lost by switching: the traffic other rigs heard is still
held, and switching back brings it up again. The one exception is the CW pane,
which is a single running stream of copied text rather than a list of frames,
so it starts empty on the rig you switch to.
Each browser tab keeps its own selection, so two tabs can watch two rigs.
---
@@ -20,14 +20,26 @@ fn main() {
for x in 0..w {
let at = (y * w + x) * 3;
let (r, g, b) = if y < h / 3 {
[(255u8, 255u8, 255u8), (255, 255, 0), (0, 255, 255), (0, 255, 0),
(255, 0, 255), (255, 0, 0), (0, 0, 255), (0, 0, 0)][x * 8 / w]
[
(255u8, 255u8, 255u8),
(255, 255, 0),
(0, 255, 255),
(0, 255, 0),
(255, 0, 255),
(255, 0, 0),
(0, 0, 255),
(0, 0, 0),
][x * 8 / w]
} else if y < 2 * h / 3 {
let t = (x * 255 / w) as u8;
(t, 255 - t, ((y * 255) / h) as u8)
} else {
// Diagonal stripes: a line-timing error shows up as a kink.
if ((x + y) / 16) % 2 == 0 { (240, 240, 40) } else { (20, 20, 90) }
if ((x + y) / 16) % 2 == 0 {
(240, 240, 40)
} else {
(20, 20, 90)
}
};
rgb[at] = r;
rgb[at + 1] = g;
@@ -39,9 +51,17 @@ fn main() {
for y in 0..h {
sent.put_row(y, &rgb[y * w * 3..(y + 1) * w * 3]);
}
std::fs::write(format!("{dir}/{name}-sent.png"), sent.to_png().expect("png")).expect("write");
std::fs::write(
format!("{dir}/{name}-sent.png"),
sent.to_png().expect("png"),
)
.expect("write");
let frame = Frame { width: w, height: h, rgb: &rgb };
let frame = Frame {
width: w,
height: h,
rgb: &rgb,
};
let mut audio = encode(mode, &frame, 48_000);
audio.extend(std::iter::repeat_n(0.0, 4800));
let mut decoder = SstvDecoder::new(48_000, SstvConfig::default());
@@ -58,7 +78,14 @@ fn main() {
for y in 0..h {
canvas.put_row(y, &image.rgb[y * w * 3..(y + 1) * w * 3]);
}
std::fs::write(format!("{dir}/{name}-decoded.png"), canvas.to_png().expect("png")).expect("write");
println!("{}: {} lines, complete={}", mode.name, image.lines, image.complete);
std::fs::write(
format!("{dir}/{name}-decoded.png"),
canvas.to_png().expect("png"),
)
.expect("write");
println!(
"{}: {} lines, complete={}",
mode.name, image.lines, image.complete
);
}
}
+85 -5
View File
@@ -79,6 +79,61 @@ pub struct SstvImage {
pub started_ms: i64,
}
impl SstvImage {
/// The picture as a canvas, for saving or encoding.
pub fn canvas(&self) -> ImageCanvas {
let width = usize::from(self.width);
let mut canvas = ImageCanvas::new(width, usize::from(self.height));
for (y, row) in self.rgb.chunks(width * 3).enumerate() {
canvas.put_row(y, row);
}
canvas
}
/// The picture as PNG bytes.
pub fn to_png(&self) -> Result<Vec<u8>, String> {
self.canvas().to_png()
}
/// The picture as a base64 PNG, for the journey to a client.
pub fn to_png_base64(&self) -> Result<String, String> {
self.canvas().to_png_base64()
}
/// Write the picture into `dir`, named for when and where it arrived.
pub fn save_png(
&self,
dir: &std::path::Path,
freq_hz: u64,
) -> Result<std::path::PathBuf, String> {
self.canvas()
.save_png(dir, freq_hz, self.mode, &stamp(self.started_ms))
}
}
/// `YYYYMMDDTHHMMSSZ` for a millisecond timestamp, for file names that sort.
fn stamp(ms: i64) -> String {
let secs = ms.div_euclid(1000);
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (year, month, day) = civil_from_days(days);
let (hour, minute, second) = (rest / 3600, (rest % 3600) / 60, rest % 60);
format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}Z")
}
/// Days since the Unix epoch to a calendar date (Howard Hinnant's algorithm).
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
enum State {
/// Listening for a header.
Searching,
@@ -192,7 +247,8 @@ impl SstvDecoder {
// named in the configuration, the first sync pulse is enough to start.
if let Some(mode) = self.config.force_mode.and_then(mode_for_vis) {
if let Some(sync) = self.find_sync(from, self.freqs.len(), mode) {
let line_start = (self.base + sync as u64) as f64 - ms_to_samples(mode.sync_offset_ms, self.sample_rate);
let line_start = (self.base + sync as u64) as f64
- ms_to_samples(mode.sync_offset_ms, self.sample_rate);
self.begin_at(mode, line_start.max(0.0), events);
return true;
}
@@ -384,7 +440,8 @@ impl SstvDecoder {
if from >= self.freqs.len() {
return None;
}
self.find_sync(from, to, mode).map(|at| self.base + at as u64)
self.find_sync(from, to, mode)
.map(|at| self.base + at as u64)
}
/// Drop what is behind the decoder, so a long reception does not grow the
@@ -461,7 +518,10 @@ fn sample_scan(
(from, to)
} else {
let at = (pixel_start - base as f64).max(0.0) as usize;
(at.min(freqs.len().saturating_sub(1)), (at + 1).min(freqs.len()))
(
at.min(freqs.len().saturating_sub(1)),
(at + 1).min(freqs.len()),
)
};
if to <= from {
out.push(0);
@@ -503,14 +563,19 @@ fn compose_rows(reception: &mut Reception, scans: &[(Channel, Vec<u8>)]) -> Vec<
// This line carries one chroma channel; the other is the one from
// the line before, which is what the mode expects a decoder to do.
let luma = find(Channel::LumaOdd).cloned().unwrap_or_default();
let chroma = find(Channel::ChromaAlternating).cloned().unwrap_or_default();
let chroma = find(Channel::ChromaAlternating)
.cloned()
.unwrap_or_default();
if reception.row.is_multiple_of(2) {
reception.last_chroma_r = Some(chroma);
} else {
reception.last_chroma_b = Some(chroma);
}
let neutral = vec![128u8; luma.len().max(1) / 2];
let cr = reception.last_chroma_r.clone().unwrap_or_else(|| neutral.clone());
let cr = reception
.last_chroma_r
.clone()
.unwrap_or_else(|| neutral.clone());
let cb = reception.last_chroma_b.clone().unwrap_or(neutral);
vec![ycrcb_row(&luma, &cr, &cb, width)]
}
@@ -577,3 +642,18 @@ fn finish(reception: &Reception) -> SstvImage {
started_ms: reception.started_ms,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_stamps_are_utc_and_sort_in_time_order() {
// Known instants, checked against `date -u -r <secs>`.
assert_eq!(stamp(0), "19700101T000000Z");
assert_eq!(stamp(1_000_000_000_000), "20010909T014640Z");
assert_eq!(stamp(1_770_000_000_000), "20260202T024000Z");
// Sorting the names sorts the pictures.
assert!(stamp(1_770_000_000_000) < stamp(1_770_000_001_000));
}
}
+19 -4
View File
@@ -176,7 +176,13 @@ fn design_bandpass_fir(sample_rate: u32) -> Vec<f32> {
let high = (f64::from(BAND_HIGH_HZ) / sr).min(0.499);
let m = (BANDPASS_TAPS - 1) as f64;
let mid = m / 2.0;
let sinc = |x: f64| if x.abs() < 1e-9 { 1.0 } else { (std::f64::consts::PI * x).sin() / (std::f64::consts::PI * x) };
let sinc = |x: f64| {
if x.abs() < 1e-9 {
1.0
} else {
(std::f64::consts::PI * x).sin() / (std::f64::consts::PI * x)
}
};
let mut coeffs = Vec::with_capacity(BANDPASS_TAPS);
for i in 0..BANDPASS_TAPS {
let n = i as f64 - mid;
@@ -223,7 +229,10 @@ mod tests {
fn measure(freq: f32, sample_rate: u32) -> f32 {
let mut demod = FreqDemod::new(sample_rate);
let mut out = Vec::new();
demod.process_into(&tone(freq, sample_rate, sample_rate as usize / 10), &mut out);
demod.process_into(
&tone(freq, sample_rate, sample_rate as usize / 10),
&mut out,
);
let settled = &out[HILBERT_TAPS * 2..];
settled.iter().sum::<f32>() / settled.len() as f32
}
@@ -276,7 +285,13 @@ mod tests {
// itself takes the filter's length to pass through.
let before = out[4800 - 200..4800 - 100].iter().sum::<f32>() / 100.0;
let after = out[4800 + 200..4800 + 300].iter().sum::<f32>() / 100.0;
assert!((before - 1500.0).abs() < 10.0, "before the step: {before:.1} Hz");
assert!((after - 2300.0).abs() < 10.0, "after the step: {after:.1} Hz");
assert!(
(before - 1500.0).abs() < 10.0,
"before the step: {before:.1} Hz"
);
assert!(
(after - 2300.0).abs() < 10.0,
"after the step: {after:.1} Hz"
);
}
}
+39 -10
View File
@@ -29,10 +29,22 @@ pub fn hz_from_level(level: u8) -> f32 {
/// The tones of a VIS header announcing `vis`.
pub fn vis_tones(vis: u8) -> Vec<Tone> {
let mut tones = vec![
Tone { hz: 1900.0, ms: 300.0 },
Tone { hz: 1200.0, ms: 10.0 },
Tone { hz: 1900.0, ms: 300.0 },
Tone { hz: 1200.0, ms: 30.0 }, // start bit
Tone {
hz: 1900.0,
ms: 300.0,
},
Tone {
hz: 1200.0,
ms: 10.0,
},
Tone {
hz: 1900.0,
ms: 300.0,
},
Tone {
hz: 1200.0,
ms: 30.0,
}, // start bit
];
let mut ones = 0;
for bit in 0..7 {
@@ -40,11 +52,20 @@ pub fn vis_tones(vis: u8) -> Vec<Tone> {
if set {
ones += 1;
}
tones.push(Tone { hz: if set { 1100.0 } else { 1300.0 }, ms: 30.0 });
tones.push(Tone {
hz: if set { 1100.0 } else { 1300.0 },
ms: 30.0,
});
}
// Even parity over the seven data bits.
tones.push(Tone { hz: if ones % 2 == 1 { 1100.0 } else { 1300.0 }, ms: 30.0 });
tones.push(Tone { hz: 1200.0, ms: 30.0 }); // stop bit
tones.push(Tone {
hz: if ones % 2 == 1 { 1100.0 } else { 1300.0 },
ms: 30.0,
});
tones.push(Tone {
hz: 1200.0,
ms: 30.0,
}); // stop bit
tones
}
@@ -97,7 +118,10 @@ pub fn encode_tones(mode: &SstvMode, frame: &Frame<'_>) -> Vec<Tone> {
let pixel_ms = ms / pixels as f64;
for x in 0..pixels {
let level = channel_level(mode, frame, channel, x, top, transmission);
tones.push(Tone { hz: hz_from_level(level), ms: pixel_ms });
tones.push(Tone {
hz: hz_from_level(level),
ms: pixel_ms,
});
}
}
}
@@ -149,7 +173,8 @@ fn chroma(mode: &SstvMode, frame: &Frame<'_>, x: usize, top: usize, want_cr: boo
/// demodulator sees no step at a tone boundary that isn't in the signal.
pub fn render(tones: &[Tone], sample_rate: u32) -> Vec<f32> {
let sr = f64::from(sample_rate);
let mut out = Vec::with_capacity((tones.iter().map(|t| t.ms).sum::<f64>() / 1000.0 * sr) as usize);
let mut out =
Vec::with_capacity((tones.iter().map(|t| t.ms).sum::<f64>() / 1000.0 * sr) as usize);
let mut phase = 0.0f64;
// Each tone's *end* is rounded to a sample, rather than its length: a
// pixel of 25.5 samples rounded up on its own puts a whole line 600
@@ -195,7 +220,11 @@ mod tests {
fn a_transmission_lasts_as_long_as_the_mode_says() {
let mode = mode_for_vis(44).expect("Martin M1");
let rgb = vec![128u8; 320 * 256 * 3];
let frame = Frame { width: 320, height: 256, rgb: &rgb };
let frame = Frame {
width: 320,
height: 256,
rgb: &rgb,
};
let audio = encode(mode, &frame, 48_000);
// Header plus 256 lines, within a line of the published duration.
let header_s = 0.94;
+2 -1
View File
@@ -107,7 +107,8 @@ impl ImageCanvas {
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let path = dir.join(format!("SSTV_{stamp}_{freq_hz}_{slug}.png"));
std::fs::write(&path, self.to_png()?).map_err(|e| format!("write {}: {e}", path.display()))?;
std::fs::write(&path, self.to_png()?)
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
}
+257 -75
View File
@@ -147,22 +147,40 @@ impl SstvMode {
const MARTIN_M1: &[Segment] = &[
Segment::Sync(4.862),
Segment::Gap(0.572),
Segment::Scan { channel: Channel::Green, ms: 146.432 },
Segment::Scan {
channel: Channel::Green,
ms: 146.432,
},
Segment::Gap(0.572),
Segment::Scan { channel: Channel::Blue, ms: 146.432 },
Segment::Scan {
channel: Channel::Blue,
ms: 146.432,
},
Segment::Gap(0.572),
Segment::Scan { channel: Channel::Red, ms: 146.432 },
Segment::Scan {
channel: Channel::Red,
ms: 146.432,
},
Segment::Gap(0.572),
];
const MARTIN_M2: &[Segment] = &[
Segment::Sync(4.862),
Segment::Gap(0.572),
Segment::Scan { channel: Channel::Green, ms: 73.216 },
Segment::Scan {
channel: Channel::Green,
ms: 73.216,
},
Segment::Gap(0.572),
Segment::Scan { channel: Channel::Blue, ms: 73.216 },
Segment::Scan {
channel: Channel::Blue,
ms: 73.216,
},
Segment::Gap(0.572),
Segment::Scan { channel: Channel::Red, ms: 73.216 },
Segment::Scan {
channel: Channel::Red,
ms: 73.216,
},
Segment::Gap(0.572),
];
@@ -174,32 +192,59 @@ const MARTIN_M2: &[Segment] = &[
const SCOTTIE_S1: &[Segment] = &[
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Green, ms: 138.240 },
Segment::Scan {
channel: Channel::Green,
ms: 138.240,
},
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Blue, ms: 138.240 },
Segment::Scan {
channel: Channel::Blue,
ms: 138.240,
},
Segment::Sync(9.0),
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Red, ms: 138.240 },
Segment::Scan {
channel: Channel::Red,
ms: 138.240,
},
];
const SCOTTIE_S2: &[Segment] = &[
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Green, ms: 88.064 },
Segment::Scan {
channel: Channel::Green,
ms: 88.064,
},
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Blue, ms: 88.064 },
Segment::Scan {
channel: Channel::Blue,
ms: 88.064,
},
Segment::Sync(9.0),
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Red, ms: 88.064 },
Segment::Scan {
channel: Channel::Red,
ms: 88.064,
},
];
const SCOTTIE_DX: &[Segment] = &[
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Green, ms: 345.6 },
Segment::Scan {
channel: Channel::Green,
ms: 345.6,
},
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Blue, ms: 345.6 },
Segment::Scan {
channel: Channel::Blue,
ms: 345.6,
},
Segment::Sync(9.0),
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Red, ms: 345.6 },
Segment::Scan {
channel: Channel::Red,
ms: 345.6,
},
];
/// Scottie's sync arrives after the green and blue scans: 1.5 + 138.24 + 1.5 +
@@ -215,22 +260,37 @@ const fn scottie_sync_offset(scan_ms: f64) -> f64 {
const ROBOT_36: &[Segment] = &[
Segment::Sync(9.0),
Segment::Gap(3.0),
Segment::Scan { channel: Channel::LumaOdd, ms: 88.0 },
Segment::Scan {
channel: Channel::LumaOdd,
ms: 88.0,
},
Segment::Gap(4.5),
Segment::Gap(1.5),
Segment::Scan { channel: Channel::ChromaAlternating, ms: 44.0 },
Segment::Scan {
channel: Channel::ChromaAlternating,
ms: 44.0,
},
];
const ROBOT_72: &[Segment] = &[
Segment::Sync(9.0),
Segment::Gap(3.0),
Segment::Scan { channel: Channel::LumaOdd, ms: 138.0 },
Segment::Scan {
channel: Channel::LumaOdd,
ms: 138.0,
},
Segment::Gap(4.5),
Segment::Gap(1.5),
Segment::Scan { channel: Channel::ChromaR, ms: 69.0 },
Segment::Scan {
channel: Channel::ChromaR,
ms: 69.0,
},
Segment::Gap(4.5),
Segment::Gap(1.5),
Segment::Scan { channel: Channel::ChromaB, ms: 69.0 },
Segment::Scan {
channel: Channel::ChromaB,
ms: 69.0,
},
];
// ---------------------------------------------------------------------------
@@ -243,10 +303,22 @@ macro_rules! pd_segments {
const $name: &[Segment] = &[
Segment::Sync(20.0),
Segment::Gap(2.08),
Segment::Scan { channel: Channel::LumaOdd, ms: $scan },
Segment::Scan { channel: Channel::ChromaR, ms: $scan },
Segment::Scan { channel: Channel::ChromaB, ms: $scan },
Segment::Scan { channel: Channel::LumaEven, ms: $scan },
Segment::Scan {
channel: Channel::LumaOdd,
ms: $scan,
},
Segment::Scan {
channel: Channel::ChromaR,
ms: $scan,
},
Segment::Scan {
channel: Channel::ChromaB,
ms: $scan,
},
Segment::Scan {
channel: Channel::LumaEven,
ms: $scan,
},
];
};
}
@@ -266,87 +338,186 @@ pd_segments!(PD_290, 228.800);
const WRAASE_SC2_180: &[Segment] = &[
Segment::Sync(5.5225),
Segment::Gap(0.5),
Segment::Scan { channel: Channel::Red, ms: 235.0 },
Segment::Scan { channel: Channel::Green, ms: 235.0 },
Segment::Scan { channel: Channel::Blue, ms: 235.0 },
Segment::Scan {
channel: Channel::Red,
ms: 235.0,
},
Segment::Scan {
channel: Channel::Green,
ms: 235.0,
},
Segment::Scan {
channel: Channel::Blue,
ms: 235.0,
},
];
/// Every mode this decoder knows, in VIS order.
pub static MODES: &[SstvMode] = &[
SstvMode {
vis: 8, name: "Robot 36", width: 320, height: 240, line_ms: 150.0,
segments: ROBOT_36, color: ColorModel::YCrCbAlternating,
lines_per_transmission: 1, sync_offset_ms: 0.0,
vis: 8,
name: "Robot 36",
width: 320,
height: 240,
line_ms: 150.0,
segments: ROBOT_36,
color: ColorModel::YCrCbAlternating,
lines_per_transmission: 1,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 12, name: "Robot 72", width: 320, height: 240, line_ms: 300.0,
segments: ROBOT_72, color: ColorModel::YCrCb,
lines_per_transmission: 1, sync_offset_ms: 0.0,
vis: 12,
name: "Robot 72",
width: 320,
height: 240,
line_ms: 300.0,
segments: ROBOT_72,
color: ColorModel::YCrCb,
lines_per_transmission: 1,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 40, name: "Martin M2", width: 320, height: 256, line_ms: 226.798,
segments: MARTIN_M2, color: ColorModel::Rgb,
lines_per_transmission: 1, sync_offset_ms: 0.0,
vis: 40,
name: "Martin M2",
width: 320,
height: 256,
line_ms: 226.798,
segments: MARTIN_M2,
color: ColorModel::Rgb,
lines_per_transmission: 1,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 44, name: "Martin M1", width: 320, height: 256, line_ms: 446.446,
segments: MARTIN_M1, color: ColorModel::Rgb,
lines_per_transmission: 1, sync_offset_ms: 0.0,
vis: 44,
name: "Martin M1",
width: 320,
height: 256,
line_ms: 446.446,
segments: MARTIN_M1,
color: ColorModel::Rgb,
lines_per_transmission: 1,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 55, name: "Wraase SC2-180", width: 320, height: 256, line_ms: 711.0225,
segments: WRAASE_SC2_180, color: ColorModel::Rgb,
lines_per_transmission: 1, sync_offset_ms: 0.0,
vis: 55,
name: "Wraase SC2-180",
width: 320,
height: 256,
line_ms: 711.0225,
segments: WRAASE_SC2_180,
color: ColorModel::Rgb,
lines_per_transmission: 1,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 56, name: "Scottie S2", width: 320, height: 256, line_ms: 277.692,
segments: SCOTTIE_S2, color: ColorModel::Rgb,
lines_per_transmission: 1, sync_offset_ms: scottie_sync_offset(88.064),
vis: 56,
name: "Scottie S2",
width: 320,
height: 256,
line_ms: 277.692,
segments: SCOTTIE_S2,
color: ColorModel::Rgb,
lines_per_transmission: 1,
sync_offset_ms: scottie_sync_offset(88.064),
},
SstvMode {
vis: 60, name: "Scottie S1", width: 320, height: 256, line_ms: 428.22,
segments: SCOTTIE_S1, color: ColorModel::Rgb,
lines_per_transmission: 1, sync_offset_ms: scottie_sync_offset(138.240),
vis: 60,
name: "Scottie S1",
width: 320,
height: 256,
line_ms: 428.22,
segments: SCOTTIE_S1,
color: ColorModel::Rgb,
lines_per_transmission: 1,
sync_offset_ms: scottie_sync_offset(138.240),
},
SstvMode {
vis: 76, name: "Scottie DX", width: 320, height: 256, line_ms: 1050.3,
segments: SCOTTIE_DX, color: ColorModel::Rgb,
lines_per_transmission: 1, sync_offset_ms: scottie_sync_offset(345.6),
vis: 76,
name: "Scottie DX",
width: 320,
height: 256,
line_ms: 1050.3,
segments: SCOTTIE_DX,
color: ColorModel::Rgb,
lines_per_transmission: 1,
sync_offset_ms: scottie_sync_offset(345.6),
},
SstvMode {
vis: 93, name: "PD50", width: 320, height: 256, line_ms: 388.16,
segments: PD_50, color: ColorModel::YCrCbPaired,
lines_per_transmission: 2, sync_offset_ms: 0.0,
vis: 93,
name: "PD50",
width: 320,
height: 256,
line_ms: 388.16,
segments: PD_50,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 94, name: "PD290", width: 800, height: 616, line_ms: 937.28,
segments: PD_290, color: ColorModel::YCrCbPaired,
lines_per_transmission: 2, sync_offset_ms: 0.0,
vis: 94,
name: "PD290",
width: 800,
height: 616,
line_ms: 937.28,
segments: PD_290,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 95, name: "PD120", width: 640, height: 496, line_ms: 508.48,
segments: PD_120, color: ColorModel::YCrCbPaired,
lines_per_transmission: 2, sync_offset_ms: 0.0,
vis: 95,
name: "PD120",
width: 640,
height: 496,
line_ms: 508.48,
segments: PD_120,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 96, name: "PD180", width: 640, height: 496, line_ms: 754.24,
segments: PD_180, color: ColorModel::YCrCbPaired,
lines_per_transmission: 2, sync_offset_ms: 0.0,
vis: 96,
name: "PD180",
width: 640,
height: 496,
line_ms: 754.24,
segments: PD_180,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 97, name: "PD240", width: 640, height: 496, line_ms: 1000.768,
segments: PD_240, color: ColorModel::YCrCbPaired,
lines_per_transmission: 2, sync_offset_ms: 0.0,
vis: 97,
name: "PD240",
width: 640,
height: 496,
line_ms: 1000.768,
segments: PD_240,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 98, name: "PD160", width: 512, height: 400, line_ms: 804.416,
segments: PD_160, color: ColorModel::YCrCbPaired,
lines_per_transmission: 2, sync_offset_ms: 0.0,
vis: 98,
name: "PD160",
width: 512,
height: 400,
line_ms: 804.416,
segments: PD_160,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 99, name: "PD90", width: 320, height: 256, line_ms: 703.04,
segments: PD_90, color: ColorModel::YCrCbPaired,
lines_per_transmission: 2, sync_offset_ms: 0.0,
vis: 99,
name: "PD90",
width: 320,
height: 256,
line_ms: 703.04,
segments: PD_90,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
];
@@ -377,7 +548,9 @@ mod tests {
assert!(
(sum - mode.line_ms).abs() < 0.001,
"{}: segments total {:.4} ms, line time says {:.4} ms",
mode.name, sum, mode.line_ms,
mode.name,
sum,
mode.line_ms,
);
}
}
@@ -404,7 +577,13 @@ mod tests {
ColorModel::YCrCb => 3,
ColorModel::YCrCbPaired => 4,
};
assert_eq!(scans.len(), expected, "{} has {} scans", mode.name, scans.len());
assert_eq!(
scans.len(),
expected,
"{} has {} scans",
mode.name,
scans.len()
);
}
}
@@ -417,7 +596,10 @@ mod tests {
let actual = mode.frame_secs();
assert!(
(actual - secs).abs() < 1.5,
"{} takes {:.1} s, expected about {:.0} s", mode.name, actual, secs,
"{} takes {:.1} s, expected about {:.0} s",
mode.name,
actual,
secs,
);
}
}
+12 -3
View File
@@ -174,7 +174,11 @@ mod tests {
let freqs = freqs_from_tones(&vis_tones(mode.vis), 48_000);
let hit = find_vis(&freqs, 48_000, 0)
.unwrap_or_else(|| panic!("{} header not found", mode.name));
assert_eq!(hit.code, mode.vis, "{} decoded as VIS {}", mode.name, hit.code);
assert_eq!(
hit.code, mode.vis,
"{} decoded as VIS {}",
mode.name, hit.code
);
}
}
@@ -188,7 +192,8 @@ mod tests {
let slack = sample_rate as usize / 100; // 10 ms
assert!(
hit.image_start.abs_diff(expected) < slack,
"image starts at {}, expected about {expected}", hit.image_start,
"image starts at {}, expected about {expected}",
hit.image_start,
);
}
@@ -199,7 +204,11 @@ mod tests {
// Flip the parity bit alone: seven data bits still say Martin M1, but
// nothing now vouches for them.
let parity = tones.len() - 2;
tones[parity].hz = if tones[parity].hz == ONE_HZ { ZERO_HZ } else { ONE_HZ };
tones[parity].hz = if tones[parity].hz == ONE_HZ {
ZERO_HZ
} else {
ONE_HZ
};
let freqs = freqs_from_tones(&tones, sample_rate);
assert_eq!(find_vis(&freqs, sample_rate, 0), None);
}
+87 -18
View File
@@ -134,16 +134,38 @@ fn round_trip(vis: u8, tolerance: f64) {
let width = usize::from(mode.width);
let height = usize::from(mode.height);
let sent = test_card(width, height);
let frame = Frame { width, height, rgb: &sent };
let frame = Frame {
width,
height,
rgb: &sent,
};
let audio = encode(mode, &frame, SAMPLE_RATE);
let (images, rows) = decode(&audio);
assert_eq!(images.len(), 1, "{}: expected one picture, got {}", mode.name, images.len());
assert_eq!(
images.len(),
1,
"{}: expected one picture, got {}",
mode.name,
images.len()
);
let image = &images[0];
assert!(image.complete, "{}: reception did not reach the bottom", mode.name);
assert!(
image.complete,
"{}: reception did not reach the bottom",
mode.name
);
assert_eq!(image.mode, mode.name);
assert_eq!(image.lines, mode.height, "{}: {} of {} lines", mode.name, image.lines, mode.height);
assert_eq!(rows, height, "{}: emitted {rows} rows for {height} lines", mode.name);
assert_eq!(
image.lines, mode.height,
"{}: {} of {} lines",
mode.name, image.lines, mode.height
);
assert_eq!(
rows, height,
"{}: emitted {rows} rows for {height} lines",
mode.name
);
let error = interior_error(width, height, &sent, &image.rgb);
assert!(
@@ -209,13 +231,21 @@ fn survives_silence_around_the_transmission() {
let mode = mode_for_vis(44).expect("Martin M1");
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
let sent = test_card(width, height);
let frame = Frame { width, height, rgb: &sent };
let frame = Frame {
width,
height,
rgb: &sent,
};
let mut audio = vec![0.0f32; SAMPLE_RATE as usize * 2];
audio.extend(encode(mode, &frame, SAMPLE_RATE));
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize));
let (images, _) = decode(&audio);
assert_eq!(images.len(), 1, "expected one picture from a transmission in silence");
assert_eq!(
images.len(),
1,
"expected one picture from a transmission in silence"
);
assert!(images[0].complete);
}
@@ -226,7 +256,11 @@ fn a_truncated_transmission_still_yields_its_lines() {
let mode = mode_for_vis(44).expect("Martin M1");
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
let sent = test_card(width, height);
let frame = Frame { width, height, rgb: &sent };
let frame = Frame {
width,
height,
rgb: &sent,
};
let full = encode(mode, &frame, SAMPLE_RATE);
// Two thirds of the picture, then silence for long enough that the decoder
// stops waiting for the rest.
@@ -234,17 +268,29 @@ fn a_truncated_transmission_still_yields_its_lines() {
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize * 5));
let (images, _) = decode(&audio);
assert_eq!(images.len(), 1, "a cut-off transmission produced no picture");
assert_eq!(
images.len(),
1,
"a cut-off transmission produced no picture"
);
let image = &images[0];
assert!(!image.complete, "a two-thirds transmission reported as complete");
assert!(
!image.complete,
"a two-thirds transmission reported as complete"
);
assert!(
image.lines > mode.height / 2 && image.lines < mode.height,
"{} lines of {} arrived", image.lines, mode.height,
"{} lines of {} arrived",
image.lines,
mode.height,
);
// What did arrive is the top of the picture, and it is right.
let rows = usize::from(image.lines).saturating_sub(4);
let error = mean_error(&sent[..width * rows * 3], &image.rgb[..width * rows * 3]);
assert!(error < 12.0, "the lines that arrived are wrong: mean error {error:.1}");
assert!(
error < 12.0,
"the lines that arrived are wrong: mean error {error:.1}"
);
}
/// Two pictures back to back: the decoder has to finish the first and pick up
@@ -254,7 +300,11 @@ fn decodes_a_second_transmission_after_the_first() {
let mode = mode_for_vis(40).expect("Martin M2");
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
let sent = test_card(width, height);
let frame = Frame { width, height, rgb: &sent };
let frame = Frame {
width,
height,
rgb: &sent,
};
let one = encode(mode, &frame, SAMPLE_RATE);
let mut audio = one.clone();
@@ -262,8 +312,16 @@ fn decodes_a_second_transmission_after_the_first() {
audio.extend(one);
let (images, _) = decode(&audio);
assert_eq!(images.len(), 2, "expected two pictures, got {}", images.len());
assert!(images.iter().all(|image| image.complete), "a picture did not finish");
assert_eq!(
images.len(),
2,
"expected two pictures, got {}",
images.len()
);
assert!(
images.iter().all(|image| image.complete),
"a picture did not finish"
);
}
/// Noise on the signal is the normal condition on HF. The picture should
@@ -273,7 +331,11 @@ fn decodes_through_noise() {
let mode = mode_for_vis(44).expect("Martin M1");
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
let sent = test_card(width, height);
let frame = Frame { width, height, rgb: &sent };
let frame = Frame {
width,
height,
rgb: &sent,
};
let clean = encode(mode, &frame, SAMPLE_RATE);
// Deterministic pseudo-noise at about 20 dB below the signal.
@@ -303,7 +365,11 @@ fn tolerates_a_transmitter_clock_that_runs_fast() {
let mode = mode_for_vis(44).expect("Martin M1");
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
let sent = test_card(width, height);
let frame = Frame { width, height, rgb: &sent };
let frame = Frame {
width,
height,
rgb: &sent,
};
// Encoding at a slightly different rate and decoding at 48 kHz is exactly
// a clock error: every duration is stretched by the same factor.
let audio = encode(mode, &frame, 48_048);
@@ -311,5 +377,8 @@ fn tolerates_a_transmitter_clock_that_runs_fast() {
let (images, _) = decode(&audio);
assert_eq!(images.len(), 1, "a 0.1% clock error cost the picture");
let error = interior_error(width, height, &sent, &images[0].rgb);
assert!(error < 12.0, "mean error with a fast clock {error:.1} levels");
assert!(
error < 12.0,
"mean error with a fast clock {error:.1} levels"
);
}
-4
View File
@@ -9,9 +9,5 @@ edition = "2021"
license = "GPL-2.0-or-later"
[dependencies]
serde = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dirs = "6"
thiserror = "2"
-88
View File
@@ -1,88 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use serde::de::DeserializeOwned;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file {0}: {1}")]
ReadError(PathBuf, String),
#[error("Failed to parse config file {0}: {1}")]
ParseError(PathBuf, String),
}
/// Returns the default search paths for `trx-rs.toml`
/// (current directory → XDG config → /etc).
fn config_search_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("trx-rs.toml")];
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("trx-rs").join("trx-rs.toml"));
}
paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml"));
paths
}
/// Extract and deserialize a named section from a TOML file.
///
/// Returns `Ok(Some(cfg))` when the section is present and parses cleanly,
/// `Ok(None)` when the section is absent, or `Err` on I/O / parse failure.
fn load_section_from_file<T: DeserializeOwned>(
path: &Path,
key: &str,
) -> Result<Option<T>, ConfigError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
let table: toml::Table = toml::from_str(&content)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
let Some(section) = table.get(key) else {
return Ok(None);
};
// Re-serialize the section then parse as T so all serde defaults apply.
let section_toml = toml::to_string(section)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
let cfg = toml::from_str::<T>(&section_toml)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
Ok(Some(cfg))
}
/// Trait for loading configuration from a `trx-rs.toml` section.
pub trait ConfigFile: Sized + Default + DeserializeOwned {
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
fn section_key() -> &'static str;
/// Load the section from a specific file path.
///
/// Returns an error if the file cannot be read, is not valid TOML, or
/// does not contain the expected `[<section_key>]` header.
fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
ConfigError::ParseError(
path.to_path_buf(),
format!("missing [{}] section", Self::section_key()),
)
})
}
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
/// the first file that contains the expected section.
///
/// Returns `(config, path_where_found)` or `(Default::default(), None)`
/// when no config file is found.
fn load_from_default_paths() -> Result<(Self, Option<PathBuf>), ConfigError> {
for path in config_search_paths() {
if path.exists() {
if let Some(cfg) = load_section_from_file::<Self>(&path, Self::section_key())? {
return Ok((cfg, Some(path)));
}
}
}
Ok((Self::default(), None))
}
}
+4 -4
View File
@@ -2,12 +2,12 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
pub mod config;
//! Shared application helpers.
//!
//! Configuration types and their loader live in the `trx-config` crate.
pub mod logging;
pub mod shared_config;
pub mod util;
pub use config::{ConfigError, ConfigFile};
pub use logging::init_logging;
pub use shared_config::{validate_log_level, validate_tokens};
pub use util::normalize_name;
+1
View File
@@ -21,6 +21,7 @@ uuid = { workspace = true }
cpal = "0.15"
opus = "0.3"
trx-app = { path = "../trx-app" }
trx-config = { path = "../trx-config" }
trx-core = { path = "../trx-core" }
trx-protocol = { path = "../trx-protocol" }
trx-frontend = { path = "trx-frontend" }
+8 -6
View File
@@ -27,11 +27,11 @@ use trx_core::audio::{
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO,
AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED,
AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB,
AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS,
AUDIO_MSG_WSPR_DECODE,
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_SSTV_DECODE,
AUDIO_MSG_SSTV_PROGRESS, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
};
use trx_core::decode::DecodedMessage;
use trx_frontend::VChanAudioCmd;
@@ -533,7 +533,9 @@ async fn handle_single_rig_connection(
| AUDIO_MSG_LRPT_IMAGE
| AUDIO_MSG_LRPT_PROGRESS
| AUDIO_MSG_WEFAX_DECODE
| AUDIO_MSG_WEFAX_PROGRESS,
| AUDIO_MSG_WEFAX_PROGRESS
| AUDIO_MSG_SSTV_DECODE
| AUDIO_MSG_SSTV_PROGRESS,
payload,
)) => {
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
File diff suppressed because it is too large Load Diff
+138 -8
View File
@@ -20,6 +20,7 @@ use tokio::task::JoinHandle;
use tracing::{error, info};
use trx_app::{init_logging, normalize_name};
use trx_config::ConfigFile;
use trx_core::audio::AudioStreamInfo;
use trx_core::decode::DecodedMessage;
@@ -50,6 +51,12 @@ struct Cli {
/// Print example configuration and exit
#[arg(long = "print-config")]
print_config: bool,
/// Treat unknown configuration keys as a fatal error
#[arg(long = "strict-config")]
strict_config: bool,
/// Validate the configuration and exit without starting anything
#[arg(long = "check-config")]
check_config: bool,
/// Remote server URL (host:port)
#[arg(short = 'u', long = "url")]
url: Option<String>,
@@ -74,7 +81,7 @@ struct Cli {
/// rigctl frontend listen address
#[arg(long = "rigctl-listen")]
rigctl_listen: Option<IpAddr>,
/// rigctl frontend listen port
/// Deprecated: ignored, use [frontends.rigctl].rig_ports
#[arg(long = "rigctl-port")]
rigctl_port: Option<u16>,
/// JSON TCP frontend listen address
@@ -109,6 +116,65 @@ async fn main() -> DynResult<()> {
Ok(())
}
/// `--check-config`: report everything wrong with the configuration and exit.
///
/// Unlike startup, this reports every problem it finds rather than stopping at
/// the first, so a config can be fixed in one pass. The file is checked as
/// written, without CLI overrides.
fn check_config(loaded: &trx_config::ConfigLoad<ClientConfig>) -> DynResult<()> {
match &loaded.path {
Some(path) => println!("{}", path.display()),
None => println!("(no config file found; checking built-in defaults)"),
}
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
warnings.extend(ClientConfig::deprecations(&loaded.present_keys));
let mut cfg = loaded.config.clone();
let mut errors = Vec::new();
if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) {
errors.push(e);
}
let cfg = &cfg;
let remotes = cfg.resolved_remotes();
if remotes.is_empty() {
warnings.push(
"no remotes configured; --url will be required at startup (add [[remotes]] entries)"
.to_string(),
);
}
errors.extend(cfg.validate_all());
if !remotes.is_empty() {
errors.extend(cfg.validate_resolved_all(&remotes));
}
for w in &warnings {
println!(" warning: {}", w);
}
for e in &errors {
println!(" error: {}", e);
}
if errors.is_empty() {
println!(
" OK: {} remote(s) configured: {}",
remotes.len(),
remotes
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if !warnings.is_empty() {
println!(" {} warning(s)", warnings.len());
}
Ok(())
} else {
Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
}
}
/// Holds the state needed after async initialization completes.
struct AppState {
shutdown_tx: watch::Sender<bool>,
@@ -135,20 +201,44 @@ async fn async_init() -> DynResult<AppState> {
std::process::exit(0);
}
let (cfg, config_path) = if let Some(ref path) = cli.config {
let cfg = ClientConfig::load_from_file(path)?;
(cfg, Some(path.clone()))
let loaded = if let Some(ref path) = cli.config {
ClientConfig::load_from_file(path)?
} else {
ClientConfig::load_from_default_paths()?
};
cfg.validate()
.map_err(|e| format!("Invalid client configuration: {}", e))?;
let config_path = loaded.path.clone();
init_logging(cfg.general.log_level.as_deref());
if cli.check_config {
match check_config(&loaded) {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
}
}
// Logging comes up before any config complaint so the warnings are visible.
init_logging(loaded.config.general.log_level.as_deref());
if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display());
}
loaded.report_unknown_keys(cli.strict_config)?;
loaded.report_deprecations();
if cli.rigctl_port.is_some() {
tracing::warn!(
"--rigctl-port is ignored; give each rig its own listener via \
[frontends.rigctl].rig_ports"
);
}
let mut cfg = loaded.config;
// Secrets configured as *_file are read before validation, so everything
// downstream sees resolved values.
cfg.resolve_secrets(config_path.as_deref())?;
cfg.validate()
.map_err(|e| format!("Invalid client configuration: {}", e))?;
frontend_runtime.http_auth.tokens = cfg
.frontends
@@ -198,10 +288,14 @@ async fn async_init() -> DynResult<AppState> {
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
vec![RemoteEntry {
spectrum_interval_ms: cfg.remote.spectrum_interval_ms,
name,
url: url.clone(),
rig_id,
auth: config::RemoteAuthConfig { token },
auth: config::RemoteAuthConfig {
token,
token_file: None,
},
poll_interval_ms,
}]
} else {
@@ -262,6 +356,30 @@ async fn async_init() -> DynResult<AppState> {
.http_json_listen
.unwrap_or(cfg.frontends.http_json.listen);
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
// Fold the CLI overrides back into the config so validation and the
// frontends agree on what is about to be bound.
cfg.frontends.http.enabled = frontends.iter().any(|f| f == "http");
cfg.frontends.rigctl.enabled = frontends.iter().any(|f| f == "rigctl");
cfg.frontends.http_json.enabled = frontends.iter().any(|f| f == "httpjson");
cfg.frontends.http.listen = http_listen;
cfg.frontends.http.port = http_port;
cfg.frontends.rigctl.listen = rigctl_listen;
cfg.frontends.http_json.listen = http_json_listen;
cfg.frontends.http_json.port = http_json_port;
// Second validation phase: the per-rig maps are keyed by remote short name,
// so they can only be checked once the remote list is final.
if cli.url.is_none() {
cfg.validate_resolved(&resolved_remotes)
.map_err(|e| format!("Invalid client configuration: {}", e))?;
} else {
// --url replaces the configured remotes outright, so only the socket
// checks still apply.
trx_config::shared::check_socket_conflicts(&cfg.bound_sockets())
.map_err(|e| format!("Invalid client configuration: {}", e))?;
}
let callsign = cli
.callsign
.clone()
@@ -366,6 +484,13 @@ async fn async_init() -> DynResult<AppState> {
.map(|e| e.poll_interval_ms)
.min()
.unwrap_or(750);
// Entries sharing a server share its connections, so the most frequent
// request wins: whoever wants spectrum fastest sets the rate.
let spectrum_interval = entries
.iter()
.map(|e| e.spectrum_interval_ms)
.min()
.unwrap_or_else(|| remote_client::DEFAULT_SPECTRUM_INTERVAL.as_millis() as u64);
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
for entry in entries {
@@ -379,6 +504,8 @@ async fn async_init() -> DynResult<AppState> {
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
rig_states: frontend_runtime.routing.rig_states.clone(),
poll_interval: Duration::from_millis(poll_interval),
spectrum_interval: Duration::from_millis(spectrum_interval),
spectrum_stream_unsupported: Arc::new(std::sync::atomic::AtomicBool::new(false)),
spectrum: frontend_runtime.spectrum.sender.clone(),
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
server_connected: frontend_runtime.routing.server_connected.clone(),
@@ -527,6 +654,9 @@ async fn async_init() -> DynResult<AppState> {
DecodedMessage::LrptProgress(_) => {}
DecodedMessage::Wefax(_) => {}
DecodedMessage::WefaxProgress(_) => {}
// Pictures replay from their own history, not this one.
DecodedMessage::Sstv(_) => {}
DecodedMessage::SstvProgress(_) => {}
}
});
+275 -103
View File
@@ -20,35 +20,22 @@ use trx_core::{RigError, RigResult};
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
use trx_protocol::rig_command_to_client;
use trx_protocol::types::RigEntry;
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate};
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, SpectrumFrame};
// Endpoint parsing lives in `trx-config` so config validation and the
// connection code agree on what a URL means.
pub use trx_config::url::{parse_audio_url, parse_remote_url, RemoteEndpoint};
const DEFAULT_REMOTE_PORT: u16 = 4530;
const DEFAULT_AUDIO_PORT: u16 = 4531;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const IO_TIMEOUT: Duration = Duration::from_secs(15);
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteEndpoint {
pub host: String,
pub port: u16,
}
impl RemoteEndpoint {
pub fn connect_addr(&self) -> String {
if self.host.contains(':') && !self.host.starts_with('[') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
}
// Keep remote spectrum reasonably responsive without returning to the old
// timeout churn caused by a much tighter request cadence.
const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(50);
// Default spectrum cadence when a config does not specify one. Both the push
// stream and the poll fallback run at the configured rate; see
// `[[remotes]].spectrum_interval_ms`.
pub const DEFAULT_SPECTRUM_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Clone)]
pub struct RemoteClientConfig {
@@ -57,6 +44,12 @@ pub struct RemoteClientConfig {
pub selected_rig_id: Arc<Mutex<Option<String>>>,
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
pub poll_interval: Duration,
/// How often spectrum frames are wanted. Drives the poll fallback and is
/// the rate the client asks the server to push at.
pub spectrum_interval: Duration,
/// Set once a server has rejected `SubscribeSpectrum`, so later
/// connections to it go straight to polling instead of asking again.
pub spectrum_stream_unsupported: Arc<AtomicBool>,
/// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe.
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
/// Shared flag: `true` while a TCP connection to trx-server is active.
@@ -505,6 +498,100 @@ async fn send_get_sat_passes_on(
))
}
/// What ended a spectrum stream attempt.
enum SpectrumStreamOutcome {
/// The stream ran and is over; the connection is spent.
Finished,
/// The server rejected the subscription. The connection is still usable,
/// so the caller can poll on it.
Unsupported,
}
/// Subscribe to the server's spectrum push for one rig and publish frames as
/// they arrive.
///
/// The server answers either with frames or, when it is too old to know the
/// command, with an error response — which leaves the connection usable for
/// polling, so falling back costs no reconnect.
async fn run_spectrum_stream(
config: &RemoteClientConfig,
writer: &mut (impl AsyncWriteExt + Unpin),
reader: &mut (impl AsyncBufRead + Unpin),
short_name: &str,
shutdown_rx: &mut watch::Receiver<bool>,
) -> RigResult<SpectrumStreamOutcome> {
let envelope = build_envelope(
config,
ClientCommand::SubscribeSpectrum,
Some(short_name.to_string()),
);
let mut payload = serde_json::to_string(&envelope)
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
payload.push('\n');
time::timeout(SPECTRUM_IO_TIMEOUT, writer.write_all(payload.as_bytes()))
.await
.map_err(|_| RigError::communication("spectrum subscribe write timed out".to_string()))?
.map_err(|e| RigError::communication(format!("spectrum subscribe write failed: {e}")))?;
time::timeout(SPECTRUM_IO_TIMEOUT, writer.flush())
.await
.map_err(|_| RigError::communication("spectrum subscribe flush timed out".to_string()))?
.map_err(|e| RigError::communication(format!("spectrum subscribe flush failed: {e}")))?;
// Re-check what the UI wants: switching rigs has to end this stream so the
// connection can be rebuilt for the new one.
let mut supervisor = time::interval(Duration::from_millis(500));
supervisor.tick().await;
loop {
tokio::select! {
changed = shutdown_rx.changed() => {
match changed {
Ok(()) if *shutdown_rx.borrow() => return Ok(SpectrumStreamOutcome::Finished),
Ok(()) => {}
Err(_) => return Ok(SpectrumStreamOutcome::Finished),
}
}
_ = supervisor.tick() => {
let wanted = active_spectrum_rig_ids(config);
if wanted.len() != 1 || wanted[0] != short_name {
return Ok(SpectrumStreamOutcome::Finished);
}
}
line = read_limited_line(reader, MAX_JSON_LINE_BYTES) => {
let line = line
.map_err(|e| RigError::communication(format!("spectrum read failed: {e}")))?
.ok_or_else(|| {
RigError::communication("spectrum connection closed".to_string())
})?;
let trimmed = line.trim_end();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<SpectrumFrame>(trimmed) {
Ok(frame) => publish_spectrum_frame(config, short_name, frame),
// Anything that is not a frame means the server would rather
// answer than stream: an older build rejecting the command.
Err(_) => return Ok(SpectrumStreamOutcome::Unsupported),
}
}
}
}
}
/// Publish one pushed frame to the per-rig and selected-rig watch channels.
fn publish_spectrum_frame(config: &RemoteClientConfig, short_name: &str, frame: SpectrumFrame) {
if let Ok(map) = config.rig_spectrums.read() {
if let Some(tx) = map.get(short_name) {
tx.send_modify(|s| s.set(Some(frame.spectrum.clone()), frame.vchan_rds.clone()));
}
}
if selected_rig_id(config).as_deref() == Some(short_name) {
config
.spectrum
.send_modify(|s| s.set(Some(frame.spectrum), frame.vchan_rds));
}
}
async fn handle_spectrum_connection(
config: &RemoteClientConfig,
stream: TcpStream,
@@ -512,7 +599,33 @@ async fn handle_spectrum_connection(
) -> RigResult<()> {
let (reader, mut writer) = stream.into_split();
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.
let cached_token = config.token.clone();
@@ -1195,92 +1308,13 @@ async fn read_limited_line<R: AsyncBufRead + Unpin>(
}
}
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
}
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
}
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(format!("{kind} url is empty"));
}
let addr = trimmed
.strip_prefix("tcp://")
.or_else(|| trimmed.strip_prefix("http-json://"))
.or_else(|| trimmed.strip_prefix("audio://"))
.unwrap_or(trimmed);
parse_host_port(addr, default_port, kind)
}
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
if let Some(rest) = input.strip_prefix('[') {
let closing = rest
.find(']')
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
let host = &rest[..closing];
let remainder = &rest[closing + 1..];
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
let port = if remainder.is_empty() {
default_port
} else if let Some(port_str) = remainder.strip_prefix(':') {
parse_port(port_str, kind)?
} else {
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
};
return Ok(RemoteEndpoint {
host: host.to_string(),
port,
});
}
if input.contains(':') {
if input.matches(':').count() > 1 {
return Err(format!(
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
));
}
let (host, port_str) = input
.rsplit_once(':')
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
return Ok(RemoteEndpoint {
host: host.to_string(),
port: parse_port(port_str, kind)?,
});
}
Ok(RemoteEndpoint {
host: input.to_string(),
port: default_port,
})
}
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
let port: u16 = port_str
.parse()
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
if port == 0 {
return Err(format!("invalid {kind} port: 0"));
}
Ok(port)
}
#[cfg(test)]
mod tests {
#[allow(unused_imports)]
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
use super::{
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
DEFAULT_SPECTRUM_INTERVAL,
};
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
@@ -1475,6 +1509,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(100),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1520,6 +1556,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1535,6 +1573,130 @@ mod tests {
assert_eq!(envelope.rig_id.as_deref(), Some("sdr"));
}
fn stream_test_config(spectrum_tx: watch::Sender<SharedSpectrum>) -> super::RemoteClientConfig {
super::RemoteClientConfig {
addr: "127.0.0.1:4530".to_string(),
token: None,
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
rig_states: Arc::new(RwLock::new(HashMap::new())),
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
rig_id_to_short_name: HashMap::new(),
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
sat_passes: Arc::new(RwLock::new(None)),
rig_meters: Arc::new(RwLock::new(HashMap::new())),
}
}
/// A pushed frame reaches the watch channel the UI reads, with no request
/// from the client beyond the initial subscribe.
#[tokio::test]
async fn spectrum_stream_publishes_pushed_frames() {
use tokio::io::{AsyncBufReadExt, BufReader};
let (spectrum_tx, mut spectrum_rx) = watch::channel(SharedSpectrum::default());
let config = stream_test_config(spectrum_tx);
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
let (client_read, mut client_write) = tokio::io::split(client_io);
let mut client_read = BufReader::new(client_read);
let task = tokio::spawn(async move {
super::run_spectrum_stream(
&config,
&mut client_write,
&mut client_read,
"sdr",
&mut shutdown_rx,
)
.await
});
// The server sees the subscribe, then pushes without being asked.
let mut server = BufReader::new(&mut server_io);
let mut subscribe = String::new();
server.read_line(&mut subscribe).await.expect("subscribe");
assert!(
subscribe.contains("subscribe_spectrum"),
"unexpected command: {subscribe}"
);
let frame = trx_protocol::SpectrumFrame {
rig_id: "sdr".to_string(),
spectrum: trx_core::rig::state::SpectrumData {
bins: vec![-70.0, -30.0],
center_hz: 14_200_000,
sample_rate: 1_920_000,
rds: None,
},
vchan_rds: None,
};
let mut line = serde_json::to_string(&frame).unwrap();
line.push('\n');
server_io.write_all(line.as_bytes()).await.expect("push");
server_io.flush().await.expect("flush");
spectrum_rx.changed().await.expect("spectrum published");
let published = spectrum_rx.borrow().clone();
let spectrum = published.frame.expect("spectrum present");
assert_eq!(spectrum.center_hz, 14_200_000);
assert_eq!(spectrum.bins, vec![-70.0, -30.0]);
let _ = shutdown_tx.send(true);
let _ = task.await;
}
/// An older server answers the unknown command with an error instead of
/// frames. That has to read as "poll instead", not as a dead connection.
#[tokio::test]
async fn spectrum_stream_falls_back_when_unsupported() {
use tokio::io::{AsyncBufReadExt, BufReader};
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
let config = stream_test_config(spectrum_tx);
let (_shutdown_tx, mut shutdown_rx) = watch::channel(false);
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
let (client_read, mut client_write) = tokio::io::split(client_io);
let mut client_read = BufReader::new(client_read);
let task = tokio::spawn(async move {
super::run_spectrum_stream(
&config,
&mut client_write,
&mut client_read,
"sdr",
&mut shutdown_rx,
)
.await
});
let mut server = BufReader::new(&mut server_io);
let mut subscribe = String::new();
server.read_line(&mut subscribe).await.expect("subscribe");
server_io
.write_all(
b"{\"success\":false,\"state\":null,\"error\":\"Invalid JSON: unknown variant\"}\n",
)
.await
.expect("error response");
server_io.flush().await.expect("flush");
let outcome = task.await.expect("join").expect("stream result");
assert!(
matches!(outcome, super::SpectrumStreamOutcome::Unsupported),
"an error response should fall back to polling"
);
}
#[test]
fn build_envelope_translates_short_name_to_server_rig_id() {
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
@@ -1548,6 +1710,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1580,6 +1744,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1604,6 +1770,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1644,6 +1812,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: known_rigs.clone(),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1717,6 +1887,8 @@ mod tests {
selected_rig_id,
known_rigs,
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
+4 -2
View File
@@ -17,8 +17,8 @@ use uuid::Uuid;
use trx_core::audio::AudioStreamInfo;
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
WsprMessage,
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
WefaxMessage, WsprMessage,
};
use trx_core::rig::state::{RigSnapshot, SpectrumData};
use trx_core::{DynResult, RigRequest, RigState};
@@ -233,6 +233,7 @@ pub struct DecodeHistoryContext {
pub ft2: DecodeHistory<Ft8Message>,
pub wspr: DecodeHistory<WsprMessage>,
pub wefax: DecodeHistory<WefaxMessage>,
pub sstv: DecodeHistory<SstvMessage>,
}
impl Default for DecodeHistoryContext {
@@ -248,6 +249,7 @@ impl Default for DecodeHistoryContext {
ft2: Arc::new(Mutex::new(VecDeque::new())),
wspr: Arc::new(Mutex::new(VecDeque::new())),
wefax: Arc::new(Mutex::new(VecDeque::new())),
sstv: Arc::new(Mutex::new(VecDeque::new())),
}
}
}
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore,
hostState
@@ -140,18 +144,22 @@ function aisLatestByVessel(messages) {
}
return Array.from(byMmsi.values());
}
function aisRigMessages() {
return forActiveRig(aisMessageHistory);
}
function updateAisSummary() {
const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
}
const vessels = aisLatestByVessel(aisMessageHistory);
const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
if (aisVesselCountEl) {
const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
}
if (aisLatestSeenEl) {
const latest = aisMessageHistory[0];
const latest = rigMessages[0];
if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet";
} else {
@@ -222,7 +230,9 @@ function updateAisBar() {
updateAisSummary();
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
const recent = aisMessageHistory.filter(
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)
);
const messages = aisLatestByVessel(recent).slice(0, 8);
if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none";
@@ -267,7 +277,7 @@ function renderAisHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of aisMessageHistory) {
for (const message of aisRigMessages()) {
fragment.appendChild(renderAisRow(message));
}
aisMessagesEl.replaceChildren(fragment);
@@ -299,10 +309,10 @@ function normalizeServerAisMessage(msg) {
}
function onServerAisBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -342,8 +352,9 @@ if (aisFilterInput) {
});
}
function onServerAis(msg) {
if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg));
const message = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
}
updateAisSummary();
window.trxPluginRuntime.registerDecoder({
@@ -353,6 +364,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
rerender: () => {
updateAisBar();
renderAisHistory();
},
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
@@ -885,7 +885,7 @@ function elementById(id) {
if (restoreFocus) more.focus();
};
api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return;
const item = document.createElement("button");
@@ -932,7 +932,7 @@ function elementById(id) {
["Overview", ["overview"]],
["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]],
["Broadcast & images", ["rds", "sat", "wefax"]]
["Broadcast & images", ["rds", "sat", "wefax", "sstv"]]
];
groups.forEach(([label, ids]) => {
const group = document.createElement("optgroup");
@@ -1065,6 +1065,9 @@ var runtime = {
plugin.prune();
return true;
},
rerenderAll() {
for (const plugin of decoders.values()) plugin.rerender?.();
},
syncMapAll() {
for (const plugin of decoders.values()) plugin.syncMap?.();
},
@@ -1529,6 +1532,7 @@ var TAB_ORDER = [
"bookmarks",
"digital-modes",
"map",
"satellites",
"statistics",
"recorder",
"settings",
@@ -1539,6 +1543,7 @@ var TAB_PATHS = {
bookmarks: "/bookmarks",
"digital-modes": "/digital-modes",
map: "/map",
satellites: "/satellites",
statistics: "/statistics",
recorder: "/recorder",
settings: "/settings",
@@ -1748,6 +1753,7 @@ var pluginGroups = {
"/background-decode.js",
"/sat.js",
"/wefax.js",
"/sstv.js",
"/ais.js",
"/vdes.js",
"/aprs.js",
@@ -1755,6 +1761,7 @@ var pluginGroups = {
],
"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"],
satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
@@ -2240,7 +2247,7 @@ function currentDecodeHistoryRetentionMs() {
}
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
window.applyDecodeHistoryRetention = function() {
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) {
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"]) {
window.trxPluginRuntime.prune(decoder);
}
};
@@ -2898,9 +2905,9 @@ function applyRigList(activeRigId, rigIds, displayNames) {
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
if (rigListChanged) {
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.populateScopePicker();
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
}
@@ -3551,6 +3558,13 @@ function positionRdsPsOverlay() {
function resetRdsDisplay() {
updateRdsPsOverlay(primaryRds);
}
function refreshDecodeBars() {
window.updateAisBar?.();
window.updateVdesBar?.();
window.updateAprsBar?.();
window.updateFt8Bar?.();
window.updateCwBar?.();
}
function resetDecoderStateOnRigSwitch() {
primaryRds = null;
vchanRdsById = /* @__PURE__ */ new Map();
@@ -3565,6 +3579,8 @@ function resetDecoderStateOnRigSwitch() {
const el = document.getElementById(id);
if (el) el.textContent = "--";
});
refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
}
function resetWfmStereoIndicator() {
if (!wfmStFlagEl) return;
@@ -4784,10 +4800,7 @@ function render(update) {
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
}
if (window.updateAisBar) window.updateAisBar();
if (window.updateVdesBar) window.updateVdesBar();
if (window.updateAprsBar) window.updateAprsBar();
if (window.updateFt8Bar) window.updateFt8Bar();
refreshDecodeBars();
for (const d of decoderRegistry) {
if (d.activation !== "toggle") continue;
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
@@ -4820,6 +4833,9 @@ function render(update) {
for (const [key, entry] of Object.entries(_decoderToggles)) {
syncDecoderToggle(entry, !!update[key], entry.label);
}
if (typeof update.sstv_decode_enabled === "boolean" && window.syncSstvToggle) {
window.syncSstvToggle(update.sstv_decode_enabled);
}
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
window.syncWefaxToggle(update.wefax_decode_enabled);
}
@@ -5840,6 +5856,7 @@ function _initMapWhenReady() {
}
function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
@@ -5869,7 +5886,11 @@ function navigateToTab(name, options = {}) {
updateTabHistory(name, replaceHistory);
}
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);
});
if (name === "map") {
@@ -5980,7 +6001,9 @@ async function initializeApp() {
showAuthGate(allowGuest);
}
}
var settingsUiReady = false;
function initSettingsUI() {
settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
@@ -6251,7 +6274,9 @@ Object.defineProperties(trxState, {
} }
});
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);
});
void initializeApp();
@@ -6316,9 +6341,6 @@ function _wireSubTabBar(bar) {
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
});
}
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
window.clearSatPredictionDom();
}
});
}
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
@@ -7594,11 +7616,43 @@ function updateDecodeStatus(text) {
if (el && el.textContent !== "Receiving") el.textContent = text;
}
}
var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
"lrpt_image",
"lrpt_progress",
"wefax",
"wefax_progress",
"sstv",
"sstv_progress"
]);
var pendingDecodeStats = [];
var PENDING_DECODE_STATS_MAX = 5e4;
function recordDecodeStat(kind, rig, tsMs) {
const stats = window.trx.modules.map;
if (stats) {
stats.statsRecordDecode(kind, rig, tsMs);
return;
}
pendingDecodeStats.push({ kind, rig, tsMs });
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
}
}
function flushPendingDecodeStats() {
const stats = window.trx.modules.map;
if (!stats || pendingDecodeStats.length === 0) return;
for (const entry of pendingDecodeStats.splice(0)) {
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
}
stats.scheduleStatsRender();
}
function scheduleStatsRenderIfLoaded() {
window.trx.modules.map?.scheduleStatsRender();
}
function dispatchDecodeMessage(msg, skipStats = false) {
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender();
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
scheduleStatsRenderIfLoaded();
}
}
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
@@ -7642,11 +7696,11 @@ function loadDecodeHistoryOnMainThread(onReady, onError) {
}
function restoreDecodeHistoryGroup(kind, messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
if (!IMAGE_DECODE_KINDS.has(kind)) {
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);
}
@@ -5,6 +5,10 @@ import {
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore,
hostState
@@ -82,20 +86,25 @@ function aprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(aprsFilterText);
}
function aprsRigPackets() {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets() {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(aprsFilterMatch);
}
function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets();
if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
}
if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0];
const latest = rigPackets[0];
if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -153,7 +162,9 @@ function updateAprsBar() {
if (!aprsBarOverlay) return;
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
const okFrames = aprsPacketHistory.filter(
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id)
);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
@@ -215,11 +226,11 @@ function normalizeServerAprsPacket(pkt) {
}
function onServerAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -277,8 +288,9 @@ if (aprsFilterInput) {
});
}
function onServerAprs(pkt) {
if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
const packet = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
}
renderAprsHistory();
window.trxPluginRuntime.registerDecoder({
@@ -288,6 +300,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
rerender: () => {
updateAprsBar();
renderAprsHistory();
},
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
@@ -19,9 +19,11 @@ var bgdWindow = window;
let bookmarkList = [];
let statusInterval = null;
let bgdDirty = false;
let statusByBookmark = /* @__PURE__ */ new Map();
let lastStatus = null;
function initBackgroundDecode(rigId, role) {
backgroundDecodeRole = role;
currentRigId = rigId || null;
currentRigId = rigId || hostState.lastActiveRigId || null;
if (currentRigId) loadBackgroundDecode();
startStatusPolling();
}
@@ -108,7 +110,7 @@ var bgdWindow = window;
}
setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist();
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
const isControl = isControlRole();
const panel = document.getElementById("background-decode-panel");
if (panel) {
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");
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
syncSaveButton();
}
function currentFilterText() {
return document.getElementById("bgd-bookmark-filter")?.value ?? "";
}
function renderBookmarkChecklist(filterText = "") {
const container = document.getElementById("bgd-bookmark-checklist");
@@ -134,20 +140,49 @@ var bgdWindow = window;
return text.indexOf(filter) >= 0;
}) : all;
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;
}
filtered.forEach(function(bookmark) {
const row = document.createElement("label");
row.className = "bgd-checklist-row";
row.dataset.bmId = bookmark.id;
const decoders = bookmarkDecoderKinds(bookmark);
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
row.innerHTML = '<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span>";
const selected = selectedIds.has(bookmark.id);
if (selected) row.classList.add("is-selected");
row.innerHTML = '<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz)) + '<span class="bgd-checklist-mode">' + escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span></span>" + stateBadgeHtml(bookmark.id, selected);
row.querySelector("input")?.addEventListener("change", function(e) {
onChecklistToggle(bookmark.id, e.currentTarget.checked);
});
container.appendChild(row);
});
renderSelectionSummary();
}
function emptyListText(supportedCount) {
if (supportedCount > 0) return "No bookmark matches that filter.";
if (bookmarkList.length > 0) {
return "None of your bookmarks name a decoder that can run in the background. Give one a decoder on the Bookmarks tab to list it here.";
}
return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
}
function stateBadgeHtml(bookmarkId, selected) {
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
const entry = statusByBookmark.get(bookmarkId);
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '"><span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
}
function renderSelectionSummary() {
const el = document.getElementById("bgd-selection-summary");
if (!el) return;
const selected = currentConfig?.bookmark_ids.length ?? 0;
if (selected === 0) {
el.textContent = "Nothing selected — background decoding is idle.";
return;
}
const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
el.textContent = currentConfig?.enabled ? `${noun}, ${String(active)} decoding now.` : `${noun}. Switch Enabled on to start decoding them.`;
}
function onChecklistToggle(bookmarkId, checked) {
if (!currentConfig) {
@@ -182,7 +217,7 @@ var bgdWindow = window;
}).catch(function(err) {
showToast(`Save failed: ${errorMessage(err)}`, true);
}).finally(function() {
if (btn) btn.disabled = false;
syncSaveButton();
});
}
async function resetBackgroundDecode() {
@@ -210,59 +245,83 @@ var bgdWindow = window;
});
}
function renderStatus(status) {
const card = document.getElementById("background-decode-status-card");
if (!card) return;
const entries = status.entries ?? [];
if (!entries.length) {
card.textContent = "No background decode bookmarks configured.";
lastStatus = status;
statusByBookmark = new Map(
(status.entries ?? []).filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0).map((entry) => [entry.bookmark_id, entry])
);
renderSpanSummary();
renderBookmarkChecklist(currentFilterText());
}
function renderSpanSummary() {
const el = document.getElementById("bgd-span-summary");
if (!el) return;
const status = lastStatus;
if (!status) {
el.textContent = "";
return;
}
const summary = [];
if (status.active_rig) {
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
} else {
summary.push("This rig is not currently selected for audio.");
if (!status.active_rig) {
el.textContent = "This rig is not the one playing audio.";
el.dataset.tone = "warn";
return;
}
const centre = typeof status.center_hz === "number" && Number.isFinite(status.center_hz) ? formatFreq(status.center_hz) : null;
const half = typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0 ? formatFreq(status.sample_rate / 2) : null;
el.dataset.tone = "";
el.textContent = centre && half ? `Span ${centre} ±${half}` : centre ? `Centre ${centre}` : "";
}
function stateHelp(state) {
switch (state) {
case "active":
return "Decoding on a hidden channel.";
case "out_of_span":
return "Outside the span the rig is tuned across, so it cannot be heard from here.";
case "waiting_for_spectrum":
return "Waiting for the first spectrum frame from the rig.";
case "waiting_for_user":
return "Nobody is listening to this rig, so no audio is being pulled.";
case "missing_bookmark":
return "The bookmark this was selected from is gone.";
case "no_supported_decoders":
return "No decoder that runs in the background can decode this bookmark.";
case "disabled":
return "Background decoding is switched off.";
case "handled_by_scheduler":
case "scheduler_has_control":
return "The scheduler is running this bookmark instead.";
case "handled_by_virtual_channel":
return "A virtual channel is already on this frequency.";
case "pending":
return "Selected, and not started yet — save to apply.";
default:
return "Selected, but not decoding.";
}
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
html += '<div class="bgd-status-list">';
entries.forEach(function(entry) {
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
const parts = [];
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
if (entry.mode) parts.push(entry.mode);
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
parts.push(entry.decoder_kinds.join("/").toUpperCase());
}
html += '<div class="bgd-status-row"><div><div class="bgd-status-name">' + escHtml(name) + '</div><div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div></div><div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '"><svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' + escHtml(prettyState(entry.state)) + "</div></div>";
});
html += "</div>";
card.innerHTML = html;
}
function prettyState(state) {
switch (state) {
case "active":
return "✓ Active";
return "Decoding";
case "out_of_span":
return "Out of span";
return "Out of span";
case "waiting_for_spectrum":
return "Waiting";
return "Waiting for spectrum";
case "waiting_for_user":
return "△ No user";
return "Nobody listening";
case "missing_bookmark":
return "✗ Missing";
return "Bookmark gone";
case "no_supported_decoders":
return "✗ Unsupported";
return "No decoder";
case "disabled":
return "△ Disabled";
return "Off";
case "handled_by_scheduler":
return "△ Scheduler";
case "scheduler_has_control":
return "Scheduler";
return "Scheduler has it";
case "handled_by_virtual_channel":
return "△ VChan";
return "On a channel";
case "pending":
return "Not saved";
default:
return "△ Inactive";
return "Idle";
}
}
function setCheckbox(id, value) {
@@ -285,13 +344,21 @@ var bgdWindow = window;
function markBgdDirty() {
if (bgdDirty) return;
bgdDirty = true;
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.add("sch-dirty");
syncSaveButton();
}
function clearBgdDirty() {
bgdDirty = false;
syncSaveButton();
}
function syncSaveButton() {
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) {
const el = document.getElementById("background-decode-toast");
@@ -311,7 +378,7 @@ var bgdWindow = window;
return bm.id;
});
currentConfig.bookmark_ids = ids;
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
renderBookmarkChecklist(currentFilterText());
markBgdDirty();
}
function deselectAllBookmarks() {
@@ -319,7 +386,7 @@ var bgdWindow = window;
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
currentConfig.bookmark_ids = [];
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
renderBookmarkChecklist(currentFilterText());
markBgdDirty();
}
function wireBackgroundDecodeEvents() {
@@ -334,7 +401,9 @@ var bgdWindow = window;
if (enabledCb && !enabledCb._wired) {
enabledCb._wired = true;
enabledCb.addEventListener("change", function() {
if (currentConfig) currentConfig.enabled = enabledCb.checked;
markBgdDirty();
renderBookmarkChecklist(currentFilterText());
});
}
const selectAllBtn = document.getElementById("bgd-select-all-btn");
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -153,9 +157,10 @@ function initializeFtxDecoder(config) {
const render = () => {
prune();
if (!messagesElement) return;
const rigMessages = forActiveRig(history);
const fragment = document.createDocumentFragment();
let count = 0;
for (const message of history) {
for (const message of rigMessages) {
if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message));
@@ -163,20 +168,26 @@ function initializeFtxDecoder(config) {
}
messagesElement.replaceChildren(fragment);
};
const normalize = (message) => {
const plotLocator = (message) => {
const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
if (grids.length === 0) return;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) {
bridge.mapAddLocator?.(raw, grids, id, station, {
...message,
freq_hz: frequency ?? message.freq_hz,
locator_details: locatorDetails
});
}
bridge.mapAddLocator?.(raw, grids, id, station, {
...message,
freq_hz: frequency ?? message.freq_hz,
locator_details: locatorDetails
});
};
const normalize = (message) => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
return {
// The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band.
rig_id: message.rig_id ?? null,
receiver: bridge.getDecodeRigMeta?.() ?? null,
ts_ms: message.ts_ms,
snr_db: message.snr_db,
@@ -188,7 +199,9 @@ function initializeFtxDecoder(config) {
};
const receiveBatch = (messages) => {
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);
prune();
bridge.setFt8FamilyBarDecoder?.(id);
@@ -202,7 +215,7 @@ function initializeFtxDecoder(config) {
bridge.clearMapMarkersByType?.(id);
};
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 = "";
for (const message of recent) {
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
@@ -226,7 +239,15 @@ function initializeFtxDecoder(config) {
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);
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 {
hostCore
} from "./chunk-KL66PICH.js";
@@ -25,7 +28,7 @@ var CW_BAR_LINE_GAP_MS = 5e3;
var cwLastAppendTime = 0;
var cwTonePickerRaf = null;
var cwBarHistory = [];
var cwBarCurrentLine = null;
var cwBarCurrentLines = /* @__PURE__ */ new Map();
var cwBarDismissedAtMs = 0;
var cwAutoLocalOverride = null;
function escapeCwHtml(input) {
@@ -50,20 +53,22 @@ cwWindow.applyCwAutoUiFromServer = function(enabled) {
if (cwAutoLocalOverride !== null) return;
applyCwAutoUi(enabled);
};
function cwBarFlushCurrentLine() {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine);
function cwBarFlushCurrentLine(key) {
const line = cwBarCurrentLines.get(key);
cwBarCurrentLines.delete(key);
if (line?.text.trim()) {
cwBarHistory.unshift(line);
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
}
cwBarCurrentLine = null;
}
function updateCwBar() {
if (!cwBarOverlay) return;
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
const isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
const inProgress = [...cwBarCurrentLines.values()].filter((l) => l.text && isActiveRigDecode(l.rigId)).sort((a, b) => b.tsMs - a.tsMs);
const liveLines = [...inProgress, ...recent];
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none";
@@ -308,7 +313,7 @@ function resetCwHistoryView() {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBarHistory = [];
cwBarCurrentLine = null;
cwBarCurrentLines.clear();
updateCwBar();
drawCwTonePicker();
}
@@ -324,8 +329,9 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
})();
});
function onServerCw(evt) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl && forSelectedRig) {
const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
const line = document.createElement("div");
@@ -346,25 +352,29 @@ function onServerCw(evt) {
}
if (evt.text) {
const now = Date.now();
const rigId = evt.rig_id ?? null;
const key = rigId ?? "";
if (evt.text === "\n") {
cwBarFlushCurrentLine();
cwBarFlushCurrentLine(key);
} else {
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine();
let line = cwBarCurrentLines.get(key);
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" });
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;
cwBarCurrentLine.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
line.text += evt.text;
line.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
}
updateCwBar();
}
if (cwSignalIndicator) {
if (cwSignalIndicator && forSelectedRig) {
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))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm));
}
@@ -389,7 +399,14 @@ cwWindow.trxPluginRuntime.registerDecoder({
id: "cw",
onMessage: onServerCw,
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() {
ensureCwToneCanvasResolution();
@@ -2,7 +2,7 @@
(() => {
// src/decode-history-worker.ts
var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"];
var workerScope = self;
function decodeCborUint(view, bytes, state, additional) {
const offset = state.offset;
@@ -1,6 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-O2Y7YEVQ.js";
} from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft2.ts
@@ -1,6 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-O2Y7YEVQ.js";
} from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft4.ts
@@ -2,7 +2,8 @@ import {
initializeFt8FamilyBar,
initializeFtxDecoder,
installFtxCompatibilityHelpers
} from "./chunk-O2Y7YEVQ.js";
} from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft8.ts
@@ -5,6 +5,10 @@ import {
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore,
hostState
@@ -67,21 +71,26 @@ function hfAprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(hfAprsFilterText);
}
function hfAprsRigPackets() {
return forActiveRig(hfAprsPacketHistory);
}
function hfAprsVisiblePackets() {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(hfAprsFilterMatch);
}
var collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
}
if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0];
const latest = rigPackets[0];
if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -140,17 +149,32 @@ function resetHfAprsHistoryView() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
}
function pruneHfAprsHistoryView() {
pruneHfAprsPacketHistory();
renderHfAprsHistory();
}
function plotHfAprsPacket(pkt) {
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
hfAprsWindow.aprsMapAddStation(
pkt.srcCall ?? "",
pkt.lat,
pkt.lon,
pkt.info ?? "",
pkt.symbolTable,
pkt.symbolCode,
pkt,
"hf_aprs"
);
}
function addHfAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender();
}
function normalizeServerHfAprsPacket(pkt) {
@@ -158,13 +182,14 @@ function normalizeServerHfAprsPacket(pkt) {
}
function onServerHfAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized = [];
for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotHfAprsPacket(next);
normalized.push(next);
}
normalized.reverse();
@@ -227,8 +252,9 @@ if (hfAprsFilterInput) {
});
}
function onServerHfAprs(pkt) {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
const packet = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
}
renderHfAprsHistory();
window.trxPluginRuntime.registerDecoder({
@@ -237,5 +263,10 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerHfAprsBatch,
restore: onServerHfAprsBatch,
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();
let selectedMapQsoKey = null;
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 MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
@@ -128,6 +128,12 @@ var mapWindow = window;
}
return trimmed;
}
function aprsEntrySource(entry) {
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
}
function aprsStationKey(call, source) {
return source === "aprs" ? call : `${source}:${call}`;
}
function refreshAprsTrack(call, entry) {
if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
@@ -149,7 +155,7 @@ var mapWindow = window;
lineJoin: "round",
interactive: false
});
track.__trxType = "aprs";
track.__trxType = aprsEntrySource(entry);
track._aprsCall = call;
entry.track = track;
}
@@ -381,6 +387,7 @@ var mapWindow = window;
}
function mapSourceLabel(type) {
if (type === "bookmark") return "Bookmarks";
if (type === "hf_aprs") return "HF APRS";
return String(type || "").toUpperCase();
}
function locatorFilterColor(type) {
@@ -396,6 +403,7 @@ var mapWindow = window;
if (type === "vdes") return "#a78bfa";
if (type === "sat") return "#f59e0b";
if (type === "aprs") return "#00d17f";
if (type === "hf_aprs") return "#fb7185";
return locatorFilterColor(type);
}
function bandForHz(hz) {
@@ -931,10 +939,8 @@ var mapWindow = window;
}
}
for (const entry of stationMarkers.values()) {
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
availableSources.add("aprs");
break;
}
if (!entry?.visibleInHistoryWindow) continue;
availableSources.add(aprsEntrySource(entry));
}
const bandMap = /* @__PURE__ */ new Map();
for (const entry of locatorMarkers.values()) {
@@ -966,7 +972,7 @@ var mapWindow = window;
for (const key of Array.from(mapLocatorFilter.bands)) {
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
}
const sourceItems = ["ais", "vdes", "aprs", "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,
label: mapSourceLabel(key),
color: mapSourceColor(key),
@@ -1028,9 +1034,10 @@ var mapWindow = window;
}
return parts.join(" ").toLowerCase();
}
if (type === "aprs") {
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(call);
if (type === "aprs" || type === "hf_aprs") {
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(key);
const call = entry?.call ?? key;
const info = entry?.info ? String(entry.info) : "";
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
return `${call} ${info} ${pktRaw}`.toLowerCase();
@@ -1200,9 +1207,10 @@ var mapWindow = window;
}
};
mapWindow.clearMapMarkersByType = function(type) {
if (type === "aprs") {
if (type === "aprs" || type === "hf_aprs") {
selectedAprsTrackCall = null;
stationMarkers.forEach((entry) => {
stationMarkers.forEach((entry, key) => {
if (aprsEntrySource(entry) !== type) return;
if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker);
@@ -1211,8 +1219,8 @@ var mapWindow = window;
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
mapMarkers.delete(entry.track);
}
stationMarkers.delete(key);
});
stationMarkers.clear();
return;
}
if (type === "ais") {
@@ -1448,13 +1456,15 @@ var mapWindow = window;
if (!ll) return;
const entry = stationMarkers.get(marker._aprsCall);
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);
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
entry.track.addTo(aprsMap);
}
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;
}
if (marker._aisMmsi) {
@@ -1848,28 +1858,33 @@ var mapWindow = window;
}
return null;
}
function _aprsAddMarkerToMap(call, entry) {
function _aprsAddMarkerToMap(key, entry) {
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 popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
const color = mapSourceColor(source);
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
radius: 6,
color: "#00d17f",
fillColor: "#00d17f",
color,
fillColor: color,
fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(popupContent);
marker.__trxType = "aprs";
marker.__trxType = source;
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
marker._aprsCall = call;
marker._aprsCall = key;
entry.marker = 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 tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
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) {
existing.pkt = pkt;
existing.lat = lat;
@@ -1888,7 +1903,8 @@ var mapWindow = window;
} else if (prevPoint) {
prevPoint.tsMs = tsMs;
}
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
existing.call = call;
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -1899,7 +1915,8 @@ var mapWindow = window;
track: null,
trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint],
type: "aprs",
type: stationSource,
call,
pkt,
lat,
lon,
@@ -1908,9 +1925,9 @@ var mapWindow = window;
symbolCode,
rigIds: new Set(msgRigId ? [msgRigId] : [])
};
stationMarkers.set(call, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
stationMarkers.set(key, entry);
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
if (aprsMap) scheduleDecodeMapMaintenance();
}
};
@@ -8,7 +8,6 @@ var satDom = {
status: document.getElementById("sat-status"),
liveView: document.getElementById("sat-live-view"),
historyView: document.getElementById("sat-history-view"),
predictionsView: document.getElementById("sat-predictions-view"),
liveLatest: document.getElementById("sat-live-latest"),
historyList: document.getElementById("sat-history-list"),
historyCount: document.getElementById("sat-history-count"),
@@ -17,29 +16,12 @@ var satDom = {
typeFilter: document.getElementById("sat-type-filter"),
lrptState: document.getElementById("sat-lrpt-state"),
viewLiveBtn: document.getElementById("sat-view-live"),
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")
viewHistoryBtn: document.getElementById("sat-view-history")
};
var satImageHistory = [];
var SAT_MAX_IMAGES = 100;
var SAT_PRED_PAGE_SIZE = 50;
var satPredShowAll = false;
var satFilterText = "";
var satActiveView = "live";
var satPredData = [];
var satPredFilterText = "";
var satPredMinEl = 0;
var satPredCategory = "all";
var satPredSatCount = 0;
var satPredCountdownTimer = null;
function scheduleSatUi(key, job) {
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
satWindow.trxScheduleUiFrameJob(key, job);
@@ -48,37 +30,19 @@ function scheduleSatUi(key, job) {
job();
}
function switchSatView(view) {
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
satActiveView = view;
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "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.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
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();
}
if (view === "history") renderSatHistoryTable();
}
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.viewHistoryBtn?.addEventListener("click", () => {
switchSatView("history");
});
satDom.viewPredBtn?.addEventListener("click", () => {
switchSatView("predictions");
});
var lastSatLrptOn = null;
satWindow.updateSatLiveState = function(update) {
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) {
if (typeof satWindow.enableMapSourceFilter === "function") {
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();
};
@@ -0,0 +1,264 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/sstv.ts
var sstvWindow = window;
var sstvDom = {
status: document.getElementById("sstv-status"),
liveView: document.getElementById("sstv-live-view"),
historyView: document.getElementById("sstv-history-view"),
liveContainer: document.getElementById("sstv-live-container"),
liveInfo: document.getElementById("sstv-live-info"),
liveCanvas: document.getElementById("sstv-live-canvas"),
liveLatest: document.getElementById("sstv-live-latest"),
historyList: document.getElementById("sstv-history-list"),
historyCount: document.getElementById("sstv-history-count"),
filterInput: document.getElementById("sstv-filter"),
sortSelect: document.getElementById("sstv-sort"),
toggleBtn: document.getElementById("sstv-decode-toggle-btn"),
clearBtn: document.getElementById("sstv-clear-btn"),
viewLiveBtn: document.getElementById("sstv-view-live"),
viewHistoryBtn: document.getElementById("sstv-view-history")
};
var SSTV_MAX_IMAGES = 100;
var sstvHistory = [];
var liveCtx = null;
var liveMode = "";
var liveHeight = 0;
var liveRows = 0;
var activeView = "live";
var filterText = "";
function retentionMs() {
return sstvWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3;
}
function pruneHistory() {
const cutoff = Date.now() - retentionMs();
sstvHistory = sstvHistory.filter((image) => (image._tsMs || 0) > cutoff);
}
function escapeHtml(value) {
return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function scheduleUi(key, job) {
if (typeof sstvWindow.trxScheduleUiFrameJob === "function") {
sstvWindow.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function imageUrl(image) {
if (!image.path) return null;
const filename = image.path.split(/[\\/]/).pop();
return filename ? `/sstv-images/${encodeURIComponent(filename)}` : null;
}
function decodeBase64(data) {
const binary = atob(data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function switchView(view) {
activeView = view;
if (sstvDom.liveView) sstvDom.liveView.style.display = view === "live" ? "" : "none";
if (sstvDom.historyView) sstvDom.historyView.style.display = view === "history" ? "" : "none";
for (const button of [sstvDom.viewLiveBtn, sstvDom.viewHistoryBtn]) {
button?.classList.remove("sat-view-active");
}
if (view === "live") sstvDom.viewLiveBtn?.classList.add("sat-view-active");
else sstvDom.viewHistoryBtn?.classList.add("sat-view-active");
if (view === "history") renderHistoryTable();
}
sstvDom.viewLiveBtn?.addEventListener("click", () => {
switchView("live");
});
sstvDom.viewHistoryBtn?.addEventListener("click", () => {
switchView("history");
});
function beginPicture(mode, width, height) {
const canvas = sstvDom.liveCanvas;
if (!canvas || width <= 0 || height <= 0) return;
liveMode = mode;
liveHeight = height;
liveRows = 0;
canvas.width = width;
canvas.height = height;
liveCtx = canvas.getContext("2d");
if (!liveCtx) return;
liveCtx.fillStyle = "#606060";
liveCtx.fillRect(0, 0, width, height);
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "";
updateLiveInfo();
}
function updateLiveInfo() {
if (!sstvDom.liveInfo) return;
const canvas = sstvDom.liveCanvas;
const size = canvas ? `${canvas.width}×${canvas.height}` : "";
sstvDom.liveInfo.textContent = liveMode ? `${liveMode} · ${size} · line ${liveRows}${liveHeight ? ` of ${liveHeight}` : ""}` : "";
}
function paintRow(line, rgb) {
const canvas = sstvDom.liveCanvas;
if (!liveCtx || !canvas) return;
const width = canvas.width;
if (line < 0 || line >= canvas.height || rgb.length < width * 3) return;
const row = liveCtx.createImageData(width, 1);
for (let x = 0; x < width; x += 1) {
row.data[x * 4] = rgb[x * 3] ?? 0;
row.data[x * 4 + 1] = rgb[x * 3 + 1] ?? 0;
row.data[x * 4 + 2] = rgb[x * 3 + 2] ?? 0;
row.data[x * 4 + 3] = 255;
}
liveCtx.putImageData(row, 0, line);
liveRows = Math.max(liveRows, line + 1);
}
function onProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state;
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
return;
}
if (typeof msg.line !== "number" || !msg.line_data) return;
const line = msg.line;
const rgb = decodeBase64(msg.line_data);
scheduleUi(`sstv-row-${line}`, () => {
paintRow(line, rgb);
updateLiveInfo();
});
}
function onImage(msg) {
const image = { ...msg };
image._tsMs = typeof msg.ts_ms === "number" ? msg.ts_ms : Date.now();
image._ts = new Date(image._tsMs).toLocaleTimeString();
sstvHistory.push(image);
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory();
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) {
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"}${image.lines ?? 0} lines`;
}
scheduleUi("sstv-latest", renderLatestCard);
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
}
function sstvRigImages() {
return forActiveRig(sstvHistory);
}
function renderLatestCard() {
if (!sstvDom.liveLatest) return;
const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
if (!latest) {
sstvDom.liveLatest.innerHTML = "";
return;
}
const url = imageUrl(latest);
const lines = `${latest.lines ?? 0}${latest.height ? ` of ${latest.height}` : ""} lines`;
const state = latest.complete ? "complete" : "partial";
sstvDom.liveLatest.innerHTML = `
<div class="sat-latest-card">
<div style="display:flex; align-items:baseline; gap:0.5rem; flex-wrap:wrap;">
<strong>${escapeHtml(latest.mode ?? "SSTV")}</strong>
<small style="color:var(--text-muted);">${escapeHtml(latest._ts ?? "")} · ${lines} · ${state}</small>
</div>
${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">
<img src="${escapeHtml(url)}" alt="Received ${escapeHtml(latest.mode ?? "SSTV")} picture"
style="margin-top:0.4rem; width:100%; max-width:640px; image-rendering:pixelated;" />
</a>` : ""}
</div>`;
}
function filteredHistory() {
const rigImages = sstvRigImages();
const text = filterText.trim().toLowerCase();
const matching = text ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : rigImages;
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching;
}
function renderHistoryTable() {
if (!sstvDom.historyList) return;
pruneHistory();
const rows = filteredHistory();
sstvDom.historyList.innerHTML = rows.map((image) => {
const url = imageUrl(image);
const size = image.width && image.height ? `${image.width}×${image.height}` : "--";
const lines = image.complete ? String(image.lines ?? 0) : `${image.lines ?? 0} (partial)`;
return `<div class="sat-history-row">
<span class="sat-col-time">${escapeHtml(image._ts ?? "")}</span>
<span class="sat-col-type">${escapeHtml(image.mode ?? "--")}</span>
<span class="sat-col-sat">${escapeHtml(size)}</span>
<span class="sat-col-lines">${escapeHtml(lines)}</span>
<span class="sat-col-link">${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">View</a>` : "--"}</span>
</div>`;
}).join("");
if (sstvDom.historyCount) {
sstvDom.historyCount.textContent = rows.length ? `${rows.length} picture${rows.length === 1 ? "" : "s"}` : "No pictures yet";
}
}
sstvDom.filterInput?.addEventListener("input", () => {
filterText = sstvDom.filterInput?.value ?? "";
renderHistoryTable();
});
sstvDom.sortSelect?.addEventListener("change", () => {
renderHistoryTable();
});
function restoreHistory(entries) {
if (!Array.isArray(entries)) return;
for (const entry of entries) onImage(entry);
}
function resetHistoryView() {
sstvHistory = [];
liveRows = 0;
liveMode = "";
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "none";
if (sstvDom.status) sstvDom.status.textContent = "Idle";
renderLatestCard();
renderHistoryTable();
}
sstvWindow.syncSstvToggle = function syncSstvToggle(enabled) {
const button = sstvDom.toggleBtn;
if (!button) return;
button.textContent = enabled ? "Disable SSTV" : "Enable SSTV";
button.setAttribute("aria-pressed", String(enabled));
button.classList.toggle("is-active", enabled);
};
sstvDom.toggleBtn?.addEventListener("click", () => {
void (async () => {
try {
if (sstvDom.toggleBtn) {
await sstvWindow.takeSchedulerControlForDecoderDisable?.(sstvDom.toggleBtn);
}
await hostCore.postPath("/toggle_sstv_decode");
} catch (e) {
console.error("SSTV toggle failed", e);
}
})();
});
sstvDom.clearBtn?.addEventListener("click", () => {
void (async () => {
try {
await hostCore.postPath("/clear_sstv_decode");
resetHistoryView();
} catch (e) {
console.error("SSTV clear failed", e);
}
})();
});
renderLatestCard();
sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv",
onMessage: onImage,
restore: restoreHistory,
prune: renderHistoryTable,
reset: resetHistoryView,
rerender: () => {
renderLatestCard();
renderHistoryTable();
}
});
sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress",
onMessage: onProgress
});
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -60,17 +64,21 @@ function vdesHexPreview(rawBytes) {
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
}
function vdesRigMessages() {
return forActiveRig(vdesMessageHistory);
}
function updateVdesSummary() {
pruneVdesMessageHistory();
if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText();
}
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) {
const count = vdesMessageHistory.length;
const count = rigMessages.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
}
if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0];
const latest = rigMessages[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
}
}
@@ -137,7 +145,7 @@ function updateVdesBar() {
updateVdesSummary();
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)).slice(0, 6);
if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = "";
@@ -182,7 +190,7 @@ function renderVdesHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) {
for (const message of vdesRigMessages()) {
fragment.appendChild(renderVdesRow(message));
}
vdesMessagesEl.replaceChildren(fragment);
@@ -209,10 +217,10 @@ function normalizeServerVdesMessage(msg) {
}
function onServerVdesBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -251,8 +259,8 @@ function plotVdesMessage(msg) {
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next);
plotVdesMessage(next);
}
@@ -269,6 +277,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
rerender: () => {
updateVdesBar();
renderVdesHistory();
},
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -102,13 +106,17 @@ function paintLine(lineBytes) {
wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++;
}
function wefaxRigImages() {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() {
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>';
return;
}
const img = wefaxImageHistory[0];
const img = rigImages[0];
if (!img) return;
const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
@@ -129,7 +137,7 @@ function renderWefaxLatestCard() {
wefaxDom.liveLatest.innerHTML = html;
}
function getWefaxFilteredHistory() {
let items = wefaxImageHistory;
let items = wefaxRigImages();
if (wefaxFilterText) {
items = items.filter(function(i) {
const haystack = [
@@ -173,7 +181,7 @@ function renderWefaxHistoryTable() {
}
wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) {
const total = wefaxImageHistory.length;
const total = wefaxRigImages().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`;
}
@@ -208,6 +216,7 @@ function addWefaxImage(msg) {
}
}
function onServerWefaxProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state && !msg.line_data) {
if (wefaxDom.status) {
wefaxDom.status.textContent = msg.state;
@@ -234,6 +243,7 @@ function onServerWefaxProgress(msg) {
}
function onServerWefax(msg) {
addWefaxImage(msg);
if (!isActiveRigDecode(msg.rig_id)) return;
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
if (wefaxDom.status) {
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
@@ -328,7 +338,11 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerWefax,
restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView
reset: resetWefaxHistoryView,
rerender: () => {
renderWefaxLatestCard();
renderWefaxHistoryTable();
}
});
wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress",
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -64,10 +68,10 @@ function renderWsprRow(msg) {
function renderWsprHistory() {
pruneWsprMessageHistory();
if (!wsprMessagesEl) return;
const rigMessages = forActiveRig(wsprMessageHistory);
const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
const message = wsprMessageHistory[i];
if (message) fragment.appendChild(renderWsprRow(message));
for (const message of rigMessages) {
fragment.appendChild(renderWsprRow(message));
}
wsprMessagesEl.replaceChildren(fragment);
}
@@ -90,6 +94,8 @@ function normalizeServerWsprMessage(msg) {
station,
rfHz,
history: {
rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms,
snr_db: msg.snr_db,
@@ -101,16 +107,11 @@ function normalizeServerWsprMessage(msg) {
}
function onServerWsprBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
});
}
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
plotWsprLocator(msg);
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
@@ -250,15 +251,19 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
}
})();
});
function onServerWspr(msg) {
if (wsprStatus) wsprStatus.textContent = "Receiving";
function plotWsprLocator(msg) {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
});
}
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...rfHz === null ? {} : { freq_hz: rfHz }
});
}
function onServerWspr(msg) {
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg);
plotWsprLocator(msg);
addWsprMessage(next.history);
}
wsprWindow.trxPluginRuntime.registerDecoder({
@@ -267,5 +272,10 @@ wsprWindow.trxPluginRuntime.registerDecoder({
onBatch: onServerWsprBatch,
restore: onServerWsprBatch,
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-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-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-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>
@@ -52,14 +53,21 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
<span class="tab-label">Bookmarks</span>
</button>
<button class="tab" data-tab="digital-modes">
<button class="tab" data-tab="digital-modes" aria-label="Digital modes">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
<span class="tab-label">Digital modes</span>
<!-- A fifth of a phone screen does not hold "Digital modes"; the
button keeps the full name for assistive tech. -->
<span class="tab-label-short" aria-hidden="true">Digital</span>
</button>
<button class="tab" data-tab="map">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
<span class="tab-label">Map</span>
</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">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
<span class="tab-label">Statistics</span>
@@ -163,13 +171,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="spectrum-size-grip" title="Drag to resize spectrum height" aria-label="Resize spectrum height"></div>
<div id="spectrum-controls">
<div id="spectrum-bw-row">
<label id="spectrum-bw-label">Bandwidth <input type="number" id="spectrum-bw-input" value="" step="0.1" min="0.1" /> kHz</label>
<button id="spectrum-bw-set-btn" type="button">Set</button>
<button id="spectrum-bw-auto-btn" type="button">Auto BW</button>
<button id="spectrum-bw-sweet-btn" type="button">Sweet-spot</button>
<label id="spectrum-bw-label" class="spectrum-field"><span class="spectrum-field-name">Bandwidth</span><input type="number" id="spectrum-bw-input" value="" step="0.1" min="0.1" /><span class="spectrum-field-unit">kHz</span></label>
<button id="spectrum-bw-set-btn" class="spectrum-btn" type="button">Set</button>
<button id="spectrum-bw-auto-btn" class="spectrum-btn" type="button">Auto BW</button>
<button id="spectrum-bw-sweet-btn" class="spectrum-btn" type="button">Sweet-spot</button>
</div>
<div class="spectrum-controls-spacer" aria-hidden="true"></div>
<div id="spectrum-level-row">
<label class="overview-control" id="spectrum-peak-hold-label">Peak Hold
<label class="overview-control spectrum-field" id="spectrum-peak-hold-label"><span class="spectrum-field-name">Peak Hold</span>
<select id="overview-peak-hold" class="status-input">
<option value="0">Off</option>
<option value="500">0.5 s</option>
@@ -182,10 +191,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<option value="60000">60 s</option>
</select>
</label>
<label id="spectrum-floor-label">Floor <input type="number" id="spectrum-floor-input" value="-115" step="5" /> dB</label>
<label id="spectrum-range-label">Range <input type="number" id="spectrum-range-input" value="90" step="10" min="10" /> dB</label>
<button id="spectrum-auto-btn" type="button">Auto</button>
<label id="spectrum-gamma-label">Contrast <input type="range" id="spectrum-gamma-input" min="0.2" max="3.0" step="0.1" value="1.0" /><span id="spectrum-gamma-value">1.0</span></label>
<label id="spectrum-floor-label" class="spectrum-field"><span class="spectrum-field-name">Floor</span><input type="number" id="spectrum-floor-input" value="-115" step="5" /><span class="spectrum-field-unit">dB</span></label>
<label id="spectrum-range-label" class="spectrum-field"><span class="spectrum-field-name">Range</span><input type="number" id="spectrum-range-input" value="90" step="10" min="10" /><span class="spectrum-field-unit">dB</span></label>
<button id="spectrum-auto-btn" class="spectrum-btn" type="button">Auto</button>
<label id="spectrum-gamma-label" class="spectrum-field"><span class="spectrum-field-name">Contrast</span><input type="range" id="spectrum-gamma-input" min="0.2" max="3.0" step="0.1" value="1.0" /><span id="spectrum-gamma-value" class="spectrum-field-value">1.0</span></label>
</div>
</div>
<div id="spectrum-hint" class="spectrum-hint-mouse">Scroll to zoom &middot; Ctrl+Scroll to tune &middot; Drag to pan &middot; Drag BW edges to resize &middot; +/- zoom &middot; Arrows pan &middot; 0 reset</div>
@@ -584,6 +593,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button class="sub-tab" data-subtab="rds">RDS</button>
<button class="sub-tab" data-subtab="sat">SAT</button>
<button class="sub-tab" data-subtab="wefax">WEFAX</button>
<button class="sub-tab" data-subtab="sstv">SSTV</button>
</div>
<div id="subtab-overview" class="sub-tab-panel">
<div class="plugin-item" data-decoder="ais">
@@ -646,6 +656,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
</div>
</div>
<div class="plugin-item" data-decoder="sstv">
<strong>SSTV Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
Slow-Scan Television &mdash; pictures in Martin, Scottie, Robot, PD and Wraase modes
</div>
</div>
<div class="plugin-item" data-decoder="wefax">
<strong>WEFAX Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
@@ -653,6 +669,56 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
</div>
<div id="subtab-sstv" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls">
<button id="sstv-decode-toggle-btn" type="button">Enable SSTV</button>
<small id="sstv-status" style="color:var(--text-muted);">Idle</small>
</div>
<!-- View selector -->
<div class="sat-view-bar">
<button id="sstv-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
<button id="sstv-view-history" class="sat-view-btn" type="button">History</button>
</div>
<!-- Live view -->
<div id="sstv-live-view">
<div style="margin:0 0 0.5rem;">
<div style="color:var(--text-muted); font-size:0.82rem; line-height:1.5;">
<strong>Slow-Scan Television</strong> &mdash; pictures sent as tones, in Martin,
Scottie, Robot, PD and Wraase modes. Tune a sideband or FM signal and enable the
decoder; the mode is read from the header the sender opens with.
</div>
</div>
<div id="sstv-live-container" style="display:none; margin:0.5rem 0;">
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.3rem;">
<strong>Receiving</strong>
<small id="sstv-live-info" style="color:var(--text-muted);"></small>
</div>
<canvas id="sstv-live-canvas" width="320" height="256"
style="width:100%; max-width:640px; image-rendering:pixelated; background:#111;"></canvas>
</div>
<div id="sstv-live-latest" style="margin-top:0.5rem;"></div>
</div>
<!-- History view -->
<div id="sstv-history-view" style="display:none;">
<div class="sat-history-controls">
<input id="sstv-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. Martin, PD120)" />
<select id="sstv-sort" class="sat-sort-select">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
<button id="sstv-clear-btn" type="button" style="font-size:0.8rem;">Clear All</button>
</div>
<div class="sat-history-header">
<span class="sat-col-time">Time</span>
<span class="sat-col-type">Mode</span>
<span class="sat-col-sat">Size</span>
<span class="sat-col-lines">Lines</span>
<span class="sat-col-link">Image</span>
</div>
<div id="sstv-history-list"></div>
<small id="sstv-history-count" style="color:var(--text-muted);font-size:0.75rem;">No pictures yet</small>
</div>
</div>
<div id="subtab-rds" class="sub-tab-panel" style="display:none;">
<div class="rds-grid">
<div class="rds-field"><span class="rds-label">Status</span><span id="rds-status" class="rds-value rds-no-signal">No signal</span></div>
@@ -857,7 +923,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<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-history" class="sat-view-btn" type="button">History</button>
<button id="sat-view-predictions" class="sat-view-btn" type="button">Predictions</button>
</div>
<!-- Live view -->
<div id="sat-live-view">
@@ -899,50 +964,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="sat-history-list"></div>
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
</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 id="subtab-wefax" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls">
@@ -1056,6 +1077,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</template>
</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;">
<h2 class="section-heading">Statistics</h2>
<template id="tmpl-statistics">
@@ -1401,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="background-decode-panel" class="sch-panel">
<div class="sch-toast" id="background-decode-toast" role="alert" aria-live="polite" style="display:none;"></div>
<!-- Now Playing status card (moved to top) -->
<div class="now-playing-card">
<div id="background-decode-status-card" class="sch-status-card">No background decode bookmarks configured.</div>
</div>
<div class="sch-section">
<div class="sch-section-title">Configuration</div>
<div class="sch-row">
<label class="sch-label bgd-toggle-wrap">Background decode
<span class="bgd-toggle-row">
<input type="checkbox" id="background-decode-enabled" />
<span>Enable hidden background decoder channels</span>
</span>
<div class="sch-section-title">Background Decode</div>
<p class="bgd-intro">
Decodes saved bookmarks on hidden channels while you work another band.
Needs an SDR rig, and a bookmark is only decoded while it falls inside
the span the rig is tuned across.
</p>
<div class="bgd-controls">
<label class="bgd-enable">
<input type="checkbox" id="background-decode-enabled" />
<span>Enabled</span>
</label>
<span id="bgd-span-summary" class="bgd-span" aria-live="polite"></span>
</div>
<div class="sch-row" style="flex-direction:column;gap:0.5rem;">
<label class="sch-label" style="min-width:100%;">Bookmarks
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter" placeholder="Filter bookmarks..." />
</label>
<div class="bgd-list-toolbar">
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter"
placeholder="Filter bookmarks&hellip;" aria-label="Filter bookmarks" />
<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-deselect-all-btn" class="bgd-select-btn" aria-label="Deselect all bookmarks">Deselect 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="Select no bookmarks">None</button>
</div>
<div id="bgd-bookmark-checklist" class="bgd-checklist"></div>
</div>
<div id="bgd-bookmark-checklist" class="bgd-checklist" role="group"
aria-label="Bookmarks decoded in the background"></div>
<div id="bgd-selection-summary" class="bgd-summary" aria-live="polite"></div>
</div>
<div class="sch-actions">
@@ -1717,6 +1717,8 @@ small { color: var(--text-muted); }
flex-shrink: 0;
}
.tab-label { display: block; }
/* Only the bottom nav is short of room. */
.tab-label-short { display: none; }
.tab-more-icon { display: none; }
.about-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.about-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; padding: 0; overflow: hidden; }
@@ -2592,7 +2594,6 @@ button.map-qso-card:focus-visible {
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
will-change: backdrop-filter;
overflow: auto;
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
}
@@ -2610,9 +2611,15 @@ button.map-qso-card:focus-visible {
.map-overlay-filters.is-hidden {
display: none;
}
/* With the filters collapsed the bar has no reason to span the map. */
/* With the filters collapsed the bar has no reason to span the map.
It keeps both anchors and shrinks inside them, rather than dropping `left`
and being sized by shrink-to-fit: the anchored box is a size the browser
already knows, and an absolutely positioned, backdrop-filtered, composited
box being asked to size itself from its content is the shape of thing that
renders as nothing on engines other than the one it was written against. */
.map-overlay-panel.filters-hidden {
left: auto;
width: fit-content;
margin-left: auto;
}
.map-overlay-actions {
display: flex;
@@ -2739,7 +2746,6 @@ button.map-qso-card:focus-visible {
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
will-change: backdrop-filter;
overflow: auto;
}
.map-band-legend.is-empty {
@@ -3810,11 +3816,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
@media (max-width: 760px) {
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
}
/* One navigation model at every width. Statistics, Recorder, Settings and
* About are occasional destinations: they live behind More rather than
* competing with the operating tabs for the row and then scrolling out of
/* One navigation model at every width. Satellites, Statistics, Recorder,
* Settings and About are occasional destinations: they live behind More rather
* 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
* 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="recorder"],
.tab-bar-nav .tab[data-tab="settings"],
@@ -3892,7 +3899,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
input.status-input, select.status-input { font-size: 1.1rem; }
:root { --header-waterfall-overlap: 0rem; }
.controls-tray-scroll { overflow-x: auto; }
.controls-tray { width: 100%; min-width: 0; padding-left: 0.85rem; padding-right: 0.85rem; }
/* A grid column sizes to its content by default, so one row wider than the
phone the mode picker, six buttons across dragged the whole tray out
with it and left the rest to be found by scrolling sideways. `minmax(0,
1fr)` lets the column be as narrow as the screen and the rows wrap inside
it. */
.controls-tray {
width: 100%;
min-width: 0;
grid-template-columns: minmax(0, 1fr);
padding-left: 0.85rem;
padding-right: 0.85rem;
}
.freq-inline { gap: 0.5rem; flex-wrap: wrap; }
.header-text { width: auto; min-width: 0; flex: 0 1 auto; }
.header-main {
@@ -4013,12 +4031,9 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
}
.tab-icon, .tab-more-icon { display: block; }
/* Shorten long tab labels to keep bottom nav compact */
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="statistics"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
.tab[data-tab="satellites"], .tab[data-tab="statistics"],
.tab[data-tab="recorder"], .tab[data-tab="settings"],
.tab[data-tab="about"] { display: none; }
.mobile-more-btn { display: flex; }
.mobile-more-menu {
position: fixed;
@@ -4030,7 +4045,48 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--accent-text);
background: color-mix(in srgb, var(--accent-green) 10%, transparent);
}
.tab .tab-label, .tab[data-tab] .tab-label { font-size: 0.75rem; }
/* The bottom nav divides the bar between its five destinations, so a tab is
about a fifth of the screen and the label has to live inside that. Sharing
the width evenly beats sizing each tab to its own label, which gave
"Digital modes" the same room as "Map" and clipped it mid-word. */
.tab-bar-nav .tab {
flex: 1 1 0;
min-width: 0;
padding-left: 0.15rem;
padding-right: 0.15rem;
}
.tab .tab-label, .tab[data-tab] .tab-label {
display: block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 0.75rem;
}
/* The long ones, sized against the screen rather than at a fixed size.
A tab is a fifth of the viewport, so what fits in it is a function of the
viewport and only roughly of the font size, since how wide a platform
renders a word varies by ten per cent or more between machines. A fixed
0.6rem fit here with ten pixels to spare and clipped in CI.
(These rules also used to sit above the one that sets the size for all
labels identical specificity, later in the file, so nothing was ever
shortened.) */
.tab[data-tab="bookmarks"] .tab-label,
.tab[data-tab="statistics"] .tab-label {
font-size: clamp(0.46rem, 2.2vw, 0.62rem);
}
/* "Digital modes" does not fit at any size worth reading, so the nav shows
the short form and the button carries the full one as its label. */
.tab[data-tab="digital-modes"] .tab-label { display: none; }
.tab[data-tab="digital-modes"] .tab-label-short {
display: block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: clamp(0.46rem, 2.2vw, 0.62rem);
}
.decoder-tab-select {
display: block;
width: 100%;
@@ -4068,10 +4124,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
flex: 0 0 auto;
min-width: 0;
}
/* The rig's name is the widest thing in the bar and it does not need to be:
139 px of a 338 px bar went on it, and everything else was pushed into the
overflow menu to make room. Enough for a short name, ellipsised past that,
with the full name still in the menu it opens. */
.header-rig-switch select,
.header-style-pick select {
width: auto;
min-width: 0;
max-width: 6.5rem;
padding-right: 0.2rem;
font-size: 0.8rem;
text-overflow: ellipsis;
}
.header-bar-btn {
flex: 0 0 auto;
@@ -4102,12 +4166,20 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.mult-col {
min-width: 0;
}
/* These are given the width of their column below; with padding on a
content box that is the column's width *plus* the padding, which is what
pushed the page itself sideways by a dozen pixels. */
.wavelength-display,
.sig-strength-display,
.jog-step,
.jog-mult {
box-sizing: border-box;
width: 100%;
}
.sig-strength-display {
min-width: 0;
font-size: 1.05rem;
}
.jog-step button,
.jog-mult button {
flex: 1 1 0;
@@ -4131,47 +4203,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.signal-measure #sig-result {
width: 100%;
}
#spectrum-controls {
flex-direction: column;
align-items: stretch;
gap: 0.45rem;
padding-top: 0.45rem;
}
#spectrum-bw-row,
#spectrum-level-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.4rem;
}
#spectrum-bw-label,
#spectrum-floor-label,
#spectrum-range-label,
#spectrum-peak-hold-label {
flex: 1 1 100%;
justify-content: space-between;
}
#spectrum-gamma-label {
flex: 1 1 100%;
justify-content: space-between;
}
#spectrum-gamma-input {
flex: 1 1 auto;
min-width: 3rem;
}
#spectrum-bw-input,
#spectrum-floor-input,
#spectrum-range-input,
#overview-peak-hold {
flex: 1 1 auto;
min-width: 3rem;
box-sizing: border-box;
}
#spectrum-bw-set-btn,
#spectrum-bw-auto-btn,
#spectrum-bw-sweet-btn,
#spectrum-auto-btn {
flex: 1 1 auto;
}
.spectrum-edge-shift {
width: 0.95rem;
}
@@ -4821,111 +4852,166 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
white-space: nowrap;
z-index: 10;
}
/* Spectrum control strip
Two clusters of controls under the plot: the receiver's bandwidth on the
left, the display's levels on the right. Everything in here is one height
and one shape a row whose controls each size themselves reads as a pile
rather than a strip.
The container wraps; a cluster does not. A cluster that cannot fit drops
whole to the next line and starts it left-aligned, rather than splitting and
leaving the cluster beside it floating at a height of its own, which is what
the two rows did to each other between 1100 and 1400 px. */
#spectrum-controls {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
padding: 3px 4px 0;
gap: 0.6rem;
gap: 0.35rem 0.5rem;
font-size: 0.78rem;
color: var(--text-muted);
}
#spectrum-bw-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
}
#spectrum-bw-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--text-muted);
min-width: 0;
}
#spectrum-bw-input {
width: 4.5rem;
padding: 1px 4px;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--input-bg);
color: var(--text);
text-align: right;
height: 1.5rem;
}
#spectrum-bw-set-btn,
#spectrum-bw-auto-btn,
#spectrum-bw-sweet-btn {
height: 1.5rem;
min-height: 0;
padding: 0 8px;
font-size: 0.75rem;
}
#spectrum-bw-row,
#spectrum-level-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: nowrap;
gap: 0.3rem;
}
#spectrum-floor-label {
display: flex;
/* Takes the slack, so the clusters reach the ends of a wide strip without
`space-between` opening a quarter-width hole in the middle of it. */
.spectrum-controls-spacer {
flex: 1 1 1.5rem;
min-width: 0;
}
/* A field is one box: name, value and unit share a border, so a number can
never be read apart from the unit it is in. */
.spectrum-field {
display: inline-flex;
align-items: center;
gap: 0.3rem;
/* Border-box on both field and button, or the button's own border and
padding add to the height and the two end up a couple of pixels apart
which is the pile-of-controls look this is meant to end. */
box-sizing: border-box;
height: 1.7rem;
padding: 0 0.4rem 0 0.5rem;
border: 1px solid var(--border-light);
border-radius: 6px;
background: var(--input-bg);
white-space: nowrap;
font-size: 0.75rem;
color: var(--text-muted);
}
#spectrum-floor-input {
width: 3.4rem;
padding: 1px 4px;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--input-bg);
color: var(--text);
text-align: right;
height: 1.5rem;
.spectrum-field:focus-within {
border-color: color-mix(in srgb, var(--accent-green) 45%, var(--border-light));
}
#spectrum-range-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
.spectrum-field-name {
font-size: 0.72rem;
color: var(--text-muted);
}
#spectrum-range-input {
width: 3.4rem;
padding: 1px 4px;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--input-bg);
/* Unit and readout hold a fixed, tabular slot: the row must not twitch as a
value goes from 1.0 to 0.9. */
.spectrum-field-unit,
.spectrum-field-value {
font-size: 0.7rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
min-width: 1.7rem;
}
.spectrum-field-value {
color: var(--text);
text-align: right;
height: 1.5rem;
}
#spectrum-auto-btn {
height: 1.5rem;
.spectrum-field input[type="number"] {
width: 3.2rem;
padding: 0;
border: 0;
background: transparent;
color: var(--text);
font-size: 0.78rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
.spectrum-field input[type="number"]:focus {
outline: none;
}
#spectrum-bw-input {
width: 3.6rem;
}
/* The select carries `status-input` for the sake of other layouts, which gives
it a border and a background of its own; inside a field that reads as a box
drawn inside a box. */
#spectrum-controls .spectrum-field select {
width: auto;
height: 1.35rem;
min-height: 0;
padding: 0 8px;
font-size: 0.75rem;
padding: 0 0.1rem;
border: 0;
border-radius: 0;
background: transparent;
color: var(--text);
font-size: 0.74rem;
}
#spectrum-gamma-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--text-muted);
}
#spectrum-gamma-input {
width: 5rem;
height: 1.5rem;
.spectrum-field input[type="range"] {
width: 5.5rem;
height: 1.2rem;
cursor: pointer;
}
#spectrum-gamma-value {
font-size: 0.75rem;
min-width: 1.6rem;
text-align: right;
.spectrum-btn {
box-sizing: border-box;
height: 1.7rem;
min-height: 0;
padding: 0 0.6rem;
border-radius: 6px;
font-size: 0.73rem;
font-weight: 600;
white-space: nowrap;
}
/* Fingers need more than a 27 px target; a mouse does not, and the strip has
no width to spare on a desktop. */
@media (hover: none) and (pointer: coarse) {
.spectrum-field,
.spectrum-btn {
height: 2.4rem;
}
}
/* Narrow screens: the same fields, stacked. This sits after the rules it
overrides `#spectrum-bw-row` in both places has identical specificity, so
whichever comes last wins, and from inside the earlier media query the
narrow rules silently lost. */
@media (max-width: 640px) {
/* Narrow: the clusters stack, and a field takes the width so its name and
its value sit at opposite ends of a line the thumb can hit. */
#spectrum-controls {
flex-direction: column;
align-items: stretch;
gap: 0.45rem;
padding-top: 0.45rem;
}
#spectrum-bw-row,
#spectrum-level-row {
flex-wrap: wrap;
gap: 0.35rem 0.4rem;
}
.spectrum-controls-spacer {
display: none;
}
#spectrum-controls .spectrum-field {
flex: 1 1 100%;
justify-content: space-between;
}
#spectrum-controls .spectrum-field input[type="number"],
#spectrum-controls .spectrum-field input[type="range"],
#spectrum-controls .spectrum-field select {
flex: 1 1 auto;
min-width: 3rem;
}
#spectrum-controls .spectrum-btn {
flex: 1 1 auto;
}
}
.spectrum-hint-mouse,
.spectrum-hint-touch {
@@ -4944,9 +5030,15 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
/* ── Phone layout (≤ 520px) ───────────────────────────────────────────── */
@media (max-width: 520px) {
/* Single-column controls: jog first, then mode, then power */
/* Single-column controls: jog first, then mode, then power.
`nowrap` is the whole point of it. The base rule wraps, and a *column*
that wraps starts a second column when its items are taller than it is
which put the transmit controls at x=400 on a 390 px screen, off the side
of a tray 354 px wide, reachable only by a horizontal scroll with nothing
to say it was there. */
.controls-row {
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
}
.controls-col-center {
@@ -5497,18 +5589,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
#sch-sat-form-cancel:hover {
background: var(--border);
}
.bgd-toggle-wrap {
min-width: 18rem;
flex: 1 1 20rem;
}
.bgd-toggle-row {
display: inline-flex;
align-items: center;
gap: 0.55rem;
min-height: var(--control-height);
color: var(--text);
font-weight: 500;
}
.bgd-add-row {
display: flex;
gap: 0.55rem;
@@ -5532,48 +5612,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--text-muted);
font-size: 0.78rem;
}
.bgd-status-list {
display: grid;
gap: 0.65rem;
}
.bgd-status-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 0.75rem;
border: 1px solid var(--border-light);
border-radius: 0.55rem;
background: color-mix(in srgb, var(--card-bg) 74%, transparent);
}
.bgd-status-name {
font-weight: 600;
}
.bgd-status-meta {
color: var(--text-muted);
font-size: 0.82rem;
}
.bgd-status-state {
align-self: center;
white-space: nowrap;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--accent-green);
}
.bgd-status-state[data-state="out_of_span"],
.bgd-status-state[data-state="waiting_for_spectrum"],
.bgd-status-state[data-state="waiting_for_user"],
.bgd-status-state[data-state="scheduler_has_control"],
.bgd-status-state[data-state="inactive"],
.bgd-status-state[data-state="handled_by_scheduler"],
.bgd-status-state[data-state="handled_by_virtual_channel"] {
color: var(--accent-yellow);
}
.bgd-status-state[data-state="missing_bookmark"],
.bgd-status-state[data-state="no_supported_decoders"] {
color: var(--accent-red);
}
/* ── "Now Playing" status card (top of scheduler & bgd panels) ──── */
.now-playing-card {
border-left: 3px solid var(--accent-green);
@@ -5789,18 +5827,65 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--text-muted);
opacity: 0.7;
}
.bgd-intro {
margin: 0 0 0.9rem;
max-width: 62ch;
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.45;
}
/* The switch and what the rig can hear, on one line: the second is the reason
a selected bookmark may not be decoding, so it belongs beside the first. */
.bgd-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.9rem;
}
.bgd-enable {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
cursor: pointer;
}
.bgd-span {
color: var(--text-muted);
font-size: 0.82rem;
}
.bgd-span[data-tone="warn"] {
color: var(--text-warn, #b7791f);
}
/* The filter and the bulk buttons sit on the list they act on, and stay on it
when the panel narrows. */
.bgd-list-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.bgd-list-toolbar .bgd-checklist-filter {
flex: 1 1 auto;
min-width: 0;
}
.bgd-checklist {
max-height: 16rem;
max-height: 22rem;
overflow-y: auto;
border: 1px solid var(--border-light);
border-radius: 0.4rem;
background: var(--bg-secondary);
}
/* One row per bookmark: pick it here, and read what it is doing here too. */
.bgd-checklist-row {
display: flex;
display: grid;
grid-template-columns: auto minmax(6rem, 1fr) auto auto;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.65rem;
padding: 0.5rem 0.65rem;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
font-size: 0.85rem;
@@ -5812,28 +5897,83 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.bgd-checklist-row:hover {
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
}
.bgd-checklist-row.is-selected {
background: color-mix(in srgb, var(--accent-green) 8%, transparent);
}
.bgd-checklist-row input[type="checkbox"] {
flex-shrink: 0;
}
.bgd-checklist-name {
font-weight: 600;
color: var(--text);
}
.bgd-checklist-meta {
color: var(--text-muted);
font-size: 0.78rem;
margin-left: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bgd-checklist-meta {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
color: var(--text-muted);
font-size: 0.78rem;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.bgd-checklist-mode {
color: var(--text-muted);
opacity: 0.85;
}
/* A dot and a word. The dot carries the state, so the word can stay a word. */
.bgd-state {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
min-width: 9.5rem;
color: var(--text-muted);
font-size: 0.76rem;
white-space: nowrap;
}
.bgd-state-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: currentcolor;
opacity: 0.55;
flex-shrink: 0;
}
.bgd-state[data-state="active"] {
color: var(--accent-green);
font-weight: 600;
}
.bgd-state[data-state="active"] .bgd-state-dot {
opacity: 1;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-green) 22%, transparent);
}
.bgd-state[data-state="missing_bookmark"],
.bgd-state[data-state="no_supported_decoders"] {
color: var(--color-error, #c0392b);
}
.bgd-state[data-state="unselected"] {
min-width: 0;
}
.bgd-checklist-empty {
padding: 0.75rem;
padding: 1rem 0.75rem;
text-align: center;
color: var(--text-muted);
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 {
display: flex;
flex-shrink: 0;
gap: 0.5rem;
}
.bgd-select-btn {
@@ -5851,15 +5991,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
background: var(--card-bg);
color: var(--text);
}
/* ── SVG State Dot Badges ─────────────────────────────────────────── */
.bgd-state-dot {
width: 8px;
height: 8px;
display: inline-block;
vertical-align: middle;
margin-right: 4px;
fill: currentColor;
}
@media (max-width: 600px) {
.channel-scheduler-controls {
flex-direction: column;
@@ -5900,8 +6031,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.bm-page-summary {
text-align: center;
}
.bgd-add-row,
.bgd-status-row {
.bgd-add-row {
flex-direction: column;
align-items: stretch;
}
@@ -5911,8 +6041,17 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.interleave-ring-label {
max-width: 8rem;
}
.bgd-checklist-meta {
margin-left: 0;
/* Two lines rather than four columns: the name and its state stay legible
when there is no room for the frequency beside them. */
.bgd-checklist-row {
grid-template-columns: auto minmax(0, 1fr);
row-gap: 0.2rem;
}
.bgd-checklist-meta,
.bgd-state {
grid-column: 2;
justify-content: flex-start;
min-width: 0;
font-size: 0.75rem;
}
}
@@ -6135,8 +6274,16 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.controls-tray { container-type: inline-size; container-name: controls; }
.decode-history-table-wrap { container-type: inline-size; container-name: decode-table; }
@container controls (max-width: 600px) {
.controls-tray .controls-row { flex-wrap: wrap; }
/* Wrapping a narrow tray's columns onto more lines is right while the row runs
left to right. Below the phone breakpoint the row is a column, and wrap there
means "start another column" which is how the transmit controls ended up
beside the tray instead of under it, off the side of the screen. This rule
outranks the phone one (two classes to its one) and sits later in the file,
so it has to exclude itself rather than be overridden. */
@media (min-width: 521px) {
@container controls (max-width: 600px) {
.controls-tray .controls-row { flex-wrap: wrap; }
}
}
@container decode-table (max-width: 500px) {
.decode-history-table th:nth-child(n+4),
@@ -23,12 +23,14 @@ await build({
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
sstv: path.join(sourceDir, "plugins", "sstv.ts"),
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
ais: path.join(sourceDir, "plugins", "ais.ts"),
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
sat: path.join(sourceDir, "plugins", "sat.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"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs",
"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"
},
"devDependencies": {
@@ -51,7 +51,7 @@ export type RigRxStatus = { sig: number | null, };
export type RigStatus = { freq: Freq, mode: RigMode, tx_en: boolean, vfo: RigVfo | null, tx: RigTxStatus | null, rx: RigRxStatus | null, lock: boolean | null, };
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, sstv_decode_enabled: boolean, recorder_enabled: boolean, };
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
@@ -78,6 +78,10 @@ export type RdsData = { pi?: number | null, program_service?: string | null, rad
export type SpectrumData = {
/**
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
*
* On the wire these are base64-encoded `i8` whole dBFS (see
* `spectrum_wire`), which is what the display draws anyway; the TypeScript
* type describes the decoded array the browser receives over SSE.
*/
bins: Array<number>,
/**
@@ -111,7 +115,7 @@ export type RigSnapshot = { info: RigInfo, status: RigStatus, band: string | nul
/**
* Per-virtual-channel RDS snapshots, when available.
*/
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, sstv_decode_enabled: boolean, recorder_enabled: boolean, };
export type RigListItem = { remote: string, display_name: string | null, manufacturer: string, model: string, supported_modes: Array<RigMode>, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, };
@@ -385,11 +385,14 @@ declare global {
refreshCwTonePicker?(): void;
updateFt8RfDisplay?(): void;
clearSatPredictionDom?(): void;
refreshSatPredictions?(): void;
syncWefaxToggle?(enabled: boolean): void;
syncSstvToggle?(enabled: boolean): void;
updateAisBar?(value?: number): void;
updateVdesBar?(value?: number): void;
updateAprsBar?(value?: number): void;
updateFt8Bar?(value?: number): void;
updateCwBar?(value?: number): void;
updateSatLiveState?(value: unknown): void;
applyCwAutoUi?(enabled: boolean): void;
applyCwAutoUiFromServer?(enabled: boolean): void;
@@ -858,7 +861,7 @@ function currentDecodeHistoryRetentionMs() {
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
window.applyDecodeHistoryRetention = function() {
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) {
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"]) {
window.trxPluginRuntime.prune(decoder);
}
};
@@ -1465,9 +1468,13 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
// Told every time, not only when the list changes: these modules load lazily
// and can miss the one telling there was, which left them holding no rig and
// showing an empty panel until the operator switched rigs. Both ignore a rig
// they already have.
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
if (rigListChanged) {
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.populateScopePicker();
void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
}
@@ -2205,6 +2212,17 @@ function resetRdsDisplay() {
updateRdsPsOverlay(primaryRds);
}
// The mini views over the waterfall show only what the rig on screen heard, so
// they have to be repainted whenever that rig — or its mode — changes, not just
// when the next decode happens to arrive.
function refreshDecodeBars() {
window.updateAisBar?.();
window.updateVdesBar?.();
window.updateAprsBar?.();
window.updateFt8Bar?.();
window.updateCwBar?.();
}
function resetDecoderStateOnRigSwitch() {
// RDS
primaryRds = null;
@@ -2224,6 +2242,12 @@ function resetDecoderStateOnRigSwitch() {
const el = document.getElementById(id);
if (el) el.textContent = "--";
});
// The decode stream is not rig-scoped, so the histories behind the mini views
// and the decoder panels survive the switch: repaint both for the rig now
// selected instead of leaving the outgoing rig's traffic on screen.
refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
}
function resetWfmStereoIndicator() {
@@ -3674,10 +3698,7 @@ function render(update: AppUpdate) {
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
}
if (window.updateAisBar) window.updateAisBar();
if (window.updateVdesBar) window.updateVdesBar();
if (window.updateAprsBar) window.updateAprsBar();
if (window.updateFt8Bar) window.updateFt8Bar();
refreshDecodeBars();
// Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong.
for (const d of decoderRegistry) {
if (d.activation !== "toggle") continue;
@@ -3712,7 +3733,11 @@ function render(update: AppUpdate) {
for (const [key, entry] of Object.entries(_decoderToggles)) {
syncDecoderToggle(entry, !!update[key], entry.label);
}
// WEFAX toggle sync (plugin-owned, belt-and-suspenders alongside _decoderToggles).
// Image decoders own their own toggle buttons; keep them in step with the
// rig state as well as with the click that set it.
if (typeof update.sstv_decode_enabled === "boolean" && window.syncSstvToggle) {
window.syncSstvToggle(update.sstv_decode_enabled);
}
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
window.syncWefaxToggle(update.wefax_decode_enabled);
}
@@ -4844,6 +4869,7 @@ function _initMapWhenReady() {
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
@@ -4884,7 +4910,15 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
updateTabHistory(name, replaceHistory);
}
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") {
_initMapWhenReady();
}
@@ -5006,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() {
settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
@@ -5159,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
// 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
void initializeApp();
@@ -5236,10 +5283,6 @@ function _wireSubTabBar(bar: WiredElement) {
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);
@@ -6567,11 +6610,55 @@ function updateDecodeStatus(text: string) {
if (el && el.textContent !== "Receiving") el.textContent = text;
}
}
// Picture decoders produce one message per image and a stream of progress
// updates; neither is a spot, and counting them would swamp the statistics.
const IMAGE_DECODE_KINDS = new Set([
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
]);
// The decode log the Statistics page counts lives in the map module, which is
// lazy: it arrives when the Map or Statistics tab is first opened, long after
// the history replayed and the live decodes started coming. Recording into a
// module that is not there yet dropped every one of them, and nothing replayed
// them afterwards -- so the page opened empty and only filled in from decodes
// heard after it, which is why it took a reload (landing on the tab, so the
// module loads at startup) to show the whole picture. Hold them here until
// the module arrives, then hand them over.
interface PendingDecodeStat { kind: string; rig: string | null; tsMs: number | undefined }
const pendingDecodeStats: PendingDecodeStat[] = [];
// The module's own log keeps 50k entries; there is no point holding more here.
const PENDING_DECODE_STATS_MAX = 50_000;
function recordDecodeStat(kind: string, rig: string | null, tsMs?: number) {
const stats = window.trx.modules.map;
if (stats) {
stats.statsRecordDecode(kind, rig, tsMs);
return;
}
pendingDecodeStats.push({ kind, rig, tsMs });
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
}
}
function flushPendingDecodeStats() {
const stats = window.trx.modules.map;
if (!stats || pendingDecodeStats.length === 0) return;
for (const entry of pendingDecodeStats.splice(0)) {
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
}
stats.scheduleStatsRender();
}
function scheduleStatsRenderIfLoaded() {
window.trx.modules.map?.scheduleStatsRender();
}
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender();
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
scheduleStatsRenderIfLoaded();
}
}
@@ -6619,11 +6706,11 @@ function loadDecodeHistoryOnMainThread(onReady: (groups: DecodeHistoryGroups) =>
function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
if (!Array.isArray(messages) || messages.length === 0) return;
// Record statistics for restored history messages.
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
if (!IMAGE_DECODE_KINDS.has(kind)) {
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);
}
@@ -5,7 +5,7 @@
export {};
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"] as const;
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"] as const;
type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number];
type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
interface DecodeState { offset: number }
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
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;
export type TabName = typeof TAB_ORDER[number];
@@ -13,6 +13,7 @@ export const TAB_PATHS: Readonly<Record<TabName, string>> = {
bookmarks: "/bookmarks",
"digital-modes": "/digital-modes",
map: "/map",
satellites: "/satellites",
statistics: "/statistics",
recorder: "/recorder",
settings: "/settings",
@@ -31,7 +31,7 @@ function mapEl(id: string): 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 DecodeDetail {
station?: string | null; source?: string | null; target?: string | null;
@@ -83,6 +83,9 @@ interface MapEntry {
bookmarks?: Array<Record<string, unknown>>;
bounds?: Leaflet.LatLngBoundsExpression;
symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
/** Callsign as heard. The map key qualifies it with the source, so that a
* station worked on both 144 MHz and 30 m keeps one marker per band. */
call?: string;
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
pathKey?: string; sourceGrid?: string; targetGrid?: string;
from?: LatLon; to?: LatLon;
@@ -171,7 +174,7 @@ interface MapWindow {
clearMapMarkersByType?(type: DecoderSource): void;
navigateToAprsMap?(lat: number, lon: number): void;
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage): 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;
vdesMapAddPoint?(message: MapMessage): 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>();
let selectedMapQsoKey: string | null = null;
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 };
/** Chip key that clears a selection rather than naming a band or a source. */
const MAP_FILTER_ALL_KEY = "__all";
@@ -302,6 +305,18 @@ const mapWindow = window as unknown as MapWindow;
return trimmed;
}
/** The source a station marker belongs to, defaulting to VHF APRS for
* entries stored before HF APRS had a source of its own. */
function aprsEntrySource(entry: MapEntry | null | undefined): DecoderSource {
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
}
/** Map key for a station: the callsign alone on VHF, source-qualified on HF,
* so one station heard on both bands keeps a marker for each. */
function aprsStationKey(call: string, source: DecoderSource): string {
return source === "aprs" ? call : `${source}:${call}`;
}
function refreshAprsTrack(call: string, entry: MapEntry): void {
if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
@@ -323,7 +338,7 @@ const mapWindow = window as unknown as MapWindow;
lineJoin: "round",
interactive: false,
}) as unknown as TrxLayer;
track.__trxType = "aprs";
track.__trxType = aprsEntrySource(entry);
track._aprsCall = call;
entry.track = track;
}
@@ -575,6 +590,7 @@ const mapWindow = window as unknown as MapWindow;
function mapSourceLabel(type: DecoderSource): string {
if (type === "bookmark") return "Bookmarks";
if (type === "hf_aprs") return "HF APRS";
return String(type || "").toUpperCase();
}
@@ -594,6 +610,8 @@ const mapWindow = window as unknown as MapWindow;
if (type === "vdes") return "#a78bfa";
if (type === "sat") return "#f59e0b";
if (type === "aprs") return "#00d17f";
// Far enough from the VHF green to tell the two apart at a glance.
if (type === "hf_aprs") return "#fb7185";
return locatorFilterColor(type);
}
@@ -1188,10 +1206,8 @@ const mapWindow = window as unknown as MapWindow;
}
}
for (const entry of stationMarkers.values()) {
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
availableSources.add("aprs");
break;
}
if (!entry?.visibleInHistoryWindow) continue;
availableSources.add(aprsEntrySource(entry));
}
const bandMap = new Map<string, FilterChip>();
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);
}
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))
.map((key) => ({
key,
@@ -1296,9 +1312,10 @@ const mapWindow = window as unknown as MapWindow;
}
return parts.join(" ").toLowerCase();
}
if (type === "aprs") {
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(call);
if (type === "aprs" || type === "hf_aprs") {
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(key);
const call = entry?.call ?? key;
const info = entry?.info ? String(entry.info) : "";
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
return `${call} ${info} ${pktRaw}`.toLowerCase();
@@ -1496,9 +1513,10 @@ const mapWindow = window as unknown as MapWindow;
};
mapWindow.clearMapMarkersByType = function(type) {
if (type === "aprs") {
if (type === "aprs" || type === "hf_aprs") {
selectedAprsTrackCall = null;
stationMarkers.forEach((entry) => {
stationMarkers.forEach((entry, key) => {
if (aprsEntrySource(entry) !== type) return;
if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
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);
mapMarkers.delete(entry.track);
}
stationMarkers.delete(key);
});
stationMarkers.clear();
return;
}
@@ -1781,13 +1799,15 @@ const mapWindow = window as unknown as MapWindow;
if (!ll) return;
const entry = stationMarkers.get(marker._aprsCall);
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);
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
entry.track.addTo(aprsMap);
}
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;
}
@@ -2270,28 +2290,33 @@ const mapWindow = window as unknown as MapWindow;
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;
refreshAprsTrack(call, entry);
refreshAprsTrack(key, entry);
const source = aprsEntrySource(entry);
const call = entry.call ?? key;
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
const color = mapSourceColor(source);
const marker = (icon
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
: L.circleMarker([entry.lat, entry.lon], {
radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8
radius: 6, color, fillColor: color, fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
marker.__trxType = "aprs";
marker.__trxType = source;
marker.__trxRigIds = entry.rigIds || new Set();
marker._aprsCall = call;
marker._aprsCall = key;
entry.marker = 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 tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
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) {
existing.pkt = pkt;
existing.lat = lat;
@@ -2310,7 +2335,8 @@ const mapWindow = window as unknown as MapWindow;
} else if (prevPoint) {
prevPoint.tsMs = tsMs;
}
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
existing.call = call;
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -2321,7 +2347,8 @@ const mapWindow = window as unknown as MapWindow;
track: null,
trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint],
type: "aprs",
type: stationSource,
call,
pkt,
lat,
lon,
@@ -2330,9 +2357,9 @@ const mapWindow = window as unknown as MapWindow;
symbolCode,
rigIds: new Set(msgRigId ? [msgRigId] : []),
};
stationMarkers.set(call, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
stationMarkers.set(key, entry);
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
if (aprsMap) scheduleDecodeMapMaintenance();
}
};
@@ -2,7 +2,9 @@
//
// 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[]>> = {
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
@@ -11,10 +13,11 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
// the Map tab. Their map calls are optional, so map-core stays lazy.
"digital-modes": [
"/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js",
"/sat.js", "/wefax.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js",
"/sat.js", "/wefax.js", "/sstv.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"],
satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
plugin.prune();
return true;
},
rerenderAll() { for (const plugin of decoders.values()) plugin.rerender?.(); },
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
clearQueued() { queued.clear(); },
hasDecoder: (id) => decoders.has(id),
@@ -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 {};
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface AisMessage {
@@ -190,20 +191,27 @@ function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
return Array.from(byMmsi.values());
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function aisRigMessages(): AisMessage[] {
return forActiveRig(aisMessageHistory);
}
function updateAisSummary() {
const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
}
const vessels = aisLatestByVessel(aisMessageHistory);
const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
if (aisVesselCountEl) {
const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
}
if (aisLatestSeenEl) {
const latest = aisMessageHistory[0];
const latest = rigMessages[0];
if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet";
} else {
@@ -314,7 +322,9 @@ function updateAisBar() {
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
const recent = aisMessageHistory.filter(
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id),
);
const messages = aisLatestByVessel(recent).slice(0, 8);
if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none";
@@ -368,7 +378,7 @@ function renderAisHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of aisMessageHistory) {
for (const message of aisRigMessages()) {
fragment.appendChild(renderAisRow(message));
}
aisMessagesEl.replaceChildren(fragment);
@@ -407,10 +417,11 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage {
function onServerAisBatch(messages: AisMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized: AisMessage[] = [];
for (const msg of messages) {
const next = normalizeServerAisMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -452,8 +463,9 @@ if (aisFilterInput) {
}
function onServerAis(msg: AisMessage): void {
if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg));
const message = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
}
updateAisSummary();
@@ -464,6 +476,7 @@ updateAisSummary();
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
rerender: () => { updateAisBar(); renderAisHistory(); },
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
});
@@ -4,6 +4,7 @@
import { hostCore, hostState } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import {
aprsAgeText,
aprsPacketCategory,
@@ -106,21 +107,29 @@ function aprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(aprsFilterText);
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function aprsRigPackets(): AprsPacket[] {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets(): AprsPacket[] {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(aprsFilterMatch);
}
function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets();
if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
}
if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0];
const latest = rigPackets[0];
if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -179,7 +188,9 @@ function updateAprsBar() {
if (!aprsBarOverlay) return;
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
const okFrames = aprsPacketHistory.filter(
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id),
);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
@@ -256,11 +267,12 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -324,8 +336,9 @@ if (aprsFilterInput) {
// --- Server-side APRS decode handler ---
function onServerAprs(pkt: AprsPacket): void {
if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
const packet = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
}
renderAprsHistory();
@@ -336,6 +349,7 @@ renderAprsHistory();
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
rerender: () => { updateAprsBar(); renderAprsHistory(); },
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
});
@@ -66,10 +66,17 @@ const bgdWindow = window as unknown as BackgroundBridge;
let bookmarkList: Bookmark[] = [];
let statusInterval: ReturnType<typeof setInterval> | null = null;
let bgdDirty = false;
/** Last polled state per bookmark, so a row can say what it is doing. */
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
let lastStatus: BackgroundDecodeStatus | null = null;
function initBackgroundDecode(rigId: string | null, role: string | null): void {
backgroundDecodeRole = role;
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();
startStatusPolling();
}
@@ -171,7 +178,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist();
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
const isControl = isControlRole();
const panel = document.getElementById("background-decode-panel");
if (panel) {
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");
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
syncSaveButton();
}
function currentFilterText(): string {
return (document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value ?? "";
}
function renderBookmarkChecklist(filterText = ""): void {
@@ -203,26 +215,65 @@ const bgdWindow = window as unknown as BackgroundBridge;
: all;
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;
}
filtered.forEach(function (bookmark) {
const row = document.createElement("label");
row.className = "bgd-checklist-row";
row.dataset.bmId = bookmark.id;
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 =
'<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-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) {
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
});
container.appendChild(row);
});
renderSelectionSummary();
}
/** Why the list is empty, in the terms the operator can act on. */
function emptyListText(supportedCount: number): string {
if (supportedCount > 0) return "No bookmark matches that filter.";
if (bookmarkList.length > 0) {
return "None of your bookmarks name a decoder that can run in the background."
+ " Give one a decoder on the Bookmarks tab to list it here.";
}
return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
}
/** The live state of a selected bookmark, as the row's own badge. */
function stateBadgeHtml(bookmarkId: string, selected: boolean): string {
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
const entry = statusByBookmark.get(bookmarkId);
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '">'
+ '<span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
}
function renderSelectionSummary(): void {
const el = document.getElementById("bgd-selection-summary");
if (!el) return;
const selected = currentConfig?.bookmark_ids.length ?? 0;
if (selected === 0) {
el.textContent = "Nothing selected — background decoding is idle.";
return;
}
const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
el.textContent = currentConfig?.enabled
? `${noun}, ${String(active)} decoding now.`
: `${noun}. Switch Enabled on to start decoding them.`;
}
function onChecklistToggle(bookmarkId: string, checked: boolean): void {
@@ -260,7 +311,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
showToast(`Save failed: ${errorMessage(err)}`, true);
})
.finally(function () {
if (btn) btn.disabled = false;
syncSaveButton();
});
}
@@ -295,58 +346,77 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
function renderStatus(status: BackgroundDecodeStatus): void {
const card = document.getElementById("background-decode-status-card");
if (!card) return;
const entries = status.entries ?? [];
if (!entries.length) {
card.textContent = "No background decode bookmarks configured.";
return;
}
const summary = [];
if (status.active_rig) {
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
} else {
summary.push("This rig is not currently selected for audio.");
}
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
html += '<div class="bgd-status-list">';
entries.forEach(function (entry) {
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
const parts = [];
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
if (entry.mode) parts.push(entry.mode);
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
parts.push(entry.decoder_kinds.join("/").toUpperCase());
}
html +=
'<div class="bgd-status-row">' +
'<div>' +
'<div class="bgd-status-name">' + escHtml(name) + '</div>' +
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
'</div>' +
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' +
'<svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' +
escHtml(prettyState(entry.state)) + '</div>' +
'</div>';
});
html += "</div>";
card.innerHTML = html;
lastStatus = status;
statusByBookmark = new Map(
(status.entries ?? [])
.filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0)
.map((entry) => [entry.bookmark_id as string, entry]),
);
renderSpanSummary();
// Rows carry the state, so the list repaints — keeping the filter the
// operator typed and the selection they have not saved yet.
renderBookmarkChecklist(currentFilterText());
}
/** What the rig is listening across, which is what decides whether a
* selected bookmark can be decoded at all. */
function renderSpanSummary(): void {
const el = document.getElementById("bgd-span-summary");
if (!el) return;
const status = lastStatus;
if (!status) {
el.textContent = "";
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 {
switch (state) {
case "active": return "\u2713 Active";
case "out_of_span": return "\u25B3 Out of span";
case "waiting_for_spectrum": return "\u25B3 Waiting";
case "waiting_for_user": return "\u25B3 No user";
case "missing_bookmark": return "\u2717 Missing";
case "no_supported_decoders": return "\u2717 Unsupported";
case "disabled": return "\u25B3 Disabled";
case "handled_by_scheduler": return "\u25B3 Scheduler";
case "scheduler_has_control": return "\u25B3 Scheduler";
case "handled_by_virtual_channel": return "\u25B3 VChan";
default: return "\u25B3 Inactive";
case "active": return "Decoding";
case "out_of_span": return "Out of span";
case "waiting_for_spectrum": return "Waiting for spectrum";
case "waiting_for_user": return "Nobody listening";
case "missing_bookmark": return "Bookmark gone";
case "no_supported_decoders": return "No decoder";
case "disabled": return "Off";
case "handled_by_scheduler":
case "scheduler_has_control": return "Scheduler has it";
case "handled_by_virtual_channel": return "On a channel";
case "pending": return "Not saved";
default: return "Idle";
}
}
@@ -380,14 +450,25 @@ const bgdWindow = window as unknown as BackgroundBridge;
function markBgdDirty() {
if (bgdDirty) return;
bgdDirty = true;
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.add("sch-dirty");
syncSaveButton();
}
function clearBgdDirty() {
bgdDirty = false;
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.remove("sch-dirty");
syncSaveButton();
}
/** Save offers itself only when there is something to save. */
function syncSaveButton() {
const btn = document.getElementById("background-decode-save-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.classList.toggle("sch-dirty", bgdDirty);
btn.disabled = !bgdDirty || !isControlRole();
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
}
function isControlRole(): boolean {
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
}
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; });
currentConfig.bookmark_ids = ids;
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
renderBookmarkChecklist(currentFilterText());
markBgdDirty();
}
@@ -416,7 +497,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
currentConfig.bookmark_ids = [];
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
renderBookmarkChecklist(currentFilterText());
markBgdDirty();
}
@@ -432,7 +513,11 @@ const bgdWindow = window as unknown as BackgroundBridge;
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
if (enabledCb && !enabledCb._wired) {
enabledCb._wired = true;
enabledCb.addEventListener("change", function () { 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;
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js";
import { isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
@@ -20,8 +21,11 @@ interface CwRenderer {
drawPoints(points: number[], size: number, color: Rgba): void;
}
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number }
interface CwEvent { rig_id?: string | null; text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
interface CwLine {
rigId: string | null; tsMs: number; ts: string; text: string;
wpm: number | null; tone_hz: number | null; lastMs: number;
}
interface CwToneRange {
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
toneSpanHz: number; lowerSideband: boolean; mode: string;
@@ -66,7 +70,10 @@ const CW_BAR_LINE_GAP_MS = 5000;
let cwLastAppendTime = 0;
let cwTonePickerRaf: number | null = null;
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;
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
// acknowledged). While set, server-state updates must not override the
@@ -101,12 +108,13 @@ cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
applyCwAutoUi(enabled);
};
function cwBarFlushCurrentLine(): void {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine);
function cwBarFlushCurrentLine(key: string): void {
const line = cwBarCurrentLines.get(key);
cwBarCurrentLines.delete(key);
if (line?.text.trim()) {
cwBarHistory.unshift(line);
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
}
cwBarCurrentLine = null;
}
function updateCwBar(): void {
@@ -114,9 +122,12 @@ function updateCwBar(): void {
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
const isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
// Prepend the in-progress line so characters appear immediately
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
// Prepend the in-progress lines so characters appear immediately
const inProgress = [...cwBarCurrentLines.values()]
.filter((l) => l.text && isActiveRigDecode(l.rigId))
.sort((a, b) => b.tsMs - a.tsMs);
const liveLines = [...inProgress, ...recent];
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none";
@@ -402,7 +413,7 @@ function resetCwHistoryView(): void {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBarHistory = [];
cwBarCurrentLine = null;
cwBarCurrentLines.clear();
updateCwBar();
drawCwTonePicker();
}
@@ -421,8 +432,13 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
// --- Server-side CW decode handler ---
function onServerCw(evt: CwEvent): void {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
// The panel is a single stream of copied text: characters from a background
// rig do not belong in it at all, and interleaving them would make both
// rigs unreadable. The bar histories below keep every rig, each on its own
// line, and pick out the selected rig when they paint.
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl && forSelectedRig) {
// Append decoded text to output
const now = Date.now();
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)
if (evt.text) {
const now = Date.now();
const rigId = evt.rig_id ?? null;
const key = rigId ?? "";
if (evt.text === "\n") {
cwBarFlushCurrentLine();
cwBarFlushCurrentLine(key);
} else {
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine();
let line = cwBarCurrentLines.get(key);
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" });
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;
cwBarCurrentLine.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
line.text += evt.text;
line.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
}
updateCwBar();
}
if (cwSignalIndicator) {
if (cwSignalIndicator && forSelectedRig) {
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))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm));
}
@@ -491,6 +511,13 @@ cwWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerCw,
restore: restoreCwHistory,
reset: resetCwHistoryView,
// The copied text of a rig that is no longer on screen cannot be unpicked
// from the pane, so the switch starts the new rig's stream from empty.
rerender: () => {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
updateCwBar();
},
});
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
@@ -15,6 +16,7 @@ export interface FtxMessage {
snr_db?: number | undefined;
dt_s?: number | undefined;
freq_hz?: number | undefined;
rig_id?: string | null | undefined;
receiver?: unknown;
[key: string]: unknown;
}
@@ -227,9 +229,12 @@ export function initializeFtxDecoder(config: FtxConfig): void {
const render = (): void => {
prune();
if (!messagesElement) return;
// The history holds every rig's decodes, for the map; the panel is about
// the rig the operator is working.
const rigMessages = forActiveRig(history);
const fragment = document.createDocumentFragment();
let count = 0;
for (const message of history) {
for (const message of rigMessages) {
if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message));
@@ -237,20 +242,32 @@ export function initializeFtxDecoder(config: FtxConfig): void {
}
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 locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0
? locatorDetails.map(({ grid }) => grid)
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
if (grids.length === 0) return;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
// Already an RF frequency on a replay, an audio offset on arrival; the
// conversion only fires below 100 kHz, so it is right either way.
const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) {
bridge.mapAddLocator?.(raw, grids, id, station, {
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
});
}
bridge.mapAddLocator?.(raw, grids, id, station, {
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
});
};
const normalize = (message: FtxMessage): FtxMessage => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
return {
// The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band.
rig_id: message.rig_id ?? null,
receiver: bridge.getDecodeRigMeta?.() ?? null,
ts_ms: message.ts_ms,
snr_db: message.snr_db,
@@ -262,7 +279,10 @@ export function initializeFtxDecoder(config: FtxConfig): void {
};
const receiveBatch = (messages: FtxMessage[]): void => {
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);
prune();
bridge.setFt8FamilyBarDecoder?.(id);
@@ -277,7 +297,8 @@ export function initializeFtxDecoder(config: FtxConfig): void {
};
const barFrames = (): BarFrames => {
const recent = history
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000)
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000
&& isActiveRigDecode(message.rig_id))
.slice(0, 8);
let html = "";
for (const message of recent) {
@@ -299,6 +320,9 @@ export function initializeFtxDecoder(config: FtxConfig): void {
restore: receiveBatch,
prune: () => { prune(); render(); },
reset,
rerender: () => { bridge.updateFt8Bar?.(); render(); },
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => { for (const message of [...history].reverse()) plotLocator(message); },
});
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
@@ -4,6 +4,7 @@
import { hostCore, hostState } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import {
aprsAgeText,
aprsPacketCategory,
@@ -19,6 +20,12 @@ interface HfAprsBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
navigateToAprsMap?: (lat: number, lon: number) => void;
aprsMapAddStation?: (
call: string, lat: number, lon: number, info: string,
symbolTable: string | null | undefined, symbolCode: string | null | undefined,
packet: AprsPacket, source: "aprs" | "hf_aprs",
) => void;
clearMapMarkersByType?: (type: string) => void;
getDecodeRigMeta?: () => unknown;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
@@ -90,23 +97,31 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(hfAprsFilterText);
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function hfAprsRigPackets(): AprsPacket[] {
return forActiveRig(hfAprsPacketHistory);
}
function hfAprsVisiblePackets(): AprsPacket[] {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(hfAprsFilterMatch);
}
const collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
}
if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0];
const latest = rigPackets[0];
if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -168,6 +183,7 @@ function resetHfAprsHistoryView(): void {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
}
function pruneHfAprsHistoryView(): void {
@@ -175,6 +191,17 @@ function pruneHfAprsHistoryView(): void {
renderHfAprsHistory();
}
/** Hands a positioned packet to the map, if the map module is loaded yet.
* HF traffic goes on as its own source: it is a different band and a
* different path, and the map filter offers it separately. */
function plotHfAprsPacket(pkt: AprsPacket): void {
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
hfAprsWindow.aprsMapAddStation(
pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "",
pkt.symbolTable, pkt.symbolCode, pkt, "hf_aprs",
);
}
function addHfAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
@@ -183,6 +210,8 @@ function addHfAprsPacket(pkt: AprsPacket): void {
hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender();
}
@@ -192,13 +221,15 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerHfAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = [];
for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotHfAprsPacket(next);
normalized.push(next);
}
normalized.reverse();
@@ -266,8 +297,9 @@ if (hfAprsFilterInput) {
// --- Server-side HF APRS decode handler ---
function onServerHfAprs(pkt: AprsPacket): void {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
const packet = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
}
renderHfAprsHistory();
@@ -278,4 +310,7 @@ renderHfAprsHistory();
restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView,
rerender: renderHfAprsHistory,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry); },
});
@@ -9,6 +9,10 @@ export interface DecoderPlugin<TMessage = unknown> {
restore?(messages: TMessage[]): void;
reset?(): void;
prune?(): void;
/** Repaint the plugin's panel from what it already holds. Called when the
* selected rig changes: the panels show one rig's traffic, and the decodes
* behind them arrived long before the switch. */
rerender?(): void;
/** Replay everything the plugin is holding onto the map. Called when the map
* module attaches, which can happen long after the decodes arrived. */
syncMap?(): void;
@@ -22,6 +26,7 @@ export interface TrxPluginRuntime {
reset(id: string): boolean;
resetAll(): void;
prune(id: string): boolean;
rerenderAll(): void;
syncMapAll(): void;
clearQueued(): void;
hasDecoder(id: string): boolean;
@@ -4,13 +4,12 @@
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";
type SatelliteView = "live" | "history" | "predictions";
type SatelliteView = "live" | "history";
interface SatelliteBridge {
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
clearSatPredictionDom?: () => void;
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
addSatMapOverlay?: (image: SatelliteImage) => void;
clearSatMapOverlays?: () => void;
@@ -25,14 +24,14 @@ const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow;
// --- SAT Plugin ---
// Live view: decoder state, latest image card
// 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) ───────────────────────────────────
const satDom = {
status: document.getElementById("sat-status"),
liveView: document.getElementById("sat-live-view"),
historyView: document.getElementById("sat-history-view"),
predictionsView: document.getElementById("sat-predictions-view"),
liveLatest: document.getElementById("sat-live-latest"),
historyList: document.getElementById("sat-history-list"),
historyCount: document.getElementById("sat-history-count"),
@@ -42,30 +41,13 @@ const satDom = {
lrptState: document.getElementById("sat-lrpt-state"),
viewLiveBtn: document.getElementById("sat-view-live"),
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 ───────────────────────────────────────────────────────────
let satImageHistory: SatelliteImage[] = [];
const SAT_MAX_IMAGES = 100;
const SAT_PRED_PAGE_SIZE = 50;
let satPredShowAll = false;
let satFilterText = "";
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 ─────────────────────────────────────────────
function scheduleSatUi(key: string, job: () => void): void {
@@ -78,33 +60,16 @@ function scheduleSatUi(key: string, job: () => void): void {
// ── View switching ──────────────────────────────────────────────────
function switchSatView(view: SatelliteView): void {
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
satActiveView = view;
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "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.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
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();
}
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
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 (view === "history") renderSatHistoryTable();
}
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.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); });
// ── Live view: decoder state ────────────────────────────────────────
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 ──────────────
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
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();
};
@@ -0,0 +1,373 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// sstv.ts — SSTV decoder panel.
//
// Live view: decoder state, a canvas the picture fills row by row as it
// arrives, and a card for the last one received. History view: a filterable
// table of received pictures with thumbnails.
//
// Watching a picture build up is most of the appeal of the mode, so rows are
// painted as they arrive rather than waiting for the frame to finish — a
// transmission takes between 36 seconds and two minutes.
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
interface SstvImage {
rig_id?: string | null;
ts_ms?: number;
vis?: number;
mode?: string;
width?: number;
height?: number;
lines?: number;
complete?: boolean;
path?: string;
_tsMs?: number;
_ts?: string;
}
interface SstvProgress {
rig_id?: string | null;
state?: string;
mode?: string;
width?: number;
height?: number;
line?: number;
line_data?: string;
}
interface SstvBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
syncSstvToggle?: (enabled: boolean) => void;
}
const sstvWindow = window as unknown as SstvBridge & PluginRuntimeWindow;
const sstvDom = {
status: document.getElementById("sstv-status"),
liveView: document.getElementById("sstv-live-view"),
historyView: document.getElementById("sstv-history-view"),
liveContainer: document.getElementById("sstv-live-container"),
liveInfo: document.getElementById("sstv-live-info"),
liveCanvas: document.getElementById("sstv-live-canvas") as HTMLCanvasElement | null,
liveLatest: document.getElementById("sstv-live-latest"),
historyList: document.getElementById("sstv-history-list"),
historyCount: document.getElementById("sstv-history-count"),
filterInput: document.getElementById("sstv-filter") as HTMLInputElement | null,
sortSelect: document.getElementById("sstv-sort") as HTMLSelectElement | null,
toggleBtn: document.getElementById("sstv-decode-toggle-btn"),
clearBtn: document.getElementById("sstv-clear-btn"),
viewLiveBtn: document.getElementById("sstv-view-live"),
viewHistoryBtn: document.getElementById("sstv-view-history"),
};
const SSTV_MAX_IMAGES = 100;
let sstvHistory: SstvImage[] = [];
let liveCtx: CanvasRenderingContext2D | null = null;
let liveMode = "";
let liveHeight = 0;
let liveRows = 0;
let activeView: "live" | "history" = "live";
let filterText = "";
// ── Helpers ─────────────────────────────────────────────────────────
function retentionMs(): number {
return sstvWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1000;
}
function pruneHistory() {
const cutoff = Date.now() - retentionMs();
sstvHistory = sstvHistory.filter((image) => (image._tsMs || 0) > cutoff);
}
function escapeHtml(value: unknown): string {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function scheduleUi(key: string, job: () => void): void {
if (typeof sstvWindow.trxScheduleUiFrameJob === "function") {
sstvWindow.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
/** The URL the server serves a saved picture from, given its stored path. */
function imageUrl(image: SstvImage): string | null {
if (!image.path) return null;
const filename = image.path.split(/[\\/]/).pop();
return filename ? `/sstv-images/${encodeURIComponent(filename)}` : null;
}
function decodeBase64(data: string): Uint8Array {
const binary = atob(data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes;
}
// ── View switching ──────────────────────────────────────────────────
function switchView(view: "live" | "history"): void {
activeView = view;
if (sstvDom.liveView) sstvDom.liveView.style.display = view === "live" ? "" : "none";
if (sstvDom.historyView) sstvDom.historyView.style.display = view === "history" ? "" : "none";
for (const button of [sstvDom.viewLiveBtn, sstvDom.viewHistoryBtn]) {
button?.classList.remove("sat-view-active");
}
if (view === "live") sstvDom.viewLiveBtn?.classList.add("sat-view-active");
else sstvDom.viewHistoryBtn?.classList.add("sat-view-active");
if (view === "history") renderHistoryTable();
}
sstvDom.viewLiveBtn?.addEventListener("click", () => { switchView("live"); });
sstvDom.viewHistoryBtn?.addEventListener("click", () => { switchView("history"); });
// ── Live canvas ─────────────────────────────────────────────────────
/** Start a new picture: size the canvas to the mode and clear it. */
function beginPicture(mode: string, width: number, height: number): void {
const canvas = sstvDom.liveCanvas;
if (!canvas || width <= 0 || height <= 0) return;
liveMode = mode;
liveHeight = height;
liveRows = 0;
canvas.width = width;
canvas.height = height;
liveCtx = canvas.getContext("2d");
if (!liveCtx) return;
// Mid-grey, not black: the rows below the one arriving have not been
// received, which is a different thing from having been received as black.
liveCtx.fillStyle = "#606060";
liveCtx.fillRect(0, 0, width, height);
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "";
updateLiveInfo();
}
function updateLiveInfo(): void {
if (!sstvDom.liveInfo) return;
const canvas = sstvDom.liveCanvas;
const size = canvas ? `${canvas.width}×${canvas.height}` : "";
sstvDom.liveInfo.textContent = liveMode
? `${liveMode} · ${size} · line ${liveRows}${liveHeight ? ` of ${liveHeight}` : ""}`
: "";
}
/** Paint one row of RGB triples at its own line number. */
function paintRow(line: number, rgb: Uint8Array): void {
const canvas = sstvDom.liveCanvas;
if (!liveCtx || !canvas) return;
const width = canvas.width;
if (line < 0 || line >= canvas.height || rgb.length < width * 3) return;
const row = liveCtx.createImageData(width, 1);
for (let x = 0; x < width; x += 1) {
row.data[x * 4] = rgb[x * 3] ?? 0;
row.data[x * 4 + 1] = rgb[x * 3 + 1] ?? 0;
row.data[x * 4 + 2] = rgb[x * 3 + 2] ?? 0;
row.data[x * 4 + 3] = 255;
}
liveCtx.putImageData(row, 0, line);
liveRows = Math.max(liveRows, line + 1);
}
// ── Server messages ─────────────────────────────────────────────────
function onProgress(msg: SstvProgress): void {
// The live canvas draws one picture at a time: a background rig's rows would
// paint straight over the one being watched.
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state;
// A state update carries the geometry; a row update carries only the row.
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
return;
}
if (typeof msg.line !== "number" || !msg.line_data) return;
const line = msg.line;
const rgb = decodeBase64(msg.line_data);
scheduleUi(`sstv-row-${line}`, () => {
paintRow(line, rgb);
updateLiveInfo();
});
}
function onImage(msg: SstvImage): void {
const image: SstvImage = { ...msg };
image._tsMs = typeof msg.ts_ms === "number" ? msg.ts_ms : Date.now();
image._ts = new Date(image._tsMs).toLocaleTimeString();
sstvHistory.push(image);
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory();
// Kept in the history either way, so switching to that rig finds it there.
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) {
sstvDom.status.textContent = image.complete
? `Received ${image.mode ?? "picture"}`
: `Partial ${image.mode ?? "picture"}${image.lines ?? 0} lines`;
}
scheduleUi("sstv-latest", renderLatestCard);
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
}
// ── Rendering ───────────────────────────────────────────────────────
/** The pictures the selected rig decoded, oldest first, as the history is kept. */
function sstvRigImages(): SstvImage[] {
return forActiveRig(sstvHistory);
}
function renderLatestCard(): void {
if (!sstvDom.liveLatest) return;
const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
if (!latest) {
sstvDom.liveLatest.innerHTML = "";
return;
}
const url = imageUrl(latest);
const lines = `${latest.lines ?? 0}${latest.height ? ` of ${latest.height}` : ""} lines`;
const state = latest.complete ? "complete" : "partial";
sstvDom.liveLatest.innerHTML = `
<div class="sat-latest-card">
<div style="display:flex; align-items:baseline; gap:0.5rem; flex-wrap:wrap;">
<strong>${escapeHtml(latest.mode ?? "SSTV")}</strong>
<small style="color:var(--text-muted);">${escapeHtml(latest._ts ?? "")} · ${lines} · ${state}</small>
</div>
${url
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">
<img src="${escapeHtml(url)}" alt="Received ${escapeHtml(latest.mode ?? "SSTV")} picture"
style="margin-top:0.4rem; width:100%; max-width:640px; image-rendering:pixelated;" />
</a>`
: ""}
</div>`;
}
function filteredHistory(): SstvImage[] {
// Every rig's pictures are kept; the panel shows the selected rig's.
const rigImages = sstvRigImages();
const text = filterText.trim().toLowerCase();
const matching = text
? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
: rigImages;
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching;
}
function renderHistoryTable(): void {
if (!sstvDom.historyList) return;
pruneHistory();
const rows = filteredHistory();
sstvDom.historyList.innerHTML = rows
.map((image) => {
const url = imageUrl(image);
const size = image.width && image.height ? `${image.width}×${image.height}` : "--";
const lines = image.complete
? String(image.lines ?? 0)
: `${image.lines ?? 0} (partial)`;
return `<div class="sat-history-row">
<span class="sat-col-time">${escapeHtml(image._ts ?? "")}</span>
<span class="sat-col-type">${escapeHtml(image.mode ?? "--")}</span>
<span class="sat-col-sat">${escapeHtml(size)}</span>
<span class="sat-col-lines">${escapeHtml(lines)}</span>
<span class="sat-col-link">${url
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">View</a>`
: "--"}</span>
</div>`;
})
.join("");
if (sstvDom.historyCount) {
sstvDom.historyCount.textContent = rows.length
? `${rows.length} picture${rows.length === 1 ? "" : "s"}`
: "No pictures yet";
}
}
sstvDom.filterInput?.addEventListener("input", () => {
filterText = sstvDom.filterInput?.value ?? "";
renderHistoryTable();
});
sstvDom.sortSelect?.addEventListener("change", () => { renderHistoryTable(); });
// ── Decoder history plumbing ────────────────────────────────────────
function restoreHistory(entries: unknown[]): void {
if (!Array.isArray(entries)) return;
for (const entry of entries) onImage(entry as SstvImage);
}
function resetHistoryView(): void {
sstvHistory = [];
liveRows = 0;
liveMode = "";
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "none";
if (sstvDom.status) sstvDom.status.textContent = "Idle";
renderLatestCard();
renderHistoryTable();
}
// ── Controls ────────────────────────────────────────────────────────
sstvWindow.syncSstvToggle = function syncSstvToggle(enabled: boolean) {
const button = sstvDom.toggleBtn as HTMLButtonElement | null;
if (!button) return;
button.textContent = enabled ? "Disable SSTV" : "Enable SSTV";
button.setAttribute("aria-pressed", String(enabled));
button.classList.toggle("is-active", enabled);
};
sstvDom.toggleBtn?.addEventListener("click", () => {
void (async () => {
try {
if (sstvDom.toggleBtn) {
await sstvWindow.takeSchedulerControlForDecoderDisable?.(sstvDom.toggleBtn);
}
await hostCore.postPath("/toggle_sstv_decode");
} catch (e) {
console.error("SSTV toggle failed", e);
}
})();
});
sstvDom.clearBtn?.addEventListener("click", () => {
void (async () => {
try {
await hostCore.postPath("/clear_sstv_decode");
resetHistoryView();
} catch (e) {
console.error("SSTV clear failed", e);
}
})();
});
renderLatestCard();
sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv",
onMessage: onImage as (msg: unknown) => void,
restore: restoreHistory,
prune: renderHistoryTable,
reset: resetHistoryView,
rerender: () => { renderLatestCard(); renderHistoryTable(); },
});
sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress",
onMessage: onProgress as (msg: unknown) => void,
});
@@ -6,6 +6,7 @@ import { hostCore } from "./host.js";
export {};
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface VdesMessage {
@@ -116,17 +117,24 @@ function vdesHexPreview(rawBytes: number[] | undefined): string {
.toUpperCase();
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function vdesRigMessages(): VdesMessage[] {
return forActiveRig(vdesMessageHistory);
}
function updateVdesSummary() {
pruneVdesMessageHistory();
if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText();
}
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) {
const count = vdesMessageHistory.length;
const count = rigMessages.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
}
if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0];
const latest = rigMessages[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
}
}
@@ -228,7 +236,9 @@ function updateVdesBar() {
updateVdesSummary();
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
const messages = vdesMessageHistory
.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id))
.slice(0, 6);
if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = "";
@@ -278,7 +288,7 @@ function renderVdesHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) {
for (const message of vdesRigMessages()) {
fragment.appendChild(renderVdesRow(message));
}
vdesMessagesEl.replaceChildren(fragment);
@@ -309,10 +319,11 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
function onServerVdesBatch(messages: VdesMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized: VdesMessage[] = [];
for (const msg of messages) {
const next = normalizeServerVdesMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -354,8 +365,8 @@ function plotVdesMessage(msg: VdesMessage): void {
}
function onServerVdes(msg: VdesMessage): void {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next);
plotVdesMessage(next);
}
@@ -374,6 +385,7 @@ updateVdesSummary();
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
rerender: () => { updateVdesBar(); renderVdesHistory(); },
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
});
@@ -4,11 +4,13 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
interface WefaxImage {
rig_id?: string | null;
ts_ms?: number;
ioc?: number;
lpm?: number;
@@ -20,6 +22,7 @@ interface WefaxImage {
}
interface WefaxProgress {
rig_id?: string | null;
state?: string;
line_data?: string;
line_count?: number;
@@ -156,15 +159,21 @@ function paintLine(lineBytes: Uint8Array): void {
}
// ── Live view: latest image card ────────────────────────────────────
/** The pictures the selected rig decoded, newest first. */
function wefaxRigImages(): WefaxImage[] {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return;
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>';
return;
}
const img = wefaxImageHistory[0];
const img = rigImages[0];
if (!img) return;
const ts = img._ts || '--';
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
@@ -193,7 +202,8 @@ function renderWefaxLatestCard() {
// ── History view: table ─────────────────────────────────────────────
function getWefaxFilteredHistory() {
let items = wefaxImageHistory;
// Every rig's pictures are kept; the panel shows the selected rig's.
let items = wefaxRigImages();
if (wefaxFilterText) {
items = items.filter(function (i) {
@@ -253,7 +263,7 @@ function renderWefaxHistoryTable() {
wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) {
const total = wefaxImageHistory.length;
const total = wefaxRigImages().length;
const shown = items.length;
wefaxDom.historyCount.textContent =
total === 0
@@ -298,6 +308,9 @@ function addWefaxImage(msg: WefaxImage): void {
// ── SSE event handlers (public API) ─────────────────────────────────
function onServerWefaxProgress(msg: WefaxProgress): void {
// The live canvas draws one picture at a time: a background rig's lines
// would paint straight over the one being watched.
if (!isActiveRigDecode(msg.rig_id)) return;
// State-only update (no image data): show decoder state in status.
if (msg.state && !msg.line_data) {
if (wefaxDom.status) {
@@ -331,6 +344,8 @@ function onServerWefaxProgress(msg: WefaxProgress): void {
function onServerWefax(msg: WefaxImage): void {
addWefaxImage(msg);
// Kept in the history either way, so switching to that rig finds it there.
if (!isActiveRigDecode(msg.rig_id)) return;
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
if (wefaxDom.status) {
@@ -438,6 +453,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView,
rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); },
});
wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress",
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
@@ -15,6 +16,10 @@ interface WsprMessage {
snr_db?: number | undefined;
dt_s?: number | undefined;
freq_hz?: number | undefined;
rig_id?: string | null | undefined;
/** RF frequency the spot was heard on, kept so a map replay does not
* recompute it against wherever the dial has moved to since. */
_rfHz?: number | null | undefined;
receiver?: unknown;
[key: string]: unknown;
}
@@ -102,10 +107,12 @@ function renderWsprRow(msg: WsprMessage): HTMLDivElement {
function renderWsprHistory(): void {
pruneWsprMessageHistory();
if (!wsprMessagesEl) return;
// The history holds every rig's decodes, for the map; the panel is about the
// rig the operator is working.
const rigMessages = forActiveRig(wsprMessageHistory);
const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
const message = wsprMessageHistory[i];
if (message) fragment.appendChild(renderWsprRow(message));
for (const message of rigMessages) {
fragment.appendChild(renderWsprRow(message));
}
wsprMessagesEl.replaceChildren(fragment);
}
@@ -130,6 +137,8 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
station,
rfHz,
history: {
rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms,
snr_db: msg.snr_db,
@@ -142,16 +151,12 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
function onServerWsprBatch(messages: WsprMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized: WsprMessage[] = [];
for (const msg of messages) {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
});
}
// "Receiving" is the panel's own status, and the panel is the rig's.
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
plotWsprLocator(msg);
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
@@ -311,15 +316,24 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
})();
});
function onServerWspr(msg: WsprMessage): void {
if (wsprStatus) wsprStatus.textContent = "Receiving";
/** Hands a spot's grid squares to the map, if the map module is loaded yet.
* The module is lazy, so a spot heard before it arrived has to be replayable. */
function plotWsprLocator(msg: WsprMessage): void {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
});
}
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
// A replayed spot carries the frequency it was heard on; a fresh one has it
// worked out from the dial it just arrived against.
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(rfHz === null ? {} : { freq_hz: rfHz }),
});
}
function onServerWspr(msg: WsprMessage): void {
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg);
plotWsprLocator(msg);
addWsprMessage(next.history);
}
@@ -330,4 +344,7 @@ wsprWindow.trxPluginRuntime.registerDecoder({
restore: onServerWsprBatch,
prune: pruneWsprHistoryView,
reset: resetWsprHistoryView,
rerender: renderWsprHistory,
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => { for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message); },
});
@@ -499,7 +499,7 @@ function elementById<T extends HTMLElement>(id: string): T {
if (restoreFocus) more.focus();
};
api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return;
const item = document.createElement("button");
@@ -541,7 +541,7 @@ function elementById<T extends HTMLElement>(id: string): T {
select.setAttribute("aria-label", "Decoder view");
const groups: Array<[string, string[]]> = [
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax", "sstv"]],
];
groups.forEach(([label, ids]) => {
const group = document.createElement("optgroup");
@@ -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,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
// A second rig listening in the background, on its own band. Both pages
// describe the rig on screen, so its traffic belongs in neither the panel nor
// the mini view — only on the map, which shows the whole station.
// HF APRS travels a different band and a different path from the VHF list, so
// it goes on the map under a source of its own that the filter can hide.
const HF_BEACON = {
type: "hf_aprs", src_call: "SP5HF-7", dest_call: "APRS", path: "WIDE2-2", info: "HF beacon",
packet_type: "position", crc_ok: true, lat: 50.06, lon: 19.94,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
const OTHER_RIG_VESSEL = {
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
lat: 51.92, lon: 4.48, rig_id: "rig-b",
};
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], 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);
try {
@@ -41,11 +57,14 @@ try {
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false,
aisRows: document.getElementById("ais-messages")?.children.length ?? 0,
mapLoaded: !!window.trx.modules.map,
}));
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
assert.equal(panels.otherRig, false, "the AIS panel listed a background rig's vessel");
// The mini view rides over the waterfall on the radio page.
await page.locator('.tab[data-tab="main"]').click();
@@ -56,11 +75,13 @@ try {
shown: getComputedStyle(bar).display !== "none",
pins: bar.querySelectorAll(".aprs-bar-pin").length,
names: bar.textContent.includes("NEDERLAND"),
otherRig: bar.textContent.includes("ELDERBERRY"),
};
});
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
assert.equal(miniView.names, true, "the mini view does not name the vessel");
assert.equal(miniView.otherRig, false, "the mini view shows a background rig's vessel");
// Following the pin: the map opens, on the vessel. This is the path that was
// broken for every decoder — the module that owned the navigation had not
@@ -89,8 +110,41 @@ try {
: Object.keys(collection ?? {}).length);
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
});
assert.ok(markers.ais > 0, "the vessel never reached the map");
assert.ok(markers.stations > 0, "the APRS station never reached the map");
// Both APRS stations are on it, each under its own source: the HF one used
// to be dropped entirely, since the HF list never plotted anything.
const aprsSources = await page.evaluate(() => {
const entries = [...(window.trx.modules.map?.stationMarkers ?? new Map()).entries()];
return entries.map(([key, entry]) => `${entry?.type ?? "?"}:${entry?.call ?? key}`).sort();
});
assert.deepEqual(aprsSources, ["aprs:SP2SJG-9", "hf_aprs:SP5HF-7"],
`the map holds ${JSON.stringify(aprsSources)}`);
// And the filter offers HF APRS on a chip of its own, next to the VHF one.
await page.locator('#map-locator-phase .map-locator-phase-btn[data-phase="type"]').click();
await page.waitForTimeout(400);
const chips = await page.evaluate(() => [...document.querySelectorAll("#map-locator-choice-filter .map-locator-chip")]
.map((chip) => chip.dataset.filterKey));
assert.ok(chips.includes("aprs") && chips.includes("hf_aprs"),
`the Show chips are ${JSON.stringify(chips)}`);
// Turning that chip off takes the HF station off the map and leaves the VHF
// one on: the two are filtered apart, which is the whole point of the split.
await page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="hf_aprs"]').click();
await page.waitForTimeout(500);
const shownAfterFilter = await page.evaluate(() => {
const map = window.trx.modules.map;
const onMap = [];
(map?.stationMarkers ?? new Map()).forEach((entry, key) => {
if (entry?.marker && map.aprsMap?.hasLayer(entry.marker)) onMap.push(entry?.call ?? key);
});
return onMap.sort();
});
assert.deepEqual(shownAfterFilter, ["SP2SJG-9"],
`hiding HF APRS left ${JSON.stringify(shownAfterFilter)} on the map`);
// The map is the whole station's view — the one place a background rig's
// traffic belongs — so both vessels are on it even though the panel and the
// mini view show only the selected rig's.
assert.equal(markers.ais, 2, `${markers.ais} of 2 vessels reached the map`);
assert.deepEqual(runtimeErrors, []);
} finally {
@@ -103,6 +157,8 @@ try {
// left the client on its retry path and the history path untested.
const HISTORY_AIS = 900;
const HISTORY_APRS = 300;
const HISTORY_FT8 = 12;
const HISTORY_WSPR = 8;
const historyFixture = await startWebFixture({
spectrum: true,
mode: "AIS",
@@ -112,6 +168,14 @@ const historyFixture = await startWebFixture({
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({
message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2,
freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({
message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5,
freq_hz: 1500 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
info: `history ${index}`, packet_type: "position", crc_ok: true,
@@ -199,6 +263,27 @@ try {
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
// The Statistics page counts the same history. Its decode log lives in the
// map module, which is lazy, so every decode that arrived before the module
// did used to be recorded into nothing at all — the page opened empty and
// only filled in from decodes heard afterwards, and it took a reload landing
// on the tab (module loaded at startup, before the history) to show the lot.
await replay.page.evaluate(() => window.navigateToTab("statistics"));
await replay.page.waitForTimeout(1500);
// The counters are written with toLocaleString(), so a four-figure count
// arrives as "1,220" — whichever separator the runner's locale picks. Read
// the digits rather than the formatting.
const counted = await replay.page.evaluate(() => {
const count = (id) => Number((document.getElementById(id)?.textContent ?? "").replace(/\D/g, ""));
return { decodes: count("stats-total-decodes"), grids: count("stats-unique-grids") };
});
assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR,
`the statistics counted ${counted.decodes} decodes`);
// Grid squares come from the FT8 and WSPR spots, which had no map replay of
// their own: the locators of everything heard before the map loaded were lost.
assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR,
`the statistics counted ${counted.grids} grid squares`);
assert.deepEqual(replay.runtimeErrors, []);
} finally {
await replay.browser.close();
@@ -0,0 +1,149 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// What a phone gets. A page that scrolls sideways is a page with something on
// it nobody will find: the transmit controls spent this whole period laid out
// at x=400 on a 390 px screen, off the side of a tray 354 px wide, reachable
// only by a horizontal scroll with nothing to say it was there.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, getComputedStyle */
const PHONES = [430, 390, 360];
const fixture = await startWebFixture({ spectrum: true, tx: true });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
for (const width of PHONES) {
await page.setViewportSize({ width, height: 900 });
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
await page.locator("#content").waitFor({ state: "visible" });
await page.waitForTimeout(1800);
const layout = await page.evaluate((viewport) => {
const box = (element) => element.getBoundingClientRect();
// Anything laid out past the right edge of the screen.
const past = [];
const walk = (element) => {
const rect = box(element);
if (rect.width > 0 && rect.right > viewport + 1) {
past.push(`${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ""}`);
}
for (const child of element.children) walk(child);
};
walk(document.getElementById("content"));
const tx = document.getElementById("tx-power-col");
return {
documentScroll: document.documentElement.scrollWidth,
past: past.slice(0, 6),
txRight: tx ? Math.round(box(tx).right) : null,
txVisible: tx ? getComputedStyle(tx).display !== "none" : false,
rigSelect: Math.round(box(document.getElementById("header-rig-switch-select")).width),
};
}, width);
assert.equal(layout.documentScroll, width,
`the page scrolls sideways at ${width}px (${layout.documentScroll}px wide)`);
assert.deepEqual(layout.past, [],
`laid out past the screen at ${width}px: ${layout.past.join(", ")}`);
if (layout.txVisible) {
assert.ok(layout.txRight <= width + 1,
`the transmit controls end at ${layout.txRight}px on a ${width}px screen`);
}
// The rig's name was taking 139px of a 338px bar and pushing everything
// else into the overflow menu.
assert.ok(layout.rigSelect <= 112,
`the rig picker is ${layout.rigSelect}px wide at ${width}px`);
// The bottom nav keeps its labels — that is what makes it navigation
// rather than five glyphs — and a label used to overflow its tab and run
// into the next one: "Bookmarks igital mode".
//
// What is asserted is that a label stays inside its own tab and, if it is
// too long for it, ends in an ellipsis. Not that it fits: how wide a
// platform draws a word varies by more than ten per cent, so "it fits"
// passes on the machine it was written on and fails on the next one —
// which is what a fixed font size did here.
const nav = await page.evaluate(() => {
const bar = document.querySelector(".tab-bar-nav");
return [...bar.querySelectorAll(".tab")]
.filter((tab) => tab.getBoundingClientRect().width > 0)
.map((tab) => {
const label = [...tab.querySelectorAll(".tab-label, .tab-label-short")]
.find((span) => getComputedStyle(span).display !== "none");
const style = label ? getComputedStyle(label) : null;
const labelBox = label?.getBoundingClientRect();
const tabBox = tab.getBoundingClientRect();
return {
text: (label?.textContent ?? "").trim(),
escapes: labelBox
? labelBox.left < tabBox.left - 1 || labelBox.right > tabBox.right + 1
: false,
truncates: style?.textOverflow === "ellipsis" && style?.overflow !== "visible",
empty: !labelBox || labelBox.width < 1,
};
});
});
assert.ok(nav.length >= 4, `the bottom nav has ${nav.length} destinations at ${width}px`);
const escaped = nav.filter((tab) => tab.escapes).map((tab) => tab.text);
assert.deepEqual(escaped, [],
`labels overflowing their tab at ${width}px: ${escaped.join(", ")}`);
const untruncatable = nav.filter((tab) => !tab.truncates).map((tab) => tab.text);
assert.deepEqual(untruncatable, [],
`labels that would be cut rather than ellipsised at ${width}px: ${untruncatable.join(", ")}`);
const blank = nav.filter((tab) => tab.empty).map((tab, index) => tab.text || `#${index}`);
assert.deepEqual(blank, [], `destinations with no label at ${width}px: ${blank.join(", ")}`);
}
// A tab whose visible label is shortened for the nav still has to say what
// it is to anything that reads the page rather than looks at it.
const named = await page.evaluate(() => {
const tab = document.querySelector('.tab[data-tab="digital-modes"]');
const short = tab.querySelector(".tab-label-short");
return {
label: tab.getAttribute("aria-label"),
shortHidden: short?.getAttribute("aria-hidden"),
};
});
assert.equal(named.label, "Digital modes", "the shortened tab lost its full name");
assert.equal(named.shortHidden, "true", "the short label is read out as well as shown");
// Hiding the map's filters leaves the bar, and the bar has to still be
// there to bring them back — it carries the only button that does.
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(`${fixture.origin}/map`, { waitUntil: "domcontentloaded" });
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await page.waitForTimeout(1200);
await page.locator("#map-overlay-toggle-btn").click();
await page.waitForTimeout(400);
const collapsed = await page.evaluate(() => {
const panel = document.querySelector(".map-overlay-panel");
const style = getComputedStyle(panel);
const rect = panel.getBoundingClientRect();
const button = document.getElementById("map-overlay-toggle-btn").getBoundingClientRect();
return {
width: Math.round(rect.width),
height: Math.round(rect.height),
opacity: Number(style.opacity),
visibility: style.visibility,
display: style.display,
buttonWidth: Math.round(button.width),
};
});
assert.ok(collapsed.width > 0 && collapsed.height > 0,
`the collapsed bar measures ${collapsed.width}x${collapsed.height}`);
assert.equal(collapsed.visibility, "visible", "the collapsed bar is not visible");
assert.notEqual(collapsed.display, "none", "the collapsed bar is display:none");
assert.equal(collapsed.opacity, 1, `the collapsed bar is at opacity ${collapsed.opacity}`);
assert.ok(collapsed.buttonWidth > 0, "Show Filters has no size to click");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.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();
}
@@ -196,6 +196,62 @@ try {
await fixture.close();
}
// The strip of controls under the plot. Its two clusters — the receiver's
// bandwidth and the display's levels — used to size themselves independently:
// four control heights on one line, and between about 1100 and 1400 px the
// left cluster wrapped to two lines while the right one did not, leaving the
// two at heights that matched neither each other nor anything else.
const stripFixture = await startWebFixture({ spectrum: true });
const strip = await startBrowser(chromium);
try {
for (const width of [1600, 1200, 900]) {
await strip.page.setViewportSize({ width, height: 950 });
await strip.page.goto(stripFixture.origin, { waitUntil: "domcontentloaded" });
await strip.page.locator("#spectrum-panel").waitFor({ state: "visible" });
await strip.page.waitForTimeout(1600);
const measured = await strip.page.evaluate(() => {
const controls = document.getElementById("spectrum-controls");
const box = (element) => element.getBoundingClientRect();
const clusters = [...controls.children]
.filter((child) => child.id)
.map((child) => ({ id: child.id, top: Math.round(box(child).top) }));
const parts = [...controls.querySelectorAll(".spectrum-field, .spectrum-btn")];
return {
heights: [...new Set(parts.map((part) => Math.round(box(part).height)))],
count: parts.length,
clusters,
// Rows are lines of the strip: clusters sharing a top are on one line.
lines: new Set(clusters.map((cluster) => cluster.top)).size,
overflows: controls.scrollWidth > controls.clientWidth + 1,
insidePanel: box(controls).right
<= box(document.getElementById("spectrum-panel")).right + 1,
};
});
// Five fields and four buttons: bandwidth, Set, Auto BW, Sweet-spot, peak
// hold, floor, range, Auto, contrast.
assert.equal(measured.count, 9, `the strip has ${measured.count} controls at ${width}px`);
assert.deepEqual(measured.heights.length, 1,
`controls are ${measured.heights.join(", ")}px tall at ${width}px`);
assert.equal(measured.overflows, false, `the strip overflows at ${width}px`);
assert.equal(measured.insidePanel, true, `the strip runs past the plot at ${width}px`);
// Either both clusters share a line, or each has one to itself. What must
// never happen is one cluster floating against the middle of the other.
const tops = measured.clusters.map((cluster) => cluster.top);
assert.ok(
measured.lines === 1 || measured.lines === measured.clusters.length,
`clusters sit at ${tops.join(", ")} at ${width}px`,
);
}
assert.deepEqual(strip.runtimeErrors, []);
} finally {
await strip.browser.close();
await stripFixture.close();
}
// The band plan is fetched once at startup, which can land before the session
// exists. It used to fail silently and never retry, so the allocations only
// turned up if the operator reloaded the page by hand.
@@ -0,0 +1,186 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The SSTV panel: a picture arriving row by row, and what is kept once it has.
// Watching the image build up is the point of the mode, so rows have to reach
// the canvas as they arrive rather than at the end of a two-minute frame.
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";
/** A DOM stub with only what the plugin reaches for. */
function makeElement(id) {
const listeners = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
style: {},
attributes: {},
classList: {
classes: new Set(),
add(name) { this.classes.add(name); },
remove(name) { this.classes.delete(name); },
toggle(name, on) { if (on) this.classes.add(name); else this.classes.delete(name); },
contains(name) { return this.classes.has(name); },
},
addEventListener(type, handler) { listeners.set(type, handler); },
setAttribute(name, value) { this.attributes[name] = String(value); },
getAttribute(name) { return this.attributes[name] ?? null; },
click() { listeners.get("click")?.(); },
fire(type, event) { listeners.get(type)?.(event); },
};
}
function makeCanvas(id) {
const element = makeElement(id);
element.width = 0;
element.height = 0;
const painted = [];
const fills = [];
element.painted = painted;
element.fills = fills;
element.getContext = () => ({
fillStyle: "",
fillRect: (...args) => { fills.push(args); },
createImageData: (width, height) => ({
width, height, data: new Uint8ClampedArray(width * height * 4),
}),
putImageData: (image, x, y) => { painted.push({ x, y, data: image.data }); },
});
return element;
}
async function loadPanel() {
const elements = new Map();
const element = (id) => {
if (!elements.has(id)) {
elements.set(id, id.endsWith("canvas") ? makeCanvas(id) : makeElement(id));
}
return elements.get(id);
};
// Touch every id the panel defines, so the plugin caches real stubs.
for (const id of [
"sstv-status", "sstv-live-view", "sstv-history-view", "sstv-live-container",
"sstv-live-info", "sstv-live-canvas", "sstv-live-latest", "sstv-history-list",
"sstv-history-count", "sstv-filter", "sstv-sort", "sstv-decode-toggle-btn",
"sstv-clear-btn", "sstv-view-live", "sstv-view-history",
]) element(id);
const window = { ...createHost() };
const context = vm.createContext({
window,
document: { getElementById: (id) => elements.get(id) ?? null },
atob: (data) => Buffer.from(data, "base64").toString("binary"),
Date, Number, String, Math, Set, Uint8Array, Uint8ClampedArray, JSON, console,
});
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL("../src/plugins/sstv.ts", import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
return { window, runtime: window.trxPluginRuntime, element };
}
/** One row of RGB triples, base64 as the server sends it. */
function rowData(width, [r, g, b]) {
const bytes = new Uint8Array(width * 3);
for (let x = 0; x < width; x += 1) {
bytes[x * 3] = r;
bytes[x * 3 + 1] = g;
bytes[x * 3 + 2] = b;
}
return Buffer.from(bytes).toString("base64");
}
test("a picture arriving paints its rows as they come", async () => {
const { runtime, element } = await loadPanel();
// The header names the mode and the geometry; the canvas takes both.
runtime.dispatch("sstv_progress", {
state: "Receiving Martin M1", mode: "Martin M1", width: 320, height: 256, line: 0,
});
const canvas = element("sstv-live-canvas");
assert.equal(canvas.width, 320, "the canvas did not take the mode's width");
assert.equal(canvas.height, 256, "the canvas did not take the mode's height");
assert.equal(element("sstv-live-container").style.display, "",
"the live view stayed hidden while a picture was arriving");
assert.match(element("sstv-status").textContent, /Martin M1/);
// Rows land at the line number they carry, not in arrival order: a decoder
// that painted them in sequence would shear a picture with a dropped line.
runtime.dispatch("sstv_progress", { line: 4, line_data: rowData(320, [255, 0, 0]) });
runtime.dispatch("sstv_progress", { line: 2, line_data: rowData(320, [0, 0, 255]) });
assert.deepEqual(canvas.painted.map((p) => p.y), [4, 2],
`rows painted at ${canvas.painted.map((p) => p.y).join(",")}`);
assert.deepEqual([...canvas.painted[0].data.slice(0, 4)], [255, 0, 0, 255], "row 4 is not red");
assert.deepEqual([...canvas.painted[1].data.slice(0, 4)], [0, 0, 255, 255], "row 2 is not blue");
assert.match(element("sstv-live-info").textContent, /Martin M1/);
});
test("a received picture is kept, shown, and linked by file name alone", async () => {
const { runtime, element } = await loadPanel();
// Recent, not a fixed date: the panel drops anything older than the history
// retention window, so a picture stamped with the day the test was written
// passes until that day is a day ago.
runtime.dispatch("sstv", {
ts_ms: Date.now() - 60_000,
vis: 44, mode: "Martin M1", width: 320, height: 256, lines: 256, complete: true,
path: "/home/op/.cache/trx-rs/sstv/SSTV_20260805T120000Z_14230000_Martin-M1.png",
});
const latest = element("sstv-live-latest").innerHTML;
assert.match(latest, /Martin M1/);
assert.match(latest, /complete/);
// The server serves pictures by file name; the path it stored is its own.
assert.match(latest, /\/sstv-images\/SSTV_20260805T120000Z_14230000_Martin-M1\.png/);
assert.doesNotMatch(latest, /home\/op/, "the server's filesystem path reached the page");
element("sstv-view-history").click();
const history = element("sstv-history-list").innerHTML;
assert.match(history, /Martin M1/);
assert.match(history, /320×256/);
assert.match(element("sstv-history-count").textContent, /1 picture/);
});
test("a picture cut short is kept, and says so", async () => {
const { runtime, element } = await loadPanel();
runtime.dispatch("sstv", {
ts_ms: Date.now(), vis: 60, mode: "Scottie S1", width: 320, height: 256,
lines: 91, complete: false, path: "/cache/SSTV_x_Scottie-S1.png",
});
assert.match(element("sstv-status").textContent, /Partial/);
element("sstv-view-history").click();
assert.match(element("sstv-history-list").innerHTML, /91 \(partial\)/);
});
test("clearing empties the panel", async () => {
const { runtime, element } = await loadPanel();
runtime.dispatch("sstv", { ts_ms: Date.now(), mode: "PD120", lines: 496, complete: true });
assert.match(element("sstv-live-latest").innerHTML, /PD120/);
runtime.reset("sstv");
assert.equal(element("sstv-live-latest").innerHTML, "");
assert.equal(element("sstv-status").textContent, "Idle");
assert.equal(element("sstv-live-container").style.display, "none");
});
test("the toggle button follows the rig state", async () => {
const { window, element } = await loadPanel();
const button = element("sstv-decode-toggle-btn");
window.syncSstvToggle(true);
assert.equal(button.textContent, "Disable SSTV");
assert.equal(button.getAttribute("aria-pressed"), "true");
window.syncSstvToggle(false);
assert.equal(button.textContent, "Enable SSTV");
assert.equal(button.getAttribute("aria-pressed"), "false");
});
@@ -27,6 +27,21 @@ const dial = () => page.evaluate(() => ({
path: window.location.pathname,
}));
// Typed, not filled. The app holds back its own refreshes of the frequency
// field from the first keystroke until Enter, so that a state update arriving
// mid-edit does not rewrite what is being typed. `fill()` sets the value
// without a keystroke, leaving the field unguarded: on a slow machine a state
// update could land between the fill and the Enter and put the old frequency
// back, and the Enter would then re-apply the frequency the radio was already
// on. Selecting first arms the guard before a single character changes.
async function tuneByHand(text) {
const field = page.locator("#freq");
await field.click();
await field.press("ControlOrMeta+a");
await field.pressSequentially(text);
await field.press("Enter");
}
try {
await page.setViewportSize({ width: 1500, height: 950 });
@@ -49,8 +64,7 @@ try {
// Tuning by hand rewrites the link. This is the part that makes the address
// bar shareable at any moment rather than only at load.
await page.locator("#freq").fill("7.040M");
await page.locator("#freq").press("Enter");
await tuneByHand("7.040M");
await page.waitForTimeout(1500);
const tuned = await dial();
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.
const historyLength = await page.evaluate(() => window.history.length);
await page.locator("#freq").fill("7.100M");
await page.locator("#freq").press("Enter");
await tuneByHand("7.100M");
await page.waitForTimeout(1200);
assert.equal(await page.evaluate(() => window.history.length), historyLength,
"tuning pushed a history entry instead of replacing one");
@@ -29,11 +29,19 @@ const DECODER_REGISTRY = [
{ id: "cw", label: "CW", activation: "toggle", active_modes: ["CW", "CWR"] },
{ id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
{ id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] },
{ id: "sstv", label: "SSTV", activation: "toggle", active_modes: ["USB", "FM"] },
{ id: "ais", label: "AIS", 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: "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([
[".css", "text/css; charset=utf-8"],
@@ -104,7 +112,7 @@ function encodeCbor(value) {
return Buffer.concat(chunks);
}
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"];
function assetPath(urlPath) {
// Every tab route has its own index handler on the server (see api/assets.rs),
@@ -131,9 +139,11 @@ export async function startWebFixture({
mode = "FM",
history = {},
bookmarks = [],
backgroundDecode = null,
bandplan = {},
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
satPasses = null,
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
@@ -203,6 +213,7 @@ export async function startWebFixture({
wspr_decode_enabled: false,
lrpt_decode_enabled: false,
wefax_decode_enabled: false,
sstv_decode_enabled: false,
recorder_enabled: false,
clients: 1,
rigctl_clients: 0,
@@ -228,6 +239,7 @@ export async function startWebFixture({
["/bandplan.json", bandplan],
["/api/recorder/status", []],
["/api/recorder/files", []],
["/sat_passes", satPasses ?? { satellite_count: 0, passes: [] }],
]);
// Flat i8 bins: the shape does not matter, only that frames arrive so the
@@ -267,6 +279,34 @@ export async function startWebFixture({
response.writeHead(200).end();
return;
}
// Background decode: the panel reads its config, its status, and the
// bookmark list, and writes the config back.
if (url.pathname.startsWith("/background-decode/")) {
const isStatus = url.pathname.endsWith("/status");
if (isStatus) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(backgroundDecode?.status ?? { entries: [] }));
return;
}
if (request.method === "PUT") {
const body = await new Promise((resolve) => {
let raw = "";
request.on("data", (chunk) => { raw += chunk; });
request.on("end", () => resolve(raw));
});
try {
const parsed = JSON.parse(body);
if (backgroundDecode) backgroundDecode.config = parsed;
} catch { /* leave the config as it was */ }
}
if (request.method === "DELETE" && backgroundDecode) {
backgroundDecode.config = { remote: "rig-a", enabled: false, bookmark_ids: [] };
}
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(backgroundDecode?.config
?? { remote: "rig-a", enabled: false, bookmark_ids: [] }));
return;
}
if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote");
if (remote) {
@@ -10,7 +10,9 @@ use actix_web::{get, HttpRequest, HttpResponse, Responder};
use std::collections::HashMap;
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;
// ---------------------------------------------------------------------------
@@ -62,49 +64,100 @@ define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css");
#[get("/")]
pub(crate) async fn index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/map")]
pub(crate) async fn map_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/digital-modes")]
pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/recorder")]
pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/settings")]
pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/about")]
pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/statistics")]
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
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")]
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
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")]
pub(crate) async fn style_css(req: HttpRequest) -> impl Responder {
let c = gz_style_css();
static_asset_response(&req, "text/css; charset=utf-8", c)
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
}
#[get("/themes.css")]
pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder {
let c = gz_themes_css();
static_asset_response(&req, "text/css; charset=utf-8", c)
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
}
// Generated filenames are supplied only by build.rs and resolved through this
@@ -172,24 +225,52 @@ pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path<String>) -
let Some(entry) = generated_asset_cache().get(filename.as_str()) else {
return HttpResponse::NotFound().finish();
};
static_asset_response(&req, content_type, entry)
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.
#[get("/sstv-images/{filename}")]
pub(crate) async fn sstv_image(path: web::Path<String>) -> impl Responder {
cached_png("sstv", &path.into_inner())
}
#[get("/images/{filename}")]
pub(crate) async fn wefax_image(path: web::Path<String>) -> impl Responder {
let filename = path.into_inner();
// Reject path traversal attempts.
cached_png("wefax", &path.into_inner())
}
/// Read a PNG out of one of the decoder cache directories.
///
/// The file name comes from a client, so it is checked rather than trusted: no
/// separators, no parent references, and a .png suffix. Everything a decoder
/// writes is named that way, and nothing else in the cache is servable.
fn cached_png(decoder: &str, filename: &str) -> HttpResponse {
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return HttpResponse::BadRequest().body("invalid filename");
}
if !filename.ends_with(".png") {
return HttpResponse::BadRequest().body("only .png files are accessible");
}
let dir = dirs::cache_dir()
let file_path = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("wefax");
let file_path = dir.join(&filename);
.join(decoder)
.join(filename);
match std::fs::read(&file_path) {
Ok(data) => HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
@@ -202,7 +283,12 @@ pub(crate) async fn wefax_image(path: web::Path<String>) -> impl Responder {
#[get("/bandplan.json")]
pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder {
let c = gz_bandplan_json();
static_asset_response(&req, "application/json; charset=utf-8", c)
static_asset_response(
&req,
"application/json; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
// ---------------------------------------------------------------------------
@@ -220,7 +306,12 @@ pub(crate) async fn dseg14_classic_woff2() -> impl Responder {
#[get("/vendor/opus-decoder-0.7.11.min.js")]
pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
let c = gz_opus_decoder_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
static_asset_response(
&req,
"application/javascript; charset=utf-8",
c,
AssetCaching::Immutable,
)
}
// ---------------------------------------------------------------------------
@@ -230,13 +321,18 @@ pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
#[get("/vendor/leaflet.js")]
pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder {
let c = gz_leaflet_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
static_asset_response(
&req,
"application/javascript; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/vendor/leaflet.css")]
pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder {
let c = gz_leaflet_css();
static_asset_response(&req, "text/css; charset=utf-8", c)
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
}
#[get("/vendor/marker-icon.png")]
@@ -336,6 +432,44 @@ mod tests {
assert!(!generated_asset_cache().contains_key("../app.js"));
}
/// A browser that visited before an upgrade must not keep the old page.
/// index.html, the stylesheets and the entry bundles all keep their URL
/// from one build to the next, so an immutable year-long policy on them
/// leaves a client running whatever it first downloaded — a fixed layout
/// stays broken in the browser that happened to cache it, and no reload
/// short of a forced one gets the fix.
#[test]
fn assets_that_keep_their_url_across_builds_are_revalidated() {
for name in ["app.js", "aprs.js", "map-core.js"] {
assert_eq!(
generated_asset_caching(name),
AssetCaching::Revalidate,
"{name} is served from the same URL after every build"
);
}
assert_eq!(
AssetCaching::Revalidate.header_value(),
"no-cache",
"revalidating assets must ask before they are reused"
);
}
/// Only the chunks carry a hash of their own bytes, so only they can be
/// kept forever.
#[test]
fn content_addressed_chunks_stay_immutable() {
let chunk = status::GENERATED_ASSETS
.iter()
.map(|(name, _)| *name)
.find(|name| name.starts_with("chunk-"))
.expect("the bundle splits into at least one shared chunk");
assert_eq!(generated_asset_caching(chunk), AssetCaching::Immutable);
assert!(
AssetCaching::Immutable.header_value().contains("immutable"),
"a hashed name is safe to keep"
);
}
#[test]
fn generated_asset_mime_types_are_restricted() {
assert_eq!(
@@ -60,6 +60,7 @@ struct DecodeHistoryPayload {
ft2: Vec<trx_core::decode::Ft8Message>,
wspr: Vec<trx_core::decode::WsprMessage>,
wefax: Vec<trx_core::decode::WefaxMessage>,
sstv: Vec<trx_core::decode::SstvMessage>,
}
impl DecodeHistoryPayload {
@@ -74,6 +75,7 @@ impl DecodeHistoryPayload {
+ self.ft2.len()
+ self.wspr.len()
+ self.wefax.len()
+ self.sstv.len()
}
}
@@ -93,6 +95,7 @@ fn collect_decode_history(
ft2: crate::server::audio::snapshot_ft2_history(context, rig_filter),
wspr: crate::server::audio::snapshot_wspr_history(context, rig_filter),
wefax: crate::server::audio::snapshot_wefax_history(context, rig_filter),
sstv: crate::server::audio::snapshot_sstv_history(context, rig_filter),
}
}
@@ -451,10 +454,40 @@ pub async fn toggle_wefax_decode(
.await
}
#[post("/toggle_sstv_decode")]
pub async fn toggle_sstv_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
send_command(
&rig_tx,
RigCommand::SetSstvDecodeEnabled(!rig_state.decoders.sstv_decode_enabled),
q.remote,
)
.await
}
// ============================================================================
// Decoder clear endpoints
// ============================================================================
#[post("/clear_sstv_decode")]
pub async fn clear_sstv_decode(
query: web::Query<RemoteQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(
&rig_tx,
RigCommand::ResetSstvDecoder,
query.into_inner().remote,
)
.await
}
#[post("/clear_wefax_decode")]
pub async fn clear_wefax_decode(
query: web::Query<RemoteQuery>,
@@ -307,10 +307,35 @@ where
}
/// Pre-compressed (gzip + brotli) + ETag-aware response for immutable embedded assets.
/// How long a browser may hold an asset before asking about it again.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum AssetCaching {
/// The name carries a hash of the bytes, so a change is a new URL and the
/// old one can be kept forever.
Immutable,
/// Served from the same URL for the life of the deployment, with different
/// bytes after an upgrade: index.html, the stylesheets, the entry bundles.
/// These must be revalidated, or a browser that visited before the upgrade
/// keeps running the old page — for a year, with the ETag never consulted,
/// which is how a fixed layout stays broken in one browser and not another.
/// The ETag makes the revalidation a 304 in the usual case.
Revalidate,
}
impl AssetCaching {
pub(crate) fn header_value(self) -> &'static str {
match self {
Self::Immutable => "public, max-age=31536000, immutable",
Self::Revalidate => "no-cache",
}
}
}
fn static_asset_response(
req: &HttpRequest,
content_type: &'static str,
entry: &GzCacheEntry,
caching: AssetCaching,
) -> HttpResponse {
let etag = &entry.etag;
// Check If-None-Match for conditional GET.
@@ -319,7 +344,7 @@ fn static_asset_response(
if val == etag || val == "*" {
return HttpResponse::NotModified()
.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();
}
}
@@ -339,7 +364,7 @@ fn static_asset_response(
.insert_header((header::CONTENT_TYPE, content_type))
.insert_header((header::CONTENT_ENCODING, encoding))
.insert_header((header::ETAG, etag.to_owned()))
.insert_header((header::CACHE_CONTROL, "public, max-age=31536000, immutable"))
.insert_header((header::CACHE_CONTROL, caching.header_value()))
.body(Bytes::copy_from_slice(body))
}
@@ -591,6 +616,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(decoder::toggle_wspr_decode)
.service(decoder::toggle_lrpt_decode)
.service(decoder::toggle_wefax_decode)
.service(decoder::toggle_sstv_decode)
.service(decoder::clear_ais_decode)
.service(decoder::clear_vdes_decode)
.service(decoder::clear_aprs_decode)
@@ -602,6 +628,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(decoder::clear_wspr_decode)
.service(decoder::clear_lrpt_decode)
.service(decoder::clear_wefax_decode)
.service(decoder::clear_sstv_decode)
// Bookmark CRUD
.service(bookmarks::list_bookmarks)
.service(bookmarks::create_bookmark)
@@ -637,6 +664,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::recorder_index)
.service(assets::settings_index)
.service(assets::about_index)
.service(assets::satellites_index)
.service(assets::statistics_index)
.service(assets::bookmarks_index)
.service(assets::favicon)
@@ -645,6 +673,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::style_css)
.service(assets::themes_css)
.service(assets::wefax_image)
.service(assets::sstv_image)
.service(assets::bandplan_json)
// Vendored DSEG14 Classic font
.service(assets::dseg14_classic_woff2)
@@ -836,6 +865,76 @@ mod tests {
// Endpoint tests using actix_web::test
// ======================================================================
/// The page and the bundles it pulls in are served from the same URLs after
/// every upgrade, so the browser has to ask whether they changed. Served
/// as immutable for a year, a browser that visited once kept the old page
/// and never saw a fix again -- which is how a corrected layout stays
/// broken in one browser while every other one has it.
#[actix_web::test]
async fn documents_and_entry_bundles_are_revalidated_not_frozen() {
let app = actix_test::init_service(
App::new()
.service(assets::index)
.service(assets::style_css)
.service(assets::generated_asset),
)
.await;
for path in ["/", "/style.css", "/app.js"] {
let req = actix_test::TestRequest::get().uri(path).to_request();
let resp = actix_test::call_service(&app, req).await;
assert_eq!(resp.status(), 200, "{path} should be served");
let cache_control = resp
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
assert_eq!(cache_control, "no-cache", "{path} must be revalidated");
assert!(
resp.headers().contains_key(header::ETAG),
"{path} needs an ETag, or revalidating costs a full download"
);
}
}
/// Revalidation has to stay cheap: an unchanged asset answers 304, and the
/// policy on that answer matches the one on the body.
#[actix_web::test]
async fn an_unchanged_document_answers_not_modified() {
let app = actix_test::init_service(App::new().service(assets::style_css)).await;
let first = actix_test::call_service(
&app,
actix_test::TestRequest::get()
.uri("/style.css")
.to_request(),
)
.await;
let etag = first
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.expect("style.css carries an ETag")
.to_owned();
let conditional = actix_test::call_service(
&app,
actix_test::TestRequest::get()
.uri("/style.css")
.insert_header((header::IF_NONE_MATCH, etag))
.to_request(),
)
.await;
assert_eq!(conditional.status(), 304);
assert_eq!(
conditional
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-cache")
);
}
/// GET /status returns 200 with valid JSON containing rig snapshot fields.
#[actix_web::test]
async fn test_status_endpoint_returns_json() {
@@ -24,8 +24,8 @@ use tracing::warn;
use uuid::Uuid;
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
WsprMessage,
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
WefaxMessage, WsprMessage,
};
use trx_frontend::FrontendRuntimeContext;
@@ -335,6 +335,43 @@ fn record_wefax(context: &FrontendRuntimeContext, mut msg: WefaxMessage) {
}
}
/// Store a received picture, saving the PNG the server sent into the local
/// cache so `/images/` can serve it back.
fn record_sstv(context: &FrontendRuntimeContext, mut msg: SstvMessage) {
if let Some(ref data) = msg.png_data {
if let Some(ref path) = msg.path {
if let Some(filename) = std::path::Path::new(path).file_name() {
let dir = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("sstv");
if std::fs::create_dir_all(&dir).is_ok() {
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
let local_path = dir.join(filename);
if let Err(e) = std::fs::write(&local_path, &bytes) {
tracing::warn!("SSTV: failed to save local image: {}", e);
}
}
}
}
}
}
// The picture itself is on disk now; keeping a megabyte of base64 per
// entry in memory is what the history does not need.
msg.png_data = None;
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.decode_history
.sstv
.lock()
.expect("sstv history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
while history.len() > 100 {
history.pop_front();
}
}
/// Returns `true` if the entry's rig_id matches the optional filter.
/// `None` filter means "all rigs".
fn matches_rig_filter(entry_rig: Option<&str>, filter: Option<&str>) -> bool {
@@ -526,6 +563,31 @@ pub fn snapshot_wefax_history(
.collect()
}
pub fn snapshot_sstv_history(
context: &FrontendRuntimeContext,
rig_filter: Option<&str>,
) -> Vec<SstvMessage> {
let history = context
.decode_history
.sstv
.lock()
.expect("sstv history mutex poisoned");
history
.iter()
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
.map(|(_, _, msg)| msg.clone())
.collect()
}
pub fn clear_sstv_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
.sstv
.lock()
.expect("sstv history mutex poisoned");
history.clear();
}
pub fn clear_wefax_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
@@ -650,6 +712,9 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
DecodedMessage::Wefax(msg) => record_wefax(&context, msg),
DecodedMessage::WefaxProgress(_) => {}
DecodedMessage::Sstv(msg) => record_sstv(&context, msg),
// Progress is for watching, not for keeping.
DecodedMessage::SstvProgress(_) => {}
DecodedMessage::LrptImage(_) => {}
DecodedMessage::LrptProgress(_) => {}
},
+24
View File
@@ -0,0 +1,24 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
[package]
name = "trx-config"
version.workspace = true
edition = "2021"
license = "GPL-2.0-or-later"
[dependencies]
serde = { workspace = true, features = ["derive"] }
toml = { workspace = true }
tracing = { workspace = true }
dirs = "6"
thiserror = "2"
trx-core = { path = "../trx-core" }
trx-decode-log = { path = "../decoders/trx-decode-log" }
trx-reporting = { path = "../trx-reporting" }
serde_ignored = "0.1"
toml_edit = "0.22"
[dev-dependencies]
tempfile = "3"
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Regenerate `trx-rs.toml.example` from the config structs.
//!
//! Run from anywhere in the workspace:
//!
//! ```text
//! cargo run -p trx-config --example generate_example
//! ```
//!
//! A test in `trx_config::example` fails when the checked-in file no longer
//! matches, which is the reminder to run this.
use std::path::PathBuf;
fn main() -> std::io::Result<()> {
let target: PathBuf = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../trx-rs.toml.example")
});
std::fs::write(&target, trx_config::example::combined_example())?;
println!("Wrote {}", target.display());
Ok(())
}
File diff suppressed because it is too large Load Diff
+295
View File
@@ -0,0 +1,295 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Generating the example configuration from the config structs.
//!
//! `trx-rs.toml.example` used to be maintained by hand and had fallen years
//! behind the code — no `[[rigs]]`, no `[[remotes]]`, no `[timeouts]`, no
//! bandplan settings. It is now produced from the structs themselves, so a new
//! field appears in the example the moment it exists, and a test fails if the
//! checked-in copy drifts.
//!
//! Section comments come from the table below. A section without an entry is
//! still emitted — only its explanatory text is missing — so forgetting to add
//! one can never drop a setting from the example.
use toml_edit::{DocumentMut, Item};
use crate::{ClientConfig, ServerConfig};
const HEADER: &str = "\
# trx-rs example configuration
#
# Generated from the config structs; regenerate with:
# cargo run -p trx-config --example generate_example
#
# Both sections are optional: trx-server reads [trx-server], trx-client reads
# [trx-client], and either may live in its own file with the section header
# omitted. Any string may use ${ENV_VAR}, and credentials may be moved out of
# this file with the matching *_file keys.
#
# Check a config without starting anything:
# trx-server --check-config --config trx-rs.toml
# trx-client --check-config --config trx-rs.toml
";
/// Explanatory comments for config sections, keyed by dotted path.
const SECTION_COMMENTS: &[(&str, &str)] = &[
("trx-server", "Server: drives the radio hardware."),
(
"trx-server.general",
"Station identity. Coordinates feed PSKReporter and the map.",
),
(
"trx-server.rig",
"Single-rig layout. For several radios, delete this and use [[rigs]].",
),
(
"trx-server.rig.access",
"How to reach the radio: serial, tcp, or sdr.",
),
("trx-server.behavior", "CAT polling and retry behaviour."),
(
"trx-server.listen",
"JSON control listener that trx-client connects to.",
),
(
"trx-server.listen.auth",
"Tokens clients must present. Empty means no authentication.\n\
Use tokens_file = \"/etc/trx-rs/tokens\" to keep them out of this file.",
),
("trx-server.audio", "Opus audio stream for trx-client."),
(
"trx-server.decoders",
"Which decoders run. Trimming this list saves real CPU on small boxes.\n\
Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr.\n\
output_dir sets where sstv/wefax/lrpt write images (default: user cache dir).",
),
(
"trx-server.pskreporter",
"Report FT8/FT4/WSPR spots to pskreporter.info.",
),
(
"trx-server.aprsfi",
"Forward received APRS frames to APRS-IS.",
),
(
"trx-server.decode_logs",
"Write decodes to JSON Lines files.",
),
(
"trx-server.sdr",
"SoapySDR pipeline; used when [rig.access] type = \"sdr\".",
),
(
"trx-server.sdr.gain",
"\"auto\" for hardware AGC, or \"manual\".",
),
(
"trx-server.sdr.squelch",
"Software squelch on demodulated audio.",
),
(
"trx-server.sdr.noise_blanker",
"Impulse-noise suppression on the IQ stream.",
),
(
"trx-server.timeouts",
"Timeout and buffer tuning. The defaults suit most setups.",
),
("trx-client", "Client: exposes the radio to users."),
("trx-client.general", "Labels shown in the web UI."),
(
"trx-client.remote",
"Legacy single-remote form; prefer [[remotes]] below.",
),
(
"trx-client.frontends.http",
"Web UI. default_rig_name and the per-rig maps are keyed by the\n\
[[remotes]] name, not the server-side rig id.",
),
(
"trx-client.frontends.http.auth",
"Passphrase login for the web UI. rx_passphrase_file and\n\
control_passphrase_file keep the secrets out of this file.",
),
(
"trx-client.frontends.rigctl",
"Hamlib-compatible TCP interface, one listener per rig.",
),
(
"trx-client.frontends.http_json",
"JSON-over-TCP control interface.",
),
(
"trx-client.frontends.audio",
"Where to fetch the audio stream from.",
),
(
"trx-client.frontends.audio.bridge",
"Play RX audio on a local sound device and capture TX from one.",
),
];
/// Render the combined `trx-rs.toml.example` contents.
pub fn combined_example() -> String {
let mut doc = DocumentMut::new();
doc.decor_mut().set_prefix(HEADER);
doc.insert("trx-server", section_item(&ServerConfig::example_config()));
doc.insert("trx-client", section_item(&ClientConfig::example_config()));
// Each section was serialized on its own, so both carry table positions
// starting at zero and would otherwise render interleaved.
renumber_tables(&mut doc);
for (path, comment) in SECTION_COMMENTS {
annotate(&mut doc, path, comment);
}
doc.to_string()
}
/// Renumber every table so the document renders in tree order.
fn renumber_tables(doc: &mut DocumentMut) {
fn walk(item: &mut Item, next: &mut usize) {
match item {
Item::Table(table) => {
table.set_position(*next);
*next += 1;
for (_, child) in table.iter_mut() {
walk(child, next);
}
}
Item::ArrayOfTables(array) => {
for table in array.iter_mut() {
table.set_position(*next);
*next += 1;
for (_, child) in table.iter_mut() {
walk(child, next);
}
}
}
_ => {}
}
}
let mut next = 0;
for (_, item) in doc.as_table_mut().iter_mut() {
walk(item, &mut next);
}
}
/// Serialize one config into a toml_edit table.
fn section_item<T: serde::Serialize>(config: &T) -> Item {
let rendered = toml::to_string_pretty(config).unwrap_or_default();
let doc: DocumentMut = rendered.parse().expect("serialized config must re-parse");
Item::Table(doc.as_table().clone())
}
/// Attach a comment above the table at `path`, if it exists.
fn annotate(doc: &mut DocumentMut, path: &str, comment: &str) {
let mut item: Option<&mut Item> = None;
for segment in path.split('.') {
let next = match item {
None => doc.get_mut(segment),
Some(current) => current.as_table_mut().and_then(|t| t.get_mut(segment)),
};
match next {
Some(found) => item = Some(found),
None => return,
}
}
let Some(table) = item.and_then(|i| i.as_table_mut()) else {
return;
};
let body: String = comment
.lines()
.map(|line| format!("# {}\n", line.trim_start()))
.collect();
table.decor_mut().set_prefix(format!("\n{body}"));
}
#[cfg(test)]
mod tests {
use super::*;
/// The checked-in example must match what the structs produce, so a new
/// config field cannot land without showing up in the example.
#[test]
fn test_checked_in_example_is_up_to_date() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../trx-rs.toml.example")
.canonicalize()
.expect("example file must exist");
let on_disk = std::fs::read_to_string(&path).expect("example file must be readable");
assert_eq!(
on_disk,
combined_example(),
"trx-rs.toml.example is out of date; regenerate with \
`cargo run -p trx-config --example generate_example`"
);
}
/// Nothing in the example may be derived from the machine that generated
/// it: [decode_logs].dir defaults to the running user's cache directory,
/// which made the generated file differ between a developer's laptop and
/// CI, and the up-to-date test fail for everyone but its author.
#[test]
fn test_example_has_no_machine_specific_paths() {
let example = combined_example();
for dir in [dirs::home_dir(), dirs::cache_dir(), dirs::config_dir()]
.into_iter()
.flatten()
{
let dir = dir.to_string_lossy().into_owned();
assert!(
!example.contains(&dir),
"the example contains this machine's {dir}; pin the value in example_config()"
);
}
}
#[test]
fn test_example_loads_and_validates() {
let mut file = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut file, combined_example().as_bytes()).unwrap();
let server = ServerConfig::load_from_file(file.path()).expect("server section loads");
assert!(
server.unknown_keys.is_empty(),
"the generated example must not contain unknown keys: {:?}",
server.unknown_keys
);
server.config.validate().expect("server section validates");
let client = ClientConfig::load_from_file(file.path()).expect("client section loads");
assert!(
client.unknown_keys.is_empty(),
"the generated example must not contain unknown keys: {:?}",
client.unknown_keys
);
client.config.validate().expect("client section validates");
}
/// Every section that gained a comment must still exist under that path.
#[test]
fn test_section_comments_match_real_sections() {
let doc: DocumentMut = combined_example().parse().unwrap();
for (path, _) in SECTION_COMMENTS {
let mut item = None;
for segment in path.split('.') {
item = match item {
None => doc.get(segment),
Some(current) => current.as_table().and_then(|t| t.get(segment)),
};
assert!(
item.is_some(),
"commented section [{path}] no longer exists"
);
}
}
}
}
+252
View File
@@ -0,0 +1,252 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Loading a config section out of a TOML file.
//!
//! Two file shapes are accepted:
//!
//! - **Sectioned** — a combined `trx-rs.toml` with `[trx-server]` and/or
//! `[trx-client]` tables. This is what `--print-config` and
//! `trx-configurator` emit.
//! - **Bare** — a standalone file whose root *is* the section, i.e. `[general]`
//! and `[rig]` at the top level. Hand-written per-binary configs use this.
//!
//! A file that carries some other component's section but not ours is treated
//! as "section absent" rather than as a bare file, so loading a client-only
//! config with the server reports the missing section instead of silently
//! falling back to defaults.
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::path::{Path, PathBuf};
use thiserror::Error;
use std::collections::BTreeSet;
use crate::unknown::{describe, flatten_paths, UnknownKey};
/// Every section key that may appear at the root of a combined config file.
pub const SECTION_KEYS: &[&str] = &["trx-server", "trx-client"];
/// A loaded config plus what the loader noticed on the way in.
#[derive(Debug, Clone)]
pub struct ConfigLoad<T> {
/// The deserialized configuration.
pub config: T,
/// File the config came from; `None` when nothing was found and defaults
/// were used.
pub path: Option<PathBuf>,
/// Keys present in the file that no config field claimed.
pub unknown_keys: Vec<UnknownKey>,
/// Every key path the file actually set. Defaults are indistinguishable
/// from explicit values once deserialized, so deprecation checks need this.
pub present_keys: BTreeSet<String>,
}
impl<T: ConfigFile> ConfigLoad<T> {
/// Log a warning for every deprecated key the file sets.
pub fn report_deprecations(&self) {
for message in T::deprecations(&self.present_keys) {
tracing::warn!("{}", message);
}
}
}
impl<T> ConfigLoad<T> {
/// Log every unknown key as a warning. With `strict`, also return an error
/// so the caller can refuse to start.
pub fn report_unknown_keys(&self, strict: bool) -> Result<(), String> {
for key in &self.unknown_keys {
tracing::warn!("{}", key);
}
if strict && !self.unknown_keys.is_empty() {
return Err(format!(
"{} unknown config key(s); refusing to start because --strict-config is set",
self.unknown_keys.len()
));
}
Ok(())
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file {0}: {1}")]
ReadError(PathBuf, String),
#[error("Failed to parse config file {0}: {1}")]
ParseError(PathBuf, String),
}
/// Returns the default search paths for `trx-rs.toml`
/// (current directory → XDG config → /etc).
fn config_search_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("trx-rs.toml")];
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("trx-rs").join("trx-rs.toml"));
}
paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml"));
paths
}
/// Pick the table holding `key`'s settings out of a parsed document.
///
/// Returns the named section when present, the whole document when it carries
/// no section headers at all (a bare standalone file), or `None` when the file
/// is sectioned but has no section for `key`.
fn select_section(table: &toml::Table, key: &str) -> Option<toml::Value> {
if let Some(section) = table.get(key) {
return Some(section.clone());
}
let is_sectioned = SECTION_KEYS.iter().any(|k| table.contains_key(*k));
if is_sectioned {
return None;
}
Some(toml::Value::Table(table.clone()))
}
/// Extract and deserialize a named section from a TOML file.
///
/// Returns `Ok(Some((cfg, unknown_keys)))` when the section is present and
/// parses cleanly, `Ok(None)` when the section is absent, or `Err` on I/O /
/// parse failure.
type LoadedSection<T> = (T, Vec<UnknownKey>, BTreeSet<String>);
fn load_section_from_file<T: ConfigFile>(
path: &Path,
key: &str,
) -> Result<Option<LoadedSection<T>>, ConfigError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
let table: toml::Table = toml::from_str(&content)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
let Some(mut section) = select_section(&table, key) else {
return Ok(None);
};
// ${VAR} references are expanded before deserializing, so any string in the
// file can come from the environment.
crate::secrets::expand_env_vars(&mut section)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e))?;
let present_keys = flatten_paths(&section);
// Deserialize straight from the TOML value so serde applies every default,
// recording any key no field claimed.
let mut ignored: Vec<String> = Vec::new();
let cfg: T = serde_ignored::deserialize(section, |path| ignored.push(path.to_string()))
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
Ok(Some((
cfg,
describe(&ignored, &T::reference_value()),
present_keys,
)))
}
/// Trait for loading configuration from a `trx-rs.toml` section.
pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize {
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
fn section_key() -> &'static str;
/// Warnings for deprecated keys the file sets, given every key path present
/// in it. Defaults to none.
fn deprecations(_present_keys: &BTreeSet<String>) -> Vec<String> {
Vec::new()
}
/// A TOML rendering of a populated config, used to suggest corrections for
/// unknown keys. Implementations should fill in list-valued sections such
/// as `[[rigs]]` so keys nested inside them can be suggested too.
fn reference_value() -> toml::Value {
toml::Value::try_from(Self::default())
.unwrap_or_else(|_| toml::Value::Table(toml::Table::new()))
}
/// Load the section from a specific file path.
///
/// Accepts both a sectioned file (`[<section_key>]` at the root) and a bare
/// file whose root is the section itself. Returns an error if the file
/// cannot be read, is not valid TOML, or is sectioned for some other
/// component only.
fn load_from_file(path: &Path) -> Result<ConfigLoad<Self>, ConfigError> {
let (config, unknown_keys, present_keys) =
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
ConfigError::ParseError(
path.to_path_buf(),
format!("missing [{}] section", Self::section_key()),
)
})?;
Ok(ConfigLoad {
config,
path: Some(path.to_path_buf()),
unknown_keys,
present_keys,
})
}
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
/// the first file that contains the expected section.
///
/// Falls back to `Self::default()` with no path when nothing is found.
fn load_from_default_paths() -> Result<ConfigLoad<Self>, ConfigError> {
for path in config_search_paths() {
if path.exists() {
if let Some((config, unknown_keys, present_keys)) =
load_section_from_file::<Self>(&path, Self::section_key())?
{
return Ok(ConfigLoad {
config,
path: Some(path),
unknown_keys,
present_keys,
});
}
}
}
Ok(ConfigLoad {
config: Self::default(),
path: None,
unknown_keys: Vec::new(),
present_keys: BTreeSet::new(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn table(s: &str) -> toml::Table {
toml::from_str(s).unwrap()
}
#[test]
fn test_select_section_prefers_named_section() {
let t = table("[trx-server]\n[trx-server.general]\ncallsign = \"W1AW\"\n");
let section = select_section(&t, "trx-server").unwrap();
assert!(section.get("general").is_some());
}
#[test]
fn test_select_section_falls_back_to_root_for_bare_file() {
let t = table("[general]\ncallsign = \"W1AW\"\n");
let section = select_section(&t, "trx-server").unwrap();
assert!(section.get("general").is_some());
}
#[test]
fn test_select_section_absent_when_other_section_present() {
let t = table("[trx-client]\n[trx-client.general]\ncallsign = \"W1AW\"\n");
assert!(select_section(&t, "trx-server").is_none());
}
#[test]
fn test_select_section_empty_file_is_bare() {
let t = table("");
assert!(select_section(&t, "trx-server").is_some());
}
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Configuration types shared by `trx-server`, `trx-client` and
//! `trx-configurator`.
//!
//! Keeping the structs, the loader and the validators in one crate means the
//! setup wizard checks a config with exactly the same code the binaries load
//! it with, so the two can never drift apart.
pub mod client;
pub mod example;
pub mod file;
pub mod secrets;
pub mod server;
pub mod shared;
pub mod unknown;
pub mod url;
pub use client::ClientConfig;
pub use file::{ConfigError, ConfigFile, ConfigLoad};
pub use server::ServerConfig;
pub use shared::{validate_log_level, validate_tokens};
pub use unknown::UnknownKey;
pub use url::{parse_audio_url, parse_remote_url, RemoteEndpoint};
+297
View File
@@ -0,0 +1,297 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Keeping credentials out of the config file.
//!
//! Tokens and passphrases used to have exactly one representation: written in
//! plain text in `trx-rs.toml`, which is awkward when the config is deployed by
//! a config-management tool, committed to a private repo, or shared between
//! machines. Two alternatives are offered:
//!
//! - `${VAR}` anywhere in a config string, expanded from the environment.
//! - A `*_file` sibling of any secret key, read from disk at startup.
//!
//! Plus a nudge: a config that holds secrets and is readable by group or others
//! gets a warning.
use std::path::Path;
/// Expand `${VAR}` references in every string in a TOML value.
///
/// An unset variable is an error rather than an empty string — a silently blank
/// passphrase is the kind of thing that disables authentication by accident.
pub fn expand_env_vars(value: &mut toml::Value) -> Result<(), String> {
match value {
toml::Value::String(s) => {
if let Some(expanded) = expand_str(s)? {
*s = expanded;
}
}
toml::Value::Table(table) => {
for (_, child) in table.iter_mut() {
expand_env_vars(child)?;
}
}
toml::Value::Array(items) => {
for item in items.iter_mut() {
expand_env_vars(item)?;
}
}
_ => {}
}
Ok(())
}
/// Expand `${VAR}` in one string; `None` when there was nothing to expand.
fn expand_str(input: &str) -> Result<Option<String>, String> {
if !input.contains("${") {
return Ok(None);
}
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
// Unterminated: leave the rest exactly as written.
out.push_str(&rest[start..]);
return Ok(Some(out));
};
let name = &after[..end];
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
// Not a variable reference; pass it through untouched.
out.push_str(&rest[start..start + 2 + end + 1]);
} else {
let value = std::env::var(name)
.map_err(|_| format!("config references unset environment variable ${{{name}}}"))?;
out.push_str(&value);
}
rest = &after[end + 1..];
}
out.push_str(rest);
Ok(Some(out))
}
/// Read a single secret from a file: the whole file, trimmed.
pub fn read_secret_file(path: &str, what: &str) -> Result<String, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?;
let secret = content.trim().to_string();
if secret.is_empty() {
return Err(format!("{what}: secret file {path} is empty"));
}
warn_if_group_readable(Path::new(path), what);
Ok(secret)
}
/// Read a list of secrets, one per line. Blank lines and `#` comments are
/// skipped.
pub fn read_secret_list_file(path: &str, what: &str) -> Result<Vec<String>, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?;
let secrets: Vec<String> = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_string)
.collect();
if secrets.is_empty() {
return Err(format!("{what}: secret file {path} contains no entries"));
}
warn_if_group_readable(Path::new(path), what);
Ok(secrets)
}
/// Fill `inline` from `file` when the config used the `*_file` form.
///
/// Setting both is an error: which one wins would be a guess.
pub fn resolve_secret(
inline: &mut Option<String>,
file: &Option<String>,
what: &str,
) -> Result<(), String> {
let Some(path) = file else {
return Ok(());
};
if inline.is_some() {
return Err(format!(
"{what}: set either the value or its _file form, not both"
));
}
*inline = Some(read_secret_file(path, what)?);
Ok(())
}
/// Fill a token list from `file` when the config used the `*_file` form.
pub fn resolve_secret_list(
inline: &mut Vec<String>,
file: &Option<String>,
what: &str,
) -> Result<(), String> {
let Some(path) = file else {
return Ok(());
};
if !inline.is_empty() {
return Err(format!(
"{what}: set either the value or its _file form, not both"
));
}
*inline = read_secret_list_file(path, what)?;
Ok(())
}
/// Warn when a file holding secrets is readable beyond its owner.
///
/// Advisory only: plenty of valid setups (a dedicated service user, an
/// immutable image) are fine, so this never fails the load.
pub fn warn_if_group_readable(path: &Path, what: &str) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(path) else {
return;
};
let mode = meta.permissions().mode() & 0o077;
if mode != 0 {
tracing::warn!(
"{} is readable by group or others (mode {:o}); it holds secrets ({}). \
Consider: chmod 600 {}",
path.display(),
meta.permissions().mode() & 0o777,
what,
path.display()
);
}
}
#[cfg(not(unix))]
{
let _ = (path, what);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_expand_leaves_plain_strings_alone() {
assert_eq!(expand_str("plain").unwrap(), None);
assert_eq!(expand_str("TRXRS-%YYYY%.log").unwrap(), None);
}
#[test]
fn test_expand_substitutes_variable() {
std::env::set_var("TRX_TEST_TOKEN", "s3cret");
assert_eq!(
expand_str("Bearer ${TRX_TEST_TOKEN}!").unwrap().as_deref(),
Some("Bearer s3cret!")
);
}
#[test]
fn test_expand_errors_on_unset_variable() {
let err = expand_str("${TRX_DEFINITELY_NOT_SET_12345}").unwrap_err();
assert!(err.contains("unset environment variable"), "{err}");
}
#[test]
fn test_expand_passes_through_non_variables() {
assert_eq!(
expand_str("${not a var}").unwrap().as_deref(),
Some("${not a var}")
);
assert_eq!(
expand_str("unterminated ${VAR").unwrap().as_deref(),
Some("unterminated ${VAR")
);
}
#[test]
fn test_expand_walks_nested_tables_and_arrays() {
std::env::set_var("TRX_TEST_HOST", "radio.example.com");
let mut value: toml::Value = toml::from_str(
r#"
[remote]
url = "${TRX_TEST_HOST}:4530"
hosts = ["${TRX_TEST_HOST}"]
"#,
)
.unwrap();
expand_env_vars(&mut value).unwrap();
assert_eq!(
value["remote"]["url"].as_str(),
Some("radio.example.com:4530")
);
assert_eq!(
value["remote"]["hosts"][0].as_str(),
Some("radio.example.com")
);
}
fn temp_file(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn test_read_secret_file_trims() {
let f = temp_file(" hunter2\n");
assert_eq!(read_secret_file(path_of(&f), "test").unwrap(), "hunter2");
}
#[test]
fn test_read_secret_file_rejects_empty() {
let f = temp_file(" \n");
assert!(read_secret_file(path_of(&f), "test").is_err());
}
#[test]
fn test_read_secret_list_skips_blanks_and_comments() {
let f = temp_file("# tokens\nalpha\n\n beta \n");
assert_eq!(
read_secret_list_file(path_of(&f), "test").unwrap(),
vec!["alpha".to_string(), "beta".to_string()]
);
}
#[test]
fn test_resolve_secret_fills_from_file() {
let f = temp_file("from-file");
let mut inline = None;
resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap();
assert_eq!(inline.as_deref(), Some("from-file"));
}
#[test]
fn test_resolve_secret_rejects_both_forms() {
let f = temp_file("from-file");
let mut inline = Some("inline".to_string());
let err = resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err();
assert!(err.contains("not both"), "{err}");
}
#[test]
fn test_resolve_secret_is_a_no_op_without_file() {
let mut inline = Some("inline".to_string());
resolve_secret(&mut inline, &None, "test").unwrap();
assert_eq!(inline.as_deref(), Some("inline"));
}
#[test]
fn test_resolve_secret_list_rejects_both_forms() {
let f = temp_file("alpha");
let mut inline = vec!["inline".to_string()];
let err =
resolve_secret_list(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err();
assert!(err.contains("not both"), "{err}");
}
fn path_of(f: &tempfile::NamedTempFile) -> &str {
f.path().to_str().unwrap()
}
}
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,52 @@
//! would either bloat both binaries with unused fields or require a trait
//! abstraction that adds complexity without clear benefit.
use std::net::IpAddr;
/// A socket a component intends to bind, and what it is for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoundSocket {
pub addr: IpAddr,
pub port: u16,
/// Human-readable owner, e.g. `[listen]` or `[frontends.http]`.
pub label: String,
}
impl BoundSocket {
pub fn new(addr: IpAddr, port: u16, label: impl Into<String>) -> Self {
Self {
addr,
port,
label: label.into(),
}
}
}
/// Reject two components trying to bind the same socket.
///
/// A wildcard address (`0.0.0.0` / `::`) conflicts with any other address on
/// the same port, since binding it claims every interface. Port 0 means "pick
/// an ephemeral port" and never conflicts.
pub fn check_socket_conflicts(sockets: &[BoundSocket]) -> Result<(), String> {
for (i, a) in sockets.iter().enumerate() {
if a.port == 0 {
continue;
}
for b in &sockets[i + 1..] {
if b.port != a.port {
continue;
}
if a.addr == b.addr || a.addr.is_unspecified() || b.addr.is_unspecified() {
return Err(format!(
"{} and {} would both bind {}:{}",
a.label, b.label, a.addr, a.port
));
}
}
}
Ok(())
}
/// Validate that a log level string is one of the accepted values.
///
/// Returns `Ok(())` when `level` is `None` (defaulting is handled elsewhere)
@@ -53,6 +99,47 @@ pub fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> {
mod tests {
use super::*;
fn sock(addr: &str, port: u16, label: &str) -> BoundSocket {
BoundSocket::new(addr.parse().unwrap(), port, label)
}
#[test]
fn test_socket_conflicts_detects_exact_duplicate() {
let err = check_socket_conflicts(&[
sock("127.0.0.1", 8080, "[frontends.http]"),
sock("127.0.0.1", 8080, "[frontends.http_json]"),
])
.unwrap_err();
assert!(err.contains("127.0.0.1:8080"), "unexpected error: {err}");
}
#[test]
fn test_socket_conflicts_detects_wildcard_overlap() {
assert!(check_socket_conflicts(&[
sock("0.0.0.0", 4530, "[listen]"),
sock("127.0.0.1", 4530, "[audio]"),
])
.is_err());
}
#[test]
fn test_socket_conflicts_allows_distinct_addresses() {
assert!(check_socket_conflicts(&[
sock("127.0.0.1", 4530, "[listen]"),
sock("192.168.1.5", 4530, "[audio]"),
])
.is_ok());
}
#[test]
fn test_socket_conflicts_ignores_ephemeral_ports() {
assert!(check_socket_conflicts(&[
sock("127.0.0.1", 0, "[frontends.http_json]"),
sock("127.0.0.1", 0, "[frontends.http]"),
])
.is_ok());
}
#[test]
fn test_validate_log_level_none() {
assert!(validate_log_level(None).is_ok());
+263
View File
@@ -0,0 +1,263 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Reporting for config keys the deserializer ignored.
//!
//! Every config struct is `#[serde(default)]`, so a misspelled key used to be
//! dropped without a word and the setting silently kept its default. The
//! loader now collects the ignored key paths and pairs each with the closest
//! known key at the same level, so `prot = 9999` reads as a typo instead of
//! looking like it worked.
use std::collections::BTreeSet;
use std::fmt;
/// A config key that the deserializer did not recognise.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownKey {
/// Dotted path of the key, e.g. `listen.prot` or `rigs.0.audio.prot`.
pub path: String,
/// Closest known key at the same level, when one is near enough to suggest.
pub suggestion: Option<String>,
}
impl fmt::Display for UnknownKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.suggestion {
Some(s) => write!(
f,
"unknown config key '{}' (did you mean '{}'?)",
self.path, s
),
None => write!(f, "unknown config key '{}'", self.path),
}
}
}
/// Flatten a TOML value into the set of dotted key paths it contains.
///
/// Array indices are normalised to `0` so a path inside `[[rigs]]` matches
/// whichever entry it came from.
pub fn flatten_paths(value: &toml::Value) -> BTreeSet<String> {
let mut paths = BTreeSet::new();
walk(value, "", &mut paths);
paths
}
fn walk(value: &toml::Value, prefix: &str, paths: &mut BTreeSet<String>) {
let join = |seg: &str| {
if prefix.is_empty() {
seg.to_string()
} else {
format!("{prefix}.{seg}")
}
};
match value {
toml::Value::Table(table) => {
for (key, child) in table {
let path = join(key);
paths.insert(path.clone());
walk(child, &path, paths);
}
}
toml::Value::Array(items) => {
// Every entry of an array of tables has the same shape, so collapse
// them onto index 0 and let one entry stand for all.
for item in items {
let path = join("0");
walk(item, &path, paths);
}
}
_ => {}
}
}
/// Replace numeric path segments with `0` so array entries compare equal.
fn normalize(path: &str) -> String {
path.split('.')
.map(|seg| {
if !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit()) {
"0"
} else {
seg
}
})
.collect::<Vec<_>>()
.join(".")
}
/// Suggest the closest known key that sits at the same level as `path`.
///
/// Returns `None` when nothing is close enough to be worth printing.
pub fn suggest(path: &str, known: &BTreeSet<String>) -> Option<String> {
let normalized = normalize(path);
let (parent, leaf) = match normalized.rsplit_once('.') {
Some((parent, leaf)) => (parent, leaf),
None => ("", normalized.as_str()),
};
// Anything longer than this is a different word, not a typo.
let limit = (leaf.chars().count() / 3).clamp(1, 3);
let mut best: Option<(usize, &str)> = None;
for candidate in known {
let (cand_parent, cand_leaf) = match candidate.rsplit_once('.') {
Some((p, l)) => (p, l),
None => ("", candidate.as_str()),
};
if cand_parent != parent || cand_leaf == leaf {
continue;
}
let distance = edit_distance(leaf, cand_leaf);
if distance <= limit && best.is_none_or(|(best_d, _)| distance < best_d) {
best = Some((distance, cand_leaf));
}
}
best.map(|(_, leaf)| {
if parent.is_empty() {
leaf.to_string()
} else {
format!("{parent}.{leaf}")
}
})
}
/// Pair each ignored path with a suggestion drawn from `reference`.
pub fn describe(paths: &[String], reference: &toml::Value) -> Vec<UnknownKey> {
let known = flatten_paths(reference);
paths
.iter()
.map(|path| UnknownKey {
path: path.clone(),
suggestion: suggest(path, &known),
})
.collect()
}
/// Optimal string alignment distance: Levenshtein plus transpositions, so the
/// common `port` → `prot` slip counts as one mistake rather than two.
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut rows = vec![vec![0usize; b.len() + 1]; a.len() + 1];
for (i, row) in rows.iter_mut().enumerate() {
row[0] = i;
}
for (j, cell) in rows[0].iter_mut().enumerate() {
*cell = j;
}
for i in 1..=a.len() {
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
let mut best = (rows[i - 1][j] + 1)
.min(rows[i][j - 1] + 1)
.min(rows[i - 1][j - 1] + cost);
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
best = best.min(rows[i - 2][j - 2] + 1);
}
rows[i][j] = best;
}
}
rows[a.len()][b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
fn reference() -> toml::Value {
toml::from_str(
r#"
[general]
callsign = "N0CALL"
log_level = "info"
[listen]
enabled = true
port = 4530
[[rigs]]
id = "hf"
[rigs.audio]
port = 4531
sample_rate = 48000
"#,
)
.unwrap()
}
#[test]
fn test_flatten_collects_nested_paths() {
let paths = flatten_paths(&reference());
assert!(paths.contains("general.callsign"));
assert!(paths.contains("listen.port"));
assert!(paths.contains("rigs.0.audio.sample_rate"));
}
#[test]
fn test_suggest_finds_close_sibling() {
let known = flatten_paths(&reference());
assert_eq!(
suggest("listen.prot", &known).as_deref(),
Some("listen.port")
);
}
#[test]
fn test_suggest_inside_array_entry() {
let known = flatten_paths(&reference());
assert_eq!(
suggest("rigs.1.audio.prot", &known).as_deref(),
Some("rigs.0.audio.port")
);
}
#[test]
fn test_suggest_ignores_distant_names() {
let known = flatten_paths(&reference());
assert_eq!(suggest("listen.bananas", &known), None);
}
#[test]
fn test_suggest_does_not_cross_levels() {
let known = flatten_paths(&reference());
// `port` exists under [listen], but not under [general].
assert_eq!(suggest("general.port", &known), None);
}
#[test]
fn test_describe_formats_message() {
let described = describe(&["listen.prot".to_string()], &reference());
assert_eq!(
described[0].to_string(),
"unknown config key 'listen.prot' (did you mean 'listen.port'?)"
);
}
#[test]
fn test_describe_without_suggestion() {
let described = describe(&["listen.bananas".to_string()], &reference());
assert_eq!(
described[0].to_string(),
"unknown config key 'listen.bananas'"
);
}
#[test]
fn test_edit_distance_counts_transposition_once() {
assert_eq!(edit_distance("port", "prot"), 1);
assert_eq!(edit_distance("port", "port"), 0);
assert_eq!(edit_distance("", "port"), 4);
assert_eq!(edit_distance("sample_rat", "sample_rate"), 1);
}
}
+173
View File
@@ -0,0 +1,173 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Parsing for the `host:port` endpoint URLs used by the client's `[[remotes]]`
//! and `[frontends.audio]` settings.
//!
//! These live next to the config structs because validation needs them; the
//! client re-exports them for its connection code.
/// Default port for the server's JSON control listener.
pub const DEFAULT_REMOTE_PORT: u16 = 4530;
/// Default port for the server's Opus audio listener.
pub const DEFAULT_AUDIO_PORT: u16 = 4531;
/// A resolved `host:port` pair.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteEndpoint {
pub host: String,
pub port: u16,
}
impl RemoteEndpoint {
/// Format as a connect string, bracketing bare IPv6 hosts.
pub fn connect_addr(&self) -> String {
if self.host.contains(':') && !self.host.starts_with('[') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
}
/// Parse a remote control URL, defaulting to port 4530.
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
}
/// Parse an audio stream URL, defaulting to port 4531.
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
}
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(format!("{kind} url is empty"));
}
let addr = trimmed
.strip_prefix("tcp://")
.or_else(|| trimmed.strip_prefix("http-json://"))
.or_else(|| trimmed.strip_prefix("audio://"))
.unwrap_or(trimmed);
parse_host_port(addr, default_port, kind)
}
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
if let Some(rest) = input.strip_prefix('[') {
let closing = rest
.find(']')
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
let host = &rest[..closing];
let remainder = &rest[closing + 1..];
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
let port = if remainder.is_empty() {
default_port
} else if let Some(port_str) = remainder.strip_prefix(':') {
parse_port(port_str, kind)?
} else {
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
};
return Ok(RemoteEndpoint {
host: host.to_string(),
port,
});
}
if input.contains(':') {
if input.matches(':').count() > 1 {
return Err(format!(
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
));
}
let (host, port_str) = input
.rsplit_once(':')
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
return Ok(RemoteEndpoint {
host: host.to_string(),
port: parse_port(port_str, kind)?,
});
}
Ok(RemoteEndpoint {
host: input.to_string(),
port: default_port,
})
}
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
let port: u16 = port_str
.parse()
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
if port == 0 {
return Err(format!("invalid {kind} port: 0"));
}
Ok(port)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_remote_url_defaults_port() {
let ep = parse_remote_url("example.com").unwrap();
assert_eq!(ep.host, "example.com");
assert_eq!(ep.port, DEFAULT_REMOTE_PORT);
}
#[test]
fn test_parse_audio_url_defaults_port() {
let ep = parse_audio_url("example.com").unwrap();
assert_eq!(ep.port, DEFAULT_AUDIO_PORT);
}
#[test]
fn test_parse_strips_schemes() {
for url in &[
"tcp://host:9000",
"http-json://host:9000",
"audio://host:9000",
] {
let ep = parse_remote_url(url).unwrap();
assert_eq!(ep.host, "host");
assert_eq!(ep.port, 9000);
}
}
#[test]
fn test_parse_bracketed_ipv6() {
let ep = parse_remote_url("[::1]:4532").unwrap();
assert_eq!(ep.host, "::1");
assert_eq!(ep.port, 4532);
assert_eq!(ep.connect_addr(), "[::1]:4532");
}
#[test]
fn test_parse_rejects_unbracketed_ipv6() {
assert!(parse_remote_url("::1:4532").is_err());
}
#[test]
fn test_parse_rejects_empty_host() {
assert!(parse_remote_url(":4530").is_err());
}
#[test]
fn test_parse_rejects_zero_port() {
assert!(parse_remote_url("host:0").is_err());
}
#[test]
fn test_parse_rejects_empty_url() {
assert!(parse_remote_url(" ").is_err());
}
}
+2
View File
@@ -15,7 +15,9 @@ path = "src/main.rs"
clap = { workspace = true, features = ["derive"] }
dialoguer = "0.11"
tokio-serial = { workspace = true }
toml = "0.8"
toml_edit = "0.22"
trx-config = { path = "../trx-config" }
[dev-dependencies]
tempfile = "3"
+136 -319
View File
@@ -2,80 +2,37 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! `trx-configurator --check`: run a config file through the same loader and
//! validators the binaries use.
//!
//! This used to be a second, hand-maintained implementation — lists of known
//! keys and a handful of re-implemented range checks — which drifted out of
//! date as soon as a field was added. It now defers entirely to `trx-config`,
//! so a config that checks clean here is one the binaries will accept.
use std::fmt::Write as _;
use std::path::Path;
use toml_edit::DocumentMut;
use trx_config::{ClientConfig, ServerConfig};
/// Known top-level keys for a standalone server config.
const SERVER_KEYS: &[&str] = &[
"general",
/// Top-level keys that only appear in a server config. Used solely to guess
/// what a section-less file is meant to be; the real key checking is done by
/// the loader.
const SERVER_MARKERS: &[&str] = &[
"rig",
"rigs",
"behavior",
"listen",
"audio",
"behavior",
"sdr",
"pskreporter",
"aprsfi",
"decode_logs",
"timeouts",
"audio",
];
/// Known top-level keys for a standalone client config.
const CLIENT_KEYS: &[&str] = &["general", "remote", "remotes", "frontends"];
/// Known top-level keys for a combined trx-rs.toml.
const COMBINED_KEYS: &[&str] = &["trx-server", "trx-client"];
/// Known sub-keys within [general] (server).
const SERVER_GENERAL_KEYS: &[&str] = &["callsign", "log_level", "latitude", "longitude"];
/// Known sub-keys within [general] (client).
const CLIENT_GENERAL_KEYS: &[&str] = &[
"callsign",
"log_level",
"website_url",
"website_name",
"ais_vessel_url_base",
];
/// Known sub-keys within [rig].
const RIG_KEYS: &[&str] = &["model", "initial_freq_hz", "initial_mode", "access"];
/// Known sub-keys within [rig.access].
const ACCESS_KEYS: &[&str] = &["type", "port", "baud", "host", "tcp_port", "args"];
/// Known sub-keys within [listen].
const LISTEN_KEYS: &[&str] = &["enabled", "listen", "port", "auth"];
/// Known sub-keys within [audio] (server).
const AUDIO_KEYS: &[&str] = &[
"enabled",
"listen",
"port",
"rx_enabled",
"tx_enabled",
"device",
"sample_rate",
"channels",
"frame_duration_ms",
"bitrate_bps",
];
/// Known sub-keys within [behavior].
const BEHAVIOR_KEYS: &[&str] = &[
"poll_interval_ms",
"poll_interval_tx_ms",
"max_retries",
"retry_base_delay_ms",
"vfo_prime",
];
/// Known sub-keys within [remote].
const REMOTE_KEYS: &[&str] = &["url", "rig_id", "auth", "poll_interval_ms"];
/// Known sub-keys within [frontends].
const FRONTENDS_KEYS: &[&str] = &["http", "rigctl", "http_json", "audio"];
/// Top-level keys that only appear in a client config.
const CLIENT_MARKERS: &[&str] = &["remote", "remotes", "frontends"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DetectedType {
@@ -100,49 +57,38 @@ pub fn check_file(path: &Path) -> Result<String, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
// Step 1: TOML syntax check
let doc: DocumentMut = content
.parse()
let table: toml::Table = toml::from_str(&content)
.map_err(|e| format!("{}: TOML syntax error: {}", path.display(), e))?;
let detected = detect_type(&table);
let mut report = String::new();
let mut warnings: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
let table = doc.as_table();
// Step 2: Detect config type
let detected = detect_type(table);
writeln!(report, "{}: valid TOML", path.display()).unwrap();
writeln!(report, " Detected type: {}", detected).unwrap();
// Step 3: Structural validation
match detected {
DetectedType::Server => {
check_unknown_keys(table, SERVER_KEYS, "", &mut warnings);
check_server_sections(table, "", &mut warnings, &mut errors);
}
DetectedType::Client => {
check_unknown_keys(table, CLIENT_KEYS, "", &mut warnings);
check_client_sections(table, "", &mut warnings, &mut errors);
}
DetectedType::Server => check_server(path, &mut warnings, &mut errors),
DetectedType::Client => check_client(path, &mut warnings, &mut errors),
DetectedType::Combined => {
check_unknown_keys(table, COMBINED_KEYS, "", &mut warnings);
if let Some(server) = table.get("trx-server").and_then(|v| v.as_table()) {
check_unknown_keys(server, SERVER_KEYS, "[trx-server].", &mut warnings);
check_server_sections(server, "[trx-server].", &mut warnings, &mut errors);
if table.contains_key("trx-server") {
check_server(path, &mut warnings, &mut errors);
}
if let Some(client) = table.get("trx-client").and_then(|v| v.as_table()) {
check_unknown_keys(client, CLIENT_KEYS, "[trx-client].", &mut warnings);
check_client_sections(client, "[trx-client].", &mut warnings, &mut errors);
if table.contains_key("trx-client") {
check_client(path, &mut warnings, &mut errors);
}
}
DetectedType::Unknown => {
warnings.push("Could not detect config type. Expected server, client, or combined (trx-rs.toml) layout.".to_string());
warnings.push(
"Could not detect config type. Expected server, client, or combined \
(trx-rs.toml) layout."
.to_string(),
);
}
}
// Step 4: Format report
for w in &warnings {
writeln!(report, " warning: {}", w).unwrap();
}
@@ -169,23 +115,54 @@ pub fn check_file(path: &Path) -> Result<String, String> {
}
}
fn detect_type(table: &toml_edit::Table) -> DetectedType {
fn check_server(path: &Path, warnings: &mut Vec<String>, errors: &mut Vec<String>) {
match ServerConfig::load_from_file(path) {
Ok(loaded) => {
warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string()));
if let Err(e) = loaded.config.validate() {
errors.push(format!("[trx-server] {}", e));
}
errors.extend(
loaded
.config
.validate_sdr()
.into_iter()
.map(|e| format!("[trx-server] {}", e)),
);
}
Err(e) => errors.push(e.to_string()),
}
}
fn check_client(path: &Path, warnings: &mut Vec<String>, errors: &mut Vec<String>) {
match ClientConfig::load_from_file(path) {
Ok(loaded) => {
warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string()));
if let Err(e) = loaded.config.validate() {
errors.push(format!("[trx-client] {}", e));
}
}
Err(e) => errors.push(e.to_string()),
}
}
fn detect_type(table: &toml::Table) -> DetectedType {
if table.contains_key("trx-server") || table.contains_key("trx-client") {
return DetectedType::Combined;
}
let keys: Vec<&str> = table.iter().map(|(k, _)| k).collect();
let keys: Vec<&str> = table.keys().map(|k| k.as_str()).collect();
let server_score = keys.iter().filter(|k| SERVER_KEYS.contains(k)).count();
let client_score = keys.iter().filter(|k| CLIENT_KEYS.contains(k)).count();
// Use distinguishing keys to break ties
if keys.contains(&"rig") || keys.contains(&"rigs") || keys.contains(&"listen") {
// Distinguishing keys first, then a simple majority.
if keys.iter().any(|k| ["rig", "rigs", "listen"].contains(k)) {
return DetectedType::Server;
}
if keys.contains(&"remote") || keys.contains(&"remotes") || keys.contains(&"frontends") {
if keys.iter().any(|k| CLIENT_MARKERS.contains(k)) {
return DetectedType::Client;
}
let server_score = keys.iter().filter(|k| SERVER_MARKERS.contains(k)).count();
let client_score = keys.iter().filter(|k| CLIENT_MARKERS.contains(k)).count();
if server_score > client_score {
DetectedType::Server
} else if client_score > server_score {
@@ -197,194 +174,6 @@ fn detect_type(table: &toml_edit::Table) -> DetectedType {
}
}
fn check_unknown_keys(
table: &toml_edit::Table,
known: &[&str],
prefix: &str,
warnings: &mut Vec<String>,
) {
for (key, _) in table.iter() {
if !known.contains(&key) {
warnings.push(format!("{}unknown key '{}'", prefix, key));
}
}
}
fn check_server_sections(
table: &toml_edit::Table,
prefix: &str,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) {
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
check_unknown_keys(
general,
SERVER_GENERAL_KEYS,
&format!("{}[general].", prefix),
warnings,
);
validate_log_level(general, &format!("{}[general]", prefix), errors);
validate_coordinates(general, &format!("{}[general]", prefix), errors);
}
if let Some(rig) = table.get("rig").and_then(|v| v.as_table()) {
check_unknown_keys(rig, RIG_KEYS, &format!("{}[rig].", prefix), warnings);
if let Some(access) = rig.get("access").and_then(|v| v.as_table()) {
check_unknown_keys(
access,
ACCESS_KEYS,
&format!("{}[rig.access].", prefix),
warnings,
);
validate_access(access, &format!("{}[rig.access]", prefix), errors);
}
}
if let Some(listen) = table.get("listen").and_then(|v| v.as_table()) {
check_unknown_keys(
listen,
LISTEN_KEYS,
&format!("{}[listen].", prefix),
warnings,
);
validate_port(listen, "port", &format!("{}[listen]", prefix), errors);
}
if let Some(audio) = table.get("audio").and_then(|v| v.as_table()) {
check_unknown_keys(audio, AUDIO_KEYS, &format!("{}[audio].", prefix), warnings);
validate_port(audio, "port", &format!("{}[audio]", prefix), errors);
}
if let Some(behavior) = table.get("behavior").and_then(|v| v.as_table()) {
check_unknown_keys(
behavior,
BEHAVIOR_KEYS,
&format!("{}[behavior].", prefix),
warnings,
);
}
}
fn check_client_sections(
table: &toml_edit::Table,
prefix: &str,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) {
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
check_unknown_keys(
general,
CLIENT_GENERAL_KEYS,
&format!("{}[general].", prefix),
warnings,
);
validate_log_level(general, &format!("{}[general]", prefix), errors);
}
if let Some(remote) = table.get("remote").and_then(|v| v.as_table()) {
check_unknown_keys(
remote,
REMOTE_KEYS,
&format!("{}[remote].", prefix),
warnings,
);
}
if let Some(frontends) = table.get("frontends").and_then(|v| v.as_table()) {
check_unknown_keys(
frontends,
FRONTENDS_KEYS,
&format!("{}[frontends].", prefix),
warnings,
);
if let Some(http) = frontends.get("http").and_then(|v| v.as_table()) {
validate_port(http, "port", &format!("{}[frontends.http]", prefix), errors);
}
if let Some(rigctl) = frontends.get("rigctl").and_then(|v| v.as_table()) {
validate_port(
rigctl,
"port",
&format!("{}[frontends.rigctl]", prefix),
errors,
);
}
}
}
// ── Value validators ────────────────────────────────────────────────────
fn validate_log_level(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(level) = table.get("log_level").and_then(|v| v.as_str()) {
if !["trace", "debug", "info", "warn", "error"].contains(&level) {
errors.push(format!(
"{}.log_level '{}' is invalid (expected: trace, debug, info, warn, error)",
context, level
));
}
}
}
fn validate_coordinates(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(lat) = table
.get("latitude")
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
{
if !(-90.0..=90.0).contains(&lat) {
errors.push(format!(
"{}.latitude {} is out of range (-90..90)",
context, lat
));
}
}
if let Some(lon) = table
.get("longitude")
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
{
if !(-180.0..=180.0).contains(&lon) {
errors.push(format!(
"{}.longitude {} is out of range (-180..180)",
context, lon
));
}
}
let has_lat = table.contains_key("latitude");
let has_lon = table.contains_key("longitude");
if has_lat != has_lon {
errors.push(format!(
"{}: latitude and longitude must be set together or both omitted",
context
));
}
}
fn validate_port(table: &toml_edit::Table, key: &str, context: &str, errors: &mut Vec<String>) {
if let Some(port) = table.get(key).and_then(|v| v.as_integer()) {
if let Some(enabled) = table.get("enabled").and_then(|v| v.as_bool()) {
if enabled && port <= 0 {
errors.push(format!("{}.{} must be > 0 when enabled", context, key));
}
}
if !(0..=65535).contains(&port) {
errors.push(format!(
"{}.{} {} is out of range (0..65535)",
context, key, port
));
}
}
}
fn validate_access(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(access_type) = table.get("type").and_then(|v| v.as_str()) {
if !["serial", "tcp", "sdr"].contains(&access_type) {
errors.push(format!(
"{}.type '{}' is invalid (expected: serial, tcp, sdr)",
context, access_type
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -418,8 +207,7 @@ enabled = true
port = 4530
"#,
);
assert!(result.is_ok());
let report = result.unwrap();
let report = result.expect("expected a clean report");
assert!(report.contains("Detected type: server"));
assert!(report.contains("No issues found"));
}
@@ -432,41 +220,40 @@ port = 4530
callsign = "W1AW"
[remote]
url = "localhost:4530"
url = "192.168.1.10:4530"
[frontends.http]
enabled = true
port = 8080
"#,
);
assert!(result.is_ok());
let report = result.unwrap();
let report = result.expect("expected a clean report");
assert!(report.contains("Detected type: client"));
assert!(report.contains("No issues found"));
}
#[test]
fn test_valid_combined_config() {
let result = check_toml(
r#"
[trx-server.general]
callsign = "W1AW"
[trx-client.general]
callsign = "W1AW"
[trx-server.rig]
model = "ft817"
[trx-server.rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[trx-client.remote]
url = "localhost:4530"
url = "127.0.0.1:4530"
"#,
);
assert!(result.is_ok());
let report = result.unwrap();
let report = result.expect("expected a clean report");
assert!(report.contains("Detected type: combined"));
}
#[test]
fn test_invalid_toml_syntax() {
let result = check_toml("this is not [valid toml");
assert!(result.is_err());
let result = check_toml("[general\ncallsign = \"W1AW\"\n");
assert!(result.unwrap_err().contains("TOML syntax error"));
}
@@ -474,19 +261,22 @@ url = "localhost:4530"
fn test_unknown_key_warning() {
let result = check_toml(
r#"
[general]
callsign = "W1AW"
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[bogus_section]
foo = "bar"
[listen]
prot = 4530
"#,
);
assert!(result.is_ok());
let report = result.unwrap();
assert!(report.contains("unknown key 'bogus_section'"));
let report = result.expect("unknown keys are warnings, not errors");
assert!(
report.contains("unknown config key 'listen.prot' (did you mean 'listen.port'?)"),
"unexpected report: {report}"
);
}
#[test]
@@ -498,11 +288,13 @@ log_level = "verbose"
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#,
);
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("log_level 'verbose' is invalid"));
assert!(result.unwrap_err().contains("log_level"));
}
#[test]
@@ -510,15 +302,17 @@ model = "ft817"
let result = check_toml(
r#"
[general]
latitude = 45.0
latitude = 52.0
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#,
);
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("latitude and longitude must be set together"));
assert!(result.unwrap_err().contains("longitude"));
}
#[test]
@@ -526,16 +320,18 @@ model = "ft817"
let result = check_toml(
r#"
[general]
latitude = 95.0
latitude = 120.0
longitude = 10.0
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#,
);
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("latitude 95 is out of range"));
assert!(result.unwrap_err().contains("latitude"));
}
#[test]
@@ -547,10 +343,31 @@ model = "ft817"
[rig.access]
type = "usb"
port = "/dev/ttyUSB0"
baud = 9600
"#,
);
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("type 'usb' is invalid"));
assert!(result.unwrap_err().contains("access"));
}
/// The old checker only knew a fixed list of top-level keys and a few range
/// rules, so it passed configs the server rejects at startup.
#[test]
fn test_catches_errors_the_key_list_checker_missed() {
let result = check_toml(
r#"
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[audio]
enabled = true
frame_duration_ms = 7
"#,
);
assert!(result.unwrap_err().contains("frame_duration_ms"));
}
}
+68 -8
View File
@@ -211,10 +211,13 @@ pub fn build_server(general: ServerGeneral, rig: RigSetup, listen: ListenSetup)
let mut doc = DocumentMut::new();
doc.decor_mut()
.set_prefix("# trx-server configuration\n# Generated by trx-configurator\n");
let tables = build_server_tables(general, rig, listen);
for (key, item) in tables.iter() {
doc.insert(key, item.clone());
}
// Emit the sectioned shape (`[trx-server]`) that trx-server writes with
// --print-config, so a generated file can be dropped into a combined
// trx-rs.toml unchanged.
doc.insert(
"trx-server",
Item::Table(build_server_tables(general, rig, listen)),
);
doc
}
@@ -350,10 +353,10 @@ pub fn build_client(
let mut doc = DocumentMut::new();
doc.decor_mut()
.set_prefix("# trx-client configuration\n# Generated by trx-configurator\n");
let tables = build_client_tables(general, remote, frontends);
for (key, item) in tables.iter() {
doc.insert(key, item.clone());
}
doc.insert(
"trx-client",
Item::Table(build_client_tables(general, remote, frontends)),
);
doc
}
@@ -453,3 +456,60 @@ pub fn write_file(doc: &DocumentMut, path: &Path) -> Result<(), String> {
println!("Wrote {}", path.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use trx_config::{ClientConfig, ServerConfig};
fn write_temp(doc: &DocumentMut) -> tempfile::NamedTempFile {
let file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
std::fs::write(file.path(), doc.to_string()).unwrap();
file
}
/// The wizard used to emit root-level `[general]` / `[rig]` tables while the
/// loader demanded a `[trx-server]` section, so every generated standalone
/// config was rejected by the binary it was generated for.
#[test]
fn test_generated_server_config_loads_and_validates() {
let file = write_temp(&build_default(ConfigType::Server));
let cfg = ServerConfig::load_from_file(file.path())
.expect("generated config must load")
.config;
cfg.validate().expect("generated config must validate");
assert_eq!(cfg.rig.model.as_deref(), Some("ft817"));
assert_eq!(cfg.listen.port, 4530);
}
#[test]
fn test_generated_client_config_loads_and_validates() {
let file = write_temp(&build_default(ConfigType::Client));
let cfg = ClientConfig::load_from_file(file.path())
.expect("generated config must load")
.config;
cfg.validate().expect("generated config must validate");
assert_eq!(cfg.remote.url.as_deref(), Some("localhost:4530"));
}
#[test]
fn test_generated_combined_config_loads_both_sections() {
let file = write_temp(&build_default(ConfigType::Combined));
let server = ServerConfig::load_from_file(file.path())
.expect("server section must load")
.config;
server.validate().expect("server section must validate");
let client = ClientConfig::load_from_file(file.path())
.expect("client section must load")
.config;
client.validate().expect("client section must validate");
}
#[test]
fn test_generated_docs_are_sectioned() {
let doc = build_default(ConfigType::Server);
assert!(doc.as_table().contains_key("trx-server"));
let doc = build_default(ConfigType::Client);
assert!(doc.as_table().contains_key("trx-client"));
}
}
+1
View File
@@ -17,3 +17,4 @@ uuid = { workspace = true }
ts-rs = { version = "12.0.1", features = ["uuid-impl"] }
sgp4 = "2"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
base64 = "0.22"
+5
View File
@@ -74,6 +74,11 @@ pub const AUDIO_MSG_LRPT_PROGRESS: u8 = 0x18;
pub const AUDIO_MSG_WEFAX_DECODE: u8 = 0x19;
/// Server → client: WEFAX decode progress (JSON `DecodedMessage::WefaxProgress`).
pub const AUDIO_MSG_WEFAX_PROGRESS: u8 = 0x1A;
/// Server → client: SSTV received picture (JSON `DecodedMessage::Sstv`).
pub const AUDIO_MSG_SSTV_DECODE: u8 = 0x1B;
/// Server → client: SSTV decode progress, one line at a time
/// (JSON `DecodedMessage::SstvProgress`).
pub const AUDIO_MSG_SSTV_PROGRESS: u8 = 0x1C;
/// Maximum payload size for normal messages (1 MB).
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
+57
View File
@@ -36,6 +36,10 @@ pub enum DecodedMessage {
Wefax(WefaxMessage),
#[serde(rename = "wefax_progress")]
WefaxProgress(WefaxProgress),
#[serde(rename = "sstv")]
Sstv(SstvMessage),
#[serde(rename = "sstv_progress")]
SstvProgress(SstvProgress),
}
impl DecodedMessage {
@@ -52,6 +56,8 @@ impl DecodedMessage {
Self::LrptProgress(m) => m.rig_id = Some(id),
Self::Wefax(m) => m.rig_id = Some(id),
Self::WefaxProgress(m) => m.rig_id = Some(id),
Self::Sstv(m) => m.rig_id = Some(id),
Self::SstvProgress(m) => m.rig_id = Some(id),
}
}
@@ -68,6 +74,8 @@ impl DecodedMessage {
Self::LrptProgress(m) => m.rig_id.as_deref(),
Self::Wefax(m) => m.rig_id.as_deref(),
Self::WefaxProgress(m) => m.rig_id.as_deref(),
Self::Sstv(m) => m.rig_id.as_deref(),
Self::SstvProgress(m) => m.rig_id.as_deref(),
}
}
}
@@ -319,3 +327,52 @@ pub struct WefaxProgress {
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
/// A received SSTV picture, complete or cut short.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SstvMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ts_ms: Option<i64>,
/// VIS code the transmission announced itself with.
pub vis: u8,
/// Mode name, e.g. "Martin M1".
pub mode: String,
pub width: u16,
pub height: u16,
/// Image lines actually received, which is the height only if the whole
/// frame arrived.
pub lines: u16,
/// Whether reception reached the bottom of the frame.
pub complete: bool,
/// Filesystem path to the saved PNG, when one was written.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
/// Base64-encoded PNG for the trip to a client. Populated when sending and
/// stripped before the message is stored in history, which would otherwise
/// hold a megabyte per picture.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub png_data: Option<String>,
}
/// A picture arriving, emitted per line so it can be watched.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SstvProgress {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ts_ms: Option<i64>,
pub vis: u8,
pub mode: String,
pub width: u16,
pub height: u16,
/// Index of the line this update carries.
pub line: u16,
/// Base64-encoded RGB triples for that one line.
#[serde(skip_serializing_if = "Option::is_none")]
pub line_data: Option<String>,
/// Decoder state for display, e.g. "Receiving Martin M1".
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
+2
View File
@@ -33,6 +33,7 @@ pub enum RigCommand {
SetWsprDecodeEnabled(bool),
SetLrptDecodeEnabled(bool),
SetWefaxDecodeEnabled(bool),
SetSstvDecodeEnabled(bool),
ResetAprsDecoder,
ResetHfAprsDecoder,
ResetCwDecoder,
@@ -42,6 +43,7 @@ pub enum RigCommand {
ResetWsprDecoder,
ResetLrptDecoder,
ResetWefaxDecoder,
ResetSstvDecoder,
SetBandwidth(u32),
SetSdrGain(f64),
SetSdrLnaGain(f64),

Some files were not shown because too many files have changed in this diff Show More