Compare commits

...
Author SHA1 Message Date
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
sjg a2c630a92b [feat](trx-frontend-http): put the tuned frequency in the address bar
CI / frontend (pull_request) Successful in 4m3s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m11s
CI / test (push) Successful in 7m20s
CI / frontend (push) Successful in 3m10s
CI / reuse (push) Successful in 2s
A receiver spreads by being linked to, and there was nothing to link to:
the routes carried the tab and nothing else, so "listen to this" could
only ever mean a screenshot and a frequency typed out in a message.

The query string now carries the dial -- rig, frequency, mode and
bandwidth -- in both directions.  Opening a link selects the rig, sets
the mode, tunes, then applies the bandwidth: a mode change brings its
own default bandwidth with it, so an explicit bw has to land after it.
Frequencies are read the way someone writes them by hand (7074k,
14.074M) and written back as whole Hz, so what comes out of the address
bar is the same link in canonical form.

After that the address bar keeps up with the dial, which is what makes
it copyable at any moment rather than only at load.  It is rewritten
with replaceState -- tuning is not navigation, and a swept dial would
otherwise bury the back button.  A link button in the top bar copies
the current link; it folds into the overflow menu when the bar is tight.

Applying a link changes the radio, so an rx session says so instead of
failing control calls one at a time.  A tab listening to a virtual
channel leaves the address alone rather than publishing a frequency the
rig is not on, and bw is skipped in both directions on rigs without
filter control, which would only refuse it.

The fixture pinned every state frame to 100 MHz plus jitter to keep
frames distinct, so no test could observe tuning at all.  The jitter
moves to the S-meter and the fixture echoes set_freq/set_mode/
set_bandwidth, as it already did for squelch.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 22:31:12 +02:00
sjg 09634eb851 [fix](trx-frontend-http): tidy up the map's filter bar
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m7s
CI / frontend (push) Successful in 2m57s
CI / reuse (push) Successful in 3s
CI / frontend (pull_request) Successful in 3m47s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m19s
The bar explained itself in prose: "All bands visible by default" sat
between the chips and the next group, taking width the bar could not
spare and reading as a stray line of text. An "All" chip says the same
thing in a chip's width and gives the selection somewhere to be undone.

Band chips also came up dimmed at the very moment every band was on the
map -- an empty selection is no filter at all, so nothing is dimmed
until something is picked. The path toggles drop their "On"/"Off"
suffix, which cost most of a row and only repeated what their own
highlight already said; state moves to aria-pressed and the tooltip.

The rest is alignment. The rule dividing the buttons from the filters
is drawn on the button block's edge, and a centred block left it
floating as a stub beside a two-row bar; stacked, it lay down the left
of a block that sits underneath. The labels sat at their natural
widths, so each row's first control started somewhere different, and
the two pairs of phase buttons differed in width, so the groups after
them missed each other by four pixels. One gutter for every label, one
width for both pairs, and the search field moved last where it can take
the room the fixed-width groups leave.

The map layout test now covers the chips, the divider's height and the
rows' shared start.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 21:43:57 +02:00
sjg 90ab7781ad [fix](trx-frontend-http): stop the browser offering saved values for frequency
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m11s
CI / frontend (pull_request) Successful in 3m48s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 7m23s
CI / frontend (push) Successful in 2m54s
CI / reuse (push) Successful in 3s
The tuned and centre frequency readouts are text inputs, so the browser
keeps what has been typed into them and offers it back in a dropdown --
Edge does this out of the box, dropping stale frequencies from other
sessions over the reading.

Turn autofill off on both, along with autocorrect and spellcheck, which
have no business near a number either.

Fixes #39

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 19:36:22 +02:00
sjg 88d04253ca [fix](trx-frontend-http): move the map's fullscreen and filter toggles into the bar
Fullscreen and Hide Filters floated in their own block over the map's
top-right corner, separate from the filter bar they sit beside.

Put them at the right-hand end of the bar, behind a separator. What made
this awkward before is that Hide Filters cannot live inside the thing it
hides, so the collapse now applies to the filters alone: the bar keeps its
two controls and shrinks to them at the map's right edge, leaving the whole
map visible and the way back one click away.

Fixes #38

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 19:34:09 +02:00
sjg 08005c5c07 [feat](trx-frontend-http): lay the map filters out as a bar across the top
CI / test (push) Successful in 8m6s
CI / frontend (push) Successful in 3m45s
CI / reuse (push) Successful in 3s
CI / lint (push) Successful in 2m15s
The filters were a 30rem column parked in the bottom-right corner, covering
a third of the map they filter. Lay them out horizontally instead: one row
per group -- label beside its control, thin rules between -- across the top
of the map, spanning ~87% of its width at 1600px and wrapping to a second
row as it narrows.

It starts clear of Leaflet's zoom buttons and stops short of the corner
controls, which stay outside it: the button that hides the filters cannot
live inside the thing it hides. The bottom-left band legend keeps its place.
The sentence explaining the two path toggles would have swallowed the bar,
so it moves to their tooltips and is shown inline only in the stacked
narrow-screen layout.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:54:21 +02:00
sjg 026f816ddb [fix](trx-frontend-http): replay stored decodes onto the map when it loads
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 3m40s
CI / reuse (push) Successful in 2s
The map module is lazy: it arrives when the Map tab is first opened, which
is normally long after startup restored the decode history. Until then
aprsMapAddStation, aisMapAddVessel and vdesMapAddPoint are undefined, and
the decoders' `if (lat != null && ... && fn)` guards quietly dropped every
restored position. Nothing replayed them once the module did arrive, so the
map came up empty and filled in only from decodes heard afterwards -- a
station heard once was never plotted at all. A second reload appeared to
fix it because the cached module then loaded early enough to win the race
against the history fetch.

Give DecoderPlugin an optional syncMap(), implement it for APRS, AIS and
VDES over the history each already retains, and have map-core call
trxPluginRuntime.syncMapAll() as it attaches. The add functions are keyed
by callsign, MMSI and point, so replaying updates in place and cannot
duplicate a marker; the replay runs oldest-first so tracks are rebuilt in
the order they happened.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:34:45 +02:00
sjg d31b6f545f [test](trx-frontend-http): watch the history progress from inside the page
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 8m18s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 2s
The replay-progress check polled the overlay from the test every 100ms.
A replay that starts and finishes between two polls is never sampled, and
the test then reports that no progress was shown at all -- the source of
the intermittent "no progress was shown while the history loaded" failure.

Record the samples from a MutationObserver installed before the page's own
scripts run, so a fast replay is observed rather than missed.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:10:57 +02:00
sjg 39f551c914 [fix](trx-frontend-http): hold the APRS symbol column open for frames without one
renderLocalAprsSymbol() returns nothing when a packet carries no symbol
table or code, so those rows lost the icon's 24px slot and every column
after it -- callsign, type badge, summary -- slid left against the rows
around them. Frames that do carry a symbol then read as indented.

Render an empty slot of the same size instead, so a list mixing position
reports with messages and telemetry still lines up.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:10:33 +02:00
sjg 6d25ecdc11 [feat](trx-frontend-http): give the AIS list the same log shape
CI / lint (push) Successful in 2m15s
CI / test (push) Successful in 8m8s
CI / frontend (push) Successful in 3m41s
CI / reuse (push) Successful in 2s
A message was three stacked lines — time and name, then MMSI and route,
then motion, distance, position and age — so a screen held eight of
them.  It is one line now: time, vessel, message type, and what the
message says, opening in place for the MMSI, the channel frequency, the
route, the age, the fix and a jump to the map.  Twenty-two fit where
eight did.

What a message says depends on what it is.  Position reports give the
fix and the motion; the static and voyage reports that carry no fix give
the callsign and where the vessel is bound.  Both fall back to whatever
fields are present rather than showing nothing.

The row vocabulary the APRS list introduced is no longer APRS-specific —
the classes are decode-line and decode-expanded now, shared by both, and
identity sits in fixed columns so the summaries line up down the list
instead of starting wherever the callsign happens to end.  The three
summary cards above the list go the way of the APRS ones.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 23:24:09 +02:00
sjg 37987b2779 [feat](trx-frontend-http): make the APRS list a log, and read the payloads
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 3m41s
CI / reuse (push) Successful in 2s
A frame was a card five lines tall — timestamp, a meta line, the
information field as it arrived on the air, three buttons, and a Details
panel repeating the four things already on the row — so five frames
filled the panel and the payload was left to be decoded by eye.

A frame is one line now: time, station, type, and what the frame says.
It opens in place for the path, the CRC, the raw field, its bytes and
the actions.  Twenty-one frames fit where five did.

And the information field is read rather than echoed.  Weather reports
give temperature, wind, humidity and pressure; telemetry gives its
sequence and channels; a message gives its addressee and text; a
position gives the fix, course and speed, and the comment the station
wrote.  Anything that cannot be summarised falls back to the raw field,
which is in the expanded view either way.

HF APRS had a copy of the same forty lines of markup, differing by one
badge, so both now build their rows from one function in the shared
module — the CSS is shared between them and this would have broken it
otherwise.  Both headers lose their three summary cards for a line of
counts beside the filters, which frees another fifth of the panel.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 23:12:02 +02:00
sjg 67a7bace4e [fix](trx-frontend-http): let the decode lists fill their panel
CI / test (push) Successful in 8m16s
CI / lint (push) Successful in 2m18s
CI / frontend (push) Successful in 3m40s
CI / reuse (push) Successful in 3s
FT8, FT4, FT2 and WSPR size their list against the panel with flex, and
the sidebar layout made the panel a grid item aligned to the start of
its row — sized to its own content.  The lists collapsed to their 120px
minimum with several hundred pixels of the page empty underneath.  The
panel stretches to the row now and the sidebar keeps its own height.

The marine lists were sized a different way, by formula: 100vh minus a
guess at everything above them.  That guess stopped matching the moment
the panel changed shape, so they left a few hundred pixels unused as
well.  They fill the panel like the rest now, and so does CW, which had
a 360px ceiling.

HF APRS had no container styling at all — no scroller, no frame, no
height — so its packets ran down the page.  It gets what the other
packet lists have.

The smoke test measures each list against its panel and requires it to
scroll on its own.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 22:49:28 +02:00
sjg b48cc23d6e [fix](trx-frontend-http): keep the rig names through the state stream
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m14s
CI / frontend (push) Successful in 3m47s
CI / reuse (push) Successful in 3s
The picker and the header showed each rig's lowercase id instead of its
configured name.  applyRigList takes the names as a parameter defaulted
to an empty map, and the state-update path passes only the rig ids —
names come from /rigs, not from a state frame — so that call landed on
the default and the body, which treats "an object" as "here are the
names", cleared them.  One frame after load the names were gone for the
rest of the session.  Omitted now means no news rather than no names.

The fixture is why this was invisible: it pushed an identical status
payload every tick and the client skips a frame equal to the last, so
render never ran and neither did the call that did the damage.  Its
event stream varies between frames now, as a real one does.

Which immediately caught a second fault: state frames arrive
continuously, and one sent before the server applied a new squelch
threshold snapped the line back to where it had just been dragged from.
A local change outranks the echo for two seconds, the same idea as the
optimistic frequency guard beside it.  The fixture also records what
/set_sdr_squelch sets and reports it back afterwards — the drag test had
been passing against a server that ignored the write.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 22:38:21 +02:00
sjg c1899229a0 [fix](trx-frontend-http): stop the decode history replay giving up at 20s
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m15s
CI / frontend (push) Successful in 3m37s
CI / reuse (push) Successful in 3s
Reloading a second time sometimes showed history the first load did not,
and the safety valve is why: it called one function that both released
the buffered live decodes and tore the history worker down, so any load
where the replay had not finished inside twenty seconds — a large
backlog, a cold cache, a slow link — dropped whatever had not arrived,
without a word.  A reload got another go at it, and the second one is
faster because everything is cached by then.

Those are two separate things now.  At the timeout the live decodes are
released so the panels are not held back, the replay carries on, and the
progress says so.  The fallback's error path retries once and then says
"Decode history unavailable" rather than leaving the operator to guess
whether there was anything to see.

The progress is no longer a scrim.  It was fixed to the whole viewport
with a wash over the page — the waterfall, the decode panels, all of it —
for the length of the replay, which is exactly when there is something
worth watching.  It is a corner card with a bar: indeterminate while the
payload is on the wire, then filling as N of M messages replay.

None of this was reachable from a test.  /decode/history answers in CBOR
and the worker reads the body as CBOR unconditionally, but the fixture
served JSON, so every browser run had been exercising the client's retry
path and never its history path.  It encodes CBOR now, including the
64-bit form the millisecond timestamps need, and decode-flow serves 1200
records and holds the client to restoring all of them on the first load,
showing progress while it does, and never covering the page with it.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 22:14:13 +02:00
sjg 84a99a3636 [fix](trx-frontend-http): load the decoders that own the digital modes panels
AIS, VDES and both APRS decoders were listed under the map plugin group
alone, so opening Digital modes and clicking AIS or APRS gave an empty
panel reading "Connected, listening for packets" while the decodes piled
up unprocessed in the plugin runtime.  They appeared only if something
had opened the Map tab first, which flushed the queue.  There is also a
map-data group naming exactly those four that nothing loads: the loader
is called with tab names and no tab is called map-data.

They load with the tab whose panels they fill now.  map-core stays lazy,
since their calls into it are optional and the Map tab can go on paying
for Leaflet by itself.

tests/decode-flow.mjs follows a decode from the wire to the map: an AIS
vessel and an APRS beacon arrive on /decode, and it asserts both panels
fill with the map module confirmed absent, the mini view names the
vessel and offers a pin, following that pin lands on /map centred on the
vessel, and both decoders leave a marker.  Nothing exercised any of this
before — the fixture served an empty decode stream, which is how the map
links came to be broken for every decoder at once.

The fixture stamps decodes as it sends them, since the client prunes
anything outside the retention window, and repeats them, since the views
collapse by vessel and need more than one frame to behave.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 21:31:30 +02:00
sjg 84bdf2593c [fix](trx-frontend-http): measure whether the tab bar fits, and watch for it
CI / lint (push) Successful in 2m20s
CI / test (push) Successful in 8m25s
CI / frontend (push) Successful in 3m36s
CI / reuse (push) Successful in 3s
CI put the tab strip 9px into the controls at 1440px with no scaling at
all, on a bar that had every degradation step available to it and used
none of them.  It used none because nothing thought anything was wrong:
the fit test was an arithmetic estimate — identity + nav.scrollWidth +
actions.scrollWidth + a 48px allowance for the gaps — and on a platform
whose fonts run wider than the one it was written on, that allowance no
longer covered what it stands for.  An estimate that says "fits" stops
the ladder before its first rung.

It reads the geometry now: the controls have to stay inside the bar, and
no tab may reach them.  That is the same measurement the test makes, so
the two cannot disagree about any platform's metrics.  The tabs are the
subject rather than the nav's box because the nav shrinks below its
content — the box gets smaller while the tabs keep their width and slide
underneath the controls.

A second fault turned up while probing this: the strip only reflowed on
window resize.  The rig name arriving from the server, the style picker
filling in, a font swapping in wider metrics — each changes what fits
without touching the window, and the bar sat there as it was through all
of them.  A ResizeObserver on the bar and the controls covers those, and
document.fonts.ready covers the swap.

The guard sweeps text scales and adds a station name too long for the
bar, but it should be said plainly: it passes against the old code too.
Nothing here reproduces on this machine — a 4px viewport sweep from 1080
to 1500, three wide font stacks and scales from 1.0 to 3.0 all failed to
make the old estimate lie.  What is fixed is the mechanism that could.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 20:32:38 +02:00
sjg c527f20a88 [feat](trx-frontend-http): give digital modes a decoder sidebar
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 8m15s
CI / frontend (push) Failing after 32s
CI / reuse (push) Successful in 3s
Thirteen decoders in a horizontal strip needed a scroller on anything
but a wide window, and the open one was marked by a single underline
among thirteen.  They are a list down the left now, all visible at once,
each keeping the state dot it already carried, with the panel for the
selected one filling the rest of the width.

No script changed: the sub-tab wiring, the aria roles, the decoder
picker and the state-dot observers all work on the same markup, so this
is layout only.

Below 760px the sidebar gives way to the picker that already existed
there.  That path needed align-content: the tab panel fills the page
height and a grid stretches its rows to match, which handed the picker a
218px row and left a 189px gap under it.

The tab icon was the signal-strength bars, which is what the S-meter
shows two rows above it; a pulse train says digital modes instead.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 08:01:22 +02:00
sjg 9f495021f0 [style](trx-frontend-http): name and fence the three audio groups
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m23s
CI / frontend (push) Successful in 3m18s
CI / reuse (push) Successful in 3s
The row carries three unrelated things — how loud it is, whether there
is any audio at all, and how much is arriving — and only the middle one
was named.  Each is a group now: VOLUME over the two sliders, SQL on its
own switch, LEVEL over the meter, with a hairline between each.

The rules are drawn only while the row is one line, measured against the
row rather than the viewport: what fits depends on whether the rig
transmits and whether it has a squelch at all.  A rule divides what sits
either side of it, so once a group wraps the wrap is the division and
the rule would just be a mark at the start of a line — which is what it
was at 900px before this.  The labels carry the grouping on their own
below that width, and the groups stack whole on a phone.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 07:46:56 +02:00
sjg 33aa7b807c [style](trx-frontend-http): fence the squelch off from the volume controls
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 8m18s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
The squelch sat in the audio row on the same gap as everything else, so
it read as a continuation of the volume sliders.  It decides whether
there is audio at all, which is not the same kind of control as how loud
it is, and a hairline says so.

The rule belongs to the squelch block, so it leaves with it on a rig
that has none, and it stands down where the row stacks: there the line
break separates them already and a leading rule would just start a line.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 07:36:53 +02:00
sjg 2c56d82a81 [feat](trx-frontend-http): make the SQL label the squelch switch
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m13s
CI / frontend (push) Failing after 30s
CI / reuse (push) Successful in 3s
Clicking SQL turns the squelch on and off.  The label and the button
beside it said the same thing twice — one naming the control, the other
reading "On" or "Off" — where the name itself is the obvious target, and
the dot already carries the state: grey when off, green while the gate
passes, amber while it holds.

The pressed state is on the label, so the switch reads the same to a
screen reader as it looks.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:41:52 +02:00
sjg 0fc2115973 [fix](trx-frontend-http): make map links work before the map has loaded
The AIS and APRS mini views link each position to the map, and neither
did anything: the map module installs itself lazily, and it was the one
defining window.navigateToAprsMap, so until something had opened the Map
tab the global did not exist.  AIS calls it inline from onclick and
threw "not a function"; APRS guards the call and so failed silently.
The grid links on FT8, FT4, FT2 and WSPR rows went the same way through
navigateToMapLocator.

The app owns both globals now, installed at startup.  They record the
target, switch tabs through navigateToTab — the only path that
materialises the panel from its template, loads the module and updates
the history entry, none of which the module's own hand-rolled tab switch
did — and the target is applied once the module reports ready.

The module keeps the focusing, which is its job, and exposes it as
focusMapPosition and focusMapLocator.

The smoke test now calls the link from a cold page, asserting the map
module is not loaded first so the check cannot pass by accident.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:41:09 +02:00
sjg 2c1df75d19 [fix](trx-frontend-http): measure auto squelch from the meter
CI / frontend (push) Successful in 3m16s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 8m14s
Auto took the spectrum's noise floor and added 6 dB, but the threshold
is compared against the channel level the meter reports, and the two sit
a long way apart: the gap is set by the FFT size and window, the channel
bandwidth, the decimation, and peak-versus-mean statistics.  Measured on
white noise it runs +22.1 dB at 48k/8k/3k, +18.7 dB at 240k/24k/12k and
-1.2 dB at 1.92M/24k/12k — a 23 dB swing across ordinary configurations.
Only the last of those is anywhere near right, so on a narrow span Auto
set the gate some 20 dB below the noise and it never closed.

It now reads the same number the DSP compares: the 20th percentile of
the meter over the last ten seconds, plus 5 dB.  The percentile keeps a
burst of traffic inside the window from dragging the estimate up, and
5 dB clears the meter's own jitter, which measured 0.9-1.6 dB.  Nothing
in it converts between scales, so no part of the signal chain can put it
out again.  With no history yet — a fresh connection, a rig switch — it
listens for a moment rather than refusing.

The fixture gained a streaming /meter, without which there is nothing to
measure, and the spectrum test pins auto to the meter it serves.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:32:18 +02:00
sjg aefd36c4b1 [feat](trx-frontend-http): set the squelch on the spectrum, in dB
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m41s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 2s
The threshold is in dB, and since the squelch fix that is the scale the
spectrum axis and the S-meter are labelled in — so the control belongs
on the plot, at the level it gates.  A dashed line spans the spectrum at
its threshold with a grip that reads it out, dragged like the bandwidth
edges, green while the signal is above it and amber while it gates.
Arrow keys move it a dB at a time for anyone not using a mouse.

The audio row keeps a compact version: the dB, an indicator lit from the
same meter the DSP compares against, Auto, and an enable toggle that no
longer doubles as the level.  The slider ran 0-100% over that dB range,
which gave the operator a number with nothing on screen to relate it to,
and zero meant "disabled", so turning the squelch off to listen threw
the threshold away.  Auto now says which level it picked.

Two things the browser could only show once it was on the plot: the grip
landed underneath the split control at the right edge, which swallowed
its pointer, and dragging to the foot of the axis hid the line — and the
grip with it — instead of pinning it where it could be dragged back.

The fixture could not exercise any of this: /audio answered 404, which
hides the audio row and the control inside it, and the status carried no
filter block, which is what tells the client the rig has a squelch at
all.  Both now look like an SDR, and the spectrum test drives the line.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:20:19 +02:00
sjg 25b9c31c9b [fix](trx-backend-soapysdr): squelch against the meter, not the post-AGC level
The threshold was compared against the block level measured after the IQ
AGC.  Holding that level at a setpoint is the AGC's entire purpose, so
for every mode that has one — FM, PKT, AIS, AM, SAM, which is to say the
modes anyone squelches — the comparison was against a near-constant.
With FM's 12 dB of gain a weak signal reads some 12 dB hotter than it
is, and the value never had the decimation correction the meter applies
on top of that: around 20 dB adrift at 48k/8k, more as decimation grows.

The threshold arrives in the other scale entirely.  The slider maps its
percentage onto -120..-30 dB and Auto takes the spectrum noise floor
plus 6, both of which are what the meter and the spectrum display show.
So a gate set just above the noise sat open on it.

It now reads last_signal_db, which is already computed each block before
the AGC and corrected for decimation — the same number the meter shows.
The post-AGC measurement had no other consumer.

The test feeds one signal twice and takes the threshold from the
channel's own meter: 6 dB above must gate it, 6 dB below must pass it.
Nothing there depends on the absolute scale, only on the two agreeing.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:03:46 +02:00
sjg d68a84f7f9 [chore](trx-client): apply cargo fmt
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m33s
CI / frontend (push) Successful in 3m18s
CI / reuse (push) Successful in 3s
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:57:33 +02:00
sjg 4fa191e9b6 [fix](trx-frontend-http): serve the band plan to every session
CI / lint (push) Failing after 1s
CI / test (push) Successful in 8m20s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
/bandplan.json needed the control role.  Route access is decided by
suffix for static assets — .js, .css, .png and so on — and ".json" is
not among them, so the band plan matched nothing and fell through to the
catch-all.  It is compiled into the binary and identical for every user,
so it is public now, like the rest of them.

Two things followed from that.  Read-only sessions never saw a band plan
at all.  And since the page asks for it during startup, the request can
land before the session is established: that 401 was swallowed by an
empty catch and never retried, which is why the allocations sometimes
only appeared after a manual reload.

So the client no longer hides the failure, retries once the auth gate
clears — which is exactly when a startup 401 becomes fixable — and
schedules a draw when the data lands, since the strip is painted from
the spectrum draw and a rig sitting between frames would stay blank.

The fixture can now refuse the first request the way the server did, and
the spectrum layout test holds the client to recovering from it.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:50:49 +02:00
sjg c073d03ffb [feat](trx-frontend-http): reorder the controls tray sections
CI / lint (push) Successful in 2m23s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
CI / test (push) Successful in 8m14s
The radio's own settings now come first, then audio, then the scheduler:
Advanced radio controls, Audio controls, Scheduler controls.

The advanced section is not in the markup — ui-core builds it at runtime
and gathers the SDR settings, virtual channel and TX limit rows into it,
appending the result, which put it last however the markup was ordered.
It is inserted ahead of the audio section instead.

The signal readout and the TX meters stay where they are, between the
controls and the sections: they are readouts rather than a section, and
on an SDR the spectrum covers them anyway.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:41:08 +02:00
sjg 730f129404 [fix](trx-frontend-http): let the header identity give way before the tabs
CI still put the tab strip into the controls, now at 1100px — the
narrowest bar in the app, since the bookmark gutters take 9.5rem a side
above that width and leave 756px against 871px at 900px.  With the
controls already in the overflow menu and the tabs already down to
icons, nothing else could give, and what gives by default is the strip:
it is the one item allowed to shrink below its content, so its tabs keep
full width and slide under the controls, out of reach.

The identity block takes the squeeze instead, ellipsised.  A clipped
station name is still readable; a destination hidden underneath the
controls is not.

The guard that was supposed to catch this scaled only the tabs and the
controls, not the title and subtitles — which is exactly what runs out
of room — and skipped 900px.  It now scales every piece of text in the
bar and checks all four widths.  Measured across text scales from 1.0 to
3.0 at each width, the bar keeps its 16px allowance everywhere; before
this, 1.6 and above overlapped at 1100px.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:40:36 +02:00
sjg f95f3d0104 [feat](trx-frontend-http): centre the radio controls, split off the mode row
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m13s
CI / frontend (push) Failing after 32s
CI / reuse (push) Successful in 3s
The controls every rig has — mode, wheel, tune step, transmit — now sit
as a centred block rather than packed against the left edge.

What the current mode adds moves out from among them: WFM's six controls
stretched the row sideways whenever it was active, pushing the wheel and
the step pickers off centre, and SAM did the same on a smaller scale.
They get a row of their own below a divider, which appears and leaves
with them — an empty one would still take a track and a gap in the tray
and draw its divider under controls it has nothing to do with.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:29:31 +02:00
sjg e70e82c8c0 [feat](trx-frontend-http): rebuild the general radio controls row
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m8s
CI / frontend (push) Failing after 36s
CI / reuse (push) Successful in 4s
Mode was a full-width select: 483px of the row to display "FM".  The
modes are three or four characters and there are at most twelve, so they
become a segmented group like the Unit and Step Scale pickers beside
them — a third of the width, and one click instead of two.

The <select> stays as the mode's value.  A dozen call sites and several
plugins read #mode.value, so replacing it outright would have reached
much further than a layout change should; it is hidden from sight and
from assistive tech, the buttons write to it, and everything downstream
runs unchanged.  Every writer re-syncs the buttons, the plugins through
a new trxCore.syncModePicker.

The row itself was a grid with a track per column, but the WFM, SAM and
transmit columns are hidden on most rigs, so it ended in some 500px of
hole.  It packs left now.  Same fault one level down: the power buttons
sat in three fixed tracks, so a rig with neither transmit nor lock kept
two empty ones and left its label chip stranded at the far edge.

Unit and Step Scale move out of the frequency row and in beside the
wheel and the +/- they modify, which were some 600px away.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:09:08 +02:00
sjg 23cc0db1fa [fix](trx-frontend-http): drop the tabs to icons when the bar runs out of room
CI / test (push) Successful in 8m15s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m17s
CI put the tab strip 9px into the controls at 1440px on a run that
passed locally: its system font is wider, and the bar had no move left
to make.  Controls are moved into the overflow menu until the bar fits,
but once all of them were in the menu nothing else gave — the nav may
shrink below its content, so the tabs kept full width and ran under the
controls, leaving the destinations nearest them unclickable.  Labels
dropping to icons was the other half of the answer, but it hung off a
max-width:1360px media query and so was unavailable at 1440px.

That class now goes on by measurement, as the last step after the menu
is exhausted, which is the same reasoning the controls' own fit test
already uses: how much fits depends on the rig name and on how wide the
platform draws the labels, not on the viewport.  The class is cleared
before measuring so the decision cannot ratchet, and icon widths are
fixed, so it always buys back the labels' width.

Labels now stay put between 1100px and 1360px while they fit, with the
style picker and theme toggle behind the overflow menu instead.

The suite could not have caught this: it passed on the fonts of the
machine that wrote it.  The layout section now repeats its fit check
with the bar's text scaled up, which reproduces a wider system font
anywhere — with this fix reverted it fails on macOS too.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 21:36:55 +02:00
sjg 657f952a2c [fix](trx-frontend-http): hold the strips' place above the spectrum
Tuning across a band edge moved the whole page under the cursor: the
band plan strip is in flow, so a range with no allocations collapsed it
from 18px to nothing and dragged every element below it up (measured at
1600x950: overview top 118 to 100, footer 1026 to 1008).  It now keeps
its height whenever a band plan could be drawn at all, and only gives it
back when the feature is off, has no data, or there is no spectrum.

The bookmark rail gets the same treatment for consistency, though it
never moved anything — it is absolutely positioned over the top of the
overview.  It stays up and blank rather than vanishing.

Which exposes something the rail was already doing wrong: it covers the
top of the plot, and a bare div still takes pointer events, so whenever
bookmarks were in range that band of the overview could not be dragged
or scrolled.  Only the chips are targets now.

tests/spectrum-layout.mjs covers this: it streams spectrum frames, tunes
between a band with bookmarks and allocations and one with neither, and
asserts nothing moves and that the rail lets clicks through.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 21:26:19 +02:00
sjg 1f256cbb68 [refactor](trx-frontend-http): extract the browser test fixture
browser-smoke.mjs carried its static server inline, which made it the
only browser test that could exist: a second one would have had to copy
180 lines of routes to change a single capability flag.  The server
moves to tests/web-fixture.mjs behind startWebFixture(), with the rig's
spectrum support, bookmarks and band plan as options.

Serving a rig with a spectrum matters because that is where the layout
actually lives — the panel, the strips above it and the waterfall are
all gated on filter_controls, and the existing fixture reports a
CAT-only rig, so none of it has ever been rendered under test.

No change to what the smoke test checks.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 21:26:06 +02:00
sjg 92fbdb692c [feat](trx-frontend-http): lay the scheduler controls out in one row
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m12s
CI / frontend (push) Failing after 29s
CI / reuse (push) Successful in 3s
The controls were a column — release, then the step buttons, then the
status line, then the entry on air last — which read bottom-up and left
the entry that is actually transmitting furthest from the buttons that
change it.  They now run left to right: step through the entries, hand
the rig back, then the current entry behind a separator.

The separator is a pseudo-element on the current-entry block rather than
an element of its own, because that block is display-toggled whenever
fewer than two entries are active; a standalone rule would be left
hanging with nothing after it.

No ids move, so the enable/disable logic in the scheduler plugin and the
release polling in vchan bind exactly as before.  The smoke test asserts
the row's order, which is also what keeps the separator in place.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:58:31 +02:00
sjg b2fbcb318d [fix](trx-frontend-http): stop Tools lighting up on every refresh
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m21s
CI / frontend (push) Failing after 30s
CI / reuse (push) Successful in 3s
navigateToTab marked the Tools button by asking whether the destination
tab was displayed, which is the right question at the wrong moment: the
first route navigation runs while the card is still behind the loading
state, where every tab computes to display:none.  Refreshing or deep
linking to any page therefore lit Tools alongside the real destination,
and nothing re-evaluated it once the page appeared.

Membership of the Tools menu answers the same question without needing
anything laid out, and still reads the grouping ui-core installs rather
than a second copy of it.

The smoke fixture now serves the SPA shell for route paths the way the
server's per-tab index handlers do, so a deep link no longer 404s and
the case is testable at all; two of them are asserted.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:51:04 +02:00
sjg 44721b579c [fix](trx-frontend-http): fill the map column down to the footer
The windowed map was capped at 75% of the viewport height and at a
width-derived aspect ratio, which left a dead band under it: 69px at
1600x950, and on a 420px-wide phone a 270px map on an 800px screen.
Neither cap was doing useful work now that the stage spans the full
width, so the map fills the column down to the footer instead.

Growing into the footer needs a bound: once the column is tall enough to
push the footer below the fold, using its position would push it further
on every pass, so the bottom edge is clamped to the viewport.  Growth
then consumes the column's spare height and settles in one pass.

Also drops three mapIsFullscreen() branches in the windowed path that
could never be taken — the fullscreen case returns above them.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:50:48 +02:00
sjg 889d00007d [feat](trx-frontend-http): box the selected tab instead of underlining it
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m19s
CI / frontend (push) Successful in 3m10s
CI / reuse (push) Successful in 3s
The desktop strip marked the current page with a 2px underline while the
mobile bottom nav already boxed it, so one navigation model looked like
two.  The box now sits on both: a transparent 1px border on the base
reserves it, so switching pages moves no neighbours, and hover fills a
fainter version of the same shape.  Tools carries it too — that button
is marked active for the destinations the strip hides.

Dropping the mobile rule's border-bottom:none, which only existed to
cancel the old desktop underline, closes the bottom edge its active box
had been missing.  The smoke test checks all four edges.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:37:31 +02:00
sjg 6f53592041 [feat](trx-frontend-http): rework the page footer
The footer floated in space below the content with no rule to close the
page, its two clusters sat on a text baseline that left the source pill
hanging, and the status hint was a plain line of text a size larger than
the attribution beside it.

Now a hairline closes the page the way .tab-bar opens it, the clusters
centre on one line, and the attribution drops the opacity it stacked on
top of --text-muted, which had put it below a readable contrast ratio.

The status hint becomes a pill with a state dot: green when ready, amber
while a command is in flight, red on connection loss.  The colour comes
from a data-state attribute, so every hint now goes through setPowerHint
instead of assigning textContent directly.  --status-ok carries the
indicator green; .about-status-on picks it up too, which darkens it on
light themes where the old value was barely legible.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:37:12 +02:00
sjg 709939f60b [feat](trx-frontend-http): span the map across the full viewport width
The map stage broke out of the centred .card column: negative inline
margins cancel the card's centring offset and its side padding, so the
stage reaches both viewport edges at every width without hardcoding
either value.  Its rounded corners and left/right borders go with it —
edge to edge, the panel reads as a band rather than a floating card.

The browser smoke test now measures the stage against the viewport, and
checks that the full-bleed width does not push the page sideways.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:36:50 +02:00
sjgandClaude Opus 5 5b7dd493d4 [feat](trx-frontend-http): draw APRS symbols from the sprite sheets
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m12s
CI / frontend (pull_request) Successful in 3m2s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 7m28s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 3s
Resolve a table/code pair to a sprite cell in aprs-shared, and use it
from both the packet lists and the map markers, which had each been
printing the raw symbol character in a bordered box.

A table identifier of / or \ selects the primary or alternate sheet
directly.  Anything else is an overlay character, which the APRS spec
draws on top of the alternate symbol -- so those stack the overlay sheet
over the alternate one rather than picking a sheet.  Codes outside
0x21..0x7E have no cell and keep the old character box.

The sheet URLs stay in the stylesheet so a min-resolution query can swap
in the retina sheets; only the cell offset is computed and set inline.
Map markers share the helper through the plugin chunk, so the map stays
free of any remote symbol fetch.

Verified in a browser against the real stylesheet and sheets: /> is a
car, /_ a WX circle, /& an igate diamond, \n a red triangle, and the
overlays S> and 7# carry their character on the alternate symbol.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:55:50 +02:00
sjgandClaude Opus 5 02fe492dbf [feat](trx-frontend-http): serve the vendored APRS symbol sprites
Embed the six sheets alongside the other vendored assets and serve them
under /vendor with the same immutable cache headers.

The browser computes a symbol's cell from a 16x6 grid of 24px cells, so
a re-vendored sheet at any other size would shift every station onto a
neighbouring icon -- wrong on every packet, and invisible unless you
know which glyph to expect.  Pin the geometry by parsing each embedded
PNG's IHDR in a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:55:50 +02:00
sjgandClaude Opus 5 1bc2c88e2f [chore](trx-rs): vendor the APRS symbol sprite sheets
The web UI had no symbol graphics at all, so a position report rendered
its raw symbol character in a bordered box.  Vendor rev H of the
hessu/aprs-symbols set: three 24px sheets (primary, alternate, and the
overlay characters) plus their retina variants.

The set carries no single license.  Individual symbols are variously
vectorizations of the original WA8LMF bitmaps with unknown terms, new
CC BY-SA work by OH7LZB, public-domain or CC sources, and a handful of
brand logos owned by their companies.  Record that as
LicenseRef-APRS-Symbols with the upstream per-symbol catalogue copied
verbatim, and carry the attribution pointer upstream asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:36:27 +02:00
sjgandClaude Opus 5 863a6d8fd4 [fix](trx-frontend-http): restore decode history from the stored records
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m19s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m26s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 2s
Replay required every restored record to carry a string `type`, and
stored records do not have one: an AIS entry holds mmsi, lat, lon,
crc_ok and its decoder's own fields, nothing more.  The filter therefore
discarded all of them, and did it silently — the fetch returned its full
payload, the worker decoded it, and no error was logged, so the history
simply never appeared.

That field identifies live SSE frames, which do carry it, which is why
only replay was affected.  History arrives already grouped and the
group's kind is delivered alongside the messages, so `type` was never
needed to route them.  Require only that a record is an object.

Confirmed against a live server: the first restored group is AIS, and
its records expose their decoder fields with `type` undefined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 18:39:13 +02:00
sjgandClaude Opus 5 b252717f6b [test](trx-frontend-http): serve a realistic decoder registry to the smoke test
CI / lint (pull_request) Successful in 2m18s
CI / test (pull_request) Successful in 7m25s
CI / frontend (pull_request) Successful in 2m10s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m26s
CI / frontend (push) Successful in 2m12s
CI / reuse (push) Successful in 3s
The fixture answered /decoders with an empty list, which hid most of the
application from the only test that runs it in a browser.  The decoder
sub-tabs, their panels, the decode toggles and the bookmark decoder
checkboxes are all built from that registry, so the run exercised three
of thirteen sub-tabs and none of the decoder UI.  Finding this needed
route interception, because nothing in the suite could see it.

Serve eleven decoders covering the modes the real registry spans.  The
run now builds 13 sub-tabs and 11 bookmark decoder checkboxes — the same
checkboxes whose construction a recent fix changed without any test
reaching them — and still reports no runtime errors.

It also makes an existing fault observable: at 1100px the decoder
sub-tab bar hides 195px of itself with no scrollbar or fade, the same
silent truncation the top strip had.  No assertion for it here, since
that would fail until the truncation is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 17:46:52 +02:00
sjgandClaude Opus 5 92697b11c5 [feat](trx-frontend-http): mark Tools active for its destinations
CI / test (pull_request) Successful in 8m9s
CI / test (push) Successful in 7m22s
CI / lint (pull_request) Successful in 2m15s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / frontend (push) Successful in 2m10s
CI / reuse (push) Successful in 2s
Grouping Statistics, Recorder, Settings and About behind Tools left the
tab strip looking identical on all four: the destination's own button
carries the active class, but the strip hides that button, so nothing
was marked.  The page titles named the page without saying how you got
there.

Mark the Tools button when the active destination is one the strip hides.
That state is read from the button's computed display rather than from a
second copy of the grouping, so the two cannot drift: whatever ui-core
puts in the menu lights up Tools, and a destination promoted back into
the strip stops doing so with no further change.

Tools already carries the tab class, so the existing active styling
applies unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 00:23:17 +02:00
sjgandClaude Opus 5 b409c57296 [test](trx-frontend-http): assert header geometry in the browser smoke test
CI / test (push) Successful in 7m27s
CI / frontend (push) Successful in 3m1s
CI / reuse (push) Successful in 3s
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 7m24s
CI / frontend (pull_request) Successful in 2m11s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m15s
Several layout faults shipped while every gate passed, because nothing
looked at geometry: a header whose height tracked the viewport, controls
at four different heights, a tab strip that ran under the controls, and a
dropdown that opened underneath the page.

Assert the invariants behind those at four widths — the header stays one
row, the tabs do not reach the controls, the controls share a height, the
page does not scroll sideways — and that the menu renders with real
dimensions and wins a hit test at its own centre.

The overlap check measures the tabs rather than the strip: with the strip
allowed to overflow, its box shrinks while its content paints across the
controls, so the container's own rect never registers the collision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 00:18:16 +02:00
sjgandClaude Opus 5 00191c8d7a [fix](trx-frontend-http): serve the Statistics and Bookmarks routes
CI / lint (pull_request) Successful in 2m18s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m17s
CI / test (pull_request) Successful in 8m23s
CI / test (push) Successful in 7m28s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 2s
The server answers /, /map, /digital-modes, /recorder, /settings and
/about with the application shell, but never had a route for /statistics
or /bookmarks.  Both fell through to the catch-all asset handler, so
reloading on either one downloaded a file instead of reopening the page.
Only in-app navigation worked, which is why it went unnoticed until
Statistics was reachable from the Tools menu.

Add the two missing shell routes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 00:03:48 +02:00
sjgandClaude Opus 5 56c363a517 [fix](trx-frontend-http): align the Statistics page and unblock the tab strip
Two faults, both mine, both visible in one screenshot of that page.

#tab-statistics was the only panel with padding of its own, so its title
and content sat 16px inside where every other page begins.  Remove it and
the page lines up with the header and with its siblings.

Removing the tab strip's `overflow-x` left it unable to shrink below its
content, so at around 1280px it ran under the controls: the Map tab sat
beneath the audio button and Tools beneath REC.  Clipping is safe again —
the menus it anchors are reparented to the body when they open — so the
strip can shrink, and the labels now give way to icons at 1360px rather
than 1180px, before it has to clip anything.

Measured at 1280px: 321px of clearance between the strip and the
controls, and the page title at the same left edge as the header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:59:58 +02:00
sjgandClaude Opus 5 5ad91b4ab6 [fix](trx-frontend-http): stop doubling the space under the Statistics title
CI / lint (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m20s
CI / test (push) Successful in 7m33s
CI / frontend (push) Successful in 2m12s
CI / reuse (push) Successful in 3s
The page titles carry a bottom margin, which is what spaces them from the
content on the plain block panels.  #tab-statistics is not one: it is a
flex column with `gap: 1rem`, so the margin landed on top of that gap and
left 28px under the title where every other page had 12px.

Drop the margin on that panel and let its own gap do the spacing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:50:03 +02:00
sjgandClaude Opus 5 1843522b45 [feat](trx-frontend-http): give the Tools destinations page titles
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 2m59s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m30s
CI / frontend (push) Successful in 2m10s
CI / reuse (push) Successful in 3s
Recorder stated its name; Statistics, Settings and About did not, so one
page in eight carried a title.  The class it used, section-heading, had
no rule behind it either, leaving even that title as a default h2.

Which way to unify follows from the navigation change.  The tab strip
highlights the destination you are on, so Radio, Bookmarks, Digital modes
and Map already say where you are and a title would repeat the strip
while costing vertical space the spectrum wants.  The four destinations
behind Tools get no such highlight — the strip looks the same on all of
them — so those are exactly the pages that have to name themselves.

Give the three that were missing a heading, and style section-heading so
all four match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:46:09 +02:00
sjgandClaude Opus 5 41ddecf5a7 [fix](trx-frontend-http): label the overflow tab Tools and hide its glyph
CI / test (pull_request) Successful in 8m17s
CI / lint (push) Successful in 2m18s
CI / lint (pull_request) Successful in 2m16s
CI / frontend (pull_request) Successful in 3m6s
CI / reuse (pull_request) Successful in 3s
CI / test (push) Successful in 7m25s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 3s
The button rendered as "•••More": the dots span carried no styling at
all, so the glyph sat flush against the label instead of behaving like
the icon it is.  Every other tab hides its icon while labels are shown
and swaps to it when they are not; the dots now follow the same rule, so
the button reads "Tools" beside the other labels and becomes the glyph
alone in the icon band.

"More" also said nothing about the destinations behind it.  The menu
holds Statistics, Recorder, Settings and About, so name it Tools and give
the button an aria-label that spells that out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:36:41 +02:00
132 changed files with 18146 additions and 5500 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
+43 -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",
@@ -3307,6 +3336,16 @@ dependencies = [
"uuid",
]
[[package]]
name = "trx-sstv"
version = "0.1.0"
dependencies = [
"base64",
"png",
"tracing",
"trx-core",
]
[[package]]
name = "trx-vdes"
version = "0.1.0"
+2
View File
@@ -12,11 +12,13 @@ members = [
"src/decoders/trx-ftx",
"src/decoders/trx-rds",
"src/decoders/trx-vdes",
"src/decoders/trx-sstv",
"src/decoders/trx-wefax",
"src/decoders/trx-wspr",
"src/trx-core",
"src/trx-protocol",
"src/trx-app",
"src/trx-config",
"src/trx-reporting",
"src/trx-server",
"src/trx-server/trx-backend",
+434
View File
@@ -0,0 +1,434 @@
APRS symbol set (https://github.com/hessu/aprs-symbols)
=========================================================
Verbatim copy of the upstream COPYRIGHT.md, retrieved 2026-08-03 from
https://github.com/hessu/aprs-symbols. The set has no single SPDX license:
individual symbols carry different terms, summarized below. Attribution
requirement from the upstream README: "If you use this symbol set, please
provide a pointer to the source (http://github.com/hessu/aprs-symbols/)."
---
Copyright and licensing information
======================================
This is a collection of vectorized symbols for use on the APRS system.
The copyright status of this collection is a bit complicated, since the
symbols come from various sources, each having different copyright owners.
Most of the vectorized symbols are loosely based on the low-resolution
"standard" bitmap symbol set as distributed by Stephen Smith, WA8LMF. That
set is used by most APRS software around the world. The low resolution of
those symbols does not allow direct vector conversion, so I've drawn new
symbols in a similar layout. The vector versions try to mimic the original
appearance and colours, with the intention of keeping the set recognizable
and familiar to existing users. In some cases the vector versions are
probably similar enough to the originals, so that they cannot be considered
"original work" by myself. In some of these cases, the originals are
probably also mimicking someone else's design.
The original symbols do not come with any information on their licensing.
They've been distributed with a lot of APRS software over time, but I don't
know who designed which symbol originally. Most likely all of them are
drawn by one of:
* Roger Barker, G4IDE, "original set provided with UI-View" (SK)
* Steve Dimse, KH4G, "U.S. customary set"
* Stephen Smith, WA8LMF
The Adobe Illustrator (.ai) file contains a copy of the original bitmaps
as a hidden layer, just for reference.
Some symbols I obtained from other sources, such as Wikipedia. In those
cases I picked SVG versions which allow commercial reuse (source known, and
the work is placed on public domain, or with a CC license which allows
adaptation and commercial reuse).
Some symbols are vectorized versions of product or brand logos. The
copyright of those is owned by the respective companies (Apple, Microsoft,
Kenwood), and each of those may have some opinions on how the logos are
used. Please check for yourself if you can use them or not.
In the list below I try to summarize the licensing status for each symbol.
Shorthand notation for common licensing status
-------------------------------------------------
* *VEC-OH7LZB* - Vectorized by OH7LZB, based on original APRS symbol set
* Source of original bitmap: http://wa8lmf.net/aprs/APRS_symbols.htm
* Original designer of individual symbol unknown at this time, but one of:
* Roger Barker, G4IDE
* Steve Dimse, KH4G
* Stephen Smith, WA8LMF
* Vectorized versions are designed to look similar
* Licensing: Unknown
* *OH7LZB* - Original vector design by Heikki Hannikainen, OH7LZB
* Different enough (by author's opinion) to make it a new original work,
instead of a copy of the old symbol
* License: CC BY-SA 2.0
* https://creativecommons.org/licenses/by-sa/2.0/
Primary table
----------------
* /! - Police station
* VEC-OH7LZB
* /# - Digipeater / Green star with D in middle
* VEC-OH7LZB
* /$ - Telephone
* VEC-OH7LZB
* /% - DX cluster
* VEC-OH7LZB
* /& - HF gateway
* VEC-OH7LZB
* /' - Small aircraft
* https://openclipart.org/detail/27182/topdown-airplane-view
* Author: Wirelizard (Brian Burger)
* With color and some other small tuning added by OH7LZB
* PD: https://openclipart.org/share
* /( - Mobile satellite station
* OH7LZB
* /) - Wheelchair, handicapped
* PD wheelchair symbol
* Vectorized from bitmap by OH7LZB
* /* - Snowmobile
* https://openclipart.org/detail/15849/snowmobile
* Author: Mystica (https://openclipart.org/user-detail/mystica)
* PD: https://openclipart.org/share
* /+ - Red Cross
* VEC-OH7LZB
* /, - Boy Scouts
* VEC-OH7LZB
* /- - House
* VEC-OH7LZB
* /. - Red X
* VEC-OH7LZB
* // - Red dot
* VEC-OH7LZB
* /0 to /9 - Numbered circles
* VEC-OH7LZB
* Fire
* http://commons.wikimedia.org/wiki/File:FireIcon.svg
* Author: Piotr Jaworski
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
* Tent
* https://openclipart.org/detail/174933/green-tent-by-stamps-174933
* Author: stamps
* PD: https://openclipart.org/share
* Motorcycle
* http://commons.wikimedia.org/wiki/File:MUTCD_W8-15P.svg
* This file is in the public domain because it comes from the Manual on
Uniform Traffic Control Devices, sign number W8-15P, which states
specifically on page I-1 that: Any traffic control device design or
application provision contained in this Manual shall be considered to
be in the public domain. Traffic control devices contained in this
Manual shall not be protected by a patent, trademark, or copyright,
except for the Interstate Shield and any other items owned by FHWA.
* Colour version by OH7LZB
* /= - Railroad engine
* http://commons.wikimedia.org/wiki/File:Icon_train.svg
* Author: http://en.wikipedia.org/wiki/User:Richtom80
* CC-BY-SA-2.5,2.0,1.0
* /> - Car
* OH7LZB
* /? - File server
* https://openclipart.org/detail/163717/file-server-by-lyte
* Author: lyte
* PD: https://openclipart.org/share
* /@ - Hurricane predicted path
* VEC-OH7LZB
* /A - Aid station
* VEC-OH7LZB
* Mail (BBS)
* https://openclipart.org/detail/29268/yellow-mail-by-rg1024-29268
* Author: rg1024
* PD: https://openclipart.org/share
* /C - Canoe
* https://openclipart.org/detail/179047/red-canoe-by-rambo-tribble-179047
* https://openclipart.org/detail/179041/canoe-paddle-by-rambo-tribble-179041
* Author: Rambo Tribble
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
* /E - Eyeball
* http://commons.wikimedia.org/wiki/File:Blue_eye.svg
* PD: "This file is from the Open Clip Art Library, which released it explicitly into the public domain"
* PD: https://openclipart.org/share
* /F - Tractor
* https://openclipart.org/detail/191654/farm-tractor-by-tmjbeary-191654
* Author: tmjbeary
* PD: https://openclipart.org/share
* /G - Grid square, 3 by 3
* VEC-OH7LZB
* /H - Hotel
* VEC-OH7LZB
* /I - TCP/IP
* VEC-OH7LZB
* /K - School
* OH7LZB
* /L - PC user
* OH7LZB
* /M - Mac apple
* Apple
* /N - NTS
* VEC-OH7LZB
* /O - Hot air balloon
* OH7LZB
* /P - Police
* OH7LZB
* /R - RV
* OH7LZB
* /S - Space Shuttle
* https://openclipart.org/detail/814/space-shuttle-by-johnny_automatic
* PD: Published by the NASA, in "The Brain in Space"
* /T - SSTV
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
* Author: rg1024
* Adjusted by OH7LZB
* PD: https://openclipart.org/share
* /U - Bus
* OH7LZB
* /V - ATV, amateur television
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
* Author: rg1024
* Adjusted by OH7LZB
* PD: https://openclipart.org/share
* /W - Wx, Weather service site
* VEC-OH7LZB
* /X - Helicopter
* OH7LZB
* /Y - Sailboat
* OH7LZB
* /Z - Windows flag
* Microsoft
* /[ - Human
* VEC-OH7LZB
* /\ - DF triangle
* VEC-OH7LZB
* /] - Mailbox, post office, letter
* /^ - Large aircraft
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
* Author: SketchArtist
* PD: https://openclipart.org/share
* /_ - Weather station
* VEC-OH7LZB
* /` - Satellite dish
* OH7LZB
* /a - Ambulance
* OH7LZB
* /b - Bicycle
* http://commons.wikimedia.org/wiki/File:Bicycle_evolution-numbers.svg
* Author: Wikipedia user: Al2
* CC BY 3.0
* /c - Incident command post
* VEC-OH7LZB
* /d - Fire station
* VEC-OH7LZB
* /e - Horse, equestrian
* https://openclipart.org/detail/142627/horse-riding-lesson-by-olku
* Author: OlKu
* PD: https://openclipart.org/share
* /f - Fire truck
* OH7LZB
* /g - Hang glider
* OH7LZB
* /h - Hospital
* VEC-OH7LZB
* /i - IOTA, islands on the air
* http://commons.wikimedia.org/wiki/File:Palm_Island_R.svg
* PI
* /j - Jeep
* OH7LZB
* /k - Truck
* OH7LZB
* /l - Laptop
* OH7LZB
* /m - Mic-E repeater
* VEC-OH7LZB
* /n - Node, black bulls-eye
* VEC-OH7LZB
* /o - Emergency operations center
* VEC-OH7LZB
* /p - Dog(e)
* OH7LZB
* /q - Grid square, 2 by 2
* VEC-OH7LZB
* /r - Repeater tower
* OH7LZB
* /s - Ship, power boat
* OH7LZB
* /t - Truck stop
* VEC-OH7LZB
* /u - Semi-trailer truck, 18-wheeler
* OH7LZB
* /v - Van
* OH7LZB
* /w - Water station
* VEC-OH7LZB
* /x - X / Unix
* https://commons.wikimedia.org/wiki/File:X11.svg
* PD
* /y - House, yagi antenna
* VEC-OH7LZB
* /z - Shelter
* VEC-OH7LZB
Secondary table
------------------
* Emergency
* VEC-OH7LZB
* Numbered digipeater / Green star
* VEC-OH7LZB
* Bank
* VEC-OH7LZB
* Numbered gateway / Black diamond
* VEC-OH7LZB
* Crash site
* OH7LZB
* Cloudy
* OH7LZB
* MEO
* VEC-OH7LZB
* Snowflake
* http://commons.wikimedia.org/wiki/File:Snowflake_01.svg
* Author: Wikipedia user: Amada44
* Public Domain
* Church
* VEC-OH7LZB
* Girl Scout
* VEC-OH7LZB
* Looks slightly like the common USA girl scouts logos. Should be different
enough to not infringe on "Girl Scouts of the USA" copyrights.
* Home (HF antenna)
* VEC-OH7LZB
* Unknown position
* VEC-OH7LZB
* Destination
* VEC-OH7LZB
* Numbered circle
* VEC-OH7LZB
* Petrol Station
* OH7LZB
* Hail
* VEC-OH7LZB
* Park
* VEC-OH7LZB
* Gale Flag
* VEC-OH7LZB
* Red car from above
* OH7LZB
* Info Kiosk
* VEC-OH7LZB
* Hurricane
* OH7LZB
* Numbered white box
* VEC-OH7LZB
* Snow blowing
* VEC-OH7LZB
* Coast Guard
* VEC-OH7LZB
* Drizzle
* VEC-OH7LZB
* Smoke / Chimney
* VEC-OH7LZB
* Freezing rain
* VEC-OH7LZB
* Snow Shwr
* VEC-OH7LZB
* Haze
* VEC-OH7LZB
* Rain Shower
* VEC-OH7LZB
* Lightning
* OH7LZB
* "Kenwood radio"
* Kenwood logo, vectorized
* "Lighthouse"
* CC BY-SA 2.0
* http://wiki.openstreetmap.org/wiki/File:Lighthouse.svg
* Nav Buoy
* OH7LZB
* Rocket
* http://www.clker.com/clipart-gglkuglug.html
* PD according to clker.com license
* Parking
* VEC-OH7LZB
* Earthquake, Restaurant
* VEC-OH7LZB
* Satellite
* OH7LZB
* Thunderstorm
* OH7LZB
* Sunny
* OH7LZB
* VORTAC, Numbered WXS
* VEC-OH7LZB
* Pharmacy Rx
* OH7LZB
* Wall Cloud
* OH7LZB
* Numbered plane
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
* Author: SketchArtist
* PD: https://openclipart.org/share
* Numbered WX Station
* VEC-OH7LZB
* Rain
* Source: http://commons.wikimedia.org/wiki/File:Heavy-rain-shower-transparent.svg
* Author: Wikipedia user: Peepo
* Public Domain
* With modifications by OH7LZB
* Numbered diamond
* VEC-OH7LZB
* Dust blowing
* NA
* Numbered civil defence
* VEC-OH7LZB
* DX spot
* VEC-OH7LZB
* Sleet
* NA
* Funnel Cloud
* NA
* Gale
* VEC-OH7LZB
* Store
* https://openclipart.org/detail/89299/cart-medium-by-martins.bruvelis
* Author: martins.bruvelis
* Public Domain
* Adjustments by OH7LZB
* Numbered black box
* VEC-OH7LZB
* Work zone / Excavator
* Based on http://www.clker.com/clipart-292480.html PNG version
* Vectorized and colors adjusted by OH7LZB
* PD according to clker.com documentation, uploader KURSVEIAL
* SUV
* OH7LZB
* Milepost, Numbered triangle, Circle sm
* VEC-OH7LZB
* Partly cloudy
* OH7LZB
* Restrooms, Numbered boat
* VEC-OH7LZB
* Tornado (also used in Funnel cloud, Skywarn)
* https://openclipart.org/detail/104887/tornado-by-laabadon
* Author: Laabadon
* Public Domain
* Numbered truck
* OH7LZB
* Numbered van
* OH7LZB
* Flooding
* NA
* Sky warn, Numbered shelter, fog
* VEC-OH7LZB
+14 -4
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
@@ -144,4 +151,7 @@ a unified set of frontends.
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
bundled third-party license files. Bundled third-party components retain their
original licenses: Leaflet is BSD-2-Clause, DSEG is OFL-1.1, and opus-decoder
is MIT.
is MIT. The APRS symbol sprites come from
[hessu/aprs-symbols](https://github.com/hessu/aprs-symbols); their per-symbol
copyright status is catalogued in
[`LICENSES/LicenseRef-APRS-Symbols.txt`](LICENSES/LicenseRef-APRS-Symbols.txt).
+16
View File
@@ -56,3 +56,19 @@ SPDX-License-Identifier = "OFL-1.1"
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/opus-decoder-0.7.11.min.js"]
SPDX-FileCopyrightText = "2021-2025 Ethan Halsall"
SPDX-License-Identifier = "MIT"
# Vendored APRS symbol sprites (https://github.com/hessu/aprs-symbols), rev H.
# The set has no single upstream license -- individual symbols carry different
# terms, catalogued in LICENSES/LicenseRef-APRS-Symbols.txt. Upstream asks that
# users point back to https://github.com/hessu/aprs-symbols/.
[[annotations]]
path = [
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-0.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-1.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-2.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-0-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-1-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-2-2x.png",
]
SPDX-FileCopyrightText = "Heikki Hannikainen OH7LZB and the APRS symbol set authors (https://github.com/hessu/aprs-symbols)"
SPDX-License-Identifier = "LicenseRef-APRS-Symbols"
+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.
+209 -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,88 @@ 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.
---
## Tune Links
Every page of the web UI carries what the radio is doing in its address, so the
URL in the address bar is always a link someone else can open:
```
http://receiver.example:8080/?rig=sdr&f=14074000&mode=USB&bw=3000
```
| Parameter | Meaning |
|-----------|---------|
| `f` | Frequency. Hz by default; `7074k` and `14.074M` also work. |
| `mode` | Demodulation mode, e.g. `USB`, `CW`, `WFM`. |
| `bw` | Filter bandwidth in Hz. Ignored by rigs without filter control. |
| `rig` | Rig to select first, by id, on a multi-rig client. |
Opening such a link selects the rig, sets the mode, tunes, and applies the
bandwidth, in that order — a mode change carries its own default bandwidth, so
an explicit `bw` is applied last. Anything the rig cannot do (an unknown mode,
a frequency outside its range) is reported and the rest of the link still
applies. All four parameters are optional.
The link button in the top bar copies the current link to the clipboard. The
address bar itself is updated as you tune, using `replaceState`, so sweeping
the dial does not fill the browser's history.
Applying a link changes the radio, so it needs the `control` role; an `rx`
session opens the page and says the link was not applied. Links describe the
rig's own dial — while a tab is listening to a virtual channel the address is
left as it was, rather than publishing a frequency the rig is not on.
---
+14
View File
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
[package]
name = "trx-sstv"
version.workspace = true
edition = "2021"
[dependencies]
trx-core = { path = "../../trx-core" }
base64 = "0.22"
png = "0.17"
tracing = "0.1"
@@ -0,0 +1,91 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Encode a test card, decode it back, write both as PNGs to a directory.
//!
//! `cargo run -p trx-sstv --example round_trip_png -- /tmp/out`
use trx_sstv::encode::{encode, Frame};
use trx_sstv::mode::mode_for_vis;
use trx_sstv::{ImageCanvas, SstvConfig, SstvDecoder, SstvEvent};
fn main() {
let dir = std::env::args().nth(1).unwrap_or_else(|| ".".into());
for vis in [44u8, 60, 12, 8, 95] {
let mode = mode_for_vis(vis).expect("mode in table");
let (w, h) = (usize::from(mode.width), usize::from(mode.height));
let mut rgb = vec![0u8; w * h * 3];
for y in 0..h {
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]
} 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)
}
};
rgb[at] = r;
rgb[at + 1] = g;
rgb[at + 2] = b;
}
}
let name = mode.name.replace(' ', "-");
let mut sent = ImageCanvas::new(w, h);
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");
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());
let mut got = None;
for block in audio.chunks(1024) {
for event in decoder.process_samples(block) {
if let SstvEvent::Complete(image) = event {
got = Some(image);
}
}
}
let image = got.expect("no image decoded");
let mut canvas = ImageCanvas::new(w, h);
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
);
}
}
+17
View File
@@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! SSTV decoder configuration.
/// Settings for [`crate::decoder::SstvDecoder`].
#[derive(Debug, Clone, Default)]
pub struct SstvConfig {
/// VIS code of a mode to assume when no header is heard, so tuning in
/// part-way through a transmission still produces a picture. `None` means
/// wait for a header, which is the safe default: guessing wrong yields a
/// convincing image of nothing.
pub force_mode: Option<u8>,
/// Directory for saved PNGs. `None` keeps images in memory only.
pub output_dir: Option<String>,
}
+659
View File
@@ -0,0 +1,659 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! The decoder: audio in, pictures out.
//!
//! Reception is a small state machine. It listens for a VIS header, and once
//! one names a mode it walks the transmission a line at a time, sampling each
//! scan at the offsets the mode's segment list gives. Every line is looked for
//! at the time the mode says it should arrive, then nudged into place by the
//! sync pulse actually found near it — a transmitter's clock and a receiver's
//! sound card never agree exactly, and over two minutes of Martin M1 an
//! uncorrected error of a few parts per million visibly shears the picture.
//!
//! Rows are emitted as they are decoded so a picture can be watched arriving,
//! which is most of the appeal of the mode.
use crate::config::SstvConfig;
use crate::demod::FreqDemod;
use crate::image::ImageCanvas;
use crate::mode::{level_from_hz, mode_for_vis, Channel, ColorModel, SstvMode};
use crate::vis::find_vis;
/// Anything below this is the sync pulse rather than picture: black is 1500 Hz
/// and sync is 1200 Hz, so the line sits between them.
const SYNC_THRESHOLD_HZ: f32 = 1350.0;
/// How much of the signal to keep while hunting for a header. A header is 940
/// ms; two seconds leaves room for one to straddle several blocks of audio.
const SEARCH_HISTORY_MS: f64 = 2000.0;
/// From the start bit to the end of the stop bit: ten 30 ms cells. A header
/// search must never retire a stretch shorter than this, or a start bit split
/// across two blocks of audio is dismissed on half a view of it.
const HEADER_SPAN_MS: f64 = 330.0;
/// How far from its predicted position a line's sync pulse is looked for.
const SYNC_SEARCH_MS: f64 = 12.0;
/// Fraction of the observed timing error applied to the next line. Damped,
/// because a sync pulse found in noise is worth less than the prediction.
const SYNC_CORRECTION: f64 = 0.45;
/// Consecutive lines with no sync pulse anywhere near the prediction before
/// the transmission is taken to have ended.
const MISSING_SYNC_LIMIT: u32 = 8;
/// What the decoder has to say.
#[derive(Debug, Clone)]
pub enum SstvEvent {
/// A header was decoded and reception has begun.
Started {
vis: u8,
mode: &'static str,
width: u16,
height: u16,
},
/// One image row is ready, as RGB triples.
Row { line: u16, rgb: Vec<u8> },
/// Reception finished — at the bottom of the frame, or because the signal
/// went away. Carries the picture either way.
Complete(SstvImage),
}
/// A received picture.
#[derive(Debug, Clone)]
pub struct SstvImage {
pub vis: u8,
pub mode: &'static str,
pub width: u16,
pub height: u16,
/// Rows actually received, which is the height only if it ran to the end.
pub lines: u16,
/// Whether the whole frame arrived.
pub complete: bool,
/// RGB triples, `width * height * 3` bytes. Rows never received are grey.
pub rgb: Vec<u8>,
/// When reception started, in milliseconds since the epoch.
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,
Receiving(Box<Reception>),
}
struct Reception {
mode: &'static SstvMode,
canvas: ImageCanvas,
/// Absolute sample index at which the next transmitted line begins.
next_line: f64,
/// Next image row to write.
row: u16,
started_ms: i64,
missing_syncs: u32,
/// Set until the first line has been decoded. A VIS header ends exactly
/// where the picture begins, so the first line is already aligned — and a
/// search would find the header's own 30 ms stop bit, which is at the sync
/// frequency and sits immediately before the picture.
first_line: bool,
/// Robot 36 sends one chroma channel per line and expects the decoder to
/// carry the other over from the line before.
last_chroma_r: Option<Vec<u8>>,
last_chroma_b: Option<Vec<u8>>,
}
pub struct SstvDecoder {
sample_rate: u32,
config: SstvConfig,
demod: FreqDemod,
/// Instantaneous frequency, one entry per audio sample. Delayed by the
/// demodulator's group delay, which is constant and so shifts the whole
/// stream — header and lines alike — without disturbing their spacing.
freqs: Vec<f32>,
/// Absolute index of `freqs[0]`, so positions survive the buffer being
/// trimmed.
base: u64,
/// Absolute index the header search has already covered.
searched_to: u64,
state: State,
}
impl SstvDecoder {
pub fn new(sample_rate: u32, config: SstvConfig) -> Self {
Self {
sample_rate,
config,
demod: FreqDemod::new(sample_rate),
freqs: Vec::new(),
base: 0,
searched_to: 0,
state: State::Searching,
}
}
/// Whether a picture is currently arriving.
pub fn is_receiving(&self) -> bool {
matches!(self.state, State::Receiving(_))
}
/// Feed a block of mono audio. Returns whatever it produced.
pub fn process_samples(&mut self, samples: &[f32]) -> Vec<SstvEvent> {
self.demod.process_into(samples, &mut self.freqs);
let mut events = Vec::new();
loop {
let progressed = match self.state {
State::Searching => self.try_start(&mut events),
State::Receiving(_) => self.try_line(&mut events),
};
if !progressed {
break;
}
}
self.trim();
events
}
/// Abandon a reception in progress, returning the picture so far.
pub fn reset(&mut self) -> Vec<SstvEvent> {
let mut events = Vec::new();
if let State::Receiving(reception) = std::mem::replace(&mut self.state, State::Searching) {
events.push(SstvEvent::Complete(finish(&reception)));
}
self.demod.reset();
self.freqs.clear();
self.base = 0;
self.searched_to = 0;
events
}
/// Look for a header, or for a bare sync pulse when the mode is forced.
fn try_start(&mut self, events: &mut Vec<SstvEvent>) -> bool {
let from = self.searched_to.saturating_sub(self.base) as usize;
if from >= self.freqs.len() {
return false;
}
if let Some(hit) = find_vis(&self.freqs, self.sample_rate, from) {
if let Some(mode) = mode_for_vis(hit.code) {
self.begin(mode, self.base + hit.image_start as u64, events);
return true;
}
// A header that parses but names a mode this decoder does not know
// is still a header: skip past it rather than finding it again.
tracing::debug!(vis = hit.code, "SSTV: unsupported mode");
self.searched_to = self.base + hit.image_start as u64;
return true;
}
// Tuning in mid-transmission means no header to find. With a mode
// 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);
self.begin_at(mode, line_start.max(0.0), events);
return true;
}
}
// Nothing yet. Rewind the cursor by a header's worth of samples before
// marking the buffer searched: audio arrives in blocks of a few
// milliseconds, so the search regularly runs over a start bit that is
// only half here. Advancing past it would retire the header for good
// on the strength of a partial view of it.
let unsearchable = ms_to_samples(HEADER_SPAN_MS, self.sample_rate) as u64;
let end = self.base + self.freqs.len() as u64;
self.searched_to = self.searched_to.max(end.saturating_sub(unsearchable));
false
}
fn begin(&mut self, mode: &'static SstvMode, image_start: u64, events: &mut Vec<SstvEvent>) {
self.begin_at(mode, image_start as f64, events);
}
fn begin_at(&mut self, mode: &'static SstvMode, line_start: f64, events: &mut Vec<SstvEvent>) {
events.push(SstvEvent::Started {
vis: mode.vis,
mode: mode.name,
width: mode.width,
height: mode.height,
});
self.state = State::Receiving(Box::new(Reception {
mode,
canvas: ImageCanvas::new(usize::from(mode.width), usize::from(mode.height)),
next_line: line_start,
row: 0,
started_ms: now_ms(),
missing_syncs: 0,
first_line: true,
last_chroma_r: None,
last_chroma_b: None,
}));
}
/// Decode one transmitted line, if all of it has arrived.
fn try_line(&mut self, events: &mut Vec<SstvEvent>) -> bool {
let State::Receiving(reception) = &mut self.state else {
return false;
};
let mode = reception.mode;
let line_samples = ms_to_samples(mode.line_ms, self.sample_rate);
let margin = ms_to_samples(SYNC_SEARCH_MS, self.sample_rate);
let start = reception.next_line;
// Enough for the line's own scans, and for the sync search to reach as
// far ahead as it looks — no further. Demanding the search margin past
// the end of every line would cost the last line of every picture,
// which is exactly where the transmission stops.
let sync_reach = start + ms_to_samples(mode.sync_offset_ms, self.sample_rate) + margin;
let end = (start + line_samples).max(sync_reach);
let available = (self.base + self.freqs.len() as u64) as f64;
if end > available {
return false;
}
// The line may begin before what is still buffered if the caller fed a
// huge block; nothing can be done about that but skip forward.
if start < self.base as f64 {
reception.next_line = self.base as f64;
return true;
}
// Line up on the sync pulse near where this line is predicted to be.
let expected_sync = start + ms_to_samples(mode.sync_offset_ms, self.sample_rate);
let from = (expected_sync - margin).max(self.base as f64) as u64;
let to = (expected_sync + margin) as u64;
let searching = !matches!(&self.state, State::Receiving(r) if r.first_line);
let found = if searching {
self.find_sync_between(from, to, mode)
} else {
None
};
let State::Receiving(reception) = &mut self.state else {
return false;
};
let start = match found {
Some(sync_at) => {
reception.missing_syncs = 0;
let error = sync_at as f64 - expected_sync;
reception.next_line += error * SYNC_CORRECTION;
reception.next_line
}
None if reception.first_line => start,
None => {
reception.missing_syncs += 1;
start
}
};
if reception.missing_syncs >= MISSING_SYNC_LIMIT {
let image = finish(reception);
self.state = State::Searching;
self.searched_to = self.base + self.freqs.len() as u64;
events.push(SstvEvent::Complete(image));
return true;
}
// Sample every scan of the line, then colour the rows.
let mut scans: Vec<(Channel, Vec<u8>)> = Vec::new();
for (channel, offset_ms, ms) in mode.scans() {
let pixels = mode.scan_pixels(channel);
let at = start + ms_to_samples(offset_ms, self.sample_rate);
let values = sample_scan(&self.freqs, self.base, at, ms, pixels, self.sample_rate);
scans.push((channel, values));
}
let State::Receiving(reception) = &mut self.state else {
return false;
};
let rows = compose_rows(reception, &scans);
for (offset, row) in rows.into_iter().enumerate() {
let line = reception.row + offset as u16;
reception.canvas.put_row(usize::from(line), &row);
events.push(SstvEvent::Row { line, rgb: row });
}
reception.first_line = false;
reception.row += mode.lines_per_transmission;
reception.next_line += line_samples;
if reception.row >= mode.height {
let image = finish(reception);
self.state = State::Searching;
self.searched_to = self.base + self.freqs.len() as u64;
events.push(SstvEvent::Complete(image));
}
true
}
/// First sync pulse of about the right length in `freqs[from..to]`,
/// as an index of its leading edge.
///
/// Works on a smoothed copy of the window: a sync pulse is 1200 Hz, where
/// the raw per-sample estimate swings by ±95 Hz, so single samples cross
/// and re-cross the threshold throughout a pulse and no run is ever long
/// enough. Pixels are sampled from the raw signal, where averaging over
/// the pixel does the same job without blurring across its edges.
fn find_sync(&self, from: usize, to: usize, mode: &SstvMode) -> Option<usize> {
let want = sync_ms(mode);
let min_run = (ms_to_samples(want, self.sample_rate) * 0.6) as usize;
// A sync pulse ends. Silence and a dead carrier demodulate to near
// zero, which is below the threshold too, and without an upper bound a
// decoder left running on an empty channel finds sync everywhere and
// fills the picture with noise it invented.
let max_run = (ms_to_samples(want, self.sample_rate) * 3.0) as usize;
let to = to.min(self.freqs.len());
if from >= to {
return None;
}
// Reach past the end of the search window by a whole pulse: a sync
// starting at the last moment the window allows still has to be
// measurable to its full length, or it is rejected for being short and
// the line it belongs to goes unaligned.
let pad = ms_to_samples(want + 2.0, self.sample_rate) as usize;
let window_from = from.saturating_sub(pad);
let window_to = (to + pad).min(self.freqs.len());
let smoothed = crate::demod::smooth(
&self.freqs[window_from..window_to],
ms_to_samples(1.0, self.sample_rate) as usize,
);
let mut i = from - window_from;
let scan_to = to - window_from;
while i < scan_to {
if smoothed[i] >= SYNC_THRESHOLD_HZ {
i += 1;
continue;
}
let mut run = 0;
while i + run < smoothed.len() && smoothed[i + run] < SYNC_THRESHOLD_HZ {
run += 1;
}
if run >= min_run && run <= max_run {
return Some(window_from + i);
}
i += run.max(1);
}
None
}
/// As [`Self::find_sync`], over an absolute index range.
fn find_sync_between(&self, from: u64, to: u64, mode: &SstvMode) -> Option<u64> {
let from = from.saturating_sub(self.base) as usize;
let to = to.saturating_sub(self.base) as usize;
if from >= self.freqs.len() {
return None;
}
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
/// buffer without bound.
fn trim(&mut self) {
let keep_from = match &self.state {
State::Searching => {
let history = ms_to_samples(SEARCH_HISTORY_MS, self.sample_rate) as u64;
(self.base + self.freqs.len() as u64).saturating_sub(history)
}
State::Receiving(reception) => {
let margin = ms_to_samples(SYNC_SEARCH_MS * 2.0, self.sample_rate) as u64;
(reception.next_line as u64).saturating_sub(margin)
}
};
if keep_from <= self.base {
return;
}
let drop = (keep_from - self.base) as usize;
if drop >= self.freqs.len() {
self.freqs.clear();
} else {
self.freqs.drain(..drop);
}
self.base = keep_from;
self.searched_to = self.searched_to.max(self.base);
}
}
/// The sync pulse length of a mode, read out of its own segment list.
fn sync_ms(mode: &SstvMode) -> f64 {
mode.segments
.iter()
.find_map(|segment| match segment {
crate::mode::Segment::Sync(ms) => Some(*ms),
_ => None,
})
.unwrap_or(9.0)
}
/// Milliseconds since the epoch, for stamping a picture with when it arrived.
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
fn ms_to_samples(ms: f64, sample_rate: u32) -> f64 {
ms / 1000.0 * f64::from(sample_rate)
}
/// Average the frequency across each pixel's window and turn it into a level.
///
/// The middle 60% of the window is used: a pixel's edges carry the
/// demodulator's transition from the pixel before, and including them smears
/// every edge in the picture.
fn sample_scan(
freqs: &[f32],
base: u64,
start: f64,
ms: f64,
pixels: usize,
sample_rate: u32,
) -> Vec<u8> {
let mut out = Vec::with_capacity(pixels);
let width = ms_to_samples(ms, sample_rate) / pixels as f64;
for x in 0..pixels {
let pixel_start = start + width * x as f64;
let from = (pixel_start + width * 0.2 - base as f64).max(0.0) as usize;
let to = ((pixel_start + width * 0.8 - base as f64).max(0.0) as usize).min(freqs.len());
// A pixel narrower than a sample still has to produce one.
let (from, to) = if to > from {
(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()),
)
};
if to <= from {
out.push(0);
continue;
}
let window = &freqs[from..to];
let mean = window.iter().sum::<f32>() / window.len() as f32;
out.push(level_from_hz(mean));
}
out
}
/// Turn one line's scans into image rows.
fn compose_rows(reception: &mut Reception, scans: &[(Channel, Vec<u8>)]) -> Vec<Vec<u8>> {
let mode = reception.mode;
let width = usize::from(mode.width);
let find = |channel: Channel| scans.iter().find(|(c, _)| *c == channel).map(|(_, v)| v);
match mode.color {
ColorModel::Rgb => {
let red = find(Channel::Red);
let green = find(Channel::Green);
let blue = find(Channel::Blue);
let mut row = vec![0u8; width * 3];
for x in 0..width {
row[x * 3] = red.and_then(|c| c.get(x).copied()).unwrap_or(0);
row[x * 3 + 1] = green.and_then(|c| c.get(x).copied()).unwrap_or(0);
row[x * 3 + 2] = blue.and_then(|c| c.get(x).copied()).unwrap_or(0);
}
vec![row]
}
ColorModel::YCrCb => {
let luma = find(Channel::LumaOdd).cloned().unwrap_or_default();
let cr = find(Channel::ChromaR).cloned().unwrap_or_default();
let cb = find(Channel::ChromaB).cloned().unwrap_or_default();
vec![ycrcb_row(&luma, &cr, &cb, width)]
}
ColorModel::YCrCbAlternating => {
// 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();
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 cb = reception.last_chroma_b.clone().unwrap_or(neutral);
vec![ycrcb_row(&luma, &cr, &cb, width)]
}
ColorModel::YCrCbPaired => {
let odd = find(Channel::LumaOdd).cloned().unwrap_or_default();
let even = find(Channel::LumaEven).cloned().unwrap_or_default();
let cr = find(Channel::ChromaR).cloned().unwrap_or_default();
let cb = find(Channel::ChromaB).cloned().unwrap_or_default();
vec![
ycrcb_row(&odd, &cr, &cb, width),
ycrcb_row(&even, &cr, &cb, width),
]
}
}
}
/// One RGB row from luminance and chrominance, stretching the chroma scans
/// across the width when they are narrower than it.
fn ycrcb_row(luma: &[u8], cr: &[u8], cb: &[u8], width: usize) -> Vec<u8> {
let mut row = vec![0u8; width * 3];
let pick = |channel: &[u8], x: usize| -> f32 {
if channel.is_empty() {
return 128.0;
}
let at = x * channel.len() / width.max(1);
f32::from(channel[at.min(channel.len() - 1)])
};
for x in 0..width {
let y = if luma.is_empty() {
0.0
} else {
let at = x * luma.len() / width.max(1);
f32::from(luma[at.min(luma.len() - 1)])
};
let (r, g, b) = ycrcb_to_rgb(y, pick(cr, x), pick(cb, x));
row[x * 3] = r;
row[x * 3 + 1] = g;
row[x * 3 + 2] = b;
}
row
}
/// The inverse of the studio-swing conversion SSTV encoders use.
fn ycrcb_to_rgb(y: f32, cr: f32, cb: f32) -> (u8, u8, u8) {
let r = 298.082 * y / 256.0 + 408.583 * cr / 256.0 - 222.921;
let g = 298.082 * y / 256.0 - 100.291 * cb / 256.0 - 208.120 * cr / 256.0 + 135.576;
let b = 298.082 * y / 256.0 + 516.412 * cb / 256.0 - 276.836;
(
r.clamp(0.0, 255.0) as u8,
g.clamp(0.0, 255.0) as u8,
b.clamp(0.0, 255.0) as u8,
)
}
fn finish(reception: &Reception) -> SstvImage {
SstvImage {
vis: reception.mode.vis,
mode: reception.mode.name,
width: reception.mode.width,
height: reception.mode.height,
lines: reception.canvas.filled_rows() as u16,
complete: reception.row >= reception.mode.height,
rgb: reception.canvas.rgb().to_vec(),
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));
}
}
+297
View File
@@ -0,0 +1,297 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Instantaneous frequency estimation.
//!
//! SSTV carries every pixel as a frequency between 1500 and 2300 Hz, and every
//! line boundary as a 1200 Hz pulse, so one measurement serves the whole
//! decoder: the frequency of the signal at each sample. A Hilbert transform
//! FIR forms the analytic signal and the phase difference between consecutive
//! samples gives the frequency.
//!
//! The same approach drives the WEFAX decoder, which maps the result straight
//! to luminance. Here the frequency itself is the output, because the VIS
//! header and the sync detector read tones far outside the pixel band.
//!
//! Block-based linear processing, per `docs/Optimization-Guidelines.md`: the
//! FIR runs over a contiguous `[tail | samples]` buffer so the inner loop is
//! straight indexing the compiler can vectorise.
use std::f32::consts::PI;
/// Taps for the Hilbert transform FIR. Odd, so the delay is a whole sample.
const HILBERT_TAPS: usize = 65;
/// Group delay of the FIR, in samples.
const HILBERT_DELAY: usize = HILBERT_TAPS / 2;
/// Taps for the input band-pass. Long enough to be worth having, short enough
/// that its delay is a couple of milliseconds.
const BANDPASS_TAPS: usize = 127;
/// The band SSTV lives in: sync at 1200 Hz, black at 1500, white at 2300.
const BAND_LOW_HZ: f32 = 900.0;
const BAND_HIGH_HZ: f32 = 2700.0;
/// Produces instantaneous frequency in Hz from real audio samples.
pub struct FreqDemod {
/// Band-pass, applied first. A phase-difference frequency detector answers
/// whatever is loudest, so hiss outside the SSTV band steers the estimate
/// even when the signal is much stronger — the picture tears rather than
/// grows grainy. Every real decoder filters to the band first.
bandpass: Vec<f32>,
bandpass_tail: Vec<f32>,
coeffs: [f32; HILBERT_TAPS],
/// The last `HILBERT_TAPS - 1` input samples, priming the next block.
tail: Vec<f32>,
prev_i: f32,
prev_q: f32,
/// `sample_rate / 2π`, the constant turning phase step into Hz.
hz_per_radian: f32,
}
impl FreqDemod {
pub fn new(sample_rate: u32) -> Self {
Self {
bandpass: design_bandpass_fir(sample_rate),
bandpass_tail: vec![0.0; BANDPASS_TAPS - 1],
coeffs: design_hilbert_fir(),
tail: vec![0.0; HILBERT_TAPS - 1],
prev_i: 0.0,
prev_q: 0.0,
hz_per_radian: sample_rate as f32 / (2.0 * PI),
}
}
/// Band-pass a block, carrying the filter's state across the seam.
fn filter(&mut self, samples: &[f32]) -> Vec<f32> {
let taps = BANDPASS_TAPS;
let tail_len = taps - 1;
let mut work = Vec::with_capacity(tail_len + samples.len());
work.extend_from_slice(&self.bandpass_tail);
work.extend_from_slice(samples);
let mut out = Vec::with_capacity(samples.len());
for i in 0..samples.len() {
let window = &work[i..i + taps];
let mut acc = 0.0f32;
for k in 0..taps {
acc += self.bandpass[k] * window[taps - 1 - k];
}
out.push(acc);
}
let work_len = work.len();
self.bandpass_tail
.copy_from_slice(&work[work_len - tail_len..]);
out
}
/// Frequency in Hz for each input sample, appended to `out`.
///
/// Output is delayed by the FIR's group delay, which is constant and so
/// affects only the absolute timing of the whole stream, not the spacing
/// between the events in it.
pub fn process_into(&mut self, samples: &[f32], out: &mut Vec<f32>) {
if samples.is_empty() {
return;
}
let samples = self.filter(samples);
let samples = samples.as_slice();
let taps = HILBERT_TAPS;
let tail_len = taps - 1;
let mut work = Vec::with_capacity(tail_len + samples.len());
work.extend_from_slice(&self.tail);
work.extend_from_slice(samples);
out.reserve(samples.len());
for i in 0..samples.len() {
let window = &work[i..i + taps];
let mut q = 0.0f32;
for k in 0..taps {
q += self.coeffs[k] * window[taps - 1 - k];
}
// In phase with the quadrature output: the input, delayed by the
// FIR's own group delay.
let i_val = window[HILBERT_DELAY];
// f = |arg(z[n] · conj(z[n-1]))| · fs / 2π
let di = i_val * self.prev_i + q * self.prev_q;
let dq = q * self.prev_i - i_val * self.prev_q;
out.push(dq.atan2(di).abs() * self.hz_per_radian);
self.prev_i = i_val;
self.prev_q = q;
}
let work_len = work.len();
self.tail.copy_from_slice(&work[work_len - tail_len..]);
}
pub fn reset(&mut self) {
self.bandpass_tail.fill(0.0);
self.tail.fill(0.0);
self.prev_i = 0.0;
self.prev_q = 0.0;
}
}
/// Boxcar mean over `window` samples, centred, returning one value per input.
///
/// The per-sample estimate ripples — badly at the low end of the band, where
/// the Hilbert approximation is weakest: a clean 1200 Hz tone reads anywhere
/// between 1110 and 1300 Hz sample to sample, though its mean is exact. Pixels
/// are averaged over their own window and so come out right regardless, but
/// anything that classifies a single sample by frequency — the VIS bits, the
/// sync pulses — has to look at a mean or it is reading the ripple.
pub fn smooth(freqs: &[f32], window: usize) -> Vec<f32> {
let window = window.max(1);
if freqs.is_empty() {
return Vec::new();
}
// Prefix sums in f64: a minute of audio is three million samples, and a
// running f32 total drifts long before that.
let mut prefix = Vec::with_capacity(freqs.len() + 1);
prefix.push(0.0f64);
for &freq in freqs {
prefix.push(prefix[prefix.len() - 1] + f64::from(freq));
}
let half = window / 2;
(0..freqs.len())
.map(|i| {
// Shrinks at the ends rather than reaching past them.
let from = i.saturating_sub(half);
let to = (i + window - half).min(freqs.len());
((prefix[to] - prefix[from]) / (to - from) as f64) as f32
})
.collect()
}
/// Windowed-sinc band-pass over the SSTV band, Hamming-windowed and
/// linear-phase, so every frequency in the band is delayed alike.
fn design_bandpass_fir(sample_rate: u32) -> Vec<f32> {
let sr = sample_rate as f64;
let low = f64::from(BAND_LOW_HZ) / sr;
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 mut coeffs = Vec::with_capacity(BANDPASS_TAPS);
for i in 0..BANDPASS_TAPS {
let n = i as f64 - mid;
// Difference of two low-passes is a band-pass.
let ideal = 2.0 * high * sinc(2.0 * high * n) - 2.0 * low * sinc(2.0 * low * n);
let window = 0.54 - 0.46 * (2.0 * std::f64::consts::PI * i as f64 / m).cos();
coeffs.push((ideal * window) as f32);
}
coeffs
}
/// Type III FIR approximating a 90° phase shift: h[n] = 2/(πn) for odd n,
/// Blackman-windowed. Independent of sample rate, so the decoder can run at
/// whatever rate the audio arrives in.
fn design_hilbert_fir() -> [f32; HILBERT_TAPS] {
let mut coeffs = [0.0f32; HILBERT_TAPS];
let m = (HILBERT_TAPS - 1) as f64;
let mid = m / 2.0;
for (i, coeff) in coeffs.iter_mut().enumerate() {
let n = i as f64 - mid;
let ni = n.round() as i64;
if ni != 0 && ni % 2 != 0 {
let h = 2.0 / (std::f64::consts::PI * n);
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / m).cos()
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / m).cos();
*coeff = (h * w) as f32;
}
}
coeffs
}
#[cfg(test)]
mod tests {
use super::*;
fn tone(freq: f32, sample_rate: u32, samples: usize) -> Vec<f32> {
(0..samples)
.map(|n| (2.0 * PI * freq * n as f32 / sample_rate as f32).sin())
.collect()
}
/// Measured on the settled part of the output: the first `HILBERT_TAPS`
/// samples are the filter filling up.
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,
);
let settled = &out[HILBERT_TAPS * 2..];
settled.iter().sum::<f32>() / settled.len() as f32
}
#[test]
fn reads_the_tones_sstv_is_made_of() {
for rate in [8000u32, 11025, 44100, 48000] {
for freq in [1200.0f32, 1500.0, 1900.0, 2300.0] {
let measured = measure(freq, rate);
assert!(
(measured - freq).abs() < 5.0,
"{rate} Hz: {freq} Hz tone measured as {measured:.1} Hz",
);
}
}
}
/// Blocks are whatever size the audio pipeline hands over, and a tone that
/// straddles two of them must not produce a discontinuity at the seam.
#[test]
fn block_boundaries_do_not_disturb_the_estimate() {
let rate = 48_000;
let samples = tone(1900.0, rate, 9600);
let mut whole = FreqDemod::new(rate);
let mut expected = Vec::new();
whole.process_into(&samples, &mut expected);
let mut split = FreqDemod::new(rate);
let mut actual = Vec::new();
for chunk in samples.chunks(137) {
split.process_into(chunk, &mut actual);
}
assert_eq!(actual.len(), expected.len());
for (i, (a, b)) in actual.iter().zip(&expected).enumerate() {
assert!((a - b).abs() < 0.01, "sample {i}: {a} vs {b}");
}
}
#[test]
fn follows_a_step_between_tones_within_a_pixel() {
let rate = 48_000;
let mut samples = tone(1500.0, rate, 4800);
samples.extend(tone(2300.0, rate, 4800));
let mut demod = FreqDemod::new(rate);
let mut out = Vec::new();
demod.process_into(&samples, &mut out);
// Well before the step it reads black; well after it, white. The step
// 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"
);
}
}
+238
View File
@@ -0,0 +1,238 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Turning an image into an SSTV signal.
//!
//! This exists so the decoder can be held to a picture rather than to a
//! description of one: the tests encode a known image, decode the audio back
//! and compare. Nothing in the receive path uses it.
//!
//! It is written to the same mode table the decoder reads, which makes a
//! round-trip a test of the decoder and not of the timings — the timings are
//! checked separately, against the published line durations, in [`crate::mode`].
use crate::mode::{Channel, Segment, SstvMode, BLACK_HZ, SYNC_HZ, WHITE_HZ};
/// A stretch of constant tone.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tone {
pub hz: f32,
pub ms: f64,
}
/// Frequency for an 8-bit level: the inverse of [`crate::mode::level_from_hz`].
pub fn hz_from_level(level: u8) -> f32 {
BLACK_HZ + (WHITE_HZ - BLACK_HZ) * f32::from(level) / 255.0
}
/// 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
];
let mut ones = 0;
for bit in 0..7 {
let set = vis & (1 << bit) != 0;
if set {
ones += 1;
}
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
}
/// RGB source for the encoder: `width * height * 3` bytes.
pub struct Frame<'a> {
pub width: usize,
pub height: usize,
pub rgb: &'a [u8],
}
impl Frame<'_> {
fn pixel(&self, x: usize, y: usize) -> (u8, u8, u8) {
let x = x.min(self.width.saturating_sub(1));
let y = y.min(self.height.saturating_sub(1));
let at = (y * self.width + x) * 3;
(self.rgb[at], self.rgb[at + 1], self.rgb[at + 2])
}
/// The colour components SSTV actually sends, for a pixel.
fn ycrcb(&self, x: usize, y: usize) -> (u8, u8, u8) {
let (r, g, b) = self.pixel(x, y);
let (r, g, b) = (f32::from(r), f32::from(g), f32::from(b));
let y_val = 16.0 + (0.003_906 * ((65.738 * r) + (129.057 * g) + (25.064 * b)));
let cr = 128.0 + (0.003_906 * ((112.439 * r) + (-94.154 * g) + (-18.285 * b)));
let cb = 128.0 + (0.003_906 * ((-37.945 * r) + (-74.494 * g) + (112.439 * b)));
(
y_val.clamp(0.0, 255.0) as u8,
cr.clamp(0.0, 255.0) as u8,
cb.clamp(0.0, 255.0) as u8,
)
}
}
/// Encode `frame` in `mode`, returning the tones of the whole transmission.
pub fn encode_tones(mode: &SstvMode, frame: &Frame<'_>) -> Vec<Tone> {
let mut tones = vis_tones(mode.vis);
let per_line = usize::from(mode.lines_per_transmission);
let transmissions = usize::from(mode.height) / per_line;
for transmission in 0..transmissions {
let top = transmission * per_line;
for segment in mode.segments {
match *segment {
Segment::Sync(ms) => tones.push(Tone { hz: SYNC_HZ, ms }),
Segment::Gap(ms) => tones.push(Tone { hz: BLACK_HZ, ms }),
Segment::Scan { channel, ms } => {
// A chroma scan carries fewer pixels than the image is
// wide, and takes proportionally less time per pixel.
let pixels = mode.scan_pixels(channel);
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
}
fn channel_level(
mode: &SstvMode,
frame: &Frame<'_>,
channel: Channel,
x: usize,
top: usize,
transmission: usize,
) -> u8 {
match channel {
Channel::Red => frame.pixel(x, top).0,
Channel::Green => frame.pixel(x, top).1,
Channel::Blue => frame.pixel(x, top).2,
Channel::LumaOdd => frame.ycrcb(x, top).0,
Channel::LumaEven => frame.ycrcb(x, top + 1).0,
Channel::ChromaR => chroma(mode, frame, x, top, true),
Channel::ChromaB => chroma(mode, frame, x, top, false),
// Robot 36 sends R-Y on odd transmitted lines and B-Y on even ones.
Channel::ChromaAlternating => chroma(mode, frame, x, top, transmission.is_multiple_of(2)),
}
}
/// Chroma scans are half the width of the image in the Robot modes, so each
/// value covers two pixels; PD averages the two image lines of the pair too.
fn chroma(mode: &SstvMode, frame: &Frame<'_>, x: usize, top: usize, want_cr: bool) -> u8 {
let scale = usize::from(mode.width) / mode.scan_pixels(Channel::ChromaR).max(1);
let x0 = x * scale;
let mut total = 0u32;
let mut count = 0u32;
let rows = usize::from(mode.lines_per_transmission);
for row in 0..rows {
for dx in 0..scale {
let (_, cr, cb) = frame.ycrcb(x0 + dx, top + row);
total += u32::from(if want_cr { cr } else { cb });
count += 1;
}
}
(total / count.max(1)) as u8
}
/// Render tones to audio at `sample_rate`, with continuous phase so the
/// 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 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
// samples late by the end of it, which is a timing error no receiver
// should have to chase and no transmitter would produce.
let mut elapsed_ms = 0.0f64;
let mut emitted = 0usize;
for tone in tones {
elapsed_ms += tone.ms;
let end = (elapsed_ms / 1000.0 * sr).round() as usize;
let samples = end.saturating_sub(emitted);
emitted = end;
let step = 2.0 * std::f64::consts::PI * f64::from(tone.hz) / sr;
for _ in 0..samples {
out.push(phase.sin() as f32);
phase += step;
if phase > std::f64::consts::TAU {
phase -= std::f64::consts::TAU;
}
}
}
out
}
/// Encode a frame straight to audio.
pub fn encode(mode: &SstvMode, frame: &Frame<'_>, sample_rate: u32) -> Vec<f32> {
render(&encode_tones(mode, frame), sample_rate)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mode::{level_from_hz, mode_for_vis};
#[test]
fn levels_survive_the_trip_through_frequency() {
for level in [0u8, 1, 64, 128, 200, 255] {
assert_eq!(level_from_hz(hz_from_level(level)), level);
}
}
#[test]
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 audio = encode(mode, &frame, 48_000);
// Header plus 256 lines, within a line of the published duration.
let header_s = 0.94;
let expected = header_s + mode.frame_secs();
let actual = audio.len() as f64 / 48_000.0;
assert!(
(actual - expected).abs() < mode.line_ms / 1000.0,
"encoded {actual:.2} s, expected {expected:.2} s",
);
}
}
+150
View File
@@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Assembling decoded lines into an image, and getting it out of the process.
//!
//! Rows arrive one at a time and the picture is worth looking at before it is
//! finished, so the assembler holds a full-size RGB canvas from the start and
//! fills it in. An unfinished frame is grey below the last decoded row rather
//! than black, which reads as "not here yet" instead of "received as black".
use std::path::{Path, PathBuf};
use base64::Engine;
/// Value the canvas starts at: mid-grey, for rows not yet received.
const UNWRITTEN: u8 = 96;
pub struct ImageCanvas {
width: usize,
height: usize,
rgb: Vec<u8>,
/// Highest row index written, plus one.
filled_rows: usize,
}
impl ImageCanvas {
pub fn new(width: usize, height: usize) -> Self {
Self {
width,
height,
rgb: vec![UNWRITTEN; width * height * 3],
filled_rows: 0,
}
}
pub fn width(&self) -> usize {
self.width
}
pub fn height(&self) -> usize {
self.height
}
/// Rows written so far.
pub fn filled_rows(&self) -> usize {
self.filled_rows
}
/// Write one row of RGB triples. Rows past the bottom of the image are
/// dropped: a transmission that runs long is not a reason to grow.
pub fn put_row(&mut self, y: usize, row: &[u8]) {
if y >= self.height {
return;
}
let at = y * self.width * 3;
let take = row.len().min(self.width * 3);
self.rgb[at..at + take].copy_from_slice(&row[..take]);
self.filled_rows = self.filled_rows.max(y + 1);
}
pub fn row(&self, y: usize) -> Option<&[u8]> {
if y >= self.height {
return None;
}
let at = y * self.width * 3;
Some(&self.rgb[at..at + self.width * 3])
}
pub fn rgb(&self) -> &[u8] {
&self.rgb
}
/// Encode the canvas as a PNG.
pub fn to_png(&self) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, self.width as u32, self.height as u32);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder
.write_header()
.map_err(|e| format!("PNG header: {e}"))?;
writer
.write_image_data(&self.rgb)
.map_err(|e| format!("PNG data: {e}"))?;
}
Ok(out)
}
/// The PNG, base64-encoded for the journey to a browser.
pub fn to_png_base64(&self) -> Result<String, String> {
Ok(base64::engine::general_purpose::STANDARD.encode(self.to_png()?))
}
/// Write the PNG into `dir`, named for when and where it was received.
pub fn save_png(
&self,
dir: &Path,
freq_hz: u64,
mode_name: &str,
stamp: &str,
) -> Result<PathBuf, String> {
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
let slug: String = mode_name
.chars()
.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()))?;
Ok(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rows_land_where_they_are_put_and_the_rest_stays_unwritten() {
let mut canvas = ImageCanvas::new(4, 3);
canvas.put_row(1, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
assert_eq!(canvas.row(1).unwrap()[0..3], [1, 2, 3]);
assert_eq!(canvas.row(0).unwrap()[0], UNWRITTEN);
assert_eq!(canvas.filled_rows(), 2);
}
#[test]
fn a_row_past_the_bottom_is_dropped_rather_than_growing_the_image() {
let mut canvas = ImageCanvas::new(2, 2);
canvas.put_row(9, &[1, 2, 3, 4, 5, 6]);
assert_eq!(canvas.filled_rows(), 0);
assert_eq!(canvas.rgb().len(), 2 * 2 * 3);
}
#[test]
fn encodes_a_png_a_decoder_can_read_back() {
let mut canvas = ImageCanvas::new(2, 2);
canvas.put_row(0, &[255, 0, 0, 0, 255, 0]);
let png_bytes = canvas.to_png().expect("png");
let decoder = png::Decoder::new(png_bytes.as_slice());
let mut reader = decoder.read_info().expect("png info");
let mut buf = vec![0; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).expect("png frame");
assert_eq!((info.width, info.height), (2, 2));
assert_eq!(&buf[0..6], &[255, 0, 0, 0, 255, 0]);
assert!(!canvas.to_png_base64().expect("base64").is_empty());
}
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! SSTV (Slow-Scan Television) decoder.
//!
//! Pure Rust, covering Martin, Scottie, Robot, PD and Wraase SC2-180, with the
//! mode taken from the VIS header that precedes every transmission. Rows are
//! emitted as they arrive so a picture can be watched building up.
//!
//! ```no_run
//! use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent};
//!
//! let mut decoder = SstvDecoder::new(48_000, SstvConfig::default());
//! # let audio: Vec<f32> = Vec::new();
//! for event in decoder.process_samples(&audio) {
//! match event {
//! SstvEvent::Started { mode, .. } => println!("receiving {mode}"),
//! SstvEvent::Row { line, .. } => println!("line {line}"),
//! SstvEvent::Complete(image) => println!("{} lines", image.lines),
//! }
//! }
//! ```
pub mod config;
pub mod decoder;
pub mod demod;
pub mod encode;
pub mod image;
pub mod mode;
pub mod vis;
pub use config::SstvConfig;
pub use decoder::{SstvDecoder, SstvEvent, SstvImage};
pub use image::ImageCanvas;
pub use mode::{mode_for_vis, SstvMode, MODES};
+615
View File
@@ -0,0 +1,615 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! SSTV mode table: what a VIS code means in timings, geometry and colour.
//!
//! Every mode transmits a line as a sequence of *segments* — a sync pulse, a
//! porch or separator at a fixed tone, and one scan per colour channel. The
//! decoder needs only the segment layout and the offset of each scan within
//! the line, so that is what a [`SstvMode`] is: a list of segments plus the
//! rules for turning the scans back into pixels.
//!
//! Timings follow the published mode specifications (JL Barber, N7CXI,
//! "Proposal for SSTV Mode Specifications", 2000), which is the same table
//! MMSSTV, QSSTV and slowrx work from.
/// Tone that marks a line boundary, in Hz.
pub const SYNC_HZ: f32 = 1200.0;
/// Tone for black, in Hz.
pub const BLACK_HZ: f32 = 1500.0;
/// Tone for white, in Hz.
pub const WHITE_HZ: f32 = 2300.0;
/// What a scan segment carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Channel {
Red,
Green,
Blue,
/// Luminance for the odd (first) image line of the pair.
LumaOdd,
/// Luminance for the even (second) image line of a PD pair.
LumaEven,
/// R-Y chrominance.
ChromaR,
/// B-Y chrominance.
ChromaB,
/// Robot 36 alternates R-Y and B-Y between transmitted lines: odd lines
/// carry R-Y, even lines B-Y, and each is held over both.
ChromaAlternating,
}
/// One piece of a transmitted line.
#[derive(Debug, Clone, Copy)]
pub enum Segment {
/// Sync pulse at [`SYNC_HZ`].
Sync(f64),
/// Porch, separator or gap at a fixed tone; the tone itself is not decoded.
Gap(f64),
/// A scan carrying pixels for one channel.
Scan { channel: Channel, ms: f64 },
}
impl Segment {
pub fn duration_ms(&self) -> f64 {
match *self {
Segment::Sync(ms) | Segment::Gap(ms) => ms,
Segment::Scan { ms, .. } => ms,
}
}
}
/// How the scans of a line become pixels.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorModel {
/// Scans are red, green and blue directly (Martin, Scottie, Wraase).
Rgb,
/// Y plus one alternating chroma channel per line (Robot 36).
YCrCbAlternating,
/// Y, R-Y and B-Y in every line (Robot 72).
YCrCb,
/// Two image lines per transmitted line: Y odd, R-Y, B-Y, Y even (PD).
YCrCbPaired,
}
/// A decodable SSTV mode.
#[derive(Debug, Clone)]
pub struct SstvMode {
/// VIS code as sent in the header.
pub vis: u8,
/// Human-readable name, e.g. "Martin M1".
pub name: &'static str,
/// Pixels across.
pub width: u16,
/// Image lines in a full frame.
pub height: u16,
/// Transmitted line duration in milliseconds.
pub line_ms: f64,
/// Segments in transmission order.
pub segments: &'static [Segment],
pub color: ColorModel,
/// Image lines produced by one transmitted line (2 for PD, otherwise 1).
pub lines_per_transmission: u16,
/// Offset from the start of a line to the leading edge of its sync pulse.
/// Zero for most modes; Scottie sends the sync in the middle of the line,
/// so a line detected at its sync starts before it.
pub sync_offset_ms: f64,
}
impl SstvMode {
/// Total of every segment, which must equal [`SstvMode::line_ms`].
pub fn segments_ms(&self) -> f64 {
self.segments.iter().map(Segment::duration_ms).sum()
}
/// Start offset, in milliseconds from the line start, of each scan.
pub fn scans(&self) -> Vec<(Channel, f64, f64)> {
let mut out = Vec::new();
let mut at = 0.0;
for segment in self.segments {
if let Segment::Scan { channel, ms } = *segment {
out.push((channel, at, ms));
}
at += segment.duration_ms();
}
out
}
/// Pixels carried by a scan of this channel.
///
/// Chroma is sent at half the width in the Robot modes — the eye takes
/// colour more coarsely than brightness, and the saving is what makes 36
/// seconds possible. PD sends chroma at full width and saves its time by
/// sharing one pair of chroma scans between two image lines instead.
pub fn scan_pixels(&self, channel: Channel) -> usize {
let width = usize::from(self.width);
match channel {
Channel::ChromaR | Channel::ChromaB | Channel::ChromaAlternating
if self.color != ColorModel::YCrCbPaired =>
{
width / 2
}
_ => width,
}
}
/// Seconds a full frame takes to send.
pub fn frame_secs(&self) -> f64 {
self.line_ms * f64::from(self.height) / f64::from(self.lines_per_transmission) / 1000.0
}
}
// ---------------------------------------------------------------------------
// Martin — sync, porch, then green, blue, red, each followed by a separator.
// ---------------------------------------------------------------------------
const MARTIN_M1: &[Segment] = &[
Segment::Sync(4.862),
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Green,
ms: 146.432,
},
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Blue,
ms: 146.432,
},
Segment::Gap(0.572),
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::Gap(0.572),
Segment::Scan {
channel: Channel::Blue,
ms: 73.216,
},
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Red,
ms: 73.216,
},
Segment::Gap(0.572),
];
// ---------------------------------------------------------------------------
// Scottie — the sync pulse sits between the blue and red scans, so a line
// starts one separator before the green scan and the sync of the *previous*
// line is what marks it.
// ---------------------------------------------------------------------------
const SCOTTIE_S1: &[Segment] = &[
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Green,
ms: 138.240,
},
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Blue,
ms: 138.240,
},
Segment::Sync(9.0),
Segment::Gap(1.5),
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::Gap(1.5),
Segment::Scan {
channel: Channel::Blue,
ms: 88.064,
},
Segment::Sync(9.0),
Segment::Gap(1.5),
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::Gap(1.5),
Segment::Scan {
channel: Channel::Blue,
ms: 345.6,
},
Segment::Sync(9.0),
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Red,
ms: 345.6,
},
];
/// Scottie's sync arrives after the green and blue scans: 1.5 + 138.24 + 1.5 +
/// 138.24 for S1, and the equivalent for the others.
const fn scottie_sync_offset(scan_ms: f64) -> f64 {
1.5 + scan_ms + 1.5 + scan_ms
}
// ---------------------------------------------------------------------------
// Robot — luminance plus chrominance, the chroma scans at half the width.
// ---------------------------------------------------------------------------
const ROBOT_36: &[Segment] = &[
Segment::Sync(9.0),
Segment::Gap(3.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,
},
];
const ROBOT_72: &[Segment] = &[
Segment::Sync(9.0),
Segment::Gap(3.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::Gap(4.5),
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::ChromaB,
ms: 69.0,
},
];
// ---------------------------------------------------------------------------
// PD — one transmitted line carries two image lines: the luminance of both,
// with a single pair of chroma scans shared between them.
// ---------------------------------------------------------------------------
macro_rules! pd_segments {
($name:ident, $scan:expr) => {
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,
},
];
};
}
pd_segments!(PD_50, 91.520);
pd_segments!(PD_90, 170.240);
pd_segments!(PD_120, 121.600);
pd_segments!(PD_160, 195.584);
pd_segments!(PD_180, 183.040);
pd_segments!(PD_240, 244.672);
pd_segments!(PD_290, 228.800);
// ---------------------------------------------------------------------------
// Wraase SC2-180 — red, green, blue in that order after one porch.
// ---------------------------------------------------------------------------
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,
},
];
/// 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,
},
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,
},
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,
},
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,
},
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,
},
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),
},
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),
},
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),
},
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,
},
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,
},
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,
},
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,
},
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,
},
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,
},
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,
},
];
/// Look a mode up by the VIS code that announced it.
pub fn mode_for_vis(vis: u8) -> Option<&'static SstvMode> {
MODES.iter().find(|mode| mode.vis == vis)
}
/// Map an instantaneous frequency to an 8-bit level: 1500 Hz is black, 2300 Hz
/// white. Frequencies outside the band clamp rather than wrap, so a sync pulse
/// that lands inside a scan reads as black instead of as bright noise.
pub fn level_from_hz(hz: f32) -> u8 {
let level = (hz - BLACK_HZ) * (255.0 / (WHITE_HZ - BLACK_HZ));
level.clamp(0.0, 255.0).round() as u8
}
#[cfg(test)]
mod tests {
use super::*;
// The segment list and the published line time are two statements of the
// same fact, entered by hand from the specification. If a digit is wrong in
// one it is unlikely to be wrong identically in the other.
#[test]
fn segments_add_up_to_the_published_line_time() {
for mode in MODES {
let sum = mode.segments_ms();
assert!(
(sum - mode.line_ms).abs() < 0.001,
"{}: segments total {:.4} ms, line time says {:.4} ms",
mode.name,
sum,
mode.line_ms,
);
}
}
#[test]
fn vis_codes_are_unique_and_resolvable() {
for mode in MODES {
assert_eq!(mode_for_vis(mode.vis).map(|m| m.name), Some(mode.name));
}
let mut codes: Vec<u8> = MODES.iter().map(|m| m.vis).collect();
codes.sort_unstable();
let count = codes.len();
codes.dedup();
assert_eq!(codes.len(), count, "two modes claim the same VIS code");
}
#[test]
fn every_mode_scans_enough_channels_for_its_colour_model() {
for mode in MODES {
let scans = mode.scans();
let expected = match mode.color {
ColorModel::Rgb => 3,
ColorModel::YCrCbAlternating => 2,
ColorModel::YCrCb => 3,
ColorModel::YCrCbPaired => 4,
};
assert_eq!(
scans.len(),
expected,
"{} has {} scans",
mode.name,
scans.len()
);
}
}
// Frame durations are what operators know these modes by — the number in
// the name is the number of seconds.
#[test]
fn frame_durations_match_the_names() {
for (vis, secs) in [(8u8, 36.0), (12, 72.0), (93, 50.0), (99, 90.0), (95, 126.0)] {
let mode = mode_for_vis(vis).expect("mode in table");
let actual = mode.frame_secs();
assert!(
(actual - secs).abs() < 1.5,
"{} takes {:.1} s, expected about {:.0} s",
mode.name,
actual,
secs,
);
}
}
#[test]
fn levels_span_black_to_white_and_clamp_outside() {
assert_eq!(level_from_hz(BLACK_HZ), 0);
assert_eq!(level_from_hz(WHITE_HZ), 255);
assert_eq!(level_from_hz(1900.0), 128);
assert_eq!(level_from_hz(SYNC_HZ), 0, "a sync pulse must read as black");
assert_eq!(level_from_hz(3000.0), 255);
}
}
+236
View File
@@ -0,0 +1,236 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! VIS header detection.
//!
//! Every transmission announces its mode in a fixed preamble:
//!
//! | Part | Tone | Duration |
//! |------|------|----------|
//! | Leader | 1900 Hz | 300 ms |
//! | Break | 1200 Hz | 10 ms |
//! | Leader | 1900 Hz | 300 ms |
//! | Start bit | 1200 Hz | 30 ms |
//! | 7 data bits, LSB first | 1100 Hz = 1, 1300 Hz = 0 | 30 ms each |
//! | Even parity | as above | 30 ms |
//! | Stop bit | 1200 Hz | 30 ms |
//!
//! The detector looks for the start bit standing behind a leader, reads the
//! eight bits that follow, and checks the parity. Parity is the only integrity
//! check the header has, so a code that fails it is discarded rather than
//! guessed at — decoding 114 seconds of Martin M1 as Scottie DX produces a
//! convincing-looking image of nothing.
/// Tone durations, in milliseconds.
const BIT_MS: f64 = 30.0;
const LEADER_MS: f64 = 300.0;
/// How far a tone may sit from its nominal frequency and still be recognised.
/// Wide enough for a rig tuned a little off, narrow enough that 1100, 1200,
/// 1300 and 1900 Hz stay distinct.
const TONE_TOLERANCE_HZ: f32 = 60.0;
const LEADER_HZ: f32 = 1900.0;
const START_HZ: f32 = 1200.0;
const ONE_HZ: f32 = 1100.0;
const ZERO_HZ: f32 = 1300.0;
/// A VIS header found in the stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VisHit {
/// The code, which names the mode.
pub code: u8,
/// Index just past the stop bit: where the image itself begins.
pub image_start: usize,
}
fn near(freq: f32, target: f32) -> bool {
(freq - target).abs() <= TONE_TOLERANCE_HZ
}
/// Mean frequency over the middle 60% of a bit cell, which keeps the filter's
/// transitions at either edge out of the measurement.
fn bit_frequency(freqs: &[f32], start: f64, samples_per_bit: f64) -> Option<f32> {
let from = (start + samples_per_bit * 0.2).round() as usize;
let to = (start + samples_per_bit * 0.8).round() as usize;
if to <= from || to > freqs.len() {
return None;
}
let window = &freqs[from..to];
Some(window.iter().sum::<f32>() / window.len() as f32)
}
/// Search `freqs` for a VIS header, starting at `from`.
///
/// Returns the first header whose parity checks out. `freqs` is instantaneous
/// frequency in Hz, one entry per audio sample.
pub fn find_vis(freqs: &[f32], sample_rate: u32, from: usize) -> Option<VisHit> {
let sr = f64::from(sample_rate);
// Header tones are 10 ms at the shortest, so a millisecond of averaging
// costs nothing and takes the demodulator's ripple — ±95 Hz at 1200 Hz —
// out of tones that are 100 Hz apart.
let smoothed = crate::demod::smooth(freqs, (sr / 1000.0).round() as usize);
let freqs = smoothed.as_slice();
let samples_per_bit = BIT_MS / 1000.0 * sr;
let leader_samples = (LEADER_MS / 1000.0 * sr) as usize;
// The start bit must be at least this long to be one.
let min_start_run = (samples_per_bit * 0.7) as usize;
// A leader has to precede the start bit. Half of one is enough evidence,
// and asking for less than the full 300 ms means the search still works on
// a buffer that begins part-way through the header.
let leader_needed = leader_samples / 2;
// The bits themselves may run to the very end of what has arrived so far;
// reading them is what decides whether there is enough, not this bound.
let mut i = from.max(leader_needed);
while i < freqs.len() {
if !near(freqs[i], START_HZ) {
i += 1;
continue;
}
// Measure the run of start tone.
let mut run = 0usize;
while i + run < freqs.len() && near(freqs[i + run], START_HZ) {
run += 1;
}
if run < min_start_run {
i += run.max(1);
continue;
}
// What came before it: the leader. Sampled rather than scanned in
// full, since only its identity matters, not its exact length.
let leader_from = i - leader_needed;
let leader_hits = freqs[leader_from..i]
.iter()
.step_by(16)
.filter(|&&f| near(f, LEADER_HZ))
.count();
let leader_total = freqs[leader_from..i].iter().step_by(16).count();
if leader_total == 0 || (leader_hits as f64) < 0.7 * leader_total as f64 {
i += run;
continue;
}
// Bits follow the start bit, which the run just measured. Use the run's
// own end rather than a nominal offset, so a start bit stretched or
// clipped by the filter does not shift every bit after it.
let bits_start = (i + run) as f64;
let mut bits = [false; 8];
let mut readable = true;
for (index, bit) in bits.iter_mut().enumerate() {
let at = bits_start + samples_per_bit * index as f64;
match bit_frequency(freqs, at, samples_per_bit) {
Some(freq) if near(freq, ONE_HZ) => *bit = true,
Some(freq) if near(freq, ZERO_HZ) => *bit = false,
_ => {
readable = false;
break;
}
}
}
if !readable {
i += run;
continue;
}
// Seven data bits, LSB first, then even parity over them.
let code = bits[..7]
.iter()
.enumerate()
.fold(0u8, |acc, (index, &set)| acc | (u8::from(set) << index));
let ones = bits[..7].iter().filter(|&&b| b).count() + usize::from(bits[7]);
if ones % 2 != 0 {
i += run;
continue;
}
// Past the stop bit is the image.
let image_start = (bits_start + samples_per_bit * 9.0).round() as usize;
return Some(VisHit { code, image_start });
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::encode::{vis_tones, Tone};
fn freqs_from_tones(tones: &[Tone], sample_rate: u32) -> Vec<f32> {
let mut out = Vec::new();
for tone in tones {
let samples = (tone.ms / 1000.0 * f64::from(sample_rate)).round() as usize;
out.extend(std::iter::repeat_n(tone.hz, samples));
}
out
}
#[test]
fn reads_every_code_the_mode_table_knows() {
for mode in crate::mode::MODES {
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
);
}
}
#[test]
fn the_image_starts_after_the_stop_bit() {
let sample_rate = 48_000;
let freqs = freqs_from_tones(&vis_tones(44), sample_rate);
let hit = find_vis(&freqs, sample_rate, 0).expect("header");
// Header is 300 + 10 + 300 ms of leader and break, then ten 30 ms bits.
let expected = ((300.0 + 10.0 + 300.0 + 300.0) / 1000.0 * f64::from(sample_rate)) as usize;
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,
);
}
#[test]
fn a_header_with_broken_parity_is_not_a_header() {
let sample_rate = 48_000;
let mut tones = vis_tones(44);
// 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
};
let freqs = freqs_from_tones(&tones, sample_rate);
assert_eq!(find_vis(&freqs, sample_rate, 0), None);
}
#[test]
fn tones_without_a_leader_are_not_a_header() {
let sample_rate = 48_000;
let mut tones = vis_tones(44);
// Same bits, but the leader before them is a pixel-band tone — which is
// what a passing image looks like.
tones[0].hz = 2000.0;
tones[2].hz = 2000.0;
let freqs = freqs_from_tones(&tones, sample_rate);
assert_eq!(find_vis(&freqs, sample_rate, 0), None);
}
#[test]
fn a_header_part_way_into_the_buffer_is_still_found() {
let sample_rate = 48_000;
let mut freqs = vec![1750.0f32; sample_rate as usize]; // a second of picture
freqs.extend(freqs_from_tones(&vis_tones(60), sample_rate));
let hit = find_vis(&freqs, sample_rate, 0).expect("header");
assert_eq!(hit.code, 60);
}
}
+384
View File
@@ -0,0 +1,384 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Decode what was encoded, and compare the pictures.
//!
//! A decoder for a picture format can only really be tested against a picture.
//! These tests build a test card, transmit it in each mode through the
//! encoder, and hold the decoder to what comes back — pixel by pixel, with a
//! tolerance that accounts for the round trip through frequency and, for the
//! colour modes, through a chroma channel at half the width.
//!
//! What this does not test is the timing table itself: the encoder reads the
//! same numbers as the decoder, so a wrong line duration would cancel out.
//! That is checked in `mode.rs` against the published line times instead.
use trx_sstv::encode::{encode, Frame};
use trx_sstv::mode::mode_for_vis;
use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent, SstvImage};
const SAMPLE_RATE: u32 = 48_000;
/// A test card with something for every part of the decoder to get wrong:
/// vertical colour bars catch channels swapped or shifted, the horizontal
/// gradient catches a line-timing drift, and the corner blocks catch a picture
/// that arrives upside down or mirrored.
fn test_card(width: usize, height: usize) -> Vec<u8> {
let mut rgb = vec![0u8; width * height * 3];
let bars: [(u8, u8, u8); 8] = [
(255, 255, 255),
(255, 255, 0),
(0, 255, 255),
(0, 255, 0),
(255, 0, 255),
(255, 0, 0),
(0, 0, 255),
(0, 0, 0),
];
for y in 0..height {
for x in 0..width {
let at = (y * width + x) * 3;
let (r, g, b) = if y < height / 2 {
bars[x * bars.len() / width]
} else {
let ramp = (x * 255 / width.max(1)) as u8;
let down = (y * 255 / height.max(1)) as u8;
(ramp, down, 255 - ramp)
};
rgb[at] = r;
rgb[at + 1] = g;
rgb[at + 2] = b;
}
}
// Corner marks: red top-left, blue bottom-right.
for y in 0..height.min(8) {
for x in 0..width.min(8) {
let at = (y * width + x) * 3;
rgb[at] = 255;
rgb[at + 1] = 0;
rgb[at + 2] = 0;
}
}
for y in height.saturating_sub(8)..height {
for x in width.saturating_sub(8)..width {
let at = (y * width + x) * 3;
rgb[at] = 0;
rgb[at + 1] = 0;
rgb[at + 2] = 255;
}
}
rgb
}
/// Run audio through the decoder in blocks the size a sound card delivers.
///
/// A little silence is fed after the signal, as a receiver that keeps
/// listening supplies: the demodulator is a filter, so the last millisecond of
/// any transmission needs the samples after it before it can be read.
fn decode(audio: &[f32]) -> (Vec<SstvImage>, usize) {
let mut tail = audio.to_vec();
tail.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize / 20));
let audio = tail.as_slice();
let mut decoder = SstvDecoder::new(SAMPLE_RATE, SstvConfig::default());
let mut images = Vec::new();
let mut rows = 0;
for block in audio.chunks(1024) {
for event in decoder.process_samples(block) {
match event {
SstvEvent::Row { .. } => rows += 1,
SstvEvent::Complete(image) => images.push(image),
SstvEvent::Started { .. } => {}
}
}
}
(images, rows)
}
/// Mean absolute error per colour channel between two same-sized images.
fn mean_error(a: &[u8], b: &[u8]) -> f64 {
assert_eq!(a.len(), b.len());
let total: u64 = a
.iter()
.zip(b)
.map(|(x, y)| u64::from(x.abs_diff(*y)))
.sum();
total as f64 / a.len() as f64
}
/// Error over the part of the picture away from channel edges, where a decoder
/// that is a pixel out on a hard colour boundary would otherwise dominate.
fn interior_error(mode_width: usize, height: usize, sent: &[u8], got: &[u8]) -> f64 {
let mut total = 0u64;
let mut count = 0u64;
for y in 2..height.saturating_sub(2) {
for x in 4..mode_width.saturating_sub(4) {
// Skip the columns where the bars change, which is where a
// half-pixel timing difference shows up as a whole-colour error.
if x % (mode_width / 8) < 3 {
continue;
}
let at = (y * mode_width + x) * 3;
for channel in 0..3 {
total += u64::from(sent[at + channel].abs_diff(got[at + channel]));
count += 1;
}
}
}
total as f64 / count.max(1) as f64
}
fn round_trip(vis: u8, tolerance: f64) {
let mode = mode_for_vis(vis).expect("mode in table");
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 audio = encode(mode, &frame, SAMPLE_RATE);
let (images, rows) = decode(&audio);
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_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
);
let error = interior_error(width, height, &sent, &image.rgb);
assert!(
error < tolerance,
"{}: mean error {error:.1} levels, tolerance {tolerance:.1}",
mode.name,
);
}
#[test]
fn martin_m1_round_trips() {
round_trip(44, 6.0);
}
#[test]
fn martin_m2_round_trips() {
round_trip(40, 8.0);
}
#[test]
fn scottie_s1_round_trips() {
round_trip(60, 6.0);
}
#[test]
fn scottie_s2_round_trips() {
round_trip(56, 8.0);
}
#[test]
fn wraase_sc2_180_round_trips() {
round_trip(55, 6.0);
}
// The colour-difference modes lose chroma resolution by design, so the bars
// bleed into one another at their edges; the tolerance is on the interior.
#[test]
fn robot_72_round_trips() {
round_trip(12, 14.0);
}
#[test]
fn robot_36_round_trips() {
// One chroma channel per line, the other carried over from the line
// before, so alternate lines are a line stale in one channel.
round_trip(8, 26.0);
}
#[test]
fn pd90_round_trips() {
round_trip(99, 14.0);
}
#[test]
fn pd120_round_trips() {
round_trip(95, 14.0);
}
/// Silence before and after is the normal case — a receiver is not started at
/// the instant the transmission does.
#[test]
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 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!(images[0].complete);
}
/// A transmission cut off part-way is what a fade or a shut-down transmitter
/// produces. The lines that did arrive are worth keeping.
#[test]
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 full = encode(mode, &frame, SAMPLE_RATE);
// Two thirds of the picture, then silence for long enough that the decoder
// stops waiting for the rest.
let mut audio = full[..full.len() * 2 / 3].to_vec();
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"
);
let image = &images[0];
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,
);
// 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}"
);
}
/// Two pictures back to back: the decoder has to finish the first and pick up
/// the header of the second.
#[test]
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 one = encode(mode, &frame, SAMPLE_RATE);
let mut audio = one.clone();
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize / 2));
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"
);
}
/// Noise on the signal is the normal condition on HF. The picture should
/// degrade, not fall apart.
#[test]
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 clean = encode(mode, &frame, SAMPLE_RATE);
// Deterministic pseudo-noise at about 20 dB below the signal.
let mut seed = 0x5eed_1234u32;
let noisy: Vec<f32> = clean
.iter()
.map(|sample| {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let noise = (seed >> 8) as f32 / f32::from(u16::MAX) / 256.0 - 0.5;
sample + noise * 0.2
})
.collect();
let (images, _) = decode(&noisy);
assert_eq!(images.len(), 1, "noise cost the whole picture");
let image = &images[0];
assert!(image.complete, "noise cost the bottom of the picture");
let error = interior_error(width, height, &sent, &image.rgb);
assert!(error < 20.0, "mean error through noise {error:.1} levels");
}
/// The sound card that plays the signal and the one that records it never
/// agree exactly. A part-per-thousand error is far worse than reality and the
/// picture should still stand up.
#[test]
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,
};
// 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);
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"
);
}
-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())),
}
}
}
@@ -160,8 +160,18 @@ function updateAisSummary() {
}
}
}
function aisSummaryText(msg) {
const parts = [];
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
const motion = aisMotionText(msg);
if (motion) parts.push(motion);
const route = aisRouteText(msg);
if (route && parts.length < 2) parts.push(route);
if (!parts.length) return route || "no position reported";
return parts.join(" · ");
}
function renderAisRow(msg) {
const row = document.createElement("div");
const row = document.createElement("details");
row.className = "ais-message";
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
hour: "2-digit",
@@ -174,7 +184,8 @@ function renderAisRow(msg) {
const motion = aisMotionText(msg);
const route = aisRouteText(msg);
const distance = aisDistanceText(msg);
const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>` : "";
const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>` : "";
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
row.dataset.filterText = [
name,
msg.mmsi,
@@ -185,7 +196,16 @@ function renderAisRow(msg) {
msg.destination,
aisTypeLabel(msg.message_type)
].filter(Boolean).join(" ").toUpperCase();
row.innerHTML = `<div class="ais-row-head"><span class="ais-time">${ts}</span><span class="ais-call">${nameHtml}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span></div><div class="ais-row-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` + (route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") + `<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span></div><div class="ais-row-detail">` + (motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) + (distance ? `<span>${escapeAisHtml(distance)}</span>` : "") + (pos ? `<span>${pos}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span></div>`;
row.innerHTML = `<summary class="decode-line"><span class="ais-time">${escapeAisHtml(ts)}</span><span class="ais-call">${nameHtml}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span><span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` + (distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span><span>${escapeAisHtml(channel.freqText)}</span>` + (route ? `<span>${escapeAisHtml(route)}</span>` : "") + (motion ? `<span>${escapeAisHtml(motion)}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` + (pos ? `<span>${pos}</span>` : "") + `</div><div class="aprs-row-actions">` + (msg.lat != null && msg.lon != null ? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>` : "") + (vesselUrl ? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>` : "") + `</div></div>`;
row.querySelectorAll("[data-ais-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
aisWindow.navigateToAprsMap?.(lat, lon);
});
});
applyAisFilterToRow(row);
return row;
}
@@ -265,9 +285,11 @@ function addAisMessage(msg) {
pruneAisMessageHistory();
scheduleAisBarUpdate();
scheduleAisHistoryRender();
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
aisWindow.aisMapAddVessel(msg);
}
plotAisMessage(msg);
}
function plotAisMessage(msg) {
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
aisWindow.aisMapAddVessel(msg);
}
function normalizeServerAisMessage(msg) {
return {
@@ -288,9 +310,7 @@ function onServerAisBatch(messages) {
minute: "2-digit",
second: "2-digit"
});
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
aisWindow.aisMapAddVessel(next);
}
plotAisMessage(next);
normalized.push(next);
}
normalized.reverse();
@@ -332,5 +352,9 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerAisBatch,
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView
prune: pruneAisHistoryView,
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
}
});
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,10 @@
import {
aprsAgeText,
aprsCategoryLabel,
aprsHexBytes,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsInfo,
renderLocalAprsSymbol
} from "./chunk-M2I6DH4X.js";
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
hostCore,
hostState
@@ -114,51 +111,27 @@ function updateAprsChipState() {
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
}
function renderAprsRow(pkt, isFresh) {
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = aprsAgeText(pkt._tsMs);
const category = aprsPacketCategory(pkt);
const categoryLabel = aprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
const distance = aprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + symbolHtml + `<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span><span>&gt;${escapeAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number);
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
aprsWindow.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", () => {
void (async () => {
const raw = copyBtn.dataset.aprsCopy ?? "";
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (clipboard) {
await clipboard.writeText(raw);
showAprsHint("Coordinates copied", 1200);
}
} catch {
showAprsHint("Copy failed", 1500);
}
})();
});
async function copyAprsCoords(text) {
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (!clipboard) return;
await clipboard.writeText(text);
showAprsHint("Coordinates copied", 1200);
} catch {
showAprsHint("Copy failed", 1500);
}
return row;
}
function renderAprsRow(pkt, isFresh) {
return renderAprsPacketRow(pkt, {
fresh: isFresh,
distance: aprsDistanceText(pkt),
onMap: (lat, lon) => {
aprsWindow.navigateToAprsMap?.(lat, lon);
},
onCopy: (text) => {
void copyAprsCoords(text);
}
});
}
function renderAprsHistory() {
pruneAprsPacketHistory();
@@ -223,15 +196,17 @@ function pruneAprsHistoryView() {
updateAprsBar();
renderAprsHistory();
}
function plotAprsPacket(pkt) {
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
function addAprsPacket(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" });
aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory();
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
plotAprsPacket(pkt);
if (pkt.crcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
}
@@ -248,9 +223,7 @@ function onServerAprsBatch(packets) {
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" });
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
}
plotAprsPacket(next);
if (next.crcOk) hasCrcOk = true;
normalized.push(next);
}
@@ -314,5 +287,9 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerAprsBatch,
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView
prune: pruneAprsHistoryView,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
}
});
@@ -328,6 +328,7 @@ function bmApply(bm) {
const modeEl = document.getElementById("mode");
if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
hostCore.syncModePicker();
}
if (bm.bandwidth_hz) {
hostState.currentBandwidthHz = bm.bandwidth_hz;
@@ -1,112 +0,0 @@
// src/plugins/aprs-shared.ts
function aprsPacketCategory(packet) {
const type = (packet.type ?? "").toLowerCase();
const info = (packet.info ?? "").toLowerCase();
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather";
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
return "other";
}
function aprsCategoryLabel(category) {
switch (category) {
case "position":
return "Position";
case "message":
return "Message";
case "weather":
return "Weather";
case "telemetry":
return "Telemetry";
default:
return "Other";
}
}
function aprsAgeText(timestampMs) {
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
if (seconds < 5) return "just now";
if (seconds < 60) return `${String(seconds)}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${String(minutes)}m ago`;
return `${String(Math.round(minutes / 60))}h ago`;
}
function aprsPacketSignature(packet) {
return [
packet.srcCall ?? "",
packet.destCall ?? "",
packet.path ?? "",
packet.info ?? "",
packet.type ?? "",
packet.lat?.toFixed(4) ?? "",
packet.lon?.toFixed(4) ?? ""
].join("|");
}
function collapseAprsDuplicates(packets) {
const seen = /* @__PURE__ */ new Set();
return packets.filter((packet) => {
const signature = aprsPacketSignature(packet);
if (seen.has(signature)) return false;
seen.add(signature);
return true;
});
}
function aprsHexBytes(bytes) {
if (!bytes?.length) return "--";
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
}
function renderAprsInfo(packet) {
if (packet.info_bytes?.length) {
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
}
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
}
function renderAprsByte(byte) {
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function renderAprsCharacter(character) {
const code = character.charCodeAt(0);
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function escapeAprsCharacter(character) {
if (character === "<") return "&lt;";
if (character === ">") return "&gt;";
if (character === "&") return "&amp;";
if (character === '"') return "&quot;";
return character;
}
function renderLocalAprsSymbol(packet, escapeHtml) {
if (!packet.symbolTable || !packet.symbolCode) return "";
const symbol = escapeHtml(packet.symbolCode);
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
}
function normalizeAprsPacket(packet, receiver) {
return {
rig_id: packet.rig_id || null,
receiver,
srcCall: packet.src_call ?? "",
destCall: packet.dest_call ?? "",
path: packet.path ?? "",
info: packet.info ?? "",
info_bytes: packet.info_bytes ?? [],
type: packet.packet_type ?? "",
crcOk: packet.crc_ok ?? false,
ts_ms: packet.ts_ms ?? null,
lat: packet.lat ?? null,
lon: packet.lon ?? null,
symbolTable: packet.symbol_table ?? null,
symbolCode: packet.symbol_code ?? null
};
}
export {
aprsPacketCategory,
aprsCategoryLabel,
aprsAgeText,
collapseAprsDuplicates,
aprsHexBytes,
renderAprsInfo,
renderLocalAprsSymbol,
normalizeAprsPacket
};
@@ -0,0 +1,254 @@
// src/plugins/aprs-shared.ts
function escapeAprsHtml(value) {
return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
function aprsPacketCategory(packet) {
const type = (packet.type ?? "").toLowerCase();
const info = (packet.info ?? "").toLowerCase();
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather";
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
return "other";
}
function aprsCategoryLabel(category) {
switch (category) {
case "position":
return "Position";
case "message":
return "Message";
case "weather":
return "Weather";
case "telemetry":
return "Telemetry";
default:
return "Other";
}
}
function aprsAgeText(timestampMs) {
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
if (seconds < 5) return "just now";
if (seconds < 60) return `${String(seconds)}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${String(minutes)}m ago`;
return `${String(Math.round(minutes / 60))}h ago`;
}
function aprsPacketSignature(packet) {
return [
packet.srcCall ?? "",
packet.destCall ?? "",
packet.path ?? "",
packet.info ?? "",
packet.type ?? "",
packet.lat?.toFixed(4) ?? "",
packet.lon?.toFixed(4) ?? ""
].join("|");
}
function collapseAprsDuplicates(packets) {
const seen = /* @__PURE__ */ new Set();
return packets.filter((packet) => {
const signature = aprsPacketSignature(packet);
if (seen.has(signature)) return false;
seen.add(signature);
return true;
});
}
function aprsHexBytes(bytes) {
if (!bytes?.length) return "--";
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
}
function renderAprsInfo(packet) {
if (packet.info_bytes?.length) {
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
}
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
}
function renderAprsByte(byte) {
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function renderAprsCharacter(character) {
const code = character.charCodeAt(0);
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function escapeAprsCharacter(character) {
if (character === "<") return "&lt;";
if (character === ">") return "&gt;";
if (character === "&") return "&amp;";
if (character === '"') return "&quot;";
return character;
}
var APRS_SPRITE_COLUMNS = 16;
var APRS_SPRITE_CELL_PX = 24;
var APRS_SPRITE_FIRST_CODE = 33;
var APRS_SPRITE_LAST_CODE = 126;
function aprsSpriteOffset(code) {
if (code.length !== 1) return null;
const point = code.charCodeAt(0);
if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
const index = point - APRS_SPRITE_FIRST_CODE;
const column = index % APRS_SPRITE_COLUMNS;
const row = Math.floor(index / APRS_SPRITE_COLUMNS);
return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
}
function aprsSymbolSprite(symbolTable, symbolCode) {
if (!symbolTable || !symbolCode) return null;
const symbolOffset = aprsSpriteOffset(symbolCode);
if (!symbolOffset) return null;
if (symbolTable === "/") {
return {
className: "aprs-symbol-primary",
backgroundPosition: symbolOffset,
label: `Primary APRS symbol ${symbolTable}${symbolCode}`
};
}
if (symbolTable === "\\") {
return {
className: "aprs-symbol-alternate",
backgroundPosition: symbolOffset,
label: `Alternate APRS symbol ${symbolTable}${symbolCode}`
};
}
const overlayOffset = aprsSpriteOffset(symbolTable);
if (!overlayOffset) return null;
return {
className: "aprs-symbol-overlaid",
backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`
};
}
function renderAprsSymbolSlot(packet, escapeHtml) {
return renderLocalAprsSymbol(packet, escapeHtml) || '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
}
function renderLocalAprsSymbol(packet, escapeHtml) {
if (!packet.symbolTable || !packet.symbolCode) return "";
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
if (sprite) {
return `<span class="aprs-symbol ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
}
const symbol = escapeHtml(packet.symbolCode);
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
}
function normalizeAprsPacket(packet, receiver) {
return {
rig_id: packet.rig_id || null,
receiver,
srcCall: packet.src_call ?? "",
destCall: packet.dest_call ?? "",
path: packet.path ?? "",
info: packet.info ?? "",
info_bytes: packet.info_bytes ?? [],
type: packet.packet_type ?? "",
crcOk: packet.crc_ok ?? false,
ts_ms: packet.ts_ms ?? null,
lat: packet.lat ?? null,
lon: packet.lon ?? null,
symbolTable: packet.symbol_table ?? null,
symbolCode: packet.symbol_code ?? null
};
}
function fahrenheitToCelsius(fahrenheit) {
return Math.round((fahrenheit - 32) * 5 / 9 * 10) / 10;
}
function summarizeAprsWeather(info) {
const parts = [];
const temperature = /t(-?\d{2,3})/.exec(info);
if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`);
const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info);
if (wind) {
const gust = /g(\d{3})/.exec(info);
const knots = Number(wind[2]);
parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`);
}
const humidity = /h(\d{2})/.exec(info);
if (humidity) {
const value = Number(humidity[1]);
parts.push(`${value === 0 ? 100 : value}% RH`);
}
const pressure = /b(\d{5})/.exec(info);
if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`);
const rain = /r(\d{3})/.exec(info);
if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`);
return parts.length ? parts.join(" · ") : null;
}
function summarizeAprsTelemetry(info) {
const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim());
if (!match?.[2]) return null;
const channels = match[2].split(",").filter((value) => value.length > 0);
const bits = match[3] ? ` · bits ${match[3]}` : "";
return `#${match[1]} · ${channels.join(" ")}${bits}`;
}
function summarizeAprsMessage(info) {
const match = /^:([^:]{9}):(.*)$/.exec(info);
if (!match?.[1] || match[2] == null) return null;
const addressee = match[1].trim();
const text = match[2].replace(/\{\d+\s*$/, "").trim();
return `${addressee}: ${text}`;
}
function summarizeAprsCourseSpeed(info) {
const match = /(\d{3})\/(\d{3})/.exec(info);
if (!match) return null;
const knots = Number(match[2]);
if (knots === 0) return null;
return `${Number(match[1])}° ${knots} kt`;
}
function summarizeAprsPayload(packet) {
const info = packet.info ?? "";
if (!info) return null;
const category = aprsPacketCategory(packet);
if (category === "message") return summarizeAprsMessage(info);
if (category === "weather") return summarizeAprsWeather(info);
if (category === "telemetry") return summarizeAprsTelemetry(info);
if (category === "position") {
const parts = [];
if (packet.lat != null && packet.lon != null) {
parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`);
}
const courseSpeed = summarizeAprsCourseSpeed(info);
if (courseSpeed) parts.push(courseSpeed);
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
if (comment && comment.length <= 60) parts.push(comment);
return parts.length ? parts.join(" · ") : null;
}
const text = info.replace(/^[>;<?]/, "").trim();
return text.length ? text : null;
}
function renderAprsPacketRow(packet, options = {}) {
const row = document.createElement("details");
row.className = "aprs-packet";
if (!packet.crcOk) row.classList.add("aprs-packet-crc");
if (options.fresh) row.classList.add("aprs-packet-new");
const time = packet._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const category = aprsPacketCategory(packet);
const summary = summarizeAprsPayload(packet);
const hasPosition = packet.lat != null && packet.lon != null;
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div></div>`;
row.querySelectorAll("[data-aprs-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number);
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon);
});
});
const copyButton = row.querySelector("[data-aprs-copy]");
if (copyButton) {
copyButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
});
}
return row;
}
export {
aprsPacketCategory,
aprsAgeText,
collapseAprsDuplicates,
aprsSymbolSprite,
normalizeAprsPacket,
renderAprsPacketRow
};
@@ -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,13 +1,10 @@
import {
aprsAgeText,
aprsCategoryLabel,
aprsHexBytes,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsInfo,
renderLocalAprsSymbol
} from "./chunk-M2I6DH4X.js";
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
hostCore,
hostState
@@ -15,7 +12,6 @@ import {
// src/plugins/hf-aprs.ts
var hfAprsWindow = window;
var escapeHfAprsHtml = (input) => hostCore.escapeMapHtml(input);
var hfAprsStatus = document.getElementById("hf-aprs-status");
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
@@ -102,51 +98,27 @@ function updateHfAprsChipState() {
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
}
function renderHfAprsRow(pkt, isFresh) {
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = aprsAgeText(pkt._tsMs);
const category = aprsPacketCategory(pkt);
const categoryLabel = aprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeHfAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
const distance = hfAprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + hfBadge + symbolHtml + `<span class="aprs-call">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span><span>&gt;${escapeHfAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeHfAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number);
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
hfAprsWindow.navigateToAprsMap(lat, lon);
}
});
return renderAprsPacketRow(pkt, {
fresh: isFresh,
badge: "HF",
distance: hfAprsDistanceText(pkt),
onMap: (lat, lon) => {
hfAprsWindow.navigateToAprsMap?.(lat, lon);
},
onCopy: (text) => {
void copyHfAprsCoords(text);
}
});
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", () => {
void (async () => {
const raw = copyBtn.dataset.aprsCopy ?? "";
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (clipboard) {
await clipboard.writeText(raw);
hostCore.showHint("Coordinates copied", 1200);
}
} catch {
hostCore.showHint("Copy failed", 1500);
}
})();
});
}
async function copyHfAprsCoords(text) {
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (!clipboard) return;
await clipboard.writeText(text);
hostCore.showHint("Coordinates copied", 1200);
} catch {
hostCore.showHint("Copy failed", 1500);
}
return row;
}
function renderHfAprsHistory() {
pruneHfAprsPacketHistory();
@@ -1,3 +1,7 @@
import {
aprsSymbolSprite
} from "./chunk-OPEIVJGD.js";
// src/map-core.ts
function mapEl(id) {
const element = document.querySelector(`#${CSS.escape(id)}`);
@@ -58,6 +62,7 @@ var mapWindow = window;
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 mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
const MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
let mapSearchFilter = "";
let mapRigFilter = "";
@@ -834,38 +839,36 @@ var mapWindow = window;
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
return;
}
let helperText = "";
const noun = kind === "band" ? "bands" : "sources";
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : [];
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
if (kind === "source") {
if (noneSelected) {
helperText = "All sources visible — click to filter";
}
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
}
const showingAll = kind === "source" ? sourceKeys.every((k) => !mapFilter[k]) : !(selectedSet instanceof Set) || selectedSet.size === 0;
const allChip = document.createElement("button");
allChip.type = "button";
allChip.className = "map-locator-chip map-locator-chip-all";
if (showingAll) allChip.classList.add("is-active");
allChip.dataset.filterKind = kind;
allChip.dataset.filterKey = MAP_FILTER_ALL_KEY;
allChip.setAttribute("aria-pressed", showingAll ? "true" : "false");
allChip.title = showingAll ? `All ${noun} shown` : `Show all ${noun}`;
allChip.innerHTML = `<span class="map-locator-chip-text">All</span>`;
container.appendChild(allChip);
for (const item of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "map-locator-chip";
const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key);
if (kind === "source" && noneSelected) {
if (showingAll) {
btn.classList.add("is-default");
} else if (!isActive) {
btn.classList.add("is-inactive");
}
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
btn.dataset.filterKind = kind;
btn.dataset.filterKey = item.key;
btn.style.setProperty("--chip-color", item.color);
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
container.appendChild(btn);
}
if (helperText) {
const hint = document.createElement("span");
hint.className = "map-locator-empty";
hint.textContent = helperText;
container.appendChild(hint);
}
}
function renderMapLocatorPhaseRow(container, phase) {
if (!container) return;
@@ -973,11 +976,10 @@ var mapWindow = window;
renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems);
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
choiceLabelEl.textContent = "Show";
if (mapLocatorFilter.phase === "band") {
choiceLabelEl.textContent = "Visible Bands";
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
} else {
choiceLabelEl.textContent = "Visible Sources";
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
}
syncLocatorMarkerStyles();
@@ -1331,7 +1333,8 @@ var mapWindow = window;
function applyMapOverlayPanelVisibility() {
const panel = document.querySelector("#map-stage .map-overlay-panel");
if (!panel) return;
panel.classList.toggle("is-hidden", !mapOverlayPanelVisible);
panel.classList.toggle("filters-hidden", !mapOverlayPanelVisible);
panel.querySelector(".map-overlay-filters")?.classList.toggle("is-hidden", !mapOverlayPanelVisible);
}
function updateMapOverlayToggleButton() {
const btn = mapEl("map-overlay-toggle-btn");
@@ -1528,7 +1531,13 @@ var mapWindow = window;
const kind = String(chip.dataset.filterKind || "");
const key = String(chip.dataset.filterKey || "");
if (!key) return;
if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
if (key === MAP_FILTER_ALL_KEY) {
if (kind === "source") {
for (const srcKey of Object.keys(DEFAULT_MAP_SOURCE_FILTER)) mapFilter[srcKey] = false;
} else {
mapLocatorFilter.bands.clear();
}
} else if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
const sourceKey = key;
mapFilter[sourceKey] = !mapFilter[sourceKey];
const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER);
@@ -1643,42 +1652,29 @@ var mapWindow = window;
return;
}
const mapRect = mapContainer.getBoundingClientRect();
const width = mapContainer.clientWidth || mapRect.width;
const footer = document.querySelector(".footer");
let bottom = mapIsFullscreen() && stage ? stage.getBoundingClientRect().bottom : window.innerHeight;
if (!mapIsFullscreen() && footer) {
let bottom = window.innerHeight;
if (footer) {
const fr = footer.getBoundingClientRect();
if (fr.top > mapRect.top + 50) bottom = fr.top;
if (fr.top > mapRect.top + 50) bottom = Math.min(fr.top, bottom);
}
const available = Math.max(0, Math.floor(bottom - mapRect.top - 8));
const widthDriven = width > 0 ? Math.floor(width / 1.55) : available;
const viewportCap = mapIsFullscreen() ? Math.floor(window.innerHeight * 0.9) : Math.floor(window.innerHeight * 0.75);
const minHeight = Math.min(260, available);
const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven));
const target = Math.max(0, Math.floor(bottom - mapRect.top - 8));
mapContainer.style.height = `${target}px`;
if (aprsMap) aprsMap.invalidateSize();
}
function aprsSymbolIcon(symbolTable, symbolCode) {
if (!symbolTable || !symbolCode) return null;
const table = symbolTable === "/" ? "primary" : "alternate";
const sprite = aprsSymbolSprite(symbolTable, symbolCode);
const html = sprite ? `<div class="aprs-symbol aprs-symbol-marker ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeMapHtml(sprite.label)}" aria-label="${escapeMapHtml(sprite.label)}"></div>` : `<div class="aprs-symbol aprs-symbol-marker aprs-symbol-local" title="${symbolTable === "/" ? "primary" : "alternate"} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`;
return L.divIcon({
className: "",
html: `<div class="aprs-symbol-local" title="${table} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`,
html,
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -12]
});
}
mapWindow.navigateToAprsMap = function(lat, lon) {
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => {
t.classList.remove("active");
});
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
function focusMapPosition(lat, lon) {
initAprsMap();
sizeAprsMapToViewport();
if (aprsMap) {
@@ -1690,19 +1686,10 @@ var mapWindow = window;
});
});
}
};
mapWindow.navigateToMapLocator = function(grid, preferredType = null) {
}
function focusMapLocator(grid, preferredType = null) {
const normalizedGrid = String(grid || "").trim().toUpperCase();
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false;
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => {
t.classList.remove("active");
});
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
initAprsMap();
sizeAprsMapToViewport();
if (!aprsMap) return false;
@@ -1747,7 +1734,7 @@ var mapWindow = window;
requestAnimationFrame(focusMarker);
});
return true;
};
}
function buildReceiverPopupHtml(rigIds) {
const call = T.serverCallsign || T.ownerCallsign || "Receiver";
let meta = "";
@@ -2165,14 +2152,16 @@ var mapWindow = window;
function updateMapContactPathsToggle() {
const btn = mapEl("map-contact-paths-toggle");
if (!btn) return;
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
btn.setAttribute("aria-pressed", mapDecodeContactPathsEnabled ? "true" : "false");
btn.title = mapDecodeContactPathsEnabled ? "Directed decode paths are drawn when the target locator is known" : "Directed decode paths are hidden";
}
function updateMapP2pPathsToggle() {
const btn = mapEl("map-p2p-paths-toggle");
if (!btn) return;
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
btn.setAttribute("aria-pressed", mapP2pRadioPathsEnabled ? "true" : "false");
btn.title = mapP2pRadioPathsEnabled ? "TRX paths are drawn from a station popup" : "TRX paths are hidden";
}
function scheduleDecodeMapMaintenance() {
if (C.decodeHistoryMapRenderingDeferred()) {
@@ -2332,7 +2321,7 @@ var mapWindow = window;
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
syncDecodeContactPathVisibility();
if (selectedMapQsoKey && entry.sourceGrid) {
mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType);
focusMapLocator(entry.sourceGrid, entry.sourceType);
}
});
const head = document.createElement("div");
@@ -2438,7 +2427,7 @@ var mapWindow = window;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
focusMapLocator(entry.grid ?? "", entry.sourceType);
});
}
const head = document.createElement("div");
@@ -2544,7 +2533,7 @@ var mapWindow = window;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
focusMapLocator(entry.grid ?? "", entry.sourceType);
});
}
const head = document.createElement("div");
@@ -3049,6 +3038,8 @@ var mapWindow = window;
}
modules.map = {
initAprsMap,
focusMapPosition,
focusMapLocator,
sizeAprsMapToViewport,
syncAprsReceiverMarker,
updateMapRigFilter,
@@ -3109,5 +3100,6 @@ var mapWindow = window;
bandForHz,
reverseGeocodeLocation
};
window.trxPluginRuntime.syncMapAll();
autoInitIfVisible();
})();
@@ -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,249 @@
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 (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 (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 renderLatestCard() {
if (!sstvDom.liveLatest) return;
const latest = sstvHistory[sstvHistory.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 text = filterText.trim().toLowerCase();
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice();
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
});
sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress",
onMessage: onProgress
});
@@ -286,7 +286,10 @@ function vchanSyncModeDisplay() {
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
if (ch && ch.mode) {
modeEl.value = ch.mode.toUpperCase();
hostCore.syncModePicker();
}
}
const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof hostState.lastModeName === "string") {
@@ -220,9 +220,7 @@ function onServerVdesBatch(messages) {
minute: "2-digit",
second: "2-digit"
});
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
plotVdesMessage(next);
normalized.push(next);
}
normalized.reverse();
@@ -248,13 +246,15 @@ if (vdesFilterInput) {
renderVdesHistory();
});
}
function plotVdesMessage(msg) {
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
addVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
plotVdesMessage(next);
}
function pruneVdesHistoryView() {
pruneVdesMessageHistory();
@@ -268,5 +268,9 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerVdesBatch,
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView
prune: pruneVdesHistoryView,
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
}
});
@@ -22,8 +22,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol>
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
<symbol id="icon-signal" viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="11" width="2.5" height="4" rx="0.5"/><rect x="4.75" y="8" width="2.5" height="7" rx="0.5"/><rect x="8.5" y="5" width="2.5" height="10" rx="0.5"/><rect x="12.25" y="2" width="2.5" height="13" rx="0.5"/></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">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-signal"/></svg>
<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>
@@ -99,6 +107,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<option value="phosphor">Phosphor</option>
</select>
</div>
<button id="header-share-btn" class="header-bar-btn header-share-btn" type="button" aria-label="Copy a link to this frequency" title="Copy a link to this frequency">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M6.5 9.5a2.6 2.6 0 0 0 3.9.3l2.2-2.2a2.6 2.6 0 0 0-3.7-3.7l-1 1"/><path d="M9.5 6.5a2.6 2.6 0 0 0-3.9-.3L3.4 8.4a2.6 2.6 0 0 0 3.7 3.7l1-1"/></svg>
</button>
<button id="theme-toggle" class="header-bar-btn" type="button" aria-label="Toggle dark or light theme">Light</button>
<button id="header-auth-btn" class="header-bar-btn" type="button" style="display:none;" aria-label="Login or Logout">Login</button>
</div>
@@ -142,6 +153,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display" aria-describedby="spectrum-text-summary"></canvas>
<p id="spectrum-text-summary" class="visually-hidden">Spectrum data is waiting for the receiver.</p>
<div id="spectrum-squelch-line" class="spectrum-squelch-line" style="display:none;" data-state="closed">
<span class="spectrum-squelch-grip" id="spectrum-squelch-grip" role="slider" tabindex="0"
aria-label="Squelch threshold" aria-valuemin="-120" aria-valuemax="-30" aria-valuenow="-95">SQL <span id="spectrum-squelch-label">-95</span> dB</span>
</div>
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
<div id="spectrum-db-axis" aria-hidden="true"></div>
@@ -156,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>
@@ -175,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>
@@ -205,38 +221,27 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="label"><span>Signal strength</span></div>
</div>
<div class="freq-field frequency-col">
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" />
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
<div class="label" id="freq-label"><span>Frequency</span></div>
</div>
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" />
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
<div class="label" id="center-freq-label"><span>Center Frequency</span></div>
</div>
<div class="freq-field unit-col">
<div class="jog-step" id="jog-step">
<button type="button" data-step="1000000">MHz</button>
<button type="button" data-step="1000" class="active">kHz</button>
<button type="button" data-step="1">Hz</button>
</div>
<div class="label"><span>Unit</span></div>
</div>
<div class="freq-field mult-col">
<div class="jog-mult" id="jog-mult">
<button type="button" data-mult="1" class="active" aria-label="Use full tune step">1x</button>
<button type="button" data-mult="10" aria-label="Use one tenth tune step">0.1x</button>
</div>
<div class="label"><span>Step Scale</span></div>
</div>
</div>
</div>
<div class="full-row controls-tray-shell">
<div class="controls-tray-scroll">
<div class="controls-tray">
<div class="controls-row full-row">
<div class="controls-col label-below-col">
<div class="controls-col controls-col-mode label-below-col">
<div class="label"><span>Mode</span></div>
<div class="inline">
<select class="status-input" id="mode" aria-label="Demodulation mode"></select>
<!-- The select stays as the mode's value: a dozen call sites and
several plugins read #mode.value. It is hidden from sight and
from assistive tech; the buttons beside it are the control. -->
<select class="visually-hidden" id="mode" tabindex="-1" aria-hidden="true"></select>
<div id="mode-picker" class="mode-picker" role="group" aria-label="Demodulation mode"></div>
</div>
</div>
<div class="controls-col controls-col-center">
@@ -248,6 +253,35 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="jog-up" type="button" class="jog-btn">+</button>
</div>
</div>
<div class="controls-col controls-col-step">
<div class="inline step-controls-inline">
<div class="freq-field unit-col">
<div class="jog-step" id="jog-step">
<button type="button" data-step="1000000">MHz</button>
<button type="button" data-step="1000" class="active">kHz</button>
<button type="button" data-step="1">Hz</button>
</div>
<div class="label"><span>Unit</span></div>
</div>
<div class="freq-field mult-col">
<div class="jog-mult" id="jog-mult">
<button type="button" data-mult="1" class="active" aria-label="Use full tune step">1x</button>
<button type="button" data-mult="10" aria-label="Use one tenth tune step">0.1x</button>
</div>
<div class="label"><span>Step Scale</span></div>
</div>
</div>
</div>
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
<div class="label"><span>Transmit / Power</span></div>
<div class="btn-grid">
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
</div>
</div>
</div>
<div class="controls-row controls-row-mode full-row" id="mode-controls-row" style="display:none">
<div class="controls-col controls-col-wfm label-below-col" id="wfm-controls-col" style="display:none;">
<div class="inline wfm-controls-inline">
<label class="wfm-control">
@@ -305,14 +339,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
<div class="label"><span>SAM</span></div>
</div>
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
<div class="label"><span>Transmit / Power</span></div>
<div class="btn-grid">
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
</div>
</div>
</div>
<div class="full-row label-below-row" id="vfo-row">
<div class="label"><span>VFO</span></div>
@@ -362,32 +388,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="vchan-picker" id="vchan-picker"></div>
</div>
</div>
<details id="scheduler-controls" class="advanced-radio-controls scheduler-controls-section">
<summary>Scheduler controls</summary>
<div class="advanced-radio-body">
<div class="scheduler-control-row" style="display:none">
<div class="scheduler-release-wrap">
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
<div class="scheduler-step-controls">
<button id="scheduler-prev-btn" type="button">Previous Entry</button>
<button id="scheduler-next-btn" type="button">Next Entry</button>
</div>
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
<div id="scheduler-cycle-status" class="interleave-ring-wrap" style="display:none;">
<svg class="interleave-ring" viewBox="0 0 36 36">
<circle class="interleave-ring-bg" cx="18" cy="18" r="15.915" />
<circle class="interleave-ring-fill" id="interleave-ring-fill" cx="18" cy="18" r="15.915"
stroke-dasharray="100" stroke-dashoffset="100" />
</svg>
<div class="interleave-ring-text">
<div class="interleave-ring-label" id="interleave-active-name">--</div>
<div class="interleave-ring-sub" id="interleave-countdown">--</div>
</div>
</div>
</div>
</div>
</div>
</details>
<div class="full-row label-below-row">
<div class="label"><span>Signal</span></div>
<div class="signal" style="gap: 1rem;">
@@ -425,13 +425,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
<button id="rx-audio-btn" type="button">Play Audio</button>
<button id="tx-audio-btn" type="button">Transmit Audio</button>
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
<label class="vol-label" id="sdr-squelch-wrap" style="display:none;">SQL<input type="range" id="sdr-squelch" min="0" max="100" value="0" class="vol-slider" /><small class="vol-pct" id="sdr-squelch-pct">Open</small><button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set squelch to current noise level">Auto</button></label>
<div id="audio-level">
<div id="audio-level-fill"></div>
<span class="audio-group audio-volume-group">
<span class="audio-group-label">Volume</span>
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
</span>
<span class="sql-control" id="sdr-squelch-wrap" style="display:none;">
<button id="sdr-squelch-toggle" type="button" class="sql-toggle" aria-pressed="false" title="Turn the squelch on or off"><span class="sql-state" id="sdr-squelch-state" data-state="off" aria-hidden="true"></span>SQL</button>
<label class="sql-db"><input type="number" id="sdr-squelch-db" min="-120" max="-30" step="1" value="-95" inputmode="numeric" aria-label="Squelch threshold in dB" /><span class="sql-db-unit">dB</span></label>
<button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set the threshold just above the noise floor">Auto</button>
</span>
<span class="audio-group audio-level-group">
<span class="audio-group-label">Level</span>
<div id="audio-level">
<div id="audio-level-fill"></div>
</div>
<small id="audio-status">Off</small>
</span>
</div>
</div>
</div>
</details>
<details id="scheduler-controls" class="advanced-radio-controls scheduler-controls-section">
<summary>Scheduler controls</summary>
<div class="advanced-radio-body">
<div class="scheduler-control-row" style="display:none">
<div class="scheduler-release-wrap">
<div class="scheduler-action-row">
<div class="scheduler-step-controls">
<button id="scheduler-prev-btn" type="button">Previous Entry</button>
<button id="scheduler-next-btn" type="button">Next Entry</button>
</div>
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
<div id="scheduler-cycle-status" class="interleave-ring-wrap" style="display:none;">
<svg class="interleave-ring" viewBox="0 0 36 36">
<circle class="interleave-ring-bg" cx="18" cy="18" r="15.915" />
<circle class="interleave-ring-fill" id="interleave-ring-fill" cx="18" cy="18" r="15.915"
stroke-dasharray="100" stroke-dashoffset="100" />
</svg>
<div class="interleave-ring-text">
<div class="interleave-ring-label" id="interleave-active-name">--</div>
<div class="interleave-ring-sub" id="interleave-countdown">--</div>
</div>
</div>
</div>
<small id="audio-status" style="min-width: 60px;">Off</small>
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
</div>
</div>
</div>
@@ -555,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">
@@ -617,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;">
@@ -624,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>
@@ -654,19 +749,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input id="ais-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. MMSI, vessel, A)" />
<small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div>
<div class="ais-summary">
<div class="ais-summary-card">
<span class="ais-summary-label">Channels</span>
<span id="ais-channel-summary" class="ais-summary-value">A 161.975 MHz · B 162.025 MHz</span>
</div>
<div class="ais-summary-card">
<span class="ais-summary-label">Tracked</span>
<span id="ais-vessel-count" class="ais-summary-value">0 vessels</span>
</div>
<div class="ais-summary-card">
<span class="ais-summary-label">Latest</span>
<span id="ais-latest-seen" class="ais-summary-value">No traffic yet</span>
</div>
<div class="aprs-filter-row">
<span class="aprs-counts">
<span id="ais-vessel-count" class="aprs-counts-value">0 vessels</span>
<span id="ais-latest-seen" class="aprs-counts-value">No traffic yet</span>
<span id="ais-channel-summary" class="aprs-counts-value">A 161.975 MHz · B 162.025 MHz</span>
</span>
</div>
<div id="ais-messages"></div>
</div>
@@ -696,20 +784,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input id="aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
<small id="aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div>
<div class="aprs-summary">
<div class="aprs-summary-card">
<span class="aprs-summary-label">Frames</span>
<span id="aprs-total-count" class="aprs-summary-value">0 total</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Visible</span>
<span id="aprs-visible-count" class="aprs-summary-value">0 shown</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Latest</span>
<span id="aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
</div>
</div>
<div class="aprs-filter-row">
<button id="aprs-type-all" class="aprs-chip active" type="button">All</button>
<button id="aprs-type-position" class="aprs-chip" type="button">Pos</button>
@@ -717,11 +791,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="aprs-type-weather" class="aprs-chip" type="button">Wx</button>
<button id="aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
<button id="aprs-type-other" class="aprs-chip" type="button">Other</button>
</div>
<div class="aprs-filter-row">
<span class="aprs-filter-sep" aria-hidden="true"></span>
<button id="aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
<button id="aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
<button id="aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
<span class="aprs-counts">
<span id="aprs-visible-count" class="aprs-counts-value">0 shown</span>
<span id="aprs-total-count" class="aprs-counts-value">0 total</span>
<span id="aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
</span>
</div>
<div id="aprs-packets"></div>
</div>
@@ -731,20 +809,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input id="hf-aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
<small id="hf-aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div>
<div class="aprs-summary">
<div class="aprs-summary-card">
<span class="aprs-summary-label">Frames</span>
<span id="hf-aprs-total-count" class="aprs-summary-value">0 total</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Visible</span>
<span id="hf-aprs-visible-count" class="aprs-summary-value">0 shown</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Latest</span>
<span id="hf-aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
</div>
</div>
<div class="aprs-filter-row">
<button id="hf-aprs-type-all" class="aprs-chip active" type="button">All</button>
<button id="hf-aprs-type-position" class="aprs-chip" type="button">Pos</button>
@@ -752,11 +816,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="hf-aprs-type-weather" class="aprs-chip" type="button">Wx</button>
<button id="hf-aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
<button id="hf-aprs-type-other" class="aprs-chip" type="button">Other</button>
</div>
<div class="aprs-filter-row">
<span class="aprs-filter-sep" aria-hidden="true"></span>
<button id="hf-aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
<button id="hf-aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
<button id="hf-aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
<span class="aprs-counts">
<span id="hf-aprs-visible-count" class="aprs-counts-value">0 shown</span>
<span id="hf-aprs-total-count" class="aprs-counts-value">0 total</span>
<span id="hf-aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
</span>
</div>
<div id="hf-aprs-packets"></div>
</div>
@@ -855,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">
@@ -897,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">
@@ -1002,55 +1025,105 @@ SPDX-License-Identifier: GPL-2.0-or-later
<template id="tmpl-map">
<div id="map-stage">
<div class="map-overlay-panel">
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Filter by</span>
<div id="map-locator-phase" class="map-locator-phase-row"></div>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Rig</span>
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
<option value="">All</option>
</select>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Search</span>
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">History</span>
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="60">1 hr</option>
<option value="180">3 hrs</option>
<option value="360">6 hrs</option>
<option value="720">12 hrs</option>
<option value="1440">24 hrs</option>
</select>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Paths</span>
<div class="map-locator-phase-row">
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn">TRX Paths On</button>
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn">Contact Paths On</button>
<span class="map-locator-empty">TRX paths on popup, directed decode paths when target locator is known</span>
<div class="map-overlay-filters">
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Filter</span>
<div id="map-locator-phase" class="map-locator-phase-row"></div>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Rig</span>
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
<option value="">All</option>
</select>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">History</span>
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="60">1 hr</option>
<option value="180">3 hrs</option>
<option value="360">6 hrs</option>
<option value="720">12 hrs</option>
<option value="1440">24 hrs</option>
</select>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Paths</span>
<div class="map-locator-phase-row">
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="TRX paths are drawn from a station popup">TRX</button>
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="Directed decode paths are drawn when the target locator is known">Contact</button>
<span class="map-paths-hint">TRX paths on popup, directed decode paths when target locator is known</span>
</div>
</div>
<!-- Last, so the search field takes whatever the fixed-width
groups leave on the bar's final row rather than a sliver. -->
<div class="map-locator-filter-group map-filter-grow">
<span class="map-locator-filter-label">Search</span>
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
</div>
</div>
</div>
<div class="map-corner-controls">
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
<div class="map-overlay-actions">
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
</div>
</div>
<div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div>
<div id="aprs-map"></div>
</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">
<div class="stats-controls">
<div class="stats-control-group">
@@ -1191,6 +1264,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</section>
</div>
<div id="tab-settings" class="tab-panel" style="display:none;">
<h2 class="section-heading">Settings</h2>
<div class="sub-tab-bar">
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
@@ -1486,6 +1560,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
<div id="tab-about" class="tab-panel" style="display:none;">
<h2 class="section-heading">About</h2>
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
<template id="tmpl-about">
<div class="sub-tab-bar">
@@ -1591,10 +1666,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
</template>
</div>
<div class="footer">
<div class="copyright">
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · <span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span><span id="copyright-year"></span>
<div class="footer-meta">
<span class="copyright">
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · &copy; <span id="copyright-year"></span>
</span>
<span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span>
</div>
<div class="hint" id="power-hint" aria-live="polite">Connecting…</div>
<div class="hint" id="power-hint" data-state="busy" aria-live="polite">Connecting…</div>
</div>
<div id="conn-lost-overlay" class="decode-history-overlay content-overlay is-hidden" aria-live="assertive" aria-atomic="true">
<div class="decode-history-overlay-card">
@@ -1628,11 +1706,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div>
</div>
</div>
<div id="decode-history-overlay" class="decode-history-overlay is-hidden" aria-live="polite" aria-atomic="true">
<div class="decode-history-overlay-card">
<div id="decode-history-overlay-title" class="decode-history-overlay-title">Loading decode history…</div>
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
<div id="decode-history-overlay" class="history-progress is-hidden" role="status" aria-live="polite" aria-atomic="true">
<div class="history-progress-text">
<span id="decode-history-overlay-title" class="history-progress-title">Loading decode history…</span>
<span id="decode-history-overlay-sub" class="history-progress-sub">Preparing recent decodes for the UI</span>
</div>
<span class="history-progress-track"><span id="decode-history-progress-bar" class="history-progress-bar"></span></span>
</div>
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
<script defer src="/vendor/leaflet.js"></script>
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -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",
"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",
"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, };
File diff suppressed because it is too large Load Diff
@@ -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",
@@ -37,3 +38,66 @@ export function updateTabHistory(name: TabName, replace = false): void {
if (replace) window.history.replaceState({}, "", nextUrl);
else window.history.pushState({}, "", nextUrl);
}
/** What a shared link says to listen to: `?rig=sdr&f=14074000&mode=USB&bw=3000`. */
export interface TuneLink {
rig: string | null;
freqHz: number | null;
mode: string | null;
bandwidthHz: number | null;
}
const FREQ_MULTIPLIER: Readonly<Record<string, number>> = { k: 1e3, m: 1e6, g: 1e9 };
/**
* Read a frequency written for a person: bare Hz as the canonical form, but
* `7074k` and `14.074M` are what someone typing a link by hand reaches for.
* Returns Hz, or null for anything that is not a positive frequency.
*/
export function parseFrequencyParam(raw: string | null | undefined): number | null {
if (typeof raw !== "string") return null;
const text = raw.trim().toLowerCase().replace(/hz$/, "").trim();
const match = /^(\d+(?:\.\d+)?)\s*([kmg]?)$/.exec(text);
if (!match) return null;
const value = Number(match[1]) * (FREQ_MULTIPLIER[match[2] ?? ""] ?? 1);
if (!Number.isFinite(value) || value <= 0) return null;
return Math.round(value);
}
/** Mode names run from FM to VDES; anything else in the URL is not one. */
function parseModeParam(raw: string | null | undefined): string | null {
const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
return /^[A-Z]{2,4}$/.test(mode) ? mode : null;
}
export function parseTuneLink(search: string): TuneLink {
const params = new URLSearchParams(search);
const rig = (params.get("rig") || "").trim();
return {
rig: rig || null,
freqHz: parseFrequencyParam(params.get("f")),
mode: parseModeParam(params.get("mode")),
bandwidthHz: parseFrequencyParam(params.get("bw")),
};
}
/**
* Rewrite the tune parameters of a query string, leaving anything else in it
* alone the address bar is the share link, so it has to keep up with the
* dial without discarding whatever else a page put there.
*/
export function tuneLinkSearch(search: string, link: TuneLink): string {
const params = new URLSearchParams(search);
const set = (key: string, value: string | null) => {
if (value == null || value === "") params.delete(key);
else params.set(key, value);
};
const hz = (value: number | null) =>
(typeof value === "number" && Number.isFinite(value) && value > 0 ? String(Math.round(value)) : null);
set("rig", link.rig);
set("f", hz(link.freqHz));
set("mode", parseModeParam(link.mode));
set("bw", hz(link.bandwidthHz));
const text = params.toString();
return text ? `?${text}` : "";
}
@@ -3,6 +3,8 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import type * as Leaflet from "leaflet";
import { aprsSymbolSprite } from "./plugins/aprs-shared";
import type { PluginRuntimeWindow } from "./plugins/runtime-contract";
export {};
@@ -228,6 +230,8 @@ const mapWindow = window as unknown as MapWindow;
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 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";
const mapLocatorFilter: { phase: "band" | "type"; bands: Set<string> } = { phase: "band", bands: new Set() };
let mapSearchFilter = "";
let mapRigFilter = ""; // "" = all rigs
@@ -1077,38 +1081,42 @@ const mapWindow = window as unknown as MapWindow;
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
return;
}
let helperText = "";
const noun = kind === "band" ? "bands" : "sources";
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[] : [];
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
if (kind === "source") {
if (noneSelected) {
helperText = "All sources visible \u2014 click to filter";
}
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
}
// Selecting nothing selects everything, for both kinds.
const showingAll = kind === "source"
? sourceKeys.every((k) => !mapFilter[k])
: !(selectedSet instanceof Set) || selectedSet.size === 0;
// An "All" chip carries what a sentence of helper text used to say, in a
// width the bar can afford, and gives the selection somewhere to be undone.
const allChip = document.createElement("button");
allChip.type = "button";
allChip.className = "map-locator-chip map-locator-chip-all";
if (showingAll) allChip.classList.add("is-active");
allChip.dataset.filterKind = kind;
allChip.dataset.filterKey = MAP_FILTER_ALL_KEY;
allChip.setAttribute("aria-pressed", showingAll ? "true" : "false");
allChip.title = showingAll ? `All ${noun} shown` : `Show all ${noun}`;
allChip.innerHTML = `<span class="map-locator-chip-text">All</span>`;
container.appendChild(allChip);
for (const item of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "map-locator-chip";
const isActive = kind === "source" ? !!mapFilter[item.key as MapFilterKey] : !!selectedSet?.has(item.key);
if (kind === "source" && noneSelected) {
// Nothing is filtered out yet, so no chip is dimmed as if it were.
if (showingAll) {
btn.classList.add("is-default");
} else if (!isActive) {
btn.classList.add("is-inactive");
}
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
btn.dataset.filterKind = kind;
btn.dataset.filterKey = item.key;
btn.style.setProperty("--chip-color", item.color);
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
container.appendChild(btn);
}
if (helperText) {
const hint = document.createElement("span");
hint.className = "map-locator-empty";
hint.textContent = helperText;
container.appendChild(hint);
}
}
function renderMapLocatorPhaseRow(container: HTMLElement, phase: "band" | "type"): void {
@@ -1232,11 +1240,12 @@ const mapWindow = window as unknown as MapWindow;
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
// The phase buttons next door already name the dimension; "Show" is the
// rest of the sentence, and it keeps the bar's labels a uniform width.
choiceLabelEl.textContent = "Show";
if (mapLocatorFilter.phase === "band") {
choiceLabelEl.textContent = "Visible Bands";
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
} else {
choiceLabelEl.textContent = "Visible Sources";
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
}
syncLocatorMarkerStyles();
@@ -1635,7 +1644,10 @@ const mapWindow = window as unknown as MapWindow;
function applyMapOverlayPanelVisibility() {
const panel = document.querySelector("#map-stage .map-overlay-panel");
if (!panel) return;
panel.classList.toggle("is-hidden", !mapOverlayPanelVisible);
// Only the filters collapse. The bar itself stays, because it carries the
// button that brings them back.
panel.classList.toggle("filters-hidden", !mapOverlayPanelVisible);
panel.querySelector(".map-overlay-filters")?.classList.toggle("is-hidden", !mapOverlayPanelVisible);
}
function updateMapOverlayToggleButton() {
@@ -1858,7 +1870,14 @@ const mapWindow = window as unknown as MapWindow;
const kind = String(chip.dataset.filterKind || "");
const key = String(chip.dataset.filterKey || "");
if (!key) return;
if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
if (key === MAP_FILTER_ALL_KEY) {
// Back to no selection at all, which is what shows everything.
if (kind === "source") {
for (const srcKey of Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[]) mapFilter[srcKey] = false;
} else {
mapLocatorFilter.bands.clear();
}
} else if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
// toggle the clicked source; when none are selected everything is shown
const sourceKey = key as MapFilterKey;
mapFilter[sourceKey] = !mapFilter[sourceKey];
@@ -1979,48 +1998,43 @@ const mapWindow = window as unknown as MapWindow;
if (aprsMap) aprsMap.invalidateSize();
return;
}
// Everything below is the windowed path — the fullscreen branch returned.
// The map tab is a whole page, so the stage fills the column down to the
// footer. Capping it at a fraction of the viewport, or at a width-derived
// aspect ratio, left a dead band under the map that grew with the window
// (and on narrow screens made the map barely a third of the page).
const mapRect = mapContainer.getBoundingClientRect();
const width = mapContainer.clientWidth || mapRect.width;
const footer = document.querySelector(".footer");
let bottom = mapIsFullscreen() && stage
? stage.getBoundingClientRect().bottom
: window.innerHeight;
if (!mapIsFullscreen() && footer) {
let bottom = window.innerHeight;
if (footer) {
const fr = footer.getBoundingClientRect();
if (fr.top > mapRect.top + 50) bottom = fr.top;
// Clamped to the viewport: once the column is tall enough to push the
// footer below the fold, growing into it would push it further still.
if (fr.top > mapRect.top + 50) bottom = Math.min(fr.top, bottom);
}
const available = Math.max(0, Math.floor(bottom - mapRect.top - 8));
const widthDriven = width > 0 ? Math.floor(width / 1.55) : available;
const viewportCap = mapIsFullscreen()
? Math.floor(window.innerHeight * 0.9)
: Math.floor(window.innerHeight * 0.75);
const minHeight = Math.min(260, available);
const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven));
const target = Math.max(0, Math.floor(bottom - mapRect.top - 8));
mapContainer.style.height = `${target}px`;
if (aprsMap) aprsMap.invalidateSize();
}
function aprsSymbolIcon(symbolTable: string, symbolCode: string): Leaflet.DivIcon | null {
if (!symbolTable || !symbolCode) return null;
const table = symbolTable === "/" ? "primary" : "alternate";
const sprite = aprsSymbolSprite(symbolTable, symbolCode);
const html = sprite
? `<div class="aprs-symbol aprs-symbol-marker ${sprite.className}" role="img"` +
` style="background-position:${sprite.backgroundPosition}"` +
` title="${escapeMapHtml(sprite.label)}" aria-label="${escapeMapHtml(sprite.label)}"></div>`
: `<div class="aprs-symbol aprs-symbol-marker aprs-symbol-local" title="${symbolTable === "/" ? "primary" : "alternate"} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`;
return L.divIcon({
className: "",
html: `<div class="aprs-symbol-local" title="${table} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`,
html,
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -12]
});
}
mapWindow.navigateToAprsMap = function(lat, lon) {
// Activate the map tab
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); });
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => (p.style.display = "none"));
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
function focusMapPosition(lat: number, lon: number) {
initAprsMap();
sizeAprsMapToViewport();
if (aprsMap) {
@@ -2032,20 +2046,12 @@ const mapWindow = window as unknown as MapWindow;
});
});
}
};
}
mapWindow.navigateToMapLocator = function(grid, preferredType = null) {
function focusMapLocator(grid: string, preferredType: string | null = null) {
const normalizedGrid = String(grid || "").trim().toUpperCase();
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false;
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); });
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => (p.style.display = "none"));
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
initAprsMap();
sizeAprsMapToViewport();
if (!aprsMap) return false;
@@ -2095,7 +2101,7 @@ const mapWindow = window as unknown as MapWindow;
requestAnimationFrame(focusMarker);
});
return true;
};
}
@@ -2592,18 +2598,26 @@ const mapWindow = window as unknown as MapWindow;
syncDecodeContactPathVisibility();
}
// The buttons light up when they are on, so the label need not repeat it —
// an "On"/"Off" suffix on each cost the bar most of a row.
function updateMapContactPathsToggle() {
const btn = mapEl("map-contact-paths-toggle");
if (!btn) return;
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
btn.setAttribute("aria-pressed", mapDecodeContactPathsEnabled ? "true" : "false");
btn.title = mapDecodeContactPathsEnabled
? "Directed decode paths are drawn when the target locator is known"
: "Directed decode paths are hidden";
}
function updateMapP2pPathsToggle() {
const btn = mapEl("map-p2p-paths-toggle");
if (!btn) return;
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
btn.setAttribute("aria-pressed", mapP2pRadioPathsEnabled ? "true" : "false");
btn.title = mapP2pRadioPathsEnabled
? "TRX paths are drawn from a station popup"
: "TRX paths are hidden";
}
function scheduleDecodeMapMaintenance() {
@@ -2806,7 +2820,7 @@ const mapWindow = window as unknown as MapWindow;
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
syncDecodeContactPathVisibility();
if (selectedMapQsoKey && entry.sourceGrid) {
mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType);
focusMapLocator(entry.sourceGrid, entry.sourceType);
}
});
@@ -2933,7 +2947,7 @@ const mapWindow = window as unknown as MapWindow;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
focusMapLocator(entry.grid ?? "", entry.sourceType);
});
}
@@ -3059,7 +3073,7 @@ const mapWindow = window as unknown as MapWindow;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
focusMapLocator(entry.grid ?? "", entry.sourceType);
});
}
@@ -3623,6 +3637,8 @@ const mapWindow = window as unknown as MapWindow;
// Register module API for core to call
modules.map = {
initAprsMap,
focusMapPosition,
focusMapLocator,
sizeAprsMapToViewport,
syncAprsReceiverMarker,
updateMapRigFilter,
@@ -3672,6 +3688,18 @@ const mapWindow = window as unknown as MapWindow;
reverseGeocodeLocation,
};
// Everything the decoders already hold goes onto the map now. This module is
// lazy -- it arrives when the Map tab is first opened, long after startup
// restored the decode history -- and until it does, aprsMapAddStation and
// friends are undefined, so every restored position was dropped on the floor.
// The map then showed only what arrived live after it loaded, which is why it
// took a second reload (with the module cached, and so loaded early enough to
// win the race against the history fetch) for the stations to appear.
//
// The add functions are keyed by callsign/MMSI/point, so replaying costs
// nothing on a second call and cannot duplicate a marker.
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.syncMapAll();
// If the map tab is already visible (direct /map URL), init immediately.
autoInitIfVisible();
})();
@@ -2,12 +2,22 @@
//
// 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[]>> = {
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
// with it. They used to come only with the map group, which left their
// sub-tabs empty — decodes queueing in the runtime — until something opened
// 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", "/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;
},
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
clearQueued() { queued.clear(); },
hasDecoder: (id) => decoders.has(id),
};
@@ -27,6 +27,7 @@ interface AisMessage {
}
interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
interface AisBridge {
navigateToAprsMap?: (lat: number, lon: number) => void;
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
@@ -212,8 +213,21 @@ function updateAisSummary() {
}
}
/** What the message says, in one line: where the vessel is and what it is
* doing, or for the static reports that carry no fix where it is going. */
function aisSummaryText(msg: AisMessage): string {
const parts: string[] = [];
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
const motion = aisMotionText(msg);
if (motion) parts.push(motion);
const route = aisRouteText(msg);
if (route && parts.length < 2) parts.push(route);
if (!parts.length) return route || "no position reported";
return parts.join(" · ");
}
function renderAisRow(msg: AisMessage): HTMLElement {
const row = document.createElement("div");
const row = document.createElement("details");
row.className = "ais-message";
const ts = msg._ts || new Date().toLocaleTimeString([], {
hour: "2-digit",
@@ -227,8 +241,9 @@ function renderAisRow(msg: AisMessage): HTMLElement {
const route = aisRouteText(msg);
const distance = aisDistanceText(msg);
const pos = msg.lat != null && msg.lon != null
? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>`
? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>`
: "";
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
row.dataset.filterText = [
name,
msg.mmsi,
@@ -243,23 +258,43 @@ function renderAisRow(msg: AisMessage): HTMLElement {
.join(" ")
.toUpperCase();
row.innerHTML =
`<div class="ais-row-head">` +
`<span class="ais-time">${ts}</span>` +
`<summary class="decode-line">` +
`<span class="ais-time">${escapeAisHtml(ts)}</span>` +
`<span class="ais-call">${nameHtml}</span>` +
`<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
`<span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span>` +
`</div>` +
`<div class="ais-row-meta">` +
`<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
(route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") +
`<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span>` +
`</div>` +
`<div class="ais-row-detail">` +
(motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) +
(distance ? `<span>${escapeAisHtml(distance)}</span>` : "") +
(pos ? `<span>${pos}</span>` : "") +
`<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` +
`<span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span>` +
`<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
(distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") +
`</summary>` +
`<div class="decode-expanded">` +
`<div class="decode-expanded-meta">` +
`<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
`<span>${escapeAisHtml(channel.freqText)}</span>` +
(route ? `<span>${escapeAisHtml(route)}</span>` : "") +
(motion ? `<span>${escapeAisHtml(motion)}</span>` : "") +
`<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` +
(pos ? `<span>${pos}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(msg.lat != null && msg.lon != null
? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>`
: "") +
(vesselUrl
? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>`
: "") +
`</div>` +
`</div>`;
row.querySelectorAll<HTMLElement>("[data-ais-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
aisWindow.navigateToAprsMap?.(lat, lon);
});
});
applyAisFilterToRow(row);
return row;
}
@@ -354,9 +389,13 @@ function addAisMessage(msg: AisMessage): void {
scheduleAisBarUpdate();
scheduleAisHistoryRender();
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
aisWindow.aisMapAddVessel(msg);
}
plotAisMessage(msg);
}
/** Hands a positioned message to the map, if the map module is loaded yet. */
function plotAisMessage(msg: AisMessage): void {
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
aisWindow.aisMapAddVessel(msg);
}
function normalizeServerAisMessage(msg: AisMessage): AisMessage {
@@ -379,9 +418,7 @@ function onServerAisBatch(messages: AisMessage[]): void {
minute: "2-digit",
second: "2-digit",
});
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
aisWindow.aisMapAddVessel(next);
}
plotAisMessage(next);
normalized.push(next);
}
normalized.reverse();
@@ -427,4 +464,6 @@ updateAisSummary();
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
});
@@ -30,6 +30,14 @@ export interface AprsPacket {
symbol_code?: string | null;
}
function escapeAprsHtml(value: string): string {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
export function aprsPacketCategory(packet: AprsPacket): AprsCategory {
const type = (packet.type ?? "").toLowerCase();
const info = (packet.info ?? "").toLowerCase();
@@ -108,8 +116,87 @@ function escapeAprsCharacter(character: string): string {
return character;
}
// The vendored sprite sheets (assets/web/vendor/aprs-symbols-24-*.png) are
// 16x6 grids of 24px cells covering the printable codes 0x21..0x7E, so a
// symbol's cell index is simply `code - 0x21`. The sheet URLs live in CSS so
// the retina variants can be picked up by a media query; only the cell offsets
// are computed here.
const APRS_SPRITE_COLUMNS = 16;
const APRS_SPRITE_CELL_PX = 24;
const APRS_SPRITE_FIRST_CODE = 0x21;
const APRS_SPRITE_LAST_CODE = 0x7e;
export interface AprsSymbolSprite {
/** Sheet modifier class appended to `.aprs-symbol`. */
className: string;
/** `background-position` covering the overlay layer first, if any. */
backgroundPosition: string;
/** Human-readable description for the tooltip. */
label: string;
}
function aprsSpriteOffset(code: string): string | null {
if (code.length !== 1) return null;
const point = code.charCodeAt(0);
if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
const index = point - APRS_SPRITE_FIRST_CODE;
const column = index % APRS_SPRITE_COLUMNS;
const row = Math.floor(index / APRS_SPRITE_COLUMNS);
return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
}
/**
* Resolve an APRS table/code pair to a sprite cell. A table identifier of `/`
* selects the primary sheet and `\` the alternate one; any other character is
* an overlay, which draws that character from the overlay sheet on top of the
* alternate symbol. Returns null when the pair is outside the sprite sheets,
* leaving callers to fall back to the raw character.
*/
export function aprsSymbolSprite(
symbolTable: string | null | undefined,
symbolCode: string | null | undefined,
): AprsSymbolSprite | null {
if (!symbolTable || !symbolCode) return null;
const symbolOffset = aprsSpriteOffset(symbolCode);
if (!symbolOffset) return null;
if (symbolTable === "/") {
return {
className: "aprs-symbol-primary",
backgroundPosition: symbolOffset,
label: `Primary APRS symbol ${symbolTable}${symbolCode}`,
};
}
if (symbolTable === "\\") {
return {
className: "aprs-symbol-alternate",
backgroundPosition: symbolOffset,
label: `Alternate APRS symbol ${symbolTable}${symbolCode}`,
};
}
const overlayOffset = aprsSpriteOffset(symbolTable);
if (!overlayOffset) return null;
return {
className: "aprs-symbol-overlaid",
backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`,
};
}
/** An empty slot of the symbol's size, so a frame without one still lines up
* with the frames around it in the list. */
export function renderAprsSymbolSlot(packet: AprsPacket, escapeHtml: (value: string) => string): string {
return renderLocalAprsSymbol(packet, escapeHtml)
|| '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
}
export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string {
if (!packet.symbolTable || !packet.symbolCode) return "";
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
if (sprite) {
return `<span class="aprs-symbol ${sprite.className}" role="img"` +
` style="background-position:${sprite.backgroundPosition}"` +
` title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
}
const symbol = escapeHtml(packet.symbolCode);
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
@@ -133,3 +220,174 @@ export function normalizeAprsPacket(packet: AprsPacket, receiver: unknown): Aprs
symbolCode: packet.symbol_code ?? null,
};
}
// ── Payload summaries ──────────────────────────────────────────────────────
// APRS packs its meaning into the information field with a set of one-character
// type identifiers and fixed-width encodings (APRS 1.0.1, chapters 6-15). The
// list showed that field as it arrives on the air, so reading a weather report
// meant decoding "_10090556c220s004g005t077..." by eye. These produce a line
// of plain text for the common types and leave the raw field to the expanded
// view, which is still the authority when a summary cannot be made.
/** `t077` → 25.0 °C. APRS carries temperature in whole degrees Fahrenheit. */
function fahrenheitToCelsius(fahrenheit: number): number {
return Math.round(((fahrenheit - 32) * 5 / 9) * 10) / 10;
}
/** Weather report fields: wind, gust, temperature, rain, humidity, pressure. */
function summarizeAprsWeather(info: string): string | null {
const parts: string[] = [];
const temperature = /t(-?\d{2,3})/.exec(info);
if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`);
const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info);
if (wind) {
const gust = /g(\d{3})/.exec(info);
const knots = Number(wind[2]);
parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`);
}
const humidity = /h(\d{2})/.exec(info);
if (humidity) {
const value = Number(humidity[1]);
parts.push(`${value === 0 ? 100 : value}% RH`);
}
const pressure = /b(\d{5})/.exec(info);
if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`);
const rain = /r(\d{3})/.exec(info);
if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`);
return parts.length ? parts.join(" · ") : null;
}
/** `T#005,199,000,255,073,123,01101001` → sequence, five channels, eight bits. */
function summarizeAprsTelemetry(info: string): string | null {
const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim());
if (!match?.[2]) return null;
const channels = match[2].split(",").filter((value) => value.length > 0);
const bits = match[3] ? ` · bits ${match[3]}` : "";
return `#${match[1]} · ${channels.join(" ")}${bits}`;
}
/** `:DEST :text{01` → addressed message text. */
function summarizeAprsMessage(info: string): string | null {
const match = /^:([^:]{9}):(.*)$/.exec(info);
if (!match?.[1] || match[2] == null) return null;
const addressee = match[1].trim();
const text = match[2].replace(/\{\d+\s*$/, "").trim();
return `${addressee}: ${text}`;
}
/** Course/speed appended to a position, as `088/036`. */
function summarizeAprsCourseSpeed(info: string): string | null {
const match = /(\d{3})\/(\d{3})/.exec(info);
if (!match) return null;
const knots = Number(match[2]);
if (knots === 0) return null;
return `${Number(match[1])}° ${knots} kt`;
}
/** Whatever a frame is worth saying in one line, or null to fall back to raw. */
export function summarizeAprsPayload(packet: AprsPacket): string | null {
const info = packet.info ?? "";
if (!info) return null;
const category = aprsPacketCategory(packet);
if (category === "message") return summarizeAprsMessage(info);
if (category === "weather") return summarizeAprsWeather(info);
if (category === "telemetry") return summarizeAprsTelemetry(info);
if (category === "position") {
const parts: string[] = [];
if (packet.lat != null && packet.lon != null) {
parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`);
}
const courseSpeed = summarizeAprsCourseSpeed(info);
if (courseSpeed) parts.push(courseSpeed);
// Whatever the station wrote after the position report.
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
if (comment && comment.length <= 60) parts.push(comment);
return parts.length ? parts.join(" · ") : null;
}
// Status and anything else: the text it carries, minus its type character.
const text = info.replace(/^[>;<?]/, "").trim();
return text.length ? text : null;
}
// ── Frame row ──────────────────────────────────────────────────────────────
// Shared by the APRS and HF APRS lists, which had a copy each of the same
// forty lines of markup and drifted only by one badge.
export interface AprsRowOptions {
/** Marks the newest frame so it can flash on arrival. */
fresh?: boolean;
/** Leading badge, e.g. the band a copy of this list is dedicated to. */
badge?: string;
/** Distance from the receiver, already formatted, or "" to leave it out. */
distance?: string;
/** Opens the map on a frame's position. */
onMap?: (lat: number, lon: number) => void;
/** Puts a frame's coordinates on the clipboard. */
onCopy?: (text: string, button: HTMLElement) => void;
}
export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement {
const row = document.createElement("details");
row.className = "aprs-packet";
if (!packet.crcOk) row.classList.add("aprs-packet-crc");
if (options.fresh) row.classList.add("aprs-packet-new");
const time = packet._ts
|| new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const category = aprsPacketCategory(packet);
const summary = summarizeAprsPayload(packet);
const hasPosition = packet.lat != null && packet.lon != null;
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
row.innerHTML =
`<summary class="decode-line">` +
`<span class="aprs-time">${escapeAprsHtml(time)}</span>` +
(options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") +
renderAprsSymbolSlot(packet, escapeAprsHtml) +
`<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span>` +
`<span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">` +
`${escapeAprsHtml(aprsCategoryLabel(category))}</span>` +
`<span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` +
(packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') +
(options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") +
`</summary>` +
`<div class="decode-expanded">` +
`<div class="decode-expanded-meta">` +
`<span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span>` +
`<span>${escapeAprsHtml(packet.path || "no path")}</span>` +
`<span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span>` +
`<span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` +
(hasPosition
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">`
+ `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>`
: "") +
`</div>` +
`<div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` +
(packet.info_bytes?.length
? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>`
: "") +
`<div class="aprs-row-actions">` +
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") +
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`</div>`;
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number);
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number);
});
});
const copyButton = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyButton) {
copyButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
});
}
return row;
}
@@ -6,13 +6,10 @@ import { hostCore, hostState } from "./host.js";
import {
aprsAgeText,
aprsCategoryLabel,
aprsHexBytes,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsInfo,
renderLocalAprsSymbol,
renderAprsPacketRow,
type AprsPacket,
type AprsTypeFilter,
} from "./aprs-shared";
@@ -141,93 +138,24 @@ function updateAprsChipState() {
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
}
function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = aprsAgeText(pkt._tsMs);
const category = aprsPacketCategory(pkt);
const categoryLabel = aprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
const posLink = pkt.lat != null && pkt.lon != null
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
: "";
const distance = aprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML =
`<div class="aprs-row-head">` +
`<span class="aprs-time">${ts}</span>` +
symbolHtml +
`<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>` +
`<span>&gt;${escapeAprsHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` +
pathBadge +
crcBadge +
`</div>` +
`<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span>` +
`</div>` +
`<div class="aprs-row-detail">` +
`<span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
(posLink ? `<span>${posLink}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`<details class="aprs-details">` +
`<summary>Details</summary>` +
`<div class="aprs-details-grid">` +
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span>` +
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span>` +
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span>` +
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span>` +
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span>` +
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` +
`</details>`;
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number);
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
aprsWindow.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", () => { void (async () => {
const raw = copyBtn.dataset.aprsCopy ?? "";
try {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (clipboard) {
await clipboard.writeText(raw);
showAprsHint("Coordinates copied", 1200);
}
} catch {
showAprsHint("Copy failed", 1500);
}
})(); });
async function copyAprsCoords(text: string): Promise<void> {
try {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (!clipboard) return;
await clipboard.writeText(text);
showAprsHint("Coordinates copied", 1200);
} catch {
showAprsHint("Copy failed", 1500);
}
}
return row;
function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
return renderAprsPacketRow(pkt, {
fresh: isFresh,
distance: aprsDistanceText(pkt),
onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); },
onCopy: (text) => { void copyAprsCoords(text); },
});
}
function renderAprsHistory() {
@@ -301,6 +229,12 @@ function pruneAprsHistoryView(): void {
renderAprsHistory();
}
/** Hands a positioned packet to the map, if the map module is loaded yet. */
function plotAprsPacket(pkt: AprsPacket): void {
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
function addAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
@@ -309,9 +243,7 @@ function addAprsPacket(pkt: AprsPacket): void {
aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory();
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
plotAprsPacket(pkt);
if (pkt.crcOk) scheduleAprsBarUpdate();
@@ -332,9 +264,7 @@ function onServerAprsBatch(packets: AprsPacket[]): void {
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" });
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
}
plotAprsPacket(next);
if (next.crcOk) hasCrcOk = true;
normalized.push(next);
}
@@ -406,4 +336,6 @@ renderAprsHistory();
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
});
@@ -456,6 +456,7 @@ function bmApply(bm: Bookmark): void {
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
hostCore.syncModePicker();
}
if (bm.bandwidth_hz) {
hostState.currentBandwidthHz = bm.bandwidth_hz;
@@ -6,13 +6,10 @@ import { hostCore, hostState } from "./host.js";
import {
aprsAgeText,
aprsCategoryLabel,
aprsHexBytes,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsInfo,
renderLocalAprsSymbol,
renderAprsPacketRow,
type AprsPacket,
type AprsTypeFilter,
} from "./aprs-shared";
@@ -27,7 +24,6 @@ interface HfAprsBridge {
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
}
const hfAprsWindow = window as unknown as HfAprsBridge;
const escapeHfAprsHtml = (input: string): string => hostCore.escapeMapHtml(input);
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
const hfAprsStatus = document.getElementById("hf-aprs-status");
@@ -128,95 +124,27 @@ function updateHfAprsChipState() {
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
}
// HF traffic goes in the same row as VHF, marked with the band it came in on.
// This was a second copy of the same forty lines of markup.
function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = aprsAgeText(pkt._tsMs);
const category = aprsPacketCategory(pkt);
const categoryLabel = aprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeHfAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
const posLink = pkt.lat != null && pkt.lon != null
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
: "";
const distance = hfAprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML =
`<div class="aprs-row-head">` +
`<span class="aprs-time">${ts}</span>` +
hfBadge +
symbolHtml +
`<span class="aprs-call">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span>` +
`<span>&gt;${escapeHfAprsHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` +
pathBadge +
crcBadge +
`</div>` +
`<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span>` +
`</div>` +
`<div class="aprs-row-detail">` +
`<span title="${escapeHfAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
(posLink ? `<span>${posLink}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`<details class="aprs-details">` +
`<summary>Details</summary>` +
`<div class="aprs-details-grid">` +
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.srcCall || "--")}</span>` +
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span>` +
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span>` +
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span>` +
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(age)}</span>` +
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` +
`</details>`;
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number);
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
hfAprsWindow.navigateToAprsMap(lat, lon);
}
});
return renderAprsPacketRow(pkt, {
fresh: isFresh,
badge: "HF",
distance: hfAprsDistanceText(pkt),
onMap: (lat: number, lon: number) => { hfAprsWindow.navigateToAprsMap?.(lat, lon); },
onCopy: (text: string) => { void copyHfAprsCoords(text); },
});
}
const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", () => { void (async () => {
const raw = copyBtn.dataset.aprsCopy ?? "";
try {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (clipboard) {
await clipboard.writeText(raw);
hostCore.showHint("Coordinates copied", 1200);
}
} catch {
hostCore.showHint("Copy failed", 1500);
}
})(); });
async function copyHfAprsCoords(text: string): Promise<void> {
try {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (!clipboard) return;
await clipboard.writeText(text);
hostCore.showHint("Coordinates copied", 1200);
} catch {
hostCore.showHint("Copy failed", 1500);
}
return row;
}
function renderHfAprsHistory() {
@@ -59,6 +59,8 @@ export interface HostCore {
setRigFrequency(frequencyHz: number): void;
syncBandwidthInput(bandwidthHz: number): void;
scheduleSpectrumDraw(): void;
/** Repaints the mode buttons from #mode after writing to it. */
syncModePicker(): void;
onDecoderRegistryReady(callback: () => void): void;
}
@@ -9,6 +9,9 @@ export interface DecoderPlugin<TMessage = unknown> {
restore?(messages: TMessage[]): void;
reset?(): void;
prune?(): 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;
}
export interface TrxPluginRuntime {
@@ -19,6 +22,7 @@ export interface TrxPluginRuntime {
reset(id: string): boolean;
resetAll(): void;
prune(id: string): boolean;
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,355 @@
// 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 type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
interface SstvImage {
ts_ms?: number;
vis?: number;
mode?: string;
width?: number;
height?: number;
lines?: number;
complete?: boolean;
path?: string;
_tsMs?: number;
_ts?: string;
}
interface SstvProgress {
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 {
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();
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 ───────────────────────────────────────────────────────
function renderLatestCard(): void {
if (!sstvDom.liveLatest) return;
const latest = sstvHistory[sstvHistory.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[] {
const text = filterText.trim().toLowerCase();
const matching = text
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
: sstvHistory.slice();
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,
});
sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress",
onMessage: onProgress as (msg: unknown) => void,
});
@@ -403,7 +403,10 @@ function vchanSyncModeDisplay() {
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
if (ch && ch.mode) {
modeEl.value = ch.mode.toUpperCase();
hostCore.syncModePicker();
}
}
// When on primary channel, app.js rig-state updates handle the picker.
const modeUpper = (modeEl.value || "").toUpperCase();
@@ -320,9 +320,7 @@ function onServerVdesBatch(messages: VdesMessage[]): void {
minute: "2-digit",
second: "2-digit",
});
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
plotVdesMessage(next);
normalized.push(next);
}
normalized.reverse();
@@ -349,13 +347,17 @@ if (vdesFilterInput) {
});
}
/** Hands a positioned message to the map, if the map module is loaded yet. */
function plotVdesMessage(msg: VdesMessage): void {
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg: VdesMessage): void {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
addVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
plotVdesMessage(next);
}
function pruneVdesHistoryView(): void {
@@ -372,4 +374,6 @@ updateVdesSummary();
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
});
@@ -317,14 +317,19 @@ function elementById<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (element) body.appendChild(element);
});
tray.appendChild(details);
// Ahead of the collapsibles the markup ships: the radio's own settings
// come before audio and the scheduler, and appending would put the
// section built here last whatever the markup says.
tray.insertBefore(details, document.getElementById("audio-controls"));
api.applyLayout(savedLayoutName(), { persist: false });
}
}
// Secondary controls, in the order they leave the bar when it gets tight.
// Audio, record and the rig picker are the operating controls and stay.
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
const overflowOrder = [
".operator-layout-picker", ".header-style-pick", "#header-share-btn", "#theme-toggle",
];
// Anchored at paint time in fixed coordinates. An absolutely positioned
// dropdown is clipped by any scrolling ancestor and trapped inside the
@@ -397,31 +402,40 @@ function elementById<T extends HTMLElement>(id: string): T {
});
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMenu(); });
// Measured against the bar, not the actions container: the actions are
// sized by their content, so their own scrollWidth never exceeds their
// clientWidth. A viewport-width threshold is not enough either — how much
// fits depends on the rig name and the translated labels, so a bar that is
// wide enough on one rig clips a control on another.
// `bar.scrollWidth > bar.clientWidth` is true even when nothing is clipped,
// so it cannot be the test. What actually matters is that the controls stay
// inside the bar and the page tabs are not squeezed into a scroller: seeing
// every tab beats keeping the style picker inline.
// Compare natural widths against the space available. Rendered widths
// cannot answer this: the nav has min-width 0 and scrolls, so it always
// shrinks to the leftover space and always reports "scrolling", while the
// bar reports overflow even when nothing is clipped. scrollWidth on a
// scroll container is its unconstrained content width, which is what a fit
// test needs.
// What "fits" means, measured rather than predicted. Two things have to
// hold: the controls stay inside the bar, and no tab reaches them. The
// tabs are the test rather than the nav's own box because the nav may
// shrink below its content — its box gets smaller while the tabs inside
// keep their width and slide under the controls, so the container reports
// nothing wrong while destinations become unclickable.
//
// This was an arithmetic estimate — identity + nav.scrollWidth +
// actions.scrollWidth + a 48px allowance for the gaps — which under-counts
// whatever the allowance does not cover. On a platform whose fonts run
// wider than this machine's it declared a fit that overlapped by 9px, and
// nothing degraded because nothing thought anything was wrong. Reading
// the geometry costs a synchronous layout per step, at most five per pass,
// and cannot disagree with what the operator sees.
const barFits = () => {
const bar = actions.closest<HTMLElement>(".tab-bar");
if (!bar) return true;
const identity = bar.querySelector<HTMLElement>(".header-main");
const nav = bar.querySelector<HTMLElement>(".tab-bar-nav");
const gutters = 48;
const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters;
return needed <= bar.clientWidth;
const nav = bar?.querySelector<HTMLElement>(".tab-bar-nav");
if (!bar || !nav) return true;
const barRect = bar.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
if (actionsRect.right > barRect.right + 1) return false;
const tabs = Array.from(nav.querySelectorAll<HTMLElement>(".tab"))
.filter((tab) => tab.offsetParent !== null);
if (!tabs.length) return true;
const tabsRight = Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right));
return tabsRight <= actionsRect.left - 1;
};
const reflowOverflow = () => {
const nav = document.querySelector<HTMLElement>(".tab-bar-nav");
const bar = actions.closest<HTMLElement>(".tab-bar");
// Measure from the roomiest state every time, so the decision is a
// function of the current widths alone and cannot ratchet.
nav?.classList.remove("nav-icons-only");
bar?.classList.remove("bar-tight");
overflowOrder.forEach((selector) => {
const element = menu.querySelector<HTMLElement>(selector);
if (element) actions.insertBefore(element, wrap);
@@ -434,11 +448,32 @@ function elementById<T extends HTMLElement>(id: string): T {
wrap.hidden = false;
menu.appendChild(element);
}
// Last resort, once every movable control is already in the menu: drop
// the tabs to their icons. Without it the nav — which may shrink below
// its content — keeps its tabs at full width and runs them under the
// controls, so the destinations nearest the controls become unclickable.
// Icon widths are fixed, so this always buys back the labels' width.
if (nav && !barFits()) nav.classList.add("nav-icons-only");
// Still short with the tabs down to icons: hand the squeeze to the
// identity block, which can ellipsise, rather than to the strip, which
// can only clip destinations out of reach.
if (bar && !barFits()) bar.classList.add("bar-tight");
wrap.hidden = menu.children.length === 0;
if (wrap.hidden) closeMenu();
};
reflowOverflow();
window.addEventListener("resize", reflowOverflow);
// A resize is not the only thing that changes what fits: the rig name
// arrives from the server, the style picker fills in, a web font swaps in
// wider metrics. Each changes the bar's content without touching the
// window, and the strip stayed as it was through all of them.
if (typeof ResizeObserver !== "undefined") {
const bar = actions.closest<HTMLElement>(".tab-bar");
const observer = new ResizeObserver(() => { reflowOverflow(); });
if (bar) observer.observe(bar);
observer.observe(actions);
}
document.fonts?.ready.then(() => { reflowOverflow(); }).catch(() => {});
}
function installMobileMore() {
@@ -448,7 +483,8 @@ function elementById<T extends HTMLElement>(id: string): T {
more.id = "mobile-more-btn";
more.className = "tab mobile-more-btn";
more.type = "button";
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">Tools</span>';
more.setAttribute("aria-label", "Tools and settings");
more.setAttribute("aria-haspopup", "menu");
more.setAttribute("aria-expanded", "false");
const menu = document.createElement("div");
@@ -463,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");
@@ -505,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,85 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { readFile } from "node:fs/promises";
import { bundleEntry } from "./bundle-entry.mjs";
const sharedUrl = new URL("../src/plugins/aprs-shared.ts", import.meta.url);
const stylePath = new URL("../../assets/web/style.css", import.meta.url);
async function loadShared() {
const source = await bundleEntry(sharedUrl, "trxAprsShared");
const context = vm.createContext({ Math, String, Number, Array, Date, Set, console });
new vm.Script(source).runInContext(context);
return context.trxAprsShared;
}
// The sheets are 16x6 grids of 24px cells covering 0x21..0x7E, so cell index
// is `code - 0x21`. Getting this wrong shifts every station to a neighbouring
// icon, which is invisible in a screenshot but wrong on every packet.
test("sprite cells are indexed from the first printable symbol code", async () => {
const { aprsSymbolSprite } = await loadShared();
const first = aprsSymbolSprite("/", "!");
assert.equal(first.className, "aprs-symbol-primary");
assert.equal(first.backgroundPosition, "0px 0px");
assert.equal(first.label, "Primary APRS symbol /!");
// '>' is 0x3E -> index 29 -> column 13, row 1.
assert.equal(aprsSymbolSprite("/", ">").backgroundPosition, "-312px -24px");
// '~' is 0x7E -> index 93 -> the last cell of the last row.
assert.equal(aprsSymbolSprite("/", "~").backgroundPosition, "-312px -120px");
});
test("the table identifier selects the primary, alternate, or overlay sheet", async () => {
const { aprsSymbolSprite } = await loadShared();
assert.equal(aprsSymbolSprite("/", "_").className, "aprs-symbol-primary");
assert.equal(aprsSymbolSprite("\\", "_").className, "aprs-symbol-alternate");
// An alphanumeric table identifier is an overlay character drawn on top of
// the alternate symbol: overlay cell first, then the symbol cell.
const overlaid = aprsSymbolSprite("S", ">");
assert.equal(overlaid.className, "aprs-symbol-overlaid");
assert.equal(overlaid.backgroundPosition, "-48px -72px, -312px -24px");
assert.equal(overlaid.label, "Alternate APRS symbol \\> with overlay S");
});
test("codes outside the sprite sheets fall back to the raw character", async () => {
const { aprsSymbolSprite, renderLocalAprsSymbol } = await loadShared();
const escape = (value) => value;
assert.equal(aprsSymbolSprite("/", " "), null);
assert.equal(aprsSymbolSprite("/", ""), null);
assert.equal(aprsSymbolSprite("/", "ab"), null);
assert.equal(aprsSymbolSprite(null, ">"), null);
assert.equal(renderLocalAprsSymbol({ symbolTable: "/", symbolCode: " " }, escape),
'<span class="aprs-symbol aprs-symbol-local" title="Primary APRS symbol "> </span>');
assert.equal(renderLocalAprsSymbol({}, escape), "");
});
test("rendered symbols carry a sprite class and an inline cell offset", async () => {
const { renderLocalAprsSymbol } = await loadShared();
const html = renderLocalAprsSymbol({ symbolTable: "/", symbolCode: ">" }, (value) => value);
assert.match(html, /class="aprs-symbol aprs-symbol-primary"/);
assert.match(html, /style="background-position:-312px -24px"/);
assert.match(html, /aria-label="Primary APRS symbol \/>"/);
assert.equal(html.includes("http"), false);
});
// The cell offsets are computed in the bundles, so the sheet URLs and the grid
// geometry have to stay in lockstep with them here.
test("the stylesheet serves every sheet locally at the sprite geometry", async () => {
const css = await readFile(stylePath, "utf8");
assert.match(css, /\.aprs-symbol\s*\{[^}]*background-size:\s*384px 144px/);
for (const sheet of ["24-0", "24-1", "24-2", "24-0-2x", "24-1-2x", "24-2-2x"]) {
assert.ok(css.includes(`url('/vendor/aprs-symbols-${sheet}.png')`), `missing sheet ${sheet}`);
}
assert.match(css, /\.aprs-symbol-overlaid\s*\{[^}]*aprs-symbols-24-2\.png'\), url\('\/vendor\/aprs-symbols-24-1\.png'\)/);
});
@@ -29,7 +29,7 @@ class ElementFixture {
// Mirrors the `window.trx` host contract published by app.ts. The plugin is a
// separate bundle, so every application service it uses arrives this way.
function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0 };
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = {
authEnabled: false,
authRole: "control",
@@ -50,6 +50,7 @@ function hostFixture(overrides = {}) {
armOptimisticFrequency: (hz) => { calls.armOptimisticFrequency.push(hz); },
syncBandwidthInput: (hz) => { calls.syncBandwidthInput.push(hz); },
scheduleSpectrumDraw: () => { calls.scheduleSpectrumDraw += 1; },
syncModePicker: () => { calls.syncModePicker += 1; },
onDecoderRegistryReady: () => {},
};
return { window: { trx: { state, core, modules: {} }, trxUi: { confirm: async () => true } }, calls };
@@ -3,167 +3,18 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const webDir = path.resolve(frontendDir, "../assets/web");
const generatedDir = path.join(webDir, "generated");
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
display_name: remote === "rig-a" ? "Primary fixture" : "Secondary fixture",
manufacturer: "Smoke",
model: "Fixture",
supported_modes: ["FM"],
tx: false,
filter_controls: false,
initialized: true,
latitude: null,
longitude: null,
}));
const rigsResponse = { rigs: rigItems, active_remote: "rig-a" };
const selectedRigs = [];
// page.evaluate callbacks run in the browser, not in this Node process.
/* global document, getComputedStyle, window, location */
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/decoders", []],
["/rigs", rigsResponse],
["/status", {
info: {
manufacturer: "Smoke",
model: "Fixture",
revision: "1",
access: { Tcp: { addr: "127.0.0.1:0" } },
capabilities: {
min_freq_step_hz: 1,
supported_bands: [],
supported_modes: ["FM"],
num_vfos: 1,
lock: false,
lockable: false,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx: false,
tx_limit: false,
vfo_switch: false,
filter_controls: false,
signal_meter: false,
},
},
status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: null, lock: null },
band: null,
enabled: true,
initialized: true,
cw_auto: false,
cw_wpm: 20,
cw_tone_hz: 700,
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
ft4_decode_enabled: false,
ft2_decode_enabled: false,
wspr_decode_enabled: false,
lrpt_decode_enabled: false,
wefax_decode_enabled: false,
recorder_enabled: false,
clients: 1,
rigctl_clients: 0,
audio_clients: 0,
active_remote: "rig-a",
remotes: ["rig-a", "rig-b"],
show_sdr_gain_control: false,
initial_map_zoom: 10,
spectrum_coverage_margin_hz: 50_000,
spectrum_usable_span_ratio: 0.92,
bandplan_enabled: false,
bandplan_region: "iaru1",
decode_history_retention_min: 1440,
server_connected: true,
}],
["/bandplan.json", {}],
["/api/recorder/status", []],
["/api/recorder/files", []],
]);
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "application/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".woff2", "font/woff2"],
]);
function assetPath(urlPath) {
if (urlPath === "/") return path.join(webDir, "index.html");
if (urlPath.startsWith("/vendor/")) return path.join(webDir, urlPath);
const generated = path.join(generatedDir, path.basename(urlPath));
if (urlPath.endsWith(".js")) return generated;
return path.join(webDir, urlPath);
}
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote");
if (remote) {
rigsResponse.active_remote = remote;
jsonRoutes.get("/status").active_remote = remote;
selectedRigs.push(remote);
}
response.writeHead(200).end();
return;
}
if (jsonRoutes.has(url.pathname)) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(jsonRoutes.get(url.pathname)));
return;
}
if (url.pathname === "/audio") {
response.writeHead(404).end();
return;
}
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
response.write(": browser smoke stream\n\n");
return;
}
try {
const file = assetPath(url.pathname);
const bytes = await readFile(file);
response.writeHead(200, {
"content-type": contentTypes.get(path.extname(file)) ?? "application/octet-stream",
});
response.end(bytes);
} catch {
response.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert(address && typeof address === "object");
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
?? "/usr/bin/chromium";
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
const page = await browser.newPage();
const runtimeErrors = [];
page.on("pageerror", (error) => runtimeErrors.push(error.stack ?? error.message));
const fixture = await startWebFixture();
const { selectedRigs } = fixture;
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "domcontentloaded" });
await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(500);
assert.deepEqual(runtimeErrors, []);
await page.locator("#content").waitFor({ state: "visible" });
@@ -172,6 +23,167 @@ try {
await page.locator("summary", { hasText: "Audio controls" }).click();
assert.equal(await page.locator("#rx-audio-btn").count(), 1);
// Digital modes: the decoders are a list down the side, and the panel for the
// selected one sits beside it. A horizontal strip put thirteen decoders in a
// scroller and marked the open one with a single underline among them.
await page.locator('.tab[data-tab="digital-modes"]').click();
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
await page.locator('.sub-tab[data-subtab="ft8"]').click();
await page.waitForTimeout(250);
const digital = await page.evaluate(() => {
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar").getBoundingClientRect();
const panel = document.getElementById("subtab-ft8").getBoundingClientRect();
const shown = [...document.querySelectorAll("#tab-digital-modes > .sub-tab-panel")]
.filter((element) => getComputedStyle(element).display !== "none")
.map((element) => element.id);
return {
sidebarIsColumn: bar.height > bar.width,
panelBesideSidebar: Math.round(panel.left) >= Math.round(bar.right),
panelsShown: shown,
decoders: document.querySelectorAll("#tab-digital-modes > .sub-tab-bar .sub-tab").length,
};
});
assert.ok(digital.sidebarIsColumn, "the decoder list is not a sidebar");
assert.ok(digital.panelBesideSidebar, "the decoder panel does not sit beside the sidebar");
assert.deepEqual(digital.panelsShown, ["subtab-ft8"], `panels shown: ${JSON.stringify(digital.panelsShown)}`);
assert.ok(digital.decoders >= 10, `only ${digital.decoders} decoders in the sidebar`);
// Each decoder's list fills its panel. FT8, FT4, FT2 and WSPR size against
// the panel with flex, so a panel sized to its own content collapsed them to
// their 120px minimum with the rest of the page left empty; the marine lists
// were sized by a viewport formula that stopped matching when the panel
// changed shape.
for (const [subtab, list] of [["ft8", "ft8-messages"], ["wspr", "wspr-messages"],
["ais", "ais-messages"], ["aprs", "aprs-packets"], ["hf-aprs", "hf-aprs-packets"]]) {
await page.locator(`.sub-tab[data-subtab="${subtab}"]`).click();
await page.waitForTimeout(150);
const filled = await page.evaluate((id) => {
const element = document.getElementById(id);
const panel = element.closest(".sub-tab-panel");
return {
list: Math.round(element.getBoundingClientRect().height),
panel: Math.round(panel.getBoundingClientRect().height),
scrolls: getComputedStyle(element).overflowY,
};
}, list);
assert.ok(filled.list > filled.panel * 0.6,
`${subtab}: the list is ${filled.list}px in a ${filled.panel}px panel`);
assert.equal(filled.scrolls, "auto", `${subtab}: the list does not scroll on its own`);
}
await page.locator('.tab[data-tab="main"]').click();
await page.waitForTimeout(200);
// Map links from decode rows, before anything has opened the Map tab. The
// lazy map module used to install these globals itself, so an AIS pin threw
// "not a function" and an APRS link silently did nothing until the tab had
// been visited once.
const mapLinkReady = await page.evaluate(() => ({
position: typeof window.navigateToAprsMap,
locator: typeof window.navigateToMapLocator,
mapModuleLoaded: !!window.trx.modules.map,
}));
assert.equal(mapLinkReady.position, "function", "navigateToAprsMap is missing before the map loads");
assert.equal(mapLinkReady.locator, "function", "navigateToMapLocator is missing before the map loads");
assert.equal(mapLinkReady.mapModuleLoaded, false, "the map module was already loaded, so this proves nothing");
await page.evaluate(() => { window.navigateToAprsMap(52.2, 21.0); });
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await page.waitForTimeout(500);
const followed = await page.evaluate(() => ({
path: location.pathname,
active: [...document.querySelectorAll(".tab-bar .tab.active")].map((tab) => tab.dataset.tab || tab.id),
mapHeight: Math.round(document.getElementById("aprs-map").getBoundingClientRect().height),
}));
assert.equal(followed.path, "/map", `the map link left the page on ${followed.path}`);
assert.ok(followed.active.includes("map"), `the strip marks ${JSON.stringify(followed.active)}`);
assert.ok(followed.mapHeight > 100, `the map came up ${followed.mapHeight}px tall`);
await page.locator('.tab[data-tab="main"]').click();
await page.waitForTimeout(200);
// Section order in the tray. "Advanced radio controls" is built at runtime,
// so it lands wherever ui-core puts it rather than where the markup says —
// appending, as it once did, always left it last.
const sections = await page.evaluate(() =>
[...document.querySelectorAll(".controls-tray > details")].map((section) =>
section.querySelector("summary").textContent.trim()));
assert.deepEqual(sections, ["Advanced radio controls", "Audio controls", "Scheduler controls"],
`tray sections are ${JSON.stringify(sections)}`);
// Mode is a button group over a hidden <select>, which stays the value a
// dozen call sites and several plugins read. The click has to reach it, and
// the select must not take part in layout while it does.
const modeBefore = await page.evaluate(() => ({
buttons: document.querySelectorAll("#mode-picker button").length,
value: document.getElementById("mode").value,
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
}));
assert.ok(modeBefore.buttons > 1, `mode picker rendered ${modeBefore.buttons} buttons`);
assert.equal(modeBefore.active, modeBefore.value, "mode picker disagrees with the select");
const target = await page.evaluate(() => {
const other = [...document.querySelectorAll("#mode-picker button")]
.find((btn) => btn.dataset.mode !== document.getElementById("mode").value);
return other?.dataset.mode;
});
await page.locator(`#mode-picker button[data-mode="${target}"]`).click();
await page.waitForTimeout(200);
const modeAfter = await page.evaluate(() => ({
value: document.getElementById("mode").value,
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
selectWidth: Math.round(document.getElementById("mode").getBoundingClientRect().width),
}));
assert.equal(modeAfter.value, target, `clicking ${target} left the select at ${modeAfter.value}`);
assert.equal(modeAfter.active, target, "the clicked mode is not the marked one");
assert.ok(modeAfter.selectWidth <= 2, `the hidden select still occupies ${modeAfter.selectWidth}px`);
// Mode-specific controls live on their own row, which has to leave with them:
// an empty one would still take a track and a gap in the tray and draw its
// divider under the controls every rig has.
const modeRowState = async () => page.evaluate(() => {
const row = document.getElementById("mode-controls-row");
return { display: getComputedStyle(row).display, height: Math.round(row.getBoundingClientRect().height) };
});
await page.locator('#mode-picker button[data-mode="WFM"]').click();
await page.waitForTimeout(250);
const withWfm = await modeRowState();
assert.notEqual(withWfm.display, "none", "WFM controls did not bring their row up");
assert.ok(withWfm.height > 0, `WFM row has no height (${withWfm.height}px)`);
await page.locator('#mode-picker button[data-mode="FM"]').click();
await page.waitForTimeout(250);
const withoutWfm = await modeRowState();
assert.equal(withoutWfm.display, "none", "the mode row stayed behind with nothing in it");
// Scheduler controls read left to right: step, hand back, then the entry on
// air. The separator is drawn by the current-entry block, so it can only sit
// in the right place if that block is last.
const schedulerRow = await page.evaluate(() => [...document.querySelectorAll(".scheduler-action-row > *")]
.map((el) => el.id || [...el.children].map((c) => c.id).join("+")));
assert.deepEqual(schedulerRow,
["scheduler-prev-btn+scheduler-next-btn", "scheduler-release-btn", "scheduler-cycle-status"],
`scheduler control order is ${JSON.stringify(schedulerRow)}`);
// The footer status pill colours its dot from data-state, so a hint written
// straight to textContent would leave the dot stuck on the previous state.
const hint = await page.evaluate(() => {
const element = document.getElementById("power-hint");
return { state: element.dataset.state, text: element.textContent.trim() };
});
assert.ok(["ok", "busy", "error"].includes(hint.state), `status pill state is ${hint.state}`);
assert.equal(hint.state, "ok", `fixture reports "${hint.text}" but the pill is ${hint.state}`);
// Rig names, and they have to survive the state stream. The updates carry
// only rig ids — /rigs is what knows the names — and applying one used to
// clear the names, so the picker and the header fell back to the lowercase
// ids a second after load and stayed there.
await page.waitForTimeout(1500);
const rigLabels = await page.evaluate(() => ({
options: [...document.getElementById("header-rig-switch-select").options].map((o) => o.textContent),
subtitle: document.getElementById("rig-subtitle").textContent,
}));
assert.deepEqual(rigLabels.options, ["Primary fixture", "Secondary fixture"],
`the picker reads ${JSON.stringify(rigLabels.options)}`);
assert.equal(rigLabels.subtitle, "Rig: Primary fixture",
`the header reads "${rigLabels.subtitle}"`);
const rigPicker = page.locator("#header-rig-switch-select");
await rigPicker.locator("option").nth(1).waitFor({ state: "attached" });
await rigPicker.selectOption("rig-b");
@@ -181,6 +193,44 @@ try {
await page.locator('.tab[data-tab="map"]').click();
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
assert.equal(new URL(page.url()).pathname, "/map");
// The selected destination is marked by a box on all four sides, so a rule
// that drops one edge (the mobile nav used to lose its bottom border) is a
// regression even though the tab still reads as "active".
const activeTab = await page.evaluate(() => {
const style = getComputedStyle(document.querySelector(".tab-bar-nav .tab.active"));
return ["Top", "Right", "Bottom", "Left"].map((side) => ({
width: style.getPropertyValue(`border-${side.toLowerCase()}-width`),
color: style.getPropertyValue(`border-${side.toLowerCase()}-color`),
}));
});
for (const edge of activeTab) {
assert.notEqual(edge.width, "0px", `active tab border: ${JSON.stringify(activeTab)}`);
assert.ok(!/rgba\(0, 0, 0, 0\)|transparent/.test(edge.color), `active tab border: ${JSON.stringify(activeTab)}`);
}
// The map is full-bleed: it breaks out of the centred .card column and
// reaches both viewport edges, without pushing the page sideways.
const stage = await page.evaluate(() => {
const rect = document.getElementById("map-stage").getBoundingClientRect();
return {
left: Math.round(rect.left),
right: Math.round(rect.right),
viewport: document.documentElement.clientWidth,
sideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
gapToFooter: Math.round(document.querySelector(".footer").getBoundingClientRect().top - rect.bottom),
pageScrolls: document.documentElement.scrollHeight > document.documentElement.clientHeight + 1,
};
});
assert.equal(stage.left, 0, `map stage starts at ${stage.left}px, not the viewport edge`);
assert.equal(stage.right, stage.viewport, `map stage ends at ${stage.right}px, not ${stage.viewport}px`);
assert.equal(stage.sideways, false, "full-bleed map makes the page scroll sideways");
// ...and fills the column down to the footer. Capping the height at a
// fraction of the viewport left a dead band that grew with the window.
assert.ok(stage.gapToFooter <= 16,
`${stage.gapToFooter}px of dead space between the map and the footer`);
assert.equal(stage.pageScrolls, false, "the map grew past the viewport");
await page.locator('.tab[data-tab="main"]').click();
assert.equal(new URL(page.url()).pathname, "/");
@@ -195,7 +245,150 @@ try {
await page.locator("#tab-main").waitFor({ state: "visible" });
assert.equal(new URL(page.url()).pathname, "/");
assert.deepEqual(runtimeErrors, []);
// Refreshing or deep-linking must mark the destination, not Tools. The first
// route navigation runs while the card is still behind the loading state, so
// a test that asked whether the tab was displayed saw "none" for every tab
// and lit Tools up on every refresh of every page.
for (const [route, tab, toolsLit] of [["/map", "map", false], ["/about", "about", true]]) {
await page.goto(`${fixture.origin}${route}`, { waitUntil: "domcontentloaded" });
await page.locator(`#tab-${tab}`).waitFor({ state: "visible" });
const marked = await page.evaluate(() => ({
actives: [...document.querySelectorAll(".tab-bar .tab.active")].map((t) => t.dataset.tab || t.id),
tools: document.getElementById("mobile-more-btn").classList.contains("active"),
}));
assert.ok(marked.actives.includes(tab), `${route} marks ${JSON.stringify(marked.actives)}`);
assert.equal(marked.tools, toolsLit, `${route}: Tools active is ${marked.tools}`);
}
await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-main").waitFor({ state: "visible" });
assert.deepEqual(runtimeErrors, []);
// --- Layout regressions -------------------------------------------------
// Every fault below shipped at some point while the rest of this file
// passed, because nothing here looked at geometry: a header whose height
// tracked the viewport, controls that stretched, a tab strip that ran under
// the controls, and a dropdown that opened underneath the spectrum.
for (const width of [1440, 1280, 1100, 900]) {
await page.setViewportSize({ width, height: 900 });
await page.waitForTimeout(250);
const header = await page.evaluate(() => {
const bar = document.querySelector(".tab-bar");
const nav = document.querySelector(".tab-bar-nav");
const actions = document.querySelector(".top-bar-actions");
const controls = [...actions.children]
.filter((el) => !el.hidden && el.getBoundingClientRect().height > 0)
.map((el) => Math.round(el.getBoundingClientRect().height));
return {
barHeight: Math.round(bar.getBoundingClientRect().height),
// The tabs, not the strip: with the strip allowed to overflow its box
// shrinks while its content paints across the controls, so the
// container's own rect never registers the collision.
overlap: Math.round(Math.max(...[...nav.querySelectorAll(".tab")]
.filter((tab) => tab.offsetParent !== null)
.map((tab) => tab.getBoundingClientRect().right))
- actions.getBoundingClientRect().left),
heights: [...new Set(controls)],
pageScrollsSideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
};
});
assert.ok(header.barHeight <= 96, `header is ${header.barHeight}px at ${width}px; it should stay one row`);
assert.ok(header.overlap <= 0, `tab strip overlaps the controls by ${header.overlap}px at ${width}px`);
assert.ok(header.heights.length <= 2, `controls have heights ${header.heights.join(", ")} at ${width}px`);
assert.equal(header.pageScrollsSideways, false, `page scrolls sideways at ${width}px`);
}
// A dropdown must paint over the page, not inside the header: fixed
// positioning escapes clipping but not the header's stacking context.
await page.setViewportSize({ width: 1280, height: 900 });
await page.waitForTimeout(250);
const menu = await page.evaluate(() => {
document.getElementById("mobile-more-btn").click();
const element = document.getElementById("mobile-more-menu");
const rect = element.getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
return { width: Math.round(rect.width), height: Math.round(rect.height), onTop: element.contains(hit) };
});
assert.ok(menu.height > 40 && menu.width > 80, `menu rendered ${menu.width}x${menu.height}`);
assert.ok(menu.onTop, "menu is painted underneath the page");
// Wider text than this machine renders. This suite passed on macOS twice
// while CI, whose system font is wider, put the tab strip into the controls —
// the second time by 9px at 1440px with no scaling at all, because the fit
// test was an arithmetic estimate whose fixed allowance for the gaps did not
// cover them at those metrics. A single scale factor cannot stand in for
// another platform's fonts, so sweep: somewhere in this range is whatever CI
// renders, and the strip has to hold at every step of it.
const applyTextScale = (scale) => page.addStyleTag({ content: `
.tab-bar .title { font-size: ${1.05 * scale}rem !important; }
.tab-bar .subtitle { font-size: ${0.78 * scale}rem !important; }
.tab-bar .tab { font-size: ${0.95 * scale}rem !important; }
.tab-bar select, .tab-bar button { font-size: ${0.95 * scale}rem !important; }
` });
const measureBar = () => page.evaluate(() => {
const nav = document.querySelector(".tab-bar-nav");
const actions = document.querySelector(".top-bar-actions");
const tabs = [...nav.querySelectorAll(".tab")].filter((tab) => tab.offsetParent !== null);
return {
overlap: Math.round(Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right))
- actions.getBoundingClientRect().left),
iconsOnly: nav.classList.contains("nav-icons-only"),
};
});
for (const scale of [1.0, 1.15, 1.3, 1.5, 1.75, 2.0]) {
await applyTextScale(scale);
// 1100px is the narrowest bar in the app: the bookmark gutters take 9.5rem
// a side above that width, leaving less room than 900px has.
for (const width of [1440, 1280, 1100, 900]) {
await page.setViewportSize({ width, height: 900 });
await page.waitForTimeout(120);
const crowded = await measureBar();
assert.ok(crowded.overlap <= 0,
`at ${scale}x text the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
}
}
// A station name long enough that the bar cannot hold it, which is the rung
// below icons: the identity has to give, not the strip.
await page.evaluate(() => {
document.getElementById("rig-subtitle").textContent =
"Rig: Shack SDR — RTL-SDR v4 on the attic dipole, north-west";
});
await applyTextScale(1.6);
for (const width of [1440, 1100]) {
await page.setViewportSize({ width, height: 900 });
await page.waitForTimeout(200);
const crowded = await measureBar();
assert.ok(crowded.overlap <= 0,
`with a long station name the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
}
// ...and when the text changes under a bar that is not resized. The rig name
// arrives from the server, a web font swaps in: neither is a window resize,
// and the strip used to sit there as it was.
await page.setViewportSize({ width: 1440, height: 900 });
await page.waitForTimeout(200);
await applyTextScale(2.4);
await page.waitForTimeout(400);
const unresized = await measureBar();
assert.ok(unresized.overlap <= 0,
`text grew without a resize and the strip overlaps by ${unresized.overlap}px`);
assert.equal(unresized.iconsOnly, true, "the strip kept its labels with no room for them");
// The frequency readouts are typed into, so the browser remembers what went
// in and offers it back in a dropdown over the reading — Edge does this by
// default. Nothing here wants to be autofilled from what was tuned last week.
const autofill = await page.evaluate(() => ["freq", "center-freq"].map((id) => {
const input = document.getElementById(id);
return { id, autocomplete: input?.getAttribute("autocomplete") ?? null };
}));
for (const field of autofill) {
assert.equal(field.autocomplete, "off",
`#${field.id} offers autofill (autocomplete=${field.autocomplete})`);
}
} finally {
await browser.close();
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await fixture.close();
}
@@ -4,7 +4,7 @@
import { build } from "esbuild";
export async function bundleEntry(entryUrl) {
export async function bundleEntry(entryUrl, globalName) {
const result = await build({
entryPoints: [entryUrl.pathname],
bundle: true,
@@ -12,6 +12,7 @@ export async function bundleEntry(entryUrl) {
platform: "browser",
target: "es2022",
write: false,
...(globalName ? { globalName } : {}),
});
const output = result.outputFiles[0];
if (!output) throw new Error(`No bundle output for ${entryUrl.pathname}`);
@@ -0,0 +1,334 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// What happens to a decode after it arrives: the panel on its tab, the mini
// view over the waterfall, the marker on the map, and the link between them.
// Nothing exercised this before — the fixture served an empty decode stream —
// which is how the map links came to be broken for every decoder at once.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, getComputedStyle, window, location, requestAnimationFrame, MutationObserver */
const VESSEL = {
type: "ais", mmsi: 244660000, lat: 52.37, lon: 4.89, vessel_name: "NEDERLAND",
callsign: "PBTX", sog_knots: 8.2, cog_deg: 91, channel: "A", message_type: 1, rig_id: "rig-a",
};
const BEACON = {
type: "aprs", src_call: "SP2SJG-9", dest_call: "APRS", path: "WIDE1-1", info: "Test beacon",
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
// 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 { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.setViewportSize({ width: 1500, height: 950 });
await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
await page.waitForTimeout(2000);
// The decoders with panels on this tab have to load with it. They used to
// come only with the map group, so these panels stayed empty — decodes
// queued in the plugin runtime — until something opened the Map tab.
const panels = await page.evaluate(() => ({
ais: document.getElementById("ais-messages")?.children.length ?? 0,
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
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})`);
// The mini view rides over the waterfall on the radio page.
await page.locator('.tab[data-tab="main"]').click();
await page.waitForTimeout(1500);
const miniView = await page.evaluate(() => {
const bar = document.getElementById("ais-bar-overlay");
return {
shown: getComputedStyle(bar).display !== "none",
pins: bar.querySelectorAll(".aprs-bar-pin").length,
names: bar.textContent.includes("NEDERLAND"),
};
});
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");
// 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
// been loaded, so the pin did nothing at all.
await page.locator("#ais-bar-overlay .aprs-bar-pin").first().click();
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await page.waitForTimeout(800);
const followed = await page.evaluate(() => {
const centre = window.trx.modules.map?.aprsMap?.getCenter?.();
return {
path: location.pathname,
lat: centre ? Number(centre.lat.toFixed(2)) : null,
lon: centre ? Number(centre.lng.toFixed(2)) : null,
};
});
assert.equal(followed.path, "/map", `the pin left the page on ${followed.path}`);
assert.equal(followed.lat, VESSEL.lat, `the map centred on ${followed.lat}, not the vessel`);
assert.equal(followed.lon, VESSEL.lon, `the map centred on ${followed.lon}, not the vessel`);
// Both decoders put their own marker on it.
await page.waitForTimeout(1200);
const markers = await page.evaluate(() => {
const map = window.trx.modules.map;
const size = (collection) => (collection instanceof Map
? collection.size
: 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");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
// Stored history, which is what is on screen a second after a page load. The
// endpoint answers in CBOR, so the fixture speaks CBOR: serving anything else
// left the client on its retry path and the history path untested.
const HISTORY_AIS = 900;
const HISTORY_APRS = 300;
const historyFixture = await startWebFixture({
spectrum: true,
mode: "AIS",
history: {
ais: Array.from({ length: HISTORY_AIS }, (_, index) => ({
mmsi: 244660000 + index, lat: 52.3 + index * 0.001, lon: 4.8,
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
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,
lat: 54.3 + (index % 15) * 0.01, lon: 18.6, rig_id: "rig-a",
ts_ms: Date.now() - (index + 1) * 1000,
})),
},
});
const replay = await startBrowser(chromium);
// Installed before the page's own scripts, so nothing can be missed: every
// time the progress element becomes visible, its geometry is recorded.
await replay.page.addInitScript(() => {
window.__historyProgressSamples = [];
const watch = () => {
const element = document.getElementById("decode-history-overlay");
if (!element) { requestAnimationFrame(watch); return; }
const sample = () => {
if (element.classList.contains("is-hidden")) return;
const rect = element.getBoundingClientRect();
window.__historyProgressSamples.push({
width: Math.round(rect.width),
coversCentre: document.elementFromPoint(700, 450)?.id === "decode-history-overlay",
});
};
new MutationObserver(sample).observe(element, { attributes: true, attributeFilter: ["class"] });
sample();
};
watch();
});
try {
await replay.page.setViewportSize({ width: 1400, height: 900 });
await replay.page.goto(`${historyFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
await replay.page.locator("#tab-digital-modes").waitFor({ state: "visible" });
// While it loads, the operator can still see the radio. This used to be a
// full-screen scrim over everything for as long as the replay ran.
//
// Watched from inside the page rather than polled from here: a fast replay
// can start and finish between two polls, and then the test reports that no
// progress was ever shown when what happened is that it blinked.
const shown = await replay.page.evaluate(() => window.__historyProgressSamples ?? []);
assert.ok(shown.length > 0, "no progress was shown while the history loaded");
for (const sample of shown) {
assert.ok(sample.width < 700, `the progress covers ${sample.width}px of a 1400px page`);
assert.equal(sample.coversCentre, false, "the progress sits over the page");
}
// And all of it arrives, on the first load.
await replay.page.waitForTimeout(2000);
const restored = await replay.page.evaluate(() => ({
ais: document.getElementById("ais-messages")?.children.length ?? 0,
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
progressHidden: document.getElementById("decode-history-overlay").classList.contains("is-hidden"),
}));
assert.equal(restored.ais, HISTORY_AIS, `restored ${restored.ais} of ${HISTORY_AIS} AIS records`);
assert.equal(restored.aprs, HISTORY_APRS, `restored ${restored.aprs} of ${HISTORY_APRS} APRS records`);
assert.equal(restored.progressHidden, true, "the progress stayed up after the replay finished");
// Opening the map for the first time has to show the stored history too.
// The map module is lazy, so at the moment the history was restored its
// aprsMapAddStation/aisMapAddVessel hooks did not exist yet and every
// position was dropped. Nothing replayed them when the module finally
// arrived, so the map came up empty and only filled in from decodes heard
// afterwards -- a station heard once was never plotted at all, and it took a
// second reload (module cached, so loaded early enough to beat the history
// fetch) before the map showed anything.
//
// This fixture serves no live decode stream on purpose: with one, fresh
// frames arriving after the module loads would paper over the whole thing.
const mapLoadedDuringReplay = await replay.page.evaluate(() => !!window.trx.modules.map);
assert.equal(mapLoadedDuringReplay, false, "the map was already loaded, so this proves nothing");
await replay.page.locator('.tab[data-tab="map"]').click();
await replay.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await replay.page.waitForTimeout(1500);
const plotted = await replay.page.evaluate(() => {
const map = window.trx.modules.map;
const size = (collection) => (collection instanceof Map
? collection.size
: Object.keys(collection ?? {}).length);
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
});
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
assert.deepEqual(replay.runtimeErrors, []);
} finally {
await replay.browser.close();
await historyFixture.close();
}
// The APRS list: one line per frame, with the information field read for the
// operator rather than shown as it arrives on the air.
const APRS_FRAMES = [
{ packet_type: "weather", info: "_10090556c220s004g005t077r000p000P000h50b09900" },
{ packet_type: "message", info: ":SP2SJG-9 :Hello from the field{01" },
{ packet_type: "telemetry", info: "T#005,199,000,255,073,123,01101001" },
{ packet_type: "position", info: "!5421.30N/01839.20E>Test beacon 73", lat: 54.35, lon: 18.65 },
].map((frame, index) => ({
src_call: `SP2SJG-${index}`, dest_call: "APRS", path: "WIDE1-1", crc_ok: true,
// Only position reports carry a symbol here, which is the case the columns
// have to survive: a frame without one used to close the gap and shift
// everything after it left.
...(frame.packet_type === "position" ? { symbol_table: "/", symbol_code: ">" } : {}),
rig_id: "rig-a", ts_ms: Date.now() - index * 1000,
...frame,
}));
// The AIS list is the same shape: a line per message, saying where the vessel
// is and what it is doing, with the identifiers behind it.
const AIS_MESSAGES = [
{ message_type: 1, mmsi: 244660001, vessel_name: "NEDERLAND", channel: "A",
lat: 54.35, lon: 18.65, sog_knots: 8.2, cog_deg: 91.4 },
{ message_type: 5, mmsi: 244660002, vessel_name: "STENA SPIRIT", channel: "B",
callsign: "PBTX", destination: "GDANSK" },
].map((message, index) => ({ rig_id: "rig-a", ts_ms: Date.now() - index * 1000, ...message }));
const aprsFixture = await startWebFixture({
spectrum: true,
mode: "AIS",
history: { aprs: APRS_FRAMES, ais: AIS_MESSAGES },
});
const aprs = await startBrowser(chromium);
try {
await aprs.page.setViewportSize({ width: 1400, height: 900 });
await aprs.page.goto(`${aprsFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
await aprs.page.locator("#tab-digital-modes").waitFor({ state: "visible" });
await aprs.page.waitForTimeout(2000);
await aprs.page.locator('.sub-tab[data-subtab="aprs"]').click();
await aprs.page.waitForTimeout(400);
const rows = await aprs.page.evaluate(() =>
[...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({
tag: row.tagName,
height: Math.round(row.getBoundingClientRect().height),
type: row.querySelector(".aprs-badge-type")?.textContent?.trim() ?? "",
summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "",
})));
// Newest first, so find each by the type it carries rather than by position.
const summaryOf = (type) => rows.find((row) => row.type === type)?.summary ?? "";
assert.equal(rows.length, APRS_FRAMES.length, `rendered ${rows.length} frames`);
for (const row of rows) {
assert.equal(row.tag, "DETAILS", "a frame is not expandable in place");
assert.ok(row.height < 44, `a frame is ${row.height}px tall; it used to be a card of about 140`);
}
// Each payload read rather than echoed: 25 °C from t077, the addressee from
// a message, the sequence from telemetry, the fix from a position report.
assert.match(summaryOf("Weather"), /25 °C/, `weather summary: ${summaryOf("Weather")}`);
assert.match(summaryOf("Weather"), /990\.0 hPa/, `weather summary: ${summaryOf("Weather")}`);
assert.match(summaryOf("Message"), /→ SP2SJG-9: Hello from the field/, `message summary: ${summaryOf("Message")}`);
assert.match(summaryOf("Telemetry"), /^#005/, `telemetry summary: ${summaryOf("Telemetry")}`);
assert.match(summaryOf("Position"), /54\.3500, 18\.6500/, `position summary: ${summaryOf("Position")}`);
// Frames with and without a symbol line up: the slot is held open either way.
const columns = await aprs.page.evaluate(() =>
[...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({
call: Math.round(row.querySelector(".aprs-call").getBoundingClientRect().x),
summary: Math.round(row.querySelector(".decode-line-summary").getBoundingClientRect().x),
symbol: !!row.querySelector(".aprs-symbol:not(.aprs-symbol-empty)"),
})));
assert.ok(columns.some((column) => column.symbol) && columns.some((column) => !column.symbol),
"the sample has to mix frames with and without a symbol to test this");
assert.equal(new Set(columns.map((column) => column.call)).size, 1,
`callsigns start at ${JSON.stringify(columns.map((column) => column.call))}`);
assert.equal(new Set(columns.map((column) => column.summary)).size, 1,
`summaries start at ${JSON.stringify(columns.map((column) => column.summary))}`);
// The frame as it arrived is still there, one click away.
await aprs.page.locator("#aprs-packets .aprs-packet", { hasText: "25 °C" })
.locator(".decode-line").first().click();
await aprs.page.waitForTimeout(200);
const expanded = await aprs.page.evaluate(() => {
const row = document.querySelector("#aprs-packets .aprs-packet[open]");
return {
open: row.hasAttribute("open"),
raw: row.querySelector(".decode-expanded-raw")?.textContent?.trim() ?? "",
meta: row.querySelector(".decode-expanded-meta")?.textContent ?? "",
};
});
assert.equal(expanded.open, true, "the frame did not open");
assert.match(expanded.raw, /^_10090556c220s004g005t077/, `raw frame: ${expanded.raw}`);
assert.match(expanded.meta, /WIDE1-1/, `expanded meta: ${expanded.meta}`);
// AIS, on the same row.
await aprs.page.locator('.sub-tab[data-subtab="ais"]').click();
await aprs.page.waitForTimeout(300);
const aisRows = await aprs.page.evaluate(() =>
[...document.querySelectorAll("#ais-messages .ais-message")].map((row) => ({
tag: row.tagName,
height: Math.round(row.getBoundingClientRect().height),
name: row.querySelector(".ais-call")?.textContent?.trim() ?? "",
summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "",
})));
assert.equal(aisRows.length, AIS_MESSAGES.length, `rendered ${aisRows.length} messages`);
for (const row of aisRows) {
assert.equal(row.tag, "DETAILS", "an AIS message is not expandable in place");
assert.ok(row.height < 44, `an AIS message is ${row.height}px tall`);
}
const positionRow = aisRows.find((row) => row.name === "NEDERLAND");
const staticRow = aisRows.find((row) => row.name === "STENA SPIRIT");
assert.match(positionRow?.summary ?? "", /54\.3500, 18\.6500/, `position: ${positionRow?.summary}`);
assert.match(positionRow?.summary ?? "", /8\.2 kn/, `position: ${positionRow?.summary}`);
// A static report carries no fix, so it says where the vessel is going.
assert.match(staticRow?.summary ?? "", /PBTX -> GDANSK/, `static: ${staticRow?.summary}`);
await aprs.page.locator("#ais-messages .ais-message .decode-line").first().click();
await aprs.page.waitForTimeout(200);
const aisExpanded = await aprs.page.evaluate(() =>
document.querySelector("#ais-messages .ais-message[open] .decode-expanded-meta")?.textContent ?? "");
assert.match(aisExpanded, /MMSI 2446600/, `expanded AIS: ${aisExpanded}`);
assert.match(aisExpanded, /MHz/, `expanded AIS: ${aisExpanded}`);
assert.deepEqual(aprs.runtimeErrors, []);
} finally {
await aprs.browser.close();
await aprsFixture.close();
}
@@ -57,6 +57,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
setRigFrequency: record("setRigFrequency"),
syncBandwidthInput: record("syncBandwidthInput"),
scheduleSpectrumDraw: record("scheduleSpectrumDraw"),
syncModePicker: record("syncModePicker"),
onDecoderRegistryReady: record("onDecoderRegistryReady"),
...core,
},
@@ -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();
}
@@ -17,7 +17,8 @@ async function loadRoutes(window) {
write: false,
});
const module = { exports: {} };
vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports, window });
// The sandbox is bare; the browser globals the module actually uses go in.
vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports, window, URLSearchParams });
return module.exports;
}
@@ -38,3 +39,55 @@ test("all top-level tabs have stable round-trip routes", async () => {
assert.equal(calls[0][0], "push");
assert.equal(calls[0][3], "/statistics?rig=one#panel");
});
// A tune link is what one operator sends another: it has to survive being
// typed by hand, and it has to come back out as the frequency that went in.
test("tune links parse the frequencies people write", async () => {
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
for (const [text, hz] of [
["14074000", 14_074_000], ["14074000hz", 14_074_000],
["7074k", 7_074_000], ["14.074M", 14_074_000], ["1.2G", 1_200_000_000],
[" 145500k ", 145_500_000],
]) {
assert.equal(routes.parseFrequencyParam(text), hz, `${text} did not read as ${hz} Hz`);
}
for (const bad of ["", " ", "abc", "-7074", "0", "14,074", "1e6", null, undefined]) {
assert.equal(routes.parseFrequencyParam(bad), null, `${bad} was read as a frequency`);
}
});
test("tune links carry rig, frequency, mode and bandwidth", async () => {
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
assert.deepEqual({ ...routes.parseTuneLink("?rig=sdr&f=14.074M&mode=usb&bw=3k") }, {
rig: "sdr", freqHz: 14_074_000, mode: "USB", bandwidthHz: 3000,
});
// A link with nothing to say must not tune anything.
assert.deepEqual({ ...routes.parseTuneLink("?tab=map") }, {
rig: null, freqHz: null, mode: null, bandwidthHz: null,
});
// Junk in a parameter is not a frequency, and must not become one.
assert.deepEqual({ ...routes.parseTuneLink("?f=&mode=NOTAMODE&bw=wide") }, {
rig: null, freqHz: null, mode: null, bandwidthHz: null,
});
});
test("writing a tune link canonicalises it and leaves other parameters alone", async () => {
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
const search = routes.tuneLinkSearch("?theme=dark&f=7074k", {
rig: "sdr", freqHz: 14_074_000.4, mode: "usb", bandwidthHz: 3000,
});
const params = new URLSearchParams(search);
assert.equal(params.get("theme"), "dark", "an unrelated parameter was dropped");
assert.equal(params.get("f"), "14074000", "the frequency was not written as whole Hz");
assert.equal(params.get("mode"), "USB");
assert.equal(params.get("bw"), "3000");
assert.equal(params.get("rig"), "sdr");
// Round trip: what was written reads back as what went in.
assert.deepEqual({ ...routes.parseTuneLink(search) }, {
rig: "sdr", freqHz: 14_074_000, mode: "USB", bandwidthHz: 3000,
});
// Nothing known yet means no stale tune parameters left behind.
const cleared = routes.tuneLinkSearch(search, { rig: null, freqHz: null, mode: null, bandwidthHz: null });
assert.equal(cleared, "?theme=dark");
assert.equal(routes.tuneLinkSearch("", { rig: null, freqHz: null, mode: null, bandwidthHz: null }), "");
});
@@ -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();
}
@@ -0,0 +1,436 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// Geometry of the spectrum area while tuning. Everything above the spectrum is
// driven by what happens to be in the visible range — band plan allocations,
// bookmarks — and the strips that show them used to appear and disappear with
// it, so tuning across a band edge moved the whole page under the operator's
// cursor. Nothing else in the suite serves a rig with a spectrum.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, getComputedStyle, window */
const BOOKMARKS = [
{ id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" },
{ id: "b2", name: "40m CW", freq_hz: 7030000, mode: "CW", category: "", comment: "", locator: "" },
];
const BANDPLAN = {
iaru1: {
bands: [{
name: "40m",
low_hz: 7000000,
high_hz: 7200000,
segments: [
{ low_hz: 7000000, high_hz: 7040000, mode: "CW", label: "CW" },
{ low_hz: 7040000, high_hz: 7200000, mode: "All", label: "All modes" },
],
}],
},
};
// 40m has both bookmarks and allocations; 20m has neither.
const BAND_WITH_CONTENT = 7074000;
const BAND_WITHOUT_CONTENT = 14074000;
// The meter sits well away from the spectrum's noise floor, so a squelch that
// took its level from the plot would land somewhere else entirely.
const METER_DB = -70;
const fixture = await startWebFixture({
spectrum: true,
bookmarks: BOOKMARKS,
bandplan: BANDPLAN,
bandplanEnabled: true,
meterDb: METER_DB,
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
function readGeometry() {
const top = (selector) => {
const el = document.querySelector(selector);
return el ? Math.round(el.getBoundingClientRect().top) : null;
};
const axis = document.getElementById("spectrum-bookmark-axis");
const strip = document.getElementById("spectrum-bandplan-strip");
return {
chips: axis.querySelectorAll(".spectrum-bookmark-chip").length,
axisEmpty: axis.classList.contains("bm-axis-empty"),
stripReserved: strip.classList.contains("bp-visible"),
stripEmpty: strip.classList.contains("bp-empty"),
overviewTop: top(".overview-strip"),
spectrumTop: top("#spectrum-panel"),
controlsTop: top(".controls-row"),
footerTop: top(".footer"),
docHeight: document.documentElement.scrollHeight,
};
}
const layoutOf = (geometry) => ({
overviewTop: geometry.overviewTop,
spectrumTop: geometry.spectrumTop,
controlsTop: geometry.controlsTop,
footerTop: geometry.footerTop,
docHeight: geometry.docHeight,
});
async function tuneTo(hz) {
fixture.setCenterHz(hz);
await page.waitForTimeout(900);
return page.evaluate(readGeometry);
}
try {
await page.setViewportSize({ width: 1600, height: 950 });
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
await page.locator("#content").waitFor({ state: "visible" });
await page.locator("#spectrum-panel").waitFor({ state: "visible" });
await page.waitForTimeout(1500);
const populated = await tuneTo(BAND_WITH_CONTENT);
assert.equal(populated.chips, BOOKMARKS.length, `expected both bookmarks, saw ${populated.chips}`);
assert.equal(populated.axisEmpty, false, "bookmark rail claims to be empty with chips in it");
assert.equal(populated.stripReserved, true, "band plan strip is missing on a band with allocations");
assert.equal(populated.stripEmpty, false, "band plan strip claims to be empty with segments in it");
const bare = await tuneTo(BAND_WITHOUT_CONTENT);
assert.equal(bare.chips, 0, `expected no bookmarks on ${BAND_WITHOUT_CONTENT}Hz, saw ${bare.chips}`);
assert.equal(bare.axisEmpty, true, "bookmark rail should show its placeholder");
assert.equal(bare.stripReserved, true, "band plan strip gave its height back");
assert.equal(bare.stripEmpty, true, "band plan strip should be marked empty");
// The point of both placeholders: tuning must not move anything.
assert.deepEqual(layoutOf(bare), layoutOf(populated),
`tuning off the band moved the page: ${JSON.stringify(populated)} -> ${JSON.stringify(bare)}`);
const back = await tuneTo(BAND_WITH_CONTENT);
assert.equal(back.chips, BOOKMARKS.length, "bookmarks did not come back");
assert.deepEqual(layoutOf(back), layoutOf(populated), "tuning back moved the page");
// The rail covers the top of the overview, so only the chips may take
// pointer events — the rest has to fall through to the plot behind it.
const hits = await page.evaluate(() => {
const chip = document.querySelector("#spectrum-bookmark-axis .spectrum-bookmark-chip");
const chipRect = chip.getBoundingClientRect();
const axisRect = document.getElementById("spectrum-bookmark-axis").getBoundingClientRect();
const onChip = document.elementFromPoint(chipRect.left + chipRect.width / 2, chipRect.top + chipRect.height / 2);
const besideChip = document.elementFromPoint(axisRect.right - 30, axisRect.top + 10);
return {
chip: onChip?.closest(".spectrum-bookmark-chip") ? "chip" : (onChip?.id || onChip?.tagName),
besideChip: besideChip?.id || besideChip?.tagName,
};
});
assert.equal(hits.chip, "chip", `chip is not clickable, hit ${hits.chip}`);
assert.equal(hits.besideChip, "overview-canvas", `rail swallows events, hit ${hits.besideChip}`);
// Squelch: the threshold is in the dB the spectrum axis is labelled in, so the
// line is the control. It used to be a percentage on a slider in the audio
// row, with nothing on screen to relate the number to.
await page.locator("summary", { hasText: "Audio controls" }).click();
await page.locator("#sdr-squelch-toggle").click();
// Auto parks it just above the noise the meter reports — not above the
// spectrum's noise floor, which sits anywhere from 1 dB below to 22 dB above
// the meter depending on span, bandwidth and decimation.
await page.locator("#sdr-squelch-auto").click();
await page.waitForTimeout(400);
const auto = await page.evaluate(() => Number(document.getElementById("sdr-squelch-db").value));
assert.ok(Math.abs(auto - (METER_DB + 5)) <= 1,
`auto put the threshold at ${auto} dB with the meter at ${METER_DB} dB`);
const squelchOn = await page.evaluate(() => {
const line = document.getElementById("spectrum-squelch-line");
return {
shown: getComputedStyle(line).display !== "none",
db: Number(document.getElementById("sdr-squelch-db").value),
label: Number(document.getElementById("spectrum-squelch-label").textContent),
toggle: document.getElementById("sdr-squelch-toggle").getAttribute("aria-pressed"),
top: Math.round(line.getBoundingClientRect().top),
};
});
assert.equal(squelchOn.shown, true, "the threshold line did not appear with the squelch on");
assert.equal(squelchOn.toggle, "true", "the SQL switch did not follow the squelch state");
assert.equal(squelchOn.label, squelchOn.db, "the line and the readout disagree on the threshold");
// Dragging the line down lowers the threshold and tells the server.
const submitted = [];
page.on("request", (request) => {
if (request.url().includes("/set_sdr_squelch")) {
submitted.push(Number(new URL(request.url()).searchParams.get("threshold_db")));
}
});
const grip = await page.locator("#spectrum-squelch-grip").boundingBox();
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
await page.mouse.down();
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2 + 60, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(400);
const dragged = await page.evaluate(() => ({
db: Number(document.getElementById("sdr-squelch-db").value),
label: Number(document.getElementById("spectrum-squelch-label").textContent),
top: Math.round(document.getElementById("spectrum-squelch-line").getBoundingClientRect().top),
}));
assert.ok(dragged.db < squelchOn.db,
`dragging down left the threshold at ${dragged.db} dB (was ${squelchOn.db})`);
assert.equal(dragged.label, dragged.db, "the line label did not follow the drag");
assert.ok(dragged.top > squelchOn.top, "the line did not move with the drag");
assert.ok(submitted.includes(dragged.db),
`the server was never told about ${dragged.db} dB (saw ${JSON.stringify(submitted)})`);
// Turning it off leaves the threshold alone — the old control conflated the
// two, so dropping to zero to listen threw the setting away.
await page.locator("#sdr-squelch-toggle").click();
await page.waitForTimeout(300);
const squelchOff = await page.evaluate(() => ({
db: Number(document.getElementById("sdr-squelch-db").value),
shown: getComputedStyle(document.getElementById("spectrum-squelch-line")).display !== "none",
dot: document.getElementById("sdr-squelch-state").dataset.state,
}));
assert.equal(squelchOff.db, dragged.db, "turning the squelch off discarded the threshold");
assert.equal(squelchOff.shown, false, "the line stayed up with the squelch off");
assert.equal(squelchOff.dot, "off", "the indicator did not follow the squelch off");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
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.
const retryFixture = await startWebFixture({
spectrum: true,
bandplan: BANDPLAN,
bandplanEnabled: true,
bandplanUnauthorizedFirst: true,
});
const retry = await startBrowser(chromium);
try {
await retry.page.setViewportSize({ width: 1600, height: 950 });
await retry.page.goto(retryFixture.origin, { waitUntil: "domcontentloaded" });
await retry.page.locator("#spectrum-panel").waitFor({ state: "visible" });
await retry.page.waitForTimeout(2000);
const strip = await retry.page.evaluate(() => {
const element = document.getElementById("spectrum-bandplan-strip");
return { segments: element.children.length, empty: element.classList.contains("bp-empty") };
});
assert.ok(strip.segments > 0,
"the band plan never arrived after its first request was refused");
assert.equal(strip.empty, false, "the strip is still showing its placeholder");
} finally {
await retry.browser.close();
await retryFixture.close();
}
// The map's filter panel is a bar across the top of the map, not a window
// sitting on it: it has to stay one or two rows tall, span most of the width,
// and keep clear of what shares the map's corners — Leaflet's zoom buttons and
// the band legend. A panel that grew a column would cover the map it filters.
// Fullscreen and the filter toggle ride at the bar's right-hand end, so only
// the filters collapse: the bar itself has to survive Hide Filters, or there
// is nothing left to click to bring them back.
const mapFixture = await startWebFixture({ spectrum: true });
const mapView = await startBrowser(chromium);
try {
for (const width of [1600, 1200]) {
await mapView.page.setViewportSize({ width, height: 950 });
await mapView.page.goto(`${mapFixture.origin}/map`, { waitUntil: "domcontentloaded" });
await mapView.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await mapView.page.waitForTimeout(1200);
const bar = await mapView.page.evaluate(() => {
const box = (element) => (element ? element.getBoundingClientRect() : null);
const panel = document.querySelector(".map-overlay-panel");
const stage = box(document.getElementById("map-stage"));
const zoom = box(document.querySelector("#aprs-map .leaflet-control-zoom"));
const legend = box(document.getElementById("map-band-legend"));
const panelBox = box(panel);
const hits = (a, b) => !!a && !!b
&& a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
return {
widthPct: Math.round((panelBox.width / stage.width) * 100),
height: Math.round(panelBox.height),
outsideStage: panelBox.right > stage.right + 1 || panelBox.bottom > stage.bottom + 1,
hitsZoom: hits(panelBox, zoom),
hitsLegend: hits(panelBox, legend),
// Both controls belong to the bar now, not to a floating corner block.
actionsInBar: [...panel.querySelectorAll(".map-overlay-actions button")]
.map((button) => button.id).join(","),
// The rule dividing them from the filters is drawn on this block's
// edge, so the block has to run the height of the filters beside it —
// centred, it left the rule floating as a stub against a two-row bar.
actionsShort: Math.round(box(panel.querySelector(".map-overlay-filters")).height
- box(panel.querySelector(".map-overlay-actions")).height),
// Every label sits in a gutter of one width, so whichever group leads
// a row leads it from the same place: with the labels at their natural
// widths, a row starting "Search" began 10px off one starting "Filter".
rowStarts: (() => {
const leaders = new Map();
for (const group of panel.querySelectorAll(".map-overlay-filters .map-locator-filter-group")) {
const rect = box(group);
// Groups of a row differ in height, so bucket them by their middle.
const row = Math.round((rect.top + rect.height / 2) / 20);
if (!leaders.has(row) || rect.left < box(leaders.get(row)).left) leaders.set(row, group);
}
return [...leaders.values()].map((group) => Math.round(box(group.children[1]).left));
})(),
clipped: panel.scrollWidth > panel.clientWidth + 1 || panel.scrollHeight > panel.clientHeight + 1,
};
});
assert.ok(bar.widthPct >= 70, `the filter bar covers ${bar.widthPct}% of the map at ${width}px`);
assert.ok(bar.height <= 140, `the filter bar is ${bar.height}px tall at ${width}px, not a bar`);
assert.equal(bar.outsideStage, false, `the filter bar runs off the map at ${width}px`);
assert.equal(bar.hitsZoom, false, `the filter bar covers the zoom buttons at ${width}px`);
assert.equal(bar.actionsInBar, "map-fullscreen-btn,map-overlay-toggle-btn",
`the bar carries "${bar.actionsInBar}" at ${width}px`);
assert.ok(bar.actionsShort <= 1,
`the buttons' divider falls ${bar.actionsShort}px short of the bar at ${width}px`);
assert.equal(new Set(bar.rowStarts).size, 1,
`the bar's rows start at ${bar.rowStarts.join(", ")}px at ${width}px`);
assert.equal(bar.hitsLegend, false, `the filter bar covers the band legend at ${width}px`);
assert.equal(bar.clipped, false, `the filter bar is clipping its own controls at ${width}px`);
}
// Band chips only exist once something has been heard on a band. The bar
// used to explain "all bands visible by default" in a line of prose wedged
// between the chips and the next group, which is neither what a toolbar is
// for nor a width it can spare: an "All" chip says it and undoes a
// selection. Nothing is dimmed while nothing is filtered out, either — every
// chip used to come up greyed at the very moment all of them were showing.
await mapView.page.evaluate(() => {
const ts = Date.now();
for (const [grid, hz] of [["JO94", 14_074_000], ["JN48", 7_074_000], ["FN42", 21_074_000]]) {
window.trxPluginRuntime.dispatch("ft8",
{ message: `CQ TEST ${grid}`, grid, freq_hz: hz, snr_db: -8, ts_ms: ts, rig_id: "rig-a" });
}
});
await mapView.page.waitForTimeout(1500);
const chipRow = () => mapView.page.evaluate(() => {
const row = document.getElementById("map-locator-choice-filter");
const chips = [...row.querySelectorAll(".map-locator-chip")];
const all = row.querySelector(".map-locator-chip-all");
return {
bands: chips.filter((chip) => chip !== all).map((chip) => chip.textContent.trim()),
prose: row.querySelector(".map-locator-empty")?.textContent ?? null,
allActive: all?.classList.contains("is-active") ?? null,
dimmed: chips.filter((chip) => chip.classList.contains("is-inactive"))
.map((chip) => chip.textContent.trim()),
selected: chips.filter((chip) => chip.getAttribute("aria-pressed") === "true"
&& chip !== all).map((chip) => chip.textContent.trim()),
};
});
const unfiltered = await chipRow();
assert.ok(unfiltered.bands.includes("20m") && unfiltered.bands.includes("40m"),
`the chip row is showing ${unfiltered.bands.join(",")}`);
assert.equal(unfiltered.prose, null, `the bar is explaining itself in prose: "${unfiltered.prose}"`);
assert.equal(unfiltered.allActive, true, "All is not lit while every band is on the map");
assert.deepEqual(unfiltered.dimmed, [], `${unfiltered.dimmed.join(",")} came up dimmed with no filter set`);
await mapView.page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="20m"]').click();
await mapView.page.waitForTimeout(300);
const filtered = await chipRow();
assert.deepEqual(filtered.selected, ["20m"], `picking 20m selected ${filtered.selected.join(",")}`);
assert.equal(filtered.allActive, false, "All stayed lit with a band picked out");
assert.ok(filtered.dimmed.includes("40m"), "the bands now filtered out are not shown as such");
// And back: All is how a selection is undone without hunting for the chips
// that are in it.
await mapView.page.locator('#map-locator-choice-filter .map-locator-chip-all').click();
await mapView.page.waitForTimeout(300);
const cleared = await chipRow();
assert.equal(cleared.allActive, true, "All did not clear the band selection");
assert.deepEqual(cleared.selected, [], `${cleared.selected.join(",")} survived All`);
assert.deepEqual(cleared.dimmed, [], `${cleared.dimmed.join(",")} stayed dimmed after All`);
// Hiding gives the map back, but leaves the bar itself — collapsed to its
// two controls at the right-hand edge — so the filters can be brought back.
await mapView.page.locator("#map-overlay-toggle-btn").click();
await mapView.page.waitForTimeout(400);
const toggled = await mapView.page.evaluate(() => {
const panel = document.querySelector(".map-overlay-panel");
const stage = document.getElementById("map-stage").getBoundingClientRect();
const panelBox = panel.getBoundingClientRect();
const visible = (id) => document.getElementById(id).getBoundingClientRect().width > 0;
return {
filtersHidden: panel.querySelector(".map-overlay-filters").classList.contains("is-hidden"),
widthPct: Math.round((panelBox.width / stage.width) * 100),
rightGap: Math.round(stage.right - panelBox.right),
label: document.getElementById("map-overlay-toggle-btn").textContent.trim(),
togglesVisible: visible("map-fullscreen-btn") && visible("map-overlay-toggle-btn"),
};
});
assert.equal(toggled.filtersHidden, true, "the filters stayed up after Hide Filters");
assert.equal(toggled.togglesVisible, true, "Hide Filters took its own button down with it");
assert.ok(toggled.widthPct < 30, `the collapsed bar still covers ${toggled.widthPct}% of the map`);
assert.ok(toggled.rightGap < 30, `the collapsed bar sits ${toggled.rightGap}px from the map's edge`);
assert.equal(toggled.label, "Show Filters", `the toggle still reads "${toggled.label}"`);
await mapView.page.locator("#map-overlay-toggle-btn").click();
await mapView.page.waitForTimeout(400);
const restored = await mapView.page.evaluate(() =>
!document.querySelector(".map-overlay-filters").classList.contains("is-hidden"));
assert.equal(restored, true, "Show Filters did not bring the filters back");
assert.deepEqual(mapView.runtimeErrors, []);
} finally {
await mapView.browser.close();
await mapFixture.close();
}
@@ -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");
});
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// A link is how one operator tells another where to listen. Opening one has to
// put the radio there, and the address bar has to keep up with the dial after
// that — a link that goes stale the moment someone tunes is worse than none,
// because it looks like it works.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, window */
const LINK_HZ = 14_074_000;
const LINK_BW = 2_800;
const TUNED_HZ = 7_040_000;
const fixture = await startWebFixture({ spectrum: true });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
const dial = () => page.evaluate(() => ({
freqHz: window.lastFreqHz,
mode: document.getElementById("mode").value,
search: window.location.search,
path: window.location.pathname,
}));
try {
await page.setViewportSize({ width: 1500, height: 950 });
// Opening the link tunes the radio: frequency, mode and bandwidth.
await page.goto(`${fixture.origin}/?f=14.074M&mode=USB&bw=${LINK_BW}`, { waitUntil: "domcontentloaded" });
await page.locator("#content").waitFor({ state: "visible" });
await page.waitForTimeout(2500);
const opened = await dial();
assert.equal(opened.freqHz, LINK_HZ, `the link left the radio on ${opened.freqHz} Hz`);
assert.equal(opened.mode, "USB", `the link left the radio in ${opened.mode}`);
const bandwidth = await page.evaluate(() => window.currentBandwidthHz);
assert.equal(bandwidth, LINK_BW, `the link left the bandwidth at ${bandwidth} Hz`);
// Hand-written frequencies are canonicalised in place, so what the operator
// copies back out is the same link in the form the app writes.
const params = new URLSearchParams(opened.search);
assert.equal(params.get("f"), String(LINK_HZ), `the URL kept "${params.get("f")}"`);
assert.equal(params.get("mode"), "USB");
// 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 page.waitForTimeout(1500);
const tuned = await dial();
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
assert.equal(new URLSearchParams(tuned.search).get("f"), String(TUNED_HZ),
`the URL still reads "${tuned.search}" after tuning`);
// 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 page.waitForTimeout(1200);
assert.equal(await page.evaluate(() => window.history.length), historyLength,
"tuning pushed a history entry instead of replacing one");
// Moving between tabs keeps the tune parameters: the path changes, the link
// does not stop being a link.
await page.locator('.tab[data-tab="map"]').click();
await page.waitForTimeout(800);
const onMap = await dial();
assert.equal(onMap.path, "/map", `the map tab landed on ${onMap.path}`);
assert.equal(new URLSearchParams(onMap.search).get("f"), "7100000",
`the map tab dropped the tune parameters: "${onMap.search}"`);
// A link naming a rig selects it before applying the rest.
await page.goto(`${fixture.origin}/?rig=rig-b&f=${TUNED_HZ}&mode=CW`, { waitUntil: "domcontentloaded" });
await page.locator("#content").waitFor({ state: "visible" });
await page.waitForTimeout(2500);
const switched = await page.evaluate(() => ({
rig: document.getElementById("header-rig-switch-select")?.value ?? null,
selected: window.location.search,
mode: document.getElementById("mode").value,
}));
assert.equal(switched.rig, "rig-b", `the link left the client on rig "${switched.rig}"`);
assert.equal(switched.mode, "CW", `the link left the radio in ${switched.mode}`);
// A link with nothing to say tunes nothing, and the address bar fills in
// with where the radio already is.
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
await page.locator("#content").waitFor({ state: "visible" });
await page.waitForTimeout(2000);
const bare = await dial();
assert.equal(new URLSearchParams(bare.search).get("f"), String(bare.freqHz),
`a bare load wrote "${bare.search}" for a radio on ${bare.freqHz} Hz`);
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
@@ -0,0 +1,431 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The static server the browser tests load the real web assets from. It was
// inline in browser-smoke.mjs until a second browser test needed a rig with a
// spectrum: the interesting layout lives in the spectrum area, and none of it
// could be exercised while the only fixture served a CAT-only rig.
import { readFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const webDir = path.resolve(frontendDir, "../assets/web");
const generatedDir = path.join(webDir, "generated");
// A realistic decoder registry. Serving an empty one hid most of the
// application from this test: the decoder sub-tabs, their panels, the decode
// toggles and the bookmark decoder checkboxes are all built from it, so with
// no decoders only three of thirteen sub-tabs existed and none of the decoder
// UI was ever constructed.
const DECODER_REGISTRY = [
{ id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"] },
{ id: "ft4", label: "FT4", activation: "toggle", active_modes: ["USB"] },
{ id: "ft2", label: "FT2", activation: "toggle", active_modes: ["USB"] },
{ id: "wspr", label: "WSPR", activation: "toggle", active_modes: ["USB"] },
{ 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 }));
const CONTENT_TYPES = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "application/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".woff2", "font/woff2"],
]);
// The history endpoint answers in CBOR (see api/decoder.rs), and the worker
// that reads it takes the body as CBOR unconditionally. Serving JSON here left
// every run exercising the client's retry path instead of its history path.
function encodeCbor(value) {
const chunks = [];
const head = (major, length) => {
if (length < 24) return Buffer.from([(major << 5) | length]);
if (length < 0x100) return Buffer.from([(major << 5) | 24, length]);
if (length < 0x10000) {
const buffer = Buffer.alloc(3);
buffer[0] = (major << 5) | 25;
buffer.writeUInt16BE(length, 1);
return buffer;
}
if (length < 0x1_0000_0000) {
const buffer = Buffer.alloc(5);
buffer[0] = (major << 5) | 26;
buffer.writeUInt32BE(length, 1);
return buffer;
}
// Timestamps are past 2^32 milliseconds, so the 64-bit form is needed.
const buffer = Buffer.alloc(9);
buffer[0] = (major << 5) | 27;
buffer.writeBigUInt64BE(BigInt(length), 1);
return buffer;
};
const write = (item) => {
if (item === null || item === undefined) { chunks.push(Buffer.from([0xf6])); return; }
if (typeof item === "boolean") { chunks.push(Buffer.from([item ? 0xf5 : 0xf4])); return; }
if (typeof item === "number") {
if (Number.isInteger(item) && item >= 0) { chunks.push(head(0, item)); return; }
if (Number.isInteger(item) && item < 0) { chunks.push(head(1, -item - 1)); return; }
const buffer = Buffer.alloc(9);
buffer[0] = 0xfb;
buffer.writeDoubleBE(item, 1);
chunks.push(buffer);
return;
}
if (typeof item === "string") {
const bytes = Buffer.from(item, "utf8");
chunks.push(head(3, bytes.length), bytes);
return;
}
if (Array.isArray(item)) {
chunks.push(head(4, item.length));
item.forEach(write);
return;
}
const entries = Object.entries(item);
chunks.push(head(5, entries.length));
for (const [key, entryValue] of entries) {
const keyBytes = Buffer.from(key, "utf8");
chunks.push(head(3, keyBytes.length), keyBytes);
write(entryValue);
}
};
write(value);
return Buffer.concat(chunks);
}
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),
// so a deep link or a refresh serves the SPA shell, not a 404.
if (!path.extname(urlPath)) return path.join(webDir, "index.html");
if (urlPath.startsWith("/vendor/")) return path.join(webDir, urlPath);
const generated = path.join(generatedDir, path.basename(urlPath));
if (urlPath.endsWith(".js")) return generated;
return path.join(webDir, urlPath);
}
/**
* Starts the fixture server.
*
* `spectrum` turns the rig into an SDR: `filter_controls` is what gates the
* spectrum panel, and frames are pushed on the /spectrum stream from a centre
* frequency the test moves with `setCenterHz` to simulate tuning across bands.
*/
export async function startWebFixture({
spectrum = false,
tx = false,
meterDb = -70,
decodes = [],
mode = "FM",
history = {},
bookmarks = [],
bandplan = {},
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
satPasses = null,
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
display_name: remote === "rig-a" ? "Primary fixture" : "Secondary fixture",
manufacturer: "Smoke",
model: "Fixture",
supported_modes: ["FM"],
tx,
filter_controls: spectrum,
initialized: true,
latitude: null,
longitude: null,
}));
const rigsResponse = { rigs: rigItems, active_remote: "rig-a" };
const selectedRigs = [];
const state = { centerHz: 7074000 };
let bandplanServed = false;
const status = {
info: {
manufacturer: "Smoke",
model: "Fixture",
revision: "1",
access: { Tcp: { addr: "127.0.0.1:0" } },
capabilities: {
min_freq_step_hz: 1,
supported_bands: [],
supported_modes: ["LSB", "USB", "CW", "CWR", "AM", "SAM", "WFM", "FM", "AIS", "VDES", "DIG", "PKT"],
num_vfos: 1,
lock: false,
lockable: tx,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx,
tx_limit: tx,
vfo_switch: false,
filter_controls: spectrum,
signal_meter: spectrum,
},
},
status: { freq: { hz: 100_000_000 }, mode, tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null },
// Reported only by SDR backends, and what makes the client show the
// squelch control at all.
filter: spectrum
? {
bandwidth_hz: 12_000,
sdr_squelch_enabled: false,
sdr_squelch_threshold_db: -95,
sdr_agc_enabled: false,
}
: null,
band: null,
enabled: true,
initialized: true,
cw_auto: false,
cw_wpm: 20,
cw_tone_hz: 700,
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
ft4_decode_enabled: false,
ft2_decode_enabled: false,
wspr_decode_enabled: false,
lrpt_decode_enabled: false,
wefax_decode_enabled: false,
sstv_decode_enabled: false,
recorder_enabled: false,
clients: 1,
rigctl_clients: 0,
audio_clients: 0,
active_remote: "rig-a",
remotes: ["rig-a", "rig-b"],
show_sdr_gain_control: false,
initial_map_zoom: 10,
spectrum_coverage_margin_hz: 50_000,
spectrum_usable_span_ratio: 0.92,
bandplan_enabled: bandplanEnabled,
bandplan_region: "iaru1",
decode_history_retention_min: 1440,
server_connected: true,
};
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse],
["/status", status],
["/bookmarks", bookmarks],
["/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
// page has a spectrum range to place bookmarks and allocations against.
const spectrumBins = Buffer.alloc(512, 200);
const spectrumB64 = spectrumBins.toString("base64");
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
// Setting a control means the next status carries the new value, the way a
// real server echoes what it applied.
if (url.pathname === "/set_sdr_squelch") {
const enabled = url.searchParams.get("enabled") === "true";
const threshold = Number(url.searchParams.get("threshold_db"));
if (status.filter) {
status.filter.sdr_squelch_enabled = enabled;
if (Number.isFinite(threshold)) status.filter.sdr_squelch_threshold_db = threshold;
}
response.writeHead(200).end();
return;
}
if (url.pathname === "/set_freq") {
const hz = Number(url.searchParams.get("hz"));
if (Number.isFinite(hz) && hz > 0) status.status.freq = { hz: Math.round(hz) };
response.writeHead(200).end();
return;
}
if (url.pathname === "/set_mode") {
const next = (url.searchParams.get("mode") || "").toUpperCase();
if (next) status.status.mode = next;
response.writeHead(200).end();
return;
}
if (url.pathname === "/set_bandwidth") {
const hz = Number(url.searchParams.get("hz"));
if (Number.isFinite(hz) && hz > 0 && status.filter) status.filter.bandwidth_hz = Math.round(hz);
response.writeHead(200).end();
return;
}
if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote");
if (remote) {
rigsResponse.active_remote = remote;
status.active_remote = remote;
selectedRigs.push(remote);
}
response.writeHead(200).end();
return;
}
// Rejects the first band plan request the way the server did before it was
// classified as a public asset: the page asks for it at startup, which can
// land before the session exists.
if (bandplanUnauthorizedFirst && url.pathname === "/bandplan.json" && !bandplanServed) {
bandplanServed = true;
response.writeHead(401).end();
return;
}
if (jsonRoutes.has(url.pathname)) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(jsonRoutes.get(url.pathname)));
return;
}
// 200 means "audio is configured": the client hides the whole audio row —
// and the squelch control with it — when this 404s.
if (url.pathname === "/audio") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ sample_rate: 48_000, channels: 1 }));
return;
}
if (spectrum && url.pathname === "/spectrum") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
const timer = setInterval(() => {
response.write(`event: b\ndata: ${state.centerHz},192000,${spectrumB64}\n\n`);
}, 100);
request.on("close", () => clearInterval(timer));
return;
}
// The meter streams like the server's does: the squelch reads its noise
// level from here, so a static snapshot would leave it nothing to measure.
if (url.pathname === "/meter") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
const timer = setInterval(() => {
response.write(`data: ${JSON.stringify({ sig: meterDb })}\n\n`);
}, 120);
request.on("close", () => clearInterval(timer));
return;
}
// Decodes arrive on this stream in the server's own shape: a routing
// "type" naming the decoder, snake_case fields inside. The mini views, the
// map markers and the history all hang off it, and serving nothing left
// every one of them untested.
if (url.pathname === "/decode/history") {
const payload = Object.fromEntries(HISTORY_GROUPS.map((group) => [group, history[group] ?? []]));
response.writeHead(200, { "content-type": "application/cbor" });
response.end(encodeCbor(payload));
return;
}
if (url.pathname === "/decode") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
response.write(": decode stream\n\n");
// Repeats: a live decoder keeps producing, and the views that collapse
// by station or vessel need more than one frame to behave like they do
// in front of a radio.
let sent = 0;
const timer = setInterval(() => {
if (!decodes.length) return;
const decode = decodes[sent++ % decodes.length];
// Stamped as they leave: the client prunes anything older than the
// retention window, so a fixed epoch would be dropped on arrival.
response.write(`data: ${JSON.stringify({ ts_ms: Date.now(), ...decode })}\n\n`);
}, 400);
request.on("close", () => clearInterval(timer));
return;
}
// The real server pushes rig state here every second or so; serving an
// open-but-silent stream meant nothing in the client's state-update path
// was ever exercised.
if (url.pathname === "/events") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
// Varying, as a real one is: the client skips a frame identical to the
// last, so a repeated payload exercises none of the state-update path.
// The variation rides on the S-meter rather than the dial — a rig that
// wandered a kilohertz every frame could never be told to tune.
const frame = () => JSON.stringify({
...status,
status: { ...status.status, rx: { ...status.status.rx, sig: meterDb + ((Date.now() % 3) - 1) } },
});
response.write(`data: ${frame()}\n\n`);
const timer = setInterval(() => {
response.write(`data: ${frame()}\n\n`);
}, 700);
request.on("close", () => clearInterval(timer));
return;
}
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
response.write(": browser smoke stream\n\n");
return;
}
try {
const file = assetPath(url.pathname);
const bytes = await readFile(file);
response.writeHead(200, {
"content-type": CONTENT_TYPES.get(path.extname(file)) ?? "application/octet-stream",
});
response.end(bytes);
} catch {
response.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address !== "object") throw new Error("fixture server has no port");
return {
origin: `http://127.0.0.1:${address.port}`,
selectedRigs,
rigsResponse,
/** Moves the spectrum centre, i.e. tunes the fixture rig to another band. */
setCenterHz(hz) { state.centerHz = hz; },
close() {
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
},
};
}
/** Launches headless Chromium and records any uncaught page error. */
export async function startBrowser(chromium) {
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
?? "/usr/bin/chromium";
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
const page = await browser.newPage();
const runtimeErrors = [];
page.on("pageerror", (error) => runtimeErrors.push(error.stack ?? error.message));
return { browser, page, runtimeErrors };
}
@@ -95,6 +95,24 @@ pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[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)
}
#[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)
}
#[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)
}
// ---------------------------------------------------------------------------
// Favicon & logo
// ---------------------------------------------------------------------------
@@ -163,21 +181,34 @@ pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path<String>) -
static_asset_response(&req, content_type, entry)
}
/// 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"))
@@ -267,6 +298,47 @@ pub(crate) async fn leaflet_layers_2x() -> impl Responder {
.body(status::LEAFLET_LAYERS_2X)
}
// ---------------------------------------------------------------------------
// Vendored APRS symbol sprites
// ---------------------------------------------------------------------------
fn embedded_png(bytes: &'static [u8]) -> HttpResponse {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
.body(bytes)
}
#[get("/vendor/aprs-symbols-24-0.png")]
pub(crate) async fn aprs_symbols_primary() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_PRIMARY)
}
#[get("/vendor/aprs-symbols-24-0-2x.png")]
pub(crate) async fn aprs_symbols_primary_2x() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_PRIMARY_2X)
}
#[get("/vendor/aprs-symbols-24-1.png")]
pub(crate) async fn aprs_symbols_alternate() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_ALTERNATE)
}
#[get("/vendor/aprs-symbols-24-1-2x.png")]
pub(crate) async fn aprs_symbols_alternate_2x() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_ALTERNATE_2X)
}
#[get("/vendor/aprs-symbols-24-2.png")]
pub(crate) async fn aprs_symbols_overlay() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_OVERLAY)
}
#[get("/vendor/aprs-symbols-24-2-2x.png")]
pub(crate) async fn aprs_symbols_overlay_2x() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_OVERLAY_2X)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -295,4 +367,41 @@ mod tests {
);
assert_eq!(generated_content_type("secret.txt"), None);
}
/// Reads the width and height out of a PNG IHDR chunk.
fn png_dimensions(bytes: &[u8]) -> (u32, u32) {
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
assert_eq!(&bytes[12..16], b"IHDR", "first chunk is not IHDR");
let width = u32::from_be_bytes(bytes[16..20].try_into().expect("IHDR width"));
let height = u32::from_be_bytes(bytes[20..24].try_into().expect("IHDR height"));
(width, height)
}
/// The browser computes sprite cell offsets from a 16x6 grid of 24px cells,
/// so a sheet at any other size would silently shift every APRS symbol.
#[test]
fn aprs_symbol_sheets_match_the_sprite_grid_the_frontend_assumes() {
for (name, bytes) in [
("aprs-symbols-24-0", status::APRS_SYMBOLS_PRIMARY),
("aprs-symbols-24-1", status::APRS_SYMBOLS_ALTERNATE),
("aprs-symbols-24-2", status::APRS_SYMBOLS_OVERLAY),
] {
assert_eq!(
png_dimensions(bytes),
(384, 144),
"{name} is not a 16x6 grid"
);
}
for (name, bytes) in [
("aprs-symbols-24-0-2x", status::APRS_SYMBOLS_PRIMARY_2X),
("aprs-symbols-24-1-2x", status::APRS_SYMBOLS_ALTERNATE_2X),
("aprs-symbols-24-2-2x", status::APRS_SYMBOLS_OVERLAY_2X),
] {
assert_eq!(
png_dimensions(bytes),
(768, 288),
"{name} is not a 2x sheet"
);
}
}
}
@@ -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>,
@@ -591,6 +591,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 +603,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,12 +639,16 @@ 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)
.service(assets::favicon_png)
.service(assets::logo)
.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)
@@ -656,6 +662,13 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::leaflet_marker_shadow)
.service(assets::leaflet_layers)
.service(assets::leaflet_layers_2x)
// Vendored APRS symbol sprites
.service(assets::aprs_symbols_primary)
.service(assets::aprs_symbols_primary_2x)
.service(assets::aprs_symbols_alternate)
.service(assets::aprs_symbols_alternate_2x)
.service(assets::aprs_symbols_overlay)
.service(assets::aprs_symbols_overlay_2x)
.service(assets::generated_asset)
// Virtual channels
.service(vchan::list_channels)
@@ -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(_) => {}
},
@@ -504,7 +504,14 @@ impl RouteAccess {
return Self::Public;
}
// Static assets
// Static assets. The band plan is one of them: it is compiled into the
// binary and identical for every user, but ".json" is not an asset
// suffix, so it used to fall through to Control — leaving read-only
// users without a band plan, and everyone else without one whenever the
// page requested it before the session was established.
if path == "/bandplan.json" {
return Self::Public;
}
if path.starts_with("/style.css")
|| path.starts_with("/app.js")
|| path.ends_with(".js")
@@ -696,6 +703,12 @@ mod tests {
assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/style.css"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/app.js"), RouteAccess::Public);
// Static reference data, served to every role: ".json" is not in the
// asset suffix list, so this one has to be named.
assert_eq!(
RouteAccess::from_path("/bandplan.json"),
RouteAccess::Public
);
}
#[test]
@@ -37,6 +37,23 @@ pub const LEAFLET_MARKER_SHADOW: &[u8] = include_bytes!("../assets/web/vendor/ma
pub const LEAFLET_LAYERS: &[u8] = include_bytes!("../assets/web/vendor/layers.png");
pub const LEAFLET_LAYERS_2X: &[u8] = include_bytes!("../assets/web/vendor/layers-2x.png");
// Vendored APRS symbol sprites (https://github.com/hessu/aprs-symbols).
// Each sheet is a 16x6 grid of 24px cells indexed by `symbol code - 0x21`:
// table 0 is the primary ('/') set, table 1 the alternate ('\') set, and
// table 2 the overlay characters drawn on top of an alternate symbol.
pub const APRS_SYMBOLS_PRIMARY: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-0.png");
pub const APRS_SYMBOLS_PRIMARY_2X: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-0-2x.png");
pub const APRS_SYMBOLS_ALTERNATE: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-1.png");
pub const APRS_SYMBOLS_ALTERNATE_2X: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-1-2x.png");
pub const APRS_SYMBOLS_OVERLAY: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-2.png");
pub const APRS_SYMBOLS_OVERLAY_2X: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-2-2x.png");
/// Build version tag used for cache-busting asset URLs and ETag headers.
/// Computed once from `PKG_VERSION` + `CLIENT_BUILD_DATE`.
pub fn build_version_tag() -> &'static str {
+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());

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