Compare commits

...
Author SHA1 Message Date
sjg 0b1fb005f6 [fix](trx-rs): install binaries under user home
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Default installation and removal to /home/sjg/.local/bin while preserving explicit prefix and binary-directory overrides. Update the service examples and README to match.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 23:08:08 +02:00
sjg be9d5c301b [docs](trx-rs): add safe deployment guide
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Document deployment with a dedicated service account, restricted device access, authenticated network listeners, systemd user services, reverse proxying, verification, upgrades, and rollback.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:53:00 +02:00
sjg b73c97dd5b [fix](trx-server): honor per-rig audio bind addresses
Use each rig's configured audio listener unless --listen explicitly overrides all bind addresses. Keep preflight socket validation consistent with runtime behavior.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:45:33 +02:00
sjg 6276c3feea [fix](trx-rs): allow systemd hardware discovery
Do not restrict socket families in the generic user units because SoapySDR and libusb require netlink sockets to enumerate radio hardware.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:44:58 +02:00
sjg 110c0e1d49 [fix](trx-rs): correct systemd network ordering
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Let the client wait for an enabled local server without activating a disabled one, and declare the socket families required by both services for configured listeners and remote connections.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:35:54 +02:00
sjg 05c337b581 [feat](trx-rs): add build/install script and systemd user services
CI / lint (pull_request) Canceled after 0s
CI / test (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / reuse (pull_request) Canceled after 0s
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Provide a one-shot installer and matching systemd *user* units so the
server and client can be built, installed system-wide, and run in the
background without hand-rolled steps.

- script/install.sh: builds all three binaries (trx-server, trx-client,
  trx-configurator) in release mode and installs them to /usr/local/bin
  (configurable via --prefix/--bindir/PREFIX, sudo only when the target
  is not writable). Seeds ~/.config/trx-rs/trx-rs.toml from the example
  without ever overwriting existing config, then installs the user units
  with @BINDIR@ substituted to the real path and runs daemon-reload.
  Flags: --no-sdr, --no-build, --no-systemd, --enable-now.
- script/uninstall.sh: stops/disables the units, removes them and the
  binaries; keeps config unless --purge.
- packaging/systemd/{trx-server,trx-client}.service: user units reading
  the combined config at ~/.config/trx-rs/trx-rs.toml. The client softly
  depends on the server (Wants/After). KillSignal=SIGINT matches how the
  binaries shut down cleanly (SIGTERM is not handled).
- README: new "Install (optional, Linux + systemd)" section.

The web UI assets are embedded in the binary, so an install needs only
the binaries plus a config file — no data directory to ship.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 23:01:45 +02:00
sjg c10b5faef4 [feat](trx-rs): redesign SDR noise blanker with tuning profiles
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
The old IQ noise blanker tracked a fast running RMS and, on a
threshold crossing, replaced the sample with the last clean one. That
hard sample-and-hold is a step discontinuity: it splatters energy back
across the wideband passband, so after the narrow channel filter it
often sounded worse than the noise it removed — especially on SSB, CW
and digital. It also had no look-ahead (the impulse leading edge leaked
through before the fast RMS reacted), blanked only single samples, and
used a fixed 1/128 time constant that did not scale with capture rate.

Redesign the blanker around accepted wideband-NB practice and add
profiles matched to the interference source:

- Noise-floor tracker updated only from clean samples and frozen while
  blanking, so a burst cannot desensitise detection.
- Detection on instantaneous power vs threshold² × noise floor.
- Look-ahead delay line so the gate closes *before* the impulse reaches
  the output, removing the leading edge.
- Raised-cosine tapered gate (ramp 1→0→1) instead of a hard hold, which
  minimises blanker splatter.
- Windowed blanking that re-arms on every detected sample to cover the
  full width of a burst.
- All timings expressed in real time and converted to samples at the
  capture rate, so behaviour is consistent across SDR sample rates.

Profiles (`NoiseBlankerProfile`, default `spike`): spike, ignition,
powerline, broadband — each selects the blank window, look-ahead, taper
and floor time constant. `threshold` stays orthogonal as the
sensitivity knob.

Core/protocol:
- New `NoiseBlankerProfile` in trx-core (serde/parse/u8/TS), re-exported
  at the crate root; `RigFilterState.sdr_nb_profile` for state sync.
- `profile` added to `RigCommand`/`ClientCommand::SetSdrNoiseBlanker`,
  the trait method, and the command mapping.

Config: `[rig.sdr.noise_blanker] profile = "spike"` (regenerated
trx-rs.toml.example).

SDR backend: `NoiseBlanker` rewritten in the channel DSP; profile wired
through `SoapySdrConfig`, the runtime setter, and `filter_state()`.

Frontend: an "NB profile" selector in the SDR advanced controls
(POST /set_sdr_noise_blanker&profile=…), reflecting server state; the
profile rides along with the enable/threshold quick toggle so it is
preserved. Regenerated generated.ts and app.js.

Tests: profile u8/parse round-trips (trx-core); DSP tests for impulse
suppression with no leading-edge leak, strong-steady-signal
pass-through, and wider-profile-blanks-longer.

Docs: User-Manual NB section rewritten for the new algorithm and
profiles.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 21:42:14 +02:00
sjgandClaude Opus 4.8 79adc8d5c6 [chore](trx-rs): run CI on self-hosted runner
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Switch every CI job from ubuntu-latest to the self-hosted runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 13:22:44 +02:00
sjgandClaude Opus 4.8 c33e3caedb [style](trx-rs): cargo fmt
CI / frontend (push) Successful in 5m32s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 10m20s
CI / reuse (push) Successful in 4s
Apply rustfmt to the wefax and DIG-sideband changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 12:50:12 +02:00
sjgandClaude Opus 4.8 5084c19899 [fix](trx-frontend-http): move passband overlay to resolved DIG sideband
CI / lint (push) Failing after 2s
CI / test (push) Successful in 9m8s
CI / frontend (push) Successful in 5m24s
CI / reuse (push) Successful in 4s
The spectrum passband overlay is drawn one-sided per mode, but DIG was
hardcoded to upper sideband — so switching the DIG sideband (or tuning
DIG/Auto across 10 MHz) left the overlay on the wrong side of the carrier
even though the backend had flipped the demodulator.

Resolve the DIG overlay direction from the current sideband policy and dial
frequency, mirroring the backend: usb → upper, lsb → lower, auto → upper at
or above 10 MHz and lower below. The overlay repaints immediately on a
policy change (optimistically on the selector, and on confirmed filter
state) and tracks frequency as it already did. Non-SDR backends keep the
historical upper-sideband overlay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 10:24:30 +02:00
sjgandClaude Opus 4.8 df7483fe30 [feat](trx-rs): configurable DIG sideband for SDR (auto/USB/LSB)
CI / frontend (push) Successful in 5m41s
CI / lint (push) Failing after 4s
CI / test (push) Successful in 10m37s
CI / reuse (push) Successful in 5s
DIG has no inherent sideband; on the SDR backend it was always demodulated
as USB. Make it resolve to USB or LSB via a policy that defaults to the
amateur SSB/data convention (USB at/above 10 MHz, LSB below) and can be
overridden globally from the advanced radio controls or per-bookmark.

Design: the logical DIG mode is kept in RigState (display, decoder gating)
while the SDR pipeline is handed a concrete USB/LSB demodulator resolved from
(policy, dial frequency). Resolution happens at the boundary — the rig for
the primary channel and the virtual-channel manager for vchans — so the hot
DSP/demod path is untouched. The resolved sideband is only re-pushed when it
actually changes (e.g. tuning DIG/Auto across 10 MHz), keeping ordinary
tuning glitch-free.

Core/protocol:
- New `DigSidebandPolicy { Auto, Usb, Lsb }` with `resolve(freq)` and an
  `effective_demod_mode()` helper (trx-core), re-exported at the crate root.
- `RigCommand::SetSdrDigSideband`, `RigSdr::set_sdr_dig_sideband`, and a
  `RigFilterState.sdr_dig_sideband` field for state sync; wired through the
  ClientCommand mapping.

Config: `[rig.sdr] dig_sideband = "auto"` (regenerated trx-rs.toml.example).

SDR backend: the vchan manager owns the shared policy (atomic); the rig
applies it to the primary channel and, on `set_sdr_dig_sideband`, re-resolves
all DIG virtual channels.

Frontend: a mode-gated "DIG sideband" selector in the SDR advanced controls
(POST /set_sdr_dig_sideband), reflecting server state; bookmarks gain an
optional `dig_sideband` field (form selector shown only for DIG) that, on
apply, sets the global policy before switching to DIG. The scheduler honours
it for automated bookmark activation too.

Tests: policy resolution / effective-mode / u8+parse round-trips (trx-core);
a vchan integration test asserting a DIG channel resolves to LSB below
10 MHz, flips with the policy, and still lists as DIG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 00:26:45 +02:00
sjgandClaude Opus 4.8 fe3b414fba [fix](trx-wefax): stop fragmenting one transmission into many images
The WEFAX decoder decoded content but chopped a single chart into many
short PNG "chunks". Two heuristics fought each other: in State::Receiving
the carrier-loss watchdog finalized the image after only 30 low-correlation
scan lines (~15 s at 120 LPM), and the Idle variance auto-start then
re-triggered on the still-present carrier ~3 s later, starting a fresh
image. Ordinary HF fading (QSB) of 5-20 s therefore split every chart into
a stream of tiny images.

Reference decoders (fldigi) keep one continuous image per APT cycle up to a
large line cap and only stop on the APT stop tone or genuine signal loss.
Align with that model:

- Raise the end-of-transmission watchdog to 120 low-correlation lines
  (~60 s at 120 LPM) so normal fades ride through within one image.
- Gate the variance-based auto-start to fire at most once per session
  (auto_start_used). After the first image a new one starts only on an APT
  start tone or an explicit reset, so trailing carrier/noise can no longer
  spawn a second image. reset() re-arms it.
- Cap a single image at 3000 lines to bound memory on an open carrier.
- Require a 2 s (was 1 s) APT tone sustain, cutting false Stop detections on
  busy image content that momentarily hits ~450 transitions/s.

Also make line slicing drift-free: 120 LPM at 11025 Hz is 5512.5 samples per
line, and slicing on the rounded integer accumulated a fractional-sample
error every line (slow horizontal slant). Boundaries are now derived from the
exact fractional line length (samples_per_line_f64) so the error never
accumulates.

Adds regression tests for the single-auto-start invariant and zero slicer
drift over 1000 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-15 23:35:09 +02:00
sjg 1829e3d17b [fix](trx-frontend-http): make Guest role composable
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m16s
CI / frontend (pull_request) Successful in 5m22s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m11s
CI / frontend (push) Successful in 4m24s
CI / reuse (push) Successful in 4s
2026-08-13 02:16:59 +02:00
sjg b037225a05 [fix](trx-frontend-http): block Guest password changes
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m5s
CI / frontend (pull_request) Successful in 5m23s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 9m11s
CI / frontend (push) Successful in 5m22s
CI / reuse (push) Successful in 5s
2026-08-11 22:47:55 +02:00
sjg e978cf8a84 [feat](trx-frontend-http): separate transmit permission
CI / frontend (pull_request) Successful in 5m21s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m11s
CI / test (push) Successful in 8m16s
CI / frontend (push) Successful in 4m24s
CI / reuse (push) Successful in 5s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m25s
Assisted-By: Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-11 18:36:33 +02:00
sjg 44870bc941 [feat](trx-frontend-http): add restricted Guest role
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m16s
CI / frontend (pull_request) Successful in 4m17s
CI / reuse (pull_request) Successful in 5s
Assisted-By: Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-11 16:04:51 +02:00
sjg 5e9dae02c7 Complete managed account lifecycle
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m6s
CI / frontend (pull_request) Successful in 5m17s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m26s
2026-08-11 07:49:53 +02:00
sjg 34507ffa17 Add composable HTTP access roles
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m24s
CI / frontend (pull_request) Successful in 5m12s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 8m8s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
2026-08-11 01:06:57 +02:00
sjg 36c1e56efa Protect the final administrator 2026-08-11 00:39:25 +02:00
sjg e4cce9a004 Add Users settings tab 2026-08-11 00:37:59 +02:00
sjg 3c3fc69542 Refactor HTTP account system
CI / frontend (pull_request) Successful in 5m13s
CI / reuse (pull_request) Successful in 29s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m24s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m19s
2026-08-10 23:47:51 +02:00
sjgandClaude Opus 5 d539ff96e5 [docs](trx-rs): write down how a contest and a confirmation are worked
CI / frontend (pull_request) Successful in 5m12s
CI / reuse (pull_request) Successful in 5s
CI / test (push) Successful in 8m0s
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m57s
CI / lint (push) Successful in 2m32s
CI / frontend (push) Successful in 4m13s
CI / reuse (push) Successful in 6s
The manual gains the contest exchange, the Cabrillo entry and the
confirmations.  The plan marks all five phases done, corrects the API paths
to the /api/logbook they were built under -- /logbook is the page, and the
bookmarks API already shadows its own page that way -- and records the two
decisions phase 5 settled: that the Cabrillo header comes from the operator
because no log can derive it, and that a confirmation counts from whichever
bureau answered.

The plan itself travels with this branch: it was written on a branch of its
own that was never proposed for merge, and the earlier attempts to update it
from the implementation branches were silent no-ops, because the edits did
not assert that they had found what they were replacing.

Refs #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 21:31:06 +02:00
sjgandClaude Opus 5 ca5cd65c85 [docs](trx-rs): settle the logbook's remaining questions
The clock is the server's, as asked: it is the machine at the radio, where
the browser may be a phone in another timezone with a clock nobody checked.
When the two disagree by more than a second the panel says so, rather than
logging a time the operator did not expect.

The rest, decided against how logging is actually done:

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

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

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

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

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

Refs #54

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

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

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

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

Refs #54

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

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

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

Refs #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 21:30:10 +02:00
sjgandClaude Opus 5 ee185b98bc [feat](trx-logbook): work a contest, and record what came back
Phase 5 of the logbook: the exchange, the entry sponsors take, and the
confirmations an award counts.

Contest fields go on the contact — the contest, the serials both ways as
numbers and as words, and the zones — because an exchange is not always a
number: a zone, a section or a name goes in as written.  The serial sent and
the contest stay between contacts, since they belong to the session and not
to the contact just logged, and the serial counts on by itself rather than
being retyped forty times an hour.

Cabrillo 3.0 is written because ADIF cannot do this job: sponsors take
Cabrillo and reject everything else.  Its shape is not ADIF's either — the
frequency is kilohertz below 30 MHz and a band designator above it, the modes
are CW, PH, FM, RY and DG, and the contacts go oldest first, as a contest log
is read.  The header cannot be derived from a log — how many operators, how
much power, what the score is claimed to be — so it comes from the operator,
with single-op, low power, all bands and mixed behind it.

QSL, LoTW and eQSL states are held as ADIF's single letters, and anything
else is refused rather than written: a log that grew states of its own would
be one no other program could read.  A contact is confirmed when any one of
the three says so — an award wants a card or an electronic match, not one of
each, and counting them separately would tell the operator they were short of
what they have.

The bands report counts contacts, distinct stations and confirmations per
band, ordered by wavelength as a band plan reads.

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 21:28:40 +02:00
sjgandClaude Opus 5 18b2d0efe6 [feat](trx-rs): keep a station log, and a layout to work the bands from
CI / lint (pull_request) Successful in 2m23s
CI / frontend (pull_request) Successful in 5m52s
CI / reuse (pull_request) Successful in 6s
CI / test (pull_request) Successful in 9m12s
The logbook of issue #54, in the shape the proposal settled on.

A new crate, trx-logbook, holds the contact, the ADIF reader and writer, the
file, and the rules for telling one contact from two.  ADIF because it is the
only thing the ecosystem reads: LoTW, eQSL, Club Log, QRZ and every other
logger take it and nothing else, so a log that cannot write .adi cannot be
uploaded, confirmed or moved.  The reader is forgiving in the ways real files
are irregular -- lowercase tags, CRLF, missing header, unknown fields, a
declared length that is the only thing ending a value -- and carries what it
does not model through to the export, so a round trip does not strip what
another program wrote.

The file is JSON Lines, appended one line per contact.  A log is the one
thing here that cannot be regenerated, and the bookmark store's whole-file
dump would rewrite megabytes to log one contact and lose all of them if the
power went halfway; an append costs the record being written and no more,
which a test tears a line in half to prove.  Edits append revisions, deletes
append tombstones, and the file compacts when the superseded outnumber the
live.

The panel is its own tab and stands in every layout.  An entry opens with six
fields and no more -- frequency, mode, rig name, time, and the callsign and
locator of whatever decode it was started from.  A report stays empty: an FT8
SNR is not what was sent.  Times come from the server, because the browser
may be a phone in another timezone, and the panel says so when the two
disagree by more than a second.  Worked-before answers as a callsign is
typed.

A decode is not a contact, so the Log button on an FT8 or APRS row opens an
entry and logs nothing by itself.

The ham layout is the fifth operator layout, opening on the logbook with the
radio controls around it, offered only where the rig can transmit.

Two bugs found on the way, both in code written here: a frequency of a whole
number of megahertz ending in a zero rendered as a tenth of itself, in Rust
and in TypeScript alike, because trimming trailing zeros from "20.000000"
walks back through the point.  The API also sits under /api/logbook rather
than /logbook, so it cannot shadow its own page the way /bookmarks does.

Closes #54

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

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

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

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

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

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

Closes #52

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 08:56:06 +02:00
sjgandClaude Opus 5 bf3bcc8a84 [fix](trx-frontend-http): show one rig at a time on the digital modes page
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m34s
CI / test (push) Successful in 7m47s
CI / frontend (pull_request) Successful in 4m28s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m21s
CI / frontend (push) Successful in 3m37s
CI / reuse (push) Successful in 6s
A client connected to several rigs decodes all of them at once, and the
decode stream carries every rig's traffic to every browser.  The decoder
panels listed all of it: a station a background rig copied on another band
appeared in the APRS list next to the selected rig's, the vessel counts and
the "latest seen" lines counted both, the status lines said "Receiving"
because some other rig was, and the CW pane interleaved two rigs into one
stream of text that read as neither.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 08:11:13 +02:00
sjgandClaude Opus 5 86dd36312e [test](trx-frontend-http): type the frequency instead of filling it
CI / lint (pull_request) Successful in 2m20s
CI / test (pull_request) Successful in 8m32s
CI / frontend (pull_request) Successful in 4m27s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m26s
CI / test (push) Successful in 7m42s
CI / frontend (push) Successful in 3m35s
CI / reuse (push) Successful in 5s
tune-links drove the dial with Playwright's fill(), which writes a value
into the field without a keystroke.  The app arms its guard against its own
refreshes on the first keydown, so a filled field stays unguarded: any state
update landing between the fill and the Enter rewrites the field with the
frequency the radio is already on, and the Enter then re-applies that.  The
window is a few milliseconds wide on a developer's machine and wide enough
to lose on a loaded CI runner, where the test failed claiming the tuning had
landed on the frequency it started from.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 02:47:04 +02:00
sjgandClaude Opus 5 15ff686542 [fix](trx-frontend-http): keep the mini views to the rig on screen
CI / lint (pull_request) Successful in 2m23s
CI / test (pull_request) Successful in 8m34s
CI / frontend (pull_request) Failing after 1m32s
CI / reuse (pull_request) Successful in 6s
The decode SSE stream and the history behind it are not rig-scoped: every
rig's decodes reach the browser, each carrying the rig that heard it.  The
panels on the decoder tabs want that — they aggregate the whole station —
but the mini views over the waterfall caption the spectrum underneath, and
they were reading the same unfiltered histories.  A background rig copying
APRS on another band put its frames over the active rig's waterfall.  The
mode gate did not help: it reads the mode of the rig on screen, so those
frames appeared whenever that rig happened to be in PKT.

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

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

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

Closes #49

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 00:46:39 +02:00
sjgandClaude Opus 5 b78c4a4dd4 [feat](trx-rs): make spectrum affordable over a slow link
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m36s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 6s
CI / frontend (pull_request) Successful in 4m27s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m48s
Spectrum dominates the server↔client connection, and all three things that
govern its cost were working against a poor link.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 00:15:50 +02:00
sjgandClaude Opus 5 f396e8f235 [fix](trx-frontend-http): give satellite passes their own page
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m50s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 6s
Pass predictions were a third view inside the Weather Satellite Decoder card,
under Digital modes — a planning tool filed behind a decoder toggle, beside the
FT8 and WEFAX panels it has nothing to do with.  Nothing about knowing when a
bird comes over belongs there.

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

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

Closes #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 00:06:00 +02:00
sjgandClaude Opus 5 46c9827e8a [fix](trx-config): keep the generated example off the machine that made it
CI / lint (pull_request) Successful in 2m17s
CI / frontend (pull_request) Successful in 3m28s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m16s
CI / test (pull_request) Successful in 7m50s
CI / frontend (push) Successful in 4m11s
CI / reuse (push) Successful in 9s
CI / test (push) Failing after 17m15s
The example is generated from the config defaults, and [decode_logs].dir
defaults to the running user's cache directory.  So the file rendered
/Users/sjg/Library/Caches/trx-rs/decoders on the machine that generated it and
/root/.cache/trx-rs/decoders in CI, and the up-to-date test failed for everyone
but its author.

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

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

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

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

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

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

    cargo run -p trx-config --example generate_example

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

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

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

Two alternatives:

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

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

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

Add [decoders] per rig:

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

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

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

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

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

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

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

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

Add validate_resolved() to both configs, run after argument parsing, plus a
shared socket-conflict check that treats a wildcard address as conflicting with
any address on the same port and ignores port 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:02:26 +02:00
sjgandClaude Opus 5 d42ca4f030 [fix](trx-config): validate every rig, not just the legacy flat one
ServerConfig::validate() checked the flat [rig]/[audio]/[behavior] fields and
gave [[rigs]] entries only an id/audio-port uniqueness pass, and
validate_sdr() returned early unless the *flat* access type was "sdr".  A
multi-rig SDR station therefore got no Nyquist, stream_opus, duplicate-decoder
or tx_enabled checking at all, and a rig entry with frame_duration_ms = 7 or a
missing baud rate started and failed at runtime.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 20:44:42 +02:00
153 changed files with 20194 additions and 5227 deletions
+4 -4
View File
@@ -27,7 +27,7 @@ env:
jobs:
lint:
runs-on: ubuntu-latest
runs-on: self-hosted
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps:
- uses: actions/checkout@v4
@@ -40,7 +40,7 @@ jobs:
run: sccache --show-stats
test:
runs-on: ubuntu-latest
runs-on: self-hosted
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps:
- uses: actions/checkout@v4
@@ -53,7 +53,7 @@ jobs:
run: sccache --show-stats
frontend:
runs-on: ubuntu-latest
runs-on: self-hosted
container: git.haxx.space/sjg/trx-rs/sdk:latest
defaults:
run:
@@ -83,7 +83,7 @@ jobs:
run: npm run verify-generated
reuse:
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- uses: fsfe/reuse-action@v5
+9 -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
@@ -49,6 +56,7 @@ src/
trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT
trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC)
trx-client/ # Client binary: remote connection, frontend spawning (~1,500 LOC)
trx-logbook/ # Station log: QSO record, ADIF reader/writer, append-only store
trx-frontend/ # Frontend trait (FrontendSpawner), runtime context
trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth
trx-frontend-http-json/ # JSON-over-TCP control frontend
Generated
+141 -9
View File
@@ -316,6 +316,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -345,6 +357,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bindgen"
version = "0.66.1"
@@ -395,6 +413,24 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.0"
@@ -525,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
@@ -721,6 +757,15 @@ dependencies = [
"windows",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
@@ -739,6 +784,16 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.1"
@@ -799,15 +854,26 @@ dependencies = [
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
dependencies = [
"block-buffer",
"block-buffer 0.12.0",
"const-oid",
"crypto-common",
"crypto-common 0.2.1",
]
[[package]]
@@ -1019,6 +1085,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1890,6 +1966,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "peeking_take_while"
version = "0.1.2"
@@ -2412,6 +2499,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"
@@ -2482,8 +2579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.3.0",
"digest 0.11.2",
]
[[package]]
@@ -3031,10 +3128,6 @@ dependencies = [
name = "trx-app"
version = "0.1.0"
dependencies = [
"dirs",
"serde",
"thiserror 2.0.18",
"toml",
"tracing",
"tracing-subscriber",
]
@@ -3115,6 +3208,7 @@ dependencies = [
"toml",
"tracing",
"trx-app",
"trx-config",
"trx-core",
"trx-frontend",
"trx-frontend-http",
@@ -3124,6 +3218,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 +3243,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",
@@ -3188,9 +3302,11 @@ version = "0.1.0"
dependencies = [
"actix-web",
"actix-ws",
"argon2",
"base64",
"brotli 7.0.0",
"bytes",
"chrono",
"dirs",
"flate2",
"futures-util",
@@ -3199,11 +3315,13 @@ dependencies = [
"rand 0.8.6",
"serde",
"serde_json",
"tempfile",
"tokio",
"tokio-stream",
"tracing",
"trx-core",
"trx-frontend",
"trx-logbook",
"trx-protocol",
"ts-rs",
"uuid",
@@ -3242,6 +3360,19 @@ dependencies = [
"rustfft",
]
[[package]]
name = "trx-logbook"
version = "0.1.0"
dependencies = [
"chrono",
"dirs",
"serde",
"serde_json",
"tempfile",
"tracing",
"uuid",
]
[[package]]
name = "trx-protocol"
version = "0.1.0"
@@ -3294,6 +3425,7 @@ dependencies = [
"trx-app",
"trx-aprs",
"trx-backend",
"trx-config",
"trx-core",
"trx-cw",
"trx-decode-log",
+2
View File
@@ -18,6 +18,7 @@ members = [
"src/trx-core",
"src/trx-protocol",
"src/trx-app",
"src/trx-config",
"src/trx-reporting",
"src/trx-server",
"src/trx-server/trx-backend",
@@ -25,6 +26,7 @@ members = [
"src/trx-server/trx-backend/trx-backend-ft450d",
"src/trx-server/trx-backend/trx-backend-soapysdr",
"src/trx-client",
"src/trx-client/trx-logbook",
"src/trx-client/trx-frontend",
"src/trx-client/trx-frontend/trx-frontend-http",
"src/trx-client/trx-frontend/trx-frontend-http-json",
+48 -3
View File
@@ -93,13 +93,17 @@ The wizard walks you through rig selection, serial port detection, audio
settings, and frontend options, then writes `trx-server.toml` and
`trx-client.toml`.
Alternatively, generate example configs and edit them by hand:
Alternatively, copy `trx-rs.toml.example` — a commented example covering every
setting — and edit it by hand:
```bash
./target/release/trx-server --print-config > trx-server.toml
./target/release/trx-client --print-config > trx-client.toml
cp trx-rs.toml.example trx-rs.toml
./target/release/trx-server --check-config --config trx-rs.toml
```
`--check-config` reports everything wrong with a config without starting
anything. `--print-config` prints the same settings without comments.
### 4. Run
```bash
@@ -107,8 +111,49 @@ 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`).
### 5. Install (optional, Linux + systemd)
To build, install the binaries system-wide, and set up systemd **user** services
that run the server and client in the background:
```bash
script/install.sh
```
This builds in release mode, installs `trx-server`, `trx-client`, and
`trx-configurator` to `~/.local/bin`, seeds
`~/.config/trx-rs/trx-rs.toml` from the example (existing config is never
overwritten), and installs `~/.config/systemd/user/{trx-server,trx-client}.service`.
```bash
script/install.sh --prefix /opt/trx-rs # choose another installation prefix
script/install.sh --no-sdr # build without SoapySDR support
script/install.sh --enable-now # also enable + start the services now
script/install.sh --help # all options
```
After editing your config, manage the services with:
```bash
systemctl --user enable --now trx-server trx-client # start now + on login
journalctl --user -u trx-server -u trx-client -f # follow logs
loginctl enable-linger "$USER" # keep running after logout
```
Serial (`/dev/ttyUSB*`) and audio access require your user to be in the
`dialout` and `audio` groups. Remove everything with `script/uninstall.sh`
(add `--purge` to also delete the config).
For an unattended or remotely accessible installation, follow the
[safe deployment guide](docs/Deployment.md). It covers a dedicated service
account, device permissions, authentication, firewall and reverse-proxy
boundaries, verification, upgrades, and rollback.
## How It Works
```mermaid
+9 -4
View File
@@ -925,9 +925,14 @@ main
### HTTP Frontend Auth
- Optional token or HTTP Basic Auth middleware
- Configured in `[frontends.http.auth]`
- Rate limiting supported
- Optional Argon2id-backed managed accounts with HttpOnly session cookies
- An exclusive Guest role plus composable Read, Control, Transmit, Write, and Administrator roles, with policy shared by middleware and handlers
- Guest sessions receive read-only station access but no account-control endpoints or panels
- Transmit separately gates PTT, TX audio frames, and TX power-limit changes
- Atomic JSON persistence with migration from the legacy single-role schema
- Account enable/disable, administrator CRUD, self-service password changes, and session revocation on security changes
- A database invariant always preserves at least one enabled administrator
- Per-IP login rate limiting; configured in `[frontends.http.auth]`
### Transport Security
@@ -1043,7 +1048,7 @@ The `FrontendRuntimeContext` struct in `trx-frontend/src/lib.rs` is decomposed i
|-----------|---------|------------|
| `AudioContext` | Audio streaming channels | `rx`, `tx`, `info`, `decode_rx`, `clients` |
| `DecodeHistoryContext` | Decode history for all types | `ais`, `vdes`, `aprs`, `hf_aprs`, `cw`, `ft8`, `ft4`, `ft2`, `wspr` |
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `rx_passphrase`, `session_ttl_secs`, `tokens` |
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin/read accounts, `session_ttl_secs`, `tokens` |
| `HttpUiConfig` | HTTP UI display config | `show_sdr_gain_control`, `initial_map_zoom`, `spectrum_*` |
| `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` |
| `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` |
+287
View File
@@ -0,0 +1,287 @@
# Safe deployment
This guide deploys trx-rs on Linux as a dedicated, unprivileged user with
systemd user services. It keeps radio-device access, configuration, and runtime
data separate from an administrator's account.
The examples use `trx-rs` as the account name and `/opt/trx-rs/bin` for
root-owned executables. Adapt group names and firewall commands to your Linux
distribution.
## 1. Decide what must be reachable
Only expose listeners that another machine actually needs:
| Listener | Typical port | Recommended exposure |
| --- | ---: | --- |
| Server control | TCP 4530 | Loopback or trusted radio LAN only |
| Server audio | TCP 4531 and per-rig ports | Trusted radio LAN only |
| Client web UI | TCP 8080 or a chosen port | Loopback behind HTTPS proxy |
| Client rigctl | Per-rig TCP ports | Loopback or trusted LAN only |
| Client JSON | Configured TCP port | Loopback or trusted LAN only |
`127.0.0.1` accepts connections only from the same host. Use a specific LAN
address when possible, or `0.0.0.0` when the listener must accept connections
on every IPv4 interface. Binding a socket does not configure the host firewall.
Do not expose unauthenticated control, audio, rigctl, or JSON listeners to the
public Internet. Prefer a VPN for links between radio sites. Put the web UI
behind an HTTPS reverse proxy when it is remotely accessible.
## 2. Create the service account
Create a non-login service account with a home directory:
```bash
sudo useradd --create-home --shell /usr/sbin/nologin trx-rs
sudo chmod 0750 /home/trx-rs
```
Add only the hardware groups required on this host. Common group names are
`dialout` for serial devices and `audio` for sound devices:
```bash
sudo usermod -aG dialout,audio trx-rs
```
SDR USB access is distribution- and device-specific. Install the vendor's udev
rules or add a narrowly scoped rule for the device's USB vendor/product IDs.
Avoid making all USB devices world-writable. After reconnecting the device,
verify access as the service account:
```bash
sudo -u trx-rs test -r /dev/ttyUSB0
sudo -u trx-rs test -w /dev/ttyUSB0
sudo -u trx-rs SoapySDRUtil --find
```
Run only the checks relevant to the configured hardware. Group membership and
udev-rule changes normally require reconnecting the device or restarting the
service.
## 3. Build and install immutable binaries
Build from a reviewed revision as a normal development user, not as root:
```bash
git clone https://github.com/stanislawgrams/trx-rs.git
cd trx-rs
git switch --detach <reviewed-tag-or-commit>
cargo build --release -p trx-server -p trx-client -p trx-configurator
```
Install root-owned binaries into a directory the service user cannot modify:
```bash
sudo install -d -o root -g root -m 0755 /opt/trx-rs/bin
sudo install -o root -g root -m 0755 \
target/release/trx-server \
target/release/trx-client \
target/release/trx-configurator \
/opt/trx-rs/bin/
```
If SDR support is not needed, build `trx-server` with
`--no-default-features`. Keep the source revision and Rust toolchain used for
the build in deployment records.
## 4. Install and validate configuration
Create private configuration and state directories, then seed the example:
```bash
sudo install -d -o trx-rs -g trx-rs -m 0700 \
/home/trx-rs/.config/trx-rs \
/home/trx-rs/.config/systemd/user
sudo install -o trx-rs -g trx-rs -m 0600 \
trx-rs.toml.example /home/trx-rs/.config/trx-rs/trx-rs.toml
sudoedit /home/trx-rs/.config/trx-rs/trx-rs.toml
```
At minimum:
- remove unused example rigs and remotes;
- select the correct serial, TCP, or SDR device;
- give every enabled rig a unique ID and audio port;
- use `127.0.0.1` for same-host connections;
- use a LAN address or `0.0.0.0` only for deliberately remote listeners;
- enable authentication before exposing server control or the web UI;
- replace every example password and token;
- keep credential files mode `0600` and owned by `trx-rs`;
- set `cookie_secure = true` when the web UI is served through HTTPS.
For a remote server, control and each per-rig audio listener need an explicit
non-loopback address:
```toml
[trx-server.listen]
enabled = true
listen = "0.0.0.0"
port = 4530
[trx-server.listen.auth]
tokens_file = "/home/trx-rs/.config/trx-rs/server-tokens"
[[trx-server.rigs]]
id = "station-hf"
[trx-server.rigs.audio]
enabled = true
listen = "0.0.0.0"
port = 4531
```
When using several `[[trx-server.rigs]]` entries, configure audio under each
`[trx-server.rigs.audio]` section. Do not rely on the legacy flat
`[trx-server.audio]` section.
For a web UI behind a reverse proxy, keep the backend on loopback and choose an
unused port. The proxy upstream must use the same address and port:
```toml
[trx-client.frontends.http]
enabled = true
listen = "127.0.0.1"
port = 7345
[trx-client.frontends.http.auth]
enabled = true
users_file = "/home/trx-rs/.config/trx-rs/http-users.json"
cookie_secure = true
```
Validate the configuration before starting either daemon:
```bash
sudo -u trx-rs /opt/trx-rs/bin/trx-server \
--check-config --config /home/trx-rs/.config/trx-rs/trx-rs.toml
sudo -u trx-rs /opt/trx-rs/bin/trx-client \
--check-config --config /home/trx-rs/.config/trx-rs/trx-rs.toml
```
Treat unknown-key and deprecated-section warnings as deployment errors. They
often mean a setting is not applied where expected.
## 5. Install the systemd user services
Render the packaged units with the immutable binary directory:
```bash
sed 's|@BINDIR@|/opt/trx-rs/bin|g' packaging/systemd/trx-server.service \
| sudo tee /home/trx-rs/.config/systemd/user/trx-server.service >/dev/null
sed 's|@BINDIR@|/opt/trx-rs/bin|g' packaging/systemd/trx-client.service \
| sudo tee /home/trx-rs/.config/systemd/user/trx-client.service >/dev/null
sudo chown trx-rs:trx-rs \
/home/trx-rs/.config/systemd/user/trx-server.service \
/home/trx-rs/.config/systemd/user/trx-client.service
sudo chmod 0644 \
/home/trx-rs/.config/systemd/user/trx-server.service \
/home/trx-rs/.config/systemd/user/trx-client.service
```
Enable lingering so the user manager runs without an interactive login, then
start it:
```bash
sudo loginctl enable-linger trx-rs
trx_uid=$(id -u trx-rs)
sudo systemctl start "user@${trx_uid}.service"
```
Manage the user services through that user's runtime directory:
```bash
trx_uid=$(id -u trx-rs)
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user daemon-reload
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user enable --now trx-server.service trx-client.service
```
The client unit has ordering, but not activation, on `trx-server.service`. If
both are enabled locally, the client starts after the server process is
started. If the local server is disabled because all remotes are external, the
client does not start it. The client retries remote connections; systemd cannot
order startup against a service on another host.
## 6. Firewall and reverse proxy
Allow only required ports and sources. For example, permit server control and
audio only from the trusted radio subnet, not from every interface. Exact
commands differ between nftables, firewalld, and ufw.
For the web UI, terminate TLS in a maintained reverse proxy and send traffic to
the loopback backend. Preserve WebSocket upgrade headers if the proxy requires
them. Example nginx location:
```nginx
location / {
proxy_pass http://127.0.0.1:7345;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
Test the backend directly before debugging a `502 Bad Gateway` response:
```bash
curl --fail --show-error http://127.0.0.1:7345/
```
A refused connection means nothing is listening at the proxy's configured
address and port. Check for port conflicts with `ss -ltnp` and confirm the
application log reports the same bind address as the proxy upstream.
## 7. Verify the deployment
```bash
trx_uid=$(id -u trx-rs)
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user status trx-server.service trx-client.service
sudo journalctl _UID="${trx_uid}" \
-u trx-server.service -u trx-client.service --since today
sudo ss -ltnp
```
Verify all of the following:
- both required services remain active without a restart loop;
- radio hardware opens successfully;
- logs show the intended control, audio, and frontend bind addresses;
- the client connects to every configured control and audio endpoint;
- only intended interfaces expose listeners;
- authentication works and anonymous access is rejected where configured;
- the HTTPS proxy returns the UI and supports live updates;
- the service account cannot write `/opt/trx-rs/bin`.
Do not rely only on `systemctl` reporting `active`: individual frontend tasks
can fail after the main client process starts. Always inspect startup logs and
probe each required endpoint.
## 8. Upgrade and roll back
Build and validate a new revision before replacing the installed executables.
Back up configuration and state first. Stop the services, install all binaries
from the same build, and restart:
```bash
trx_uid=$(id -u trx-rs)
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user stop trx-client.service trx-server.service
sudo install -o root -g root -m 0755 \
target/release/trx-server \
target/release/trx-client \
target/release/trx-configurator \
/opt/trx-rs/bin/
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user start trx-server.service trx-client.service
```
Keep the previous binaries or package revision available for rollback. If the
new version fails, stop both services, restore the complete previous binary
set, restore configuration only if its format changed, and start the services
again.
+266
View File
@@ -322,3 +322,269 @@ trx-configurator
| `trx-app` | Config types and validation | Yes |
| `serialport` | Serial port enumeration | Yes (transitive) |
| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) |
---
## Logbook and Ham Radio Layout
Two halves of one feature ([#54](https://git.haxx.space/sjg/trx-rs/issues/54)): a station
logbook in a panel of its own, and an operator layout that puts a transceiver's controls
around it.
Nothing in the application records a QSO today. The map's "QSO summary" cards describe
contacts *between other stations*, reconstructed from decoded traffic; bookmarks are
frequencies, not contacts. Neither is the operator's own log.
### Requirements
| ID | Description |
|----|-------------|
| REQ-LOG-001 | The system shall record QSOs the operator makes, each holding at minimum callsign, date, time, band, frequency, mode and both signal reports. |
| REQ-LOG-002 | When starting a log entry, the system shall pre-fill exactly six fields: frequency, mode, rig name, time, callsign and locator. |
| REQ-LOG-003 | The system shall leave every other field of a log entry empty for the operator to fill. |
| REQ-LOG-004 | The system shall allow a logged QSO to be edited and deleted. |
| REQ-LOG-005 | The system shall survive a crash without losing a QSO that was recorded before it. |
| REQ-LOG-006 | The system shall list, search and filter the log by callsign, band, mode and date range. |
| REQ-LOG-007 | Where a decoded station is on screen, the system shall offer to start a log entry from it, pre-filled, without logging it unattended. |
| REQ-LOG-008 | The system shall show whether a callsign has been worked before, and on which bands. |
| REQ-FMT-001 | The system shall export the log as an ADIF 3.1.x `.adi` file. |
| REQ-FMT-002 | The system shall import ADIF `.adi` files produced by other logging software, preserving fields it does not itself use. |
| REQ-FMT-003 | When importing, the system shall identify QSOs already held and shall not duplicate them. |
| REQ-FMT-004 | The system shall export a filtered selection of the log as a Cabrillo 3.0 file for contest submission. |
| REQ-LOG-009 | The system shall stamp QSO times from the server's clock in UTC, and shall tell the operator when the browser's clock disagrees with it by more than one second. |
| REQ-LAY-001 | The system shall present the logbook in a panel of its own, reachable whatever layout is selected. |
| REQ-LAY-002 | The system shall offer a "Ham radio" operator layout presenting the transceiver controls and that panel together. |
| REQ-LAY-003 | Where the selected rig cannot transmit, the system shall not offer the ham layout. |
### A decode is not a QSO
The decoders are receive-only: FT8, CW, APRS and the rest report what was *heard*. A heard
callsign is the beginning of a log entry, not a contact, and the logbook must never write one
by itself — REQ-LOG-007 says offer and pre-fill, never auto-log. Digital QSOs made in
WSJT-X or similar arrive the way every other logger takes them: through ADIF import.
### What is pre-filled, and what is not
Six fields, and no more (REQ-LOG-002, REQ-LOG-003):
| Field | From | ADIF |
|-------|------|------|
| Frequency | the selected rig's dial | `FREQ`, with `BAND` derived from it |
| Mode | the selected rig | `MODE`, and `SUBMODE` where the mode implies one |
| Rig name | the rig's display name | `MY_RIG` |
| Time | the server's clock, UTC, when the entry opens | `QSO_DATE`, `TIME_ON` |
| Callsign | the decode row or map station the entry was started from, else empty | `CALL` |
| Locator | that station's grid, where the decode carried one, else empty | `GRIDSQUARE` |
Signal reports, power, name, QTH and the rest stay empty. A report in particular is the
operator's to give: an FT8 SNR is not what was sent, and pre-filling one would put a number in
the log that nobody exchanged.
The station's own callsign and locator are not pre-filled per entry either — they are station
identity, taken from configuration when the QSO is written (`STATION_CALLSIGN`, `OPERATOR`,
`MY_GRIDSQUARE`), and shown once at the top of the panel rather than typed into every row.
### Architecture
#### New crate: `trx-logbook`
```
src/trx-client/
trx-logbook/
src/
lib.rs # LogbookHandle: open, add, edit, delete, query, import, export
qso.rs # Qso: the record, its ADIF field mapping, band/mode helpers
adif.rs # ADI reader and writer (pure Rust, no new dependencies)
store.rs # Append-only JSON Lines file, in-memory index, compaction
dedupe.rs # Worked-before and import-collision rules
```
A library crate under `src/trx-client/`, beside `trx-frontend`, consumed by
`trx-frontend-http`. The log belongs to the station rather than to a rig or a frontend, so it
sits where any frontend can reach it.
#### Storage: append-only, not a rewritten blob
Bookmarks use `PickleDb` with `AutoDump`, which rewrites the whole file on every write. That
is right for a few dozen bookmarks and wrong for a log: a station with 40 000 QSOs would
rewrite several megabytes to log one contact, and lose the lot if the power went during the
dump.
The log is instead a JSON Lines file — the shape `trx-decode-log` already uses — appended one
record per write and read into an in-memory index at startup. An edit or a delete appends a new revision of that record's id; the load
keeps the last one, and a compaction pass rewrites the file when superseded records exceed a
threshold. Appending is O(1) and atomic per line, so a crash costs at most the line being
written.
#### Two formats, for the two things a log is asked for
The file formats were left open ("pick a well-known ham format"), so: **ADIF for interchange,
Cabrillo for contest submission.** Both are implemented in-repo, in the way this project
already implements its decoders, and neither adds a dependency.
**ADIF has to stay.** It is not one option among several — it is the only thing the ecosystem
reads. LoTW, eQSL, Club Log, QRZ.com and every other logger take ADIF and nothing else, so a
log that cannot write `.adi` cannot be uploaded, confirmed, or moved to another program. That
is a one-way door, and the interoperability is most of the point of keeping a log at all.
Nothing on disk is ADI regardless: the store is JSON Lines, and ADIF is what comes out of an
export.
ADI is a tagged text format — `<FIELD:length>value`, records ended by `<EOR>`, a header ended
by `<EOH>`, everything outside a tag ignored — small enough to implement exactly. The reader
must be lenient in the ways real files are irregular (lowercase tags, CRLF, missing header,
unknown fields, type indicators) and the writer strict. Unknown fields are carried through
import to export unchanged, so a round trip through trx-rs does not quietly strip what another
logger wrote. ADX, the XML serialisation of the same data model, is out of scope: it is part
of the standard but almost nothing reads it.
**Cabrillo is the second format, because ADIF cannot do its job.** Contest logs are submitted
to sponsors in Cabrillo 3.0 and are rejected in anything else — a header of `CALLSIGN:`,
`CONTEST:`, `CATEGORY-*` and `CLAIMED-SCORE:` lines, then one fixed-column `QSO:` line per
contact carrying frequency in kHz, a mode code (`CW`, `PH`, `FM`, `RY`, `DG`), the UTC date and
time, and both stations' calls, reports and exchanges. It is export-only and drops everything
outside the contest's exchange, which is why it complements ADIF rather than replacing it.
It arrives with the contest exchange fields in phase 5, since without a serial or a zone to
put in the exchange there is nothing for it to write.
#### Integration points
| Source | What it gives the log | How |
|--------|----------------------|-----|
| `RigState` | `FREQ`, `BAND`, `MODE`/`SUBMODE`, and the rig id a QSO was made on | watch channel already in the frontend context |
| Client config `general.callsign` | `STATION_CALLSIGN`, and the default `OPERATOR` | already surfaced as `owner_callsign` in frontend meta |
| The QSO's own rig, and its position | `MY_GRIDSQUARE` | per-rig latitude and longitude already carried in the rig list |
| Server clock | `QSO_DATE`, `TIME_ON` in UTC | new `GET /api/logbook/now`, which also feeds the browser-clock check |
| Decoder panels and map | a pre-filled entry: callsign, grid, and the report to offer | existing decode history; no new plumbing |
| `bandForHz` | `BAND` from a frequency | exists in `map-core.ts`; move to a shared module |
#### HTTP API
Under `/api/logbook`, as the recorder's endpoints are, because `/logbook` itself is the page:
the `/bookmarks` API and the bookmarks page already share a path, and whichever is registered
first wins.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/logbook` | Query: filters, paging |
| `POST` | `/api/logbook` | Add a QSO |
| `PUT` | `/api/logbook/{id}` | Edit |
| `DELETE` | `/api/logbook/{id}` | Delete |
| `GET` | `/api/logbook/export.adi` | ADIF export, honouring the current filter |
| `GET` | `/api/logbook/export.cbr` | Cabrillo export of a contest selection |
| `POST` | `/api/logbook/import` | Import, answering with counts: added, duplicate, rejected |
| `GET` | `/api/logbook/worked/{call}` | Worked-before: bands and modes |
| `GET` | `/api/logbook/statistics` | Contacts, stations and confirmations, per band |
| `GET` | `/api/logbook/now` | The server's UTC clock, for checking the browser's |
| `GET` | `/api/logbook/prefill` | The six fields an entry opens with |
Writes require the admin role, as the rig endpoints do.
### Frontend
The logbook is **its own panel**, not a strip bolted to the radio page: a `logbook` entry in
the tab order beside Bookmarks, holding the entry form, the table with the filters of
REQ-LOG-006, and import and export. It stands on its own in every layout, so a log can be kept
without adopting the ham layout, and read while another layout is selected (REQ-LAY-001).
The panel is three parts: the station line at the top (own callsign, locator, the rig a QSO
would be logged against), the entry form beneath it opening with the six pre-filled fields,
and the log itself under that, filtered as REQ-LOG-006 asks. Worked-before shows against
the callsign as it is typed.
The **ham layout** is a fifth entry in the operator layouts (`compact`, `broadcast`, `digital`,
`full`), which already gate on capability, seed the disclosure sections and persist per rig:
```ts
ham: {
label: "Ham radio",
unavailable: "Ham radio needs a rig that can transmit",
advanced: true, audio: true, scheduler: false,
preferredTab: "logbook", capability: "ham",
}
```
with the `ham` capability set from `RigCapabilities.tx`. It keeps frequency, VFO, mode, filter,
PTT, power and the meters, and hides the broadcast furniture. What it adds over `full` is where
it starts: the logbook panel, with the radio controls a keystroke away rather than the other
way round — the layout an operator working the bands wants, where logging the contact is the
task and the rig is the instrument.
### Phases
All five are implemented.
| Phase | Lands |
|-------|-------|
| 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers |
| 2 | Store, dedupe, and the HTTP API behind the admin role |
| 3 | Logbook tab: entry, table, filters, import, export |
| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before |
| 5 | Contest exchange fields and Cabrillo export; QSL and LoTW/eQSL fields; per-band worked/confirmed statistics |
Two decisions phase 5 settled. The Cabrillo header cannot be derived from a log — how many
operators, how much power, what the score is claimed to be — so it comes from the operator,
with `SINGLE-OP`, `LOW`, `ALL` and `MIXED` behind it. And a confirmation counts from whichever
bureau answered: an award wants one card or one electronic match, not one of each, so a log
that counted them separately would tell the operator they were short of what they have.
### Decisions
**One station log, not one per rig.** Awards and uploads are per station callsign — DXCC, WAS
and LoTW all count the callsign, not the radio — and a station worked on the second rig is
still worked. The rig is recorded on the QSO (`MY_RIG`) rather than dividing the log by it.
The station *location* fields do follow the rig, though: trx-rs rigs can be in different
places, so `MY_GRIDSQUARE` is taken from the rig that made the QSO rather than from one global
setting, which is also what LoTW's station locations expect.
**The operator is a per-QSO field, set once per session.** ADIF separates `STATION_CALLSIGN`
(the call used on the air) from `OPERATOR` (the person at the key); multi-operator stations
rotate operators through one station callsign, which is why contest loggers record it per QSO.
It is stored per QSO, defaulted from the configured callsign so a single operator never touches
it, and changed on the station line at the top of the panel where it sticks for the session.
It cannot be inferred from the session's identity: an account username need not be an operator
callsign, and operational accounts may be shared.
**Server clock, and the log says so.** The server is the machine at the radio; the browser may
be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the
server, and when a browser's clock disagrees by more than a second the panel says so rather
than silently logging a time the operator did not expect.
**The log file is configurable, and defaults to the user's data directory.** Bookmarks live in
the config directory because they are settings; decode logs live in the cache directory because
they are disposable. A QSO log is neither — it is irreplaceable, and cache directories are
swept by cleaners. `[logbook].path` in the client config, defaulting to
`dirs::data_dir()/trx-rs/logbook.jsonl`, so a station that keeps its log on a synced or
backed-up volume can say so.
**Import collisions: callsign, band, mode and a two-minute window.** Two loggers rarely agree
to the second on the same QSO — one records the time the contact started, another the time it
was entered — so an exact-minute key duplicates half of what it is asked to merge. Two minutes
absorbs that. It does not swallow legitimate re-works: contest rules forbid a second contact
with the same station on the same band and mode, so a repeat inside two minutes is the same
QSO. Times are compared as instants rather than date and time strings, so a QSO either side of
midnight matches. Modes are normalised before comparison, or a log that stored `SSB` would fail
to match ours that stored `USB`.
### Rig modes to ADIF modes
The rig reports what it is demodulating; ADIF wants what the contact was made on, which is not
always the same word:
| Rig mode | ADIF `MODE` | ADIF `SUBMODE` |
|----------|-------------|----------------|
| `USB`, `LSB` | `SSB` | `USB` / `LSB` |
| `CW`, `CWR` | `CW` | — |
| `AM`, `SAM` | `AM` | — |
| `FM`, `WFM` | `FM` | — |
| `PKT` | `PKT` | — |
| `DIG` | decided by the decoder in use, not by the rig | |
| `AIS`, `VDES` | none — not amateur modes, and these rigs do not log | |
| `Other(..)` | passed through when it names an ADIF mode, else left for the operator | |
`DIG` is the one that cannot come from the rig: a rig in `DIG` is in FT8, FT4 or something else
depending on which decoder is running, and an entry started from an FT8 row logs `FT8` rather
than the rig's word for it. WSPR never opens an entry at all — it is a beacon mode, and hearing
a beacon is not a contact.
The table is data in `qso.rs`, checked against the ADIF enumeration when it is written, with
anything unrecognised left to the operator rather than guessed into the log.
+19 -12
View File
@@ -121,13 +121,14 @@ The spectrum panel uses `<canvas>` elements (WebGL renderer optional) and offers
When auth is enabled, an **auth gate** blocks the UI with:
- Title: "Access Required"
- Subtitle: "Enter passphrase to continue"
- Password input + Login button (green accent, full-width)
- Optional "Continue as Guest" button (shown when RX passphrase is not set)
- Subtitle: "Sign in to continue"
- Username and password inputs + Login button (green accent, full-width)
- Error message area (red `#ff6b6b`)
- Role badge display
Two roles: **Rx** (read-only) and **Control** (full access including TX/PTT).
**Guest** provides read-only station access and is exclusive. Non-Guest accounts
may combine **Read**, **Control**, **Transmit**, **Write**, and **Administrator** roles.
Administrator implies all permissions.
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
@@ -336,25 +337,31 @@ Logo and favicon are embedded at compile time via `include_bytes!`. The logo ima
### 7.1 Route Access Classification
Routes are classified into three tiers:
Routes are classified into access tiers:
| Tier | Examples | Requirement |
|---|---|---|
| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role |
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only |
| **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Guest, Read, Control, Transmit, or Administrator role |
| **Control** | `/set_freq`, `/set_mode`, `/toggle_power`, receive-side radio-control POST routes | Control or Administrator role |
| **Transmit** | `/set_ptt`, `/set_tx_limit`, outbound `/audio` frames | Transmit or Administrator role |
| **Write** | Logbook access and bookmark mutations | Write or Administrator role |
### 7.2 Session Management
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies
- Configurable TTL (default from TOML config)
- Expired sessions auto-pruned on access
- Constant-time passphrase comparison to mitigate timing attacks
- Passwords are verified against salted Argon2id hashes
### 7.3 TX Access Control
### 7.3 User Management
An additional `tx_access_control_enabled` flag can restrict transmit-related actions even
for Control-role users, providing an extra safety layer.
Every authenticated non-Guest account gets a Settings > Account tab for changing
its own password. Guest sees neither Account nor Users and both account-control
APIs deny Guest sessions. Only administrators get Settings > Users, where accounts can be
created, enabled/disabled, assigned multiple roles, given a new password, or
removed. The final enabled administrator cannot be disabled, removed, or
demoted. Account security changes revoke every active session for that account.
---
+373 -58
View File
@@ -17,30 +17,60 @@ 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].bootstrap_admin_password` | `bootstrap_admin_password_file` | the initial administrator password |
| `[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 +126,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 +152,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 +235,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 +301,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 +335,34 @@ 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` | Enable the user/password ACL |
| `users_file` | string | `"trx-http-users.json"` | Persistent managed user database |
| `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent |
| `bootstrap_admin_password` | string | — | First administrator password |
| `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead |
| `bootstrap_read_enabled` | bool | `true` | Create the default Guest account when the database is absent |
| `bootstrap_read_username` | string | `"guest"` | Initial Guest username |
| `bootstrap_read_password` | string | `"guest"` | Initial Guest password |
| `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` |
When enabling ACL for the first time, configure both bootstrap fields. After
the database exists, remove the bootstrap credentials from configuration.
#### `[frontends.rigctl]`
@@ -261,7 +370,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 +384,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 +404,153 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
loopback on Linux, BlackHole on macOS).
### Spectrum over a slow link
Spectrum dominates the server↔client connection: everything else is a few
hundred bytes, a frame is a few kilobytes. Three things govern what it costs.
**Frames are pushed, not polled.** The client subscribes and the server sends
frames at `[sdr].spectrum_interval_ms`. Polling cost a round trip per frame, so
the rate was capped at 1/RTT — on a 200 ms link you could not exceed 5 frames a
second however often the client asked. Clients fall back to polling
automatically against a server too old to stream.
**Bins travel as whole dBFS.** They are base64-encoded `i8` on the wire, about
an eighth of the JSON array of floats they used to be, at the resolution the
display draws anyway.
**Both ends have a rate, and the slower one wins.** The server pushes no faster
than `[sdr].spectrum_interval_ms`; the client asks for no more than
`[[remotes]].spectrum_interval_ms`.
For a link that struggles, start here:
```toml
[trx-server.sdr]
spectrum_fft_size = 512 # half the bins, half the bytes
spectrum_interval_ms = 200 # 5 frames/s instead of 20
[[trx-client.remotes]]
name = "remote-site"
url = "radio.example.com:4530"
spectrum_interval_ms = 200
```
That is roughly 0.7 KB per frame at 5 frames/s — about 3.5 KB/s, against
roughly 200 KB/s for 1024 float bins at 20 frames/s.
### CLI Override Summary
**trx-server:**
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
`--port`. SDR options are file-only.
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`,
`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only.
**trx-client:**
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`,
`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`,
`--http-port`, `--rigctl-listen`, `--http-json-listen`, `--http-json-port`,
`--callsign`.
`--listen` on the server overrides the bind address of both the control
listener and every rig's audio listener.
---
## Multiple Rigs in the Web UI
A client connected to several rigs decodes all of them at once, whichever one
is on screen. The rig picker in the header decides which rig the UI is about,
and each page answers that differently:
| Page | Shows |
|------|-------|
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. HF APRS is a source of its own there, filtered apart from VHF APRS. |
| Statistics | The whole station, including the per-rig comparison. |
Switching rigs repaints the radio and digital modes pages for the rig now
selected. Nothing is lost by switching: the traffic other rigs heard is still
held, and switching back brings it up again. The one exception is the CW pane,
which is a single running stream of copied text rather than a list of frames,
so it starts empty on the rig you switch to.
Each browser tab keeps its own selection, so two tabs can watch two rigs.
---
## Logbook
The **Logbook** tab keeps the station's contacts and speaks ADIF, so a log can
be uploaded to LoTW, eQSL, Club Log or QRZ, or moved to another logger.
An entry opens pre-filled with six fields and no more: the frequency, mode and
name of the rig you are on, the time from the server's clock, and — when the
entry was started from a decode or a map station — that station's callsign and
locator. Signal reports, name, comment and the rest are yours to fill in; a
report in particular is never guessed, because an FT8 SNR is not what was sent.
Your own callsign and locator are not per-contact fields. They are station
identity: the callsign comes from `[trx-client.general].callsign`, the locator
from the rig's position, and both are shown once at the top of the panel. The
operator defaults to the station callsign and can be changed for the session,
which is what a multi-operator station needs.
**Times are the server's**, in UTC — the server is the machine at the radio. If
the browser's clock disagrees by more than a second the entry says so rather
than logging a time you did not expect.
**A decode is not a contact.** The decoders are receive-only, so a **Log**
button on an FT8 or APRS row opens an entry with what was heard in it and logs
nothing by itself. Digital QSOs made in WSJT-X come in through Import ADIF, the
way every other logger takes them.
| Action | What it does |
|--------|--------------|
| Log contact | Writes the entry and opens a fresh one |
| Export ADIF | Downloads the log — filtered, if a filter is set |
| Import ADIF | Reads a file, skipping contacts already held and reporting what could not be read |
| Worked before | Shows, as you type a callsign, which bands and modes it has been worked on |
Two contacts are treated as the same when the callsign, band and mode match and
the times are within two minutes: two loggers rarely stamp a QSO to the same
minute, one recording when it started and the other when it was typed.
The log is a JSON Lines file, appended one contact at a time so a crash costs at
most the contact being written. It lives in your data directory by default;
`[trx-client.logbook].path` moves it, for a station that keeps its log on a
backed-up volume.
### Contests
The **Contest exchange** block on the entry holds the contest's name and the
serials — sent and received. Both stay between contacts, because they belong to
the session rather than to the contact just logged, and the serial sent counts
on by itself so it is not retyped forty times an hour. An exchange that is a
zone, a section or a name rather than a number is kept as written.
**Contest entry (Cabrillo)** exports the entry sponsors accept. Only the
contacts of the contest named there are included. The header — operator
category, power, claimed score — cannot be worked out from a log, so it is
yours to fill in; the defaults are single operator, low power, all bands, mixed
mode.
### Confirmations
The **QSL** column shows a tick when the other station has confirmed, and the
**Confirm** button on a row records a card that has arrived. A confirmation
counts from wherever it came: a paper card, LoTW or eQSL. An award wants one of
them, not all three, so the log does not ask for all three.
**Bands worked** counts, per band, the contacts made, the distinct stations
worked, and how many of those contacts are confirmed.
### Ham radio layout
The **Ham radio** operator layout opens on the logbook with the transceiver
controls around it — the arrangement for working the bands, where logging the
contact is the task and the radio is the instrument. It is offered only where
the selected rig can transmit; a receiver has no contacts to log.
---
@@ -326,7 +580,7 @@ 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`
Applying a link changes the radio, so it needs the `Control` role; a `Read`
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.
@@ -335,53 +589,72 @@ left as it was, rather than publishing a frequency the rig is not on.
## Authentication
The HTTP frontend supports optional passphrase-based authentication with two
roles:
The HTTP frontend supports an optional user/password ACL:
- **rx** — read-only access (monitoring, audio, decode streams)
- **control** — full access (frequency, mode, PTT, and all settings)
- **Guest** — read-only station access with no Account or Users controls; Guest cannot be combined with another role
- **Read** — monitoring, audio, decode streams, and bookmark reads
- **Control** — tuning, mode, power, and receive-side radio controls
- **Transmit** — PTT, transmitted audio, and TX power-limit controls
- **Write** — logbook access and bookmark changes
- **Administrator** — user management and all other permissions
### Configuration
```toml
[frontends.http.auth]
enabled = false
rx_passphrase = "rx-only-passphrase"
control_passphrase = "full-control-passphrase"
tx_access_control_enabled = true
users_file = "trx-http-users.json"
bootstrap_admin_username = "admin"
bootstrap_admin_password = "change-this-password"
bootstrap_read_enabled = true
bootstrap_read_username = "guest"
bootstrap_read_password = "guest"
session_ttl_min = 480
cookie_secure = false # true if served via HTTPS
cookie_same_site = "Lax" # Strict|Lax|None
```
When `enabled = false` (the default), all auth is bypassed and the UI behaves
as before. When enabled, at least one passphrase must be set.
as before. When enabling it for the first time, bootstrap credentials create
the initial administrator (with every non-Guest role), the default `guest`/`guest` Guest
account, and the Argon2id-hashed user database. Change or disable the guest
credentials in configuration before first startup on an exposed deployment.
### Behaviour
- On login, the server issues an `HttpOnly` session cookie.
- Sessions are in-memory; a server restart invalidates all sessions.
- Rate limiting is applied per IP to mitigate brute-force attempts.
- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and
rejected for unauthenticated or `rx`-role users.
- User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
- Non-Guest roles are independent; for example, an account may have Read and Write without Control.
- Guest accounts have no account-control panels and cannot call account-control endpoints.
- Every non-Guest signed-in user can change their own password in Settings > Account. This signs out all of their sessions.
- Administrators can add, enable/disable, or remove users and change roles/passwords in Settings > Users.
- At least one enabled administrator must always remain and cannot be disabled, removed, or demoted.
- Disabling/removing an account or changing its password/roles revokes all of its sessions.
- Existing account files migrate automatically: legacy accounts are enabled by default and legacy `user`/`admin` roles become Read/all roles.
### Routes
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/auth/login` | POST | Submit `{ "passphrase": "..." }` |
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
| `/auth/logout` | POST | Clear session |
| `/auth/session` | GET | Check current session/role |
| `/auth/session` | GET | Check current session/roles |
| `/auth/account/password` | PATCH | Change a non-Guest user's password after verifying the current password |
| `/auth/users` | GET/POST | List or add users (admin only) |
| `/auth/users/{username}` | PATCH/DELETE | Change enabled state/password/roles or remove user (administrator only) |
Protected routes require at least `rx` role. Control routes (set frequency,
mode, PTT, etc.) require `control` role.
Read routes accept Guest or require Read. Tuning and receive-side radio mutations
require Control. PTT, transmitted audio, and TX limit changes require Transmit.
Logbook access and bookmark mutations require Write. Administrator grants every permission.
### Frontend Flow
1. On load, the UI calls `/auth/session`.
2. If unauthenticated, a login screen is shown.
3. On successful login, the normal UI loads.
4. `rx` users see a read-only interface; `control` users get full controls.
4. The interface enables controls according to the account's roles.
5. If a session expires mid-use, streams stop and the login screen returns.
### Transport Security
@@ -479,9 +752,30 @@ A dedicated tab with a clock icon provides:
## SDR Noise Blanker
The noise blanker suppresses impulse noise (clicks, pops, ignition interference)
on raw IQ samples before any mixing or filtering takes place. It works by
tracking a running RMS level of the signal and replacing any sample whose
magnitude exceeds **threshold x RMS** with the last known clean sample.
on raw IQ samples before any mixing or filtering takes place — the only point in
the chain where an impulse is still short in time, since the narrow channel
filter downstream smears it into un-removable ringing.
It combines four elements:
- A **noise-floor tracker** — an exponential estimate of the background level,
updated only from clean samples (and frozen during a blank) so a burst cannot
drag the reference up and blind the detector.
- **Detection** — a sample is flagged when its power exceeds
**threshold² × noise-floor**.
- **Look-ahead** — the stream is delayed a few microseconds so the gate can
begin closing *before* the impulse reaches the output, catching its leading
edge instead of letting it leak through.
- A **tapered gate** — instead of a hard sample-and-hold (which splatters energy
back across the passband and is what made the old blanker sound worse on
SSB/CW/data), the gain ramps smoothly down and back up, so blanking costs only
a short, quiet notch.
The blank window, look-ahead, taper, and floor time constant are chosen by a
**profile** matched to the interference source. The `threshold` control is
orthogonal — it sets detection sensitivity within the chosen profile. All
profile timings are specified in real time and converted to samples at the
capture rate, so the blanker behaves consistently across SDR sample rates.
### Configuration (server-side)
@@ -498,6 +792,7 @@ type = "sdr"
[rigs.sdr.noise_blanker]
enabled = true
threshold = 10.0 # 1 100; lower = more aggressive blanking
profile = "spike" # spike | ignition | powerline | broadband
```
For the legacy single-rig (flat) config the path is `[sdr.noise_blanker]`:
@@ -506,15 +801,30 @@ For the legacy single-rig (flat) config the path is `[sdr.noise_blanker]`:
[sdr.noise_blanker]
enabled = true
threshold = 10.0
profile = "spike"
```
| Field | Type | Default | Range | Description |
|-------------|-------|---------|---------|-------------|
| `enabled` | bool | false | — | Turn the noise blanker on or off. |
| `threshold` | float | 10.0 | 1 100 | Multiplier applied to the running RMS. A sample whose magnitude exceeds this multiple is replaced. Lower values blank more aggressively; higher values only catch strong impulses. |
| Field | Type | Default | Range | Description |
|-------------|--------|-----------|---------|-------------|
| `enabled` | bool | false | — | Turn the noise blanker on or off. |
| `threshold` | float | 10.0 | 1 100 | Multiplier applied to the tracked noise floor. A sample whose magnitude exceeds this multiple is blanked. Lower values blank more aggressively; higher values only catch strong impulses. |
| `profile` | string | `"spike"` | see below | Tuning profile matched to the interference source. |
The noise blanker is off by default.
### Profiles
Each profile sets the blank-window width, look-ahead, gate taper, and
noise-floor time constant. Pick the one that matches what you are hearing, then
fine-tune with the threshold.
| Profile | Blank window | Best for |
|--------------|--------------|----------|
| `spike` | Narrowest | Sharp, sparse impulses — ignition sparks, static crashes, keyed relays. The safe default: minimal impact on the wanted signal, good for SSB/CW/digital. |
| `ignition` | Medium | Automotive ignition, electric fences, PWM/LED drivers — clusters of medium-width pulses at a high repetition rate. |
| `powerline` | Wide | Power-line and arcing noise — buzzy bursts locked to the 100/120 Hz mains cycle. Uses a slower floor tracker to ride out the burst. |
| `broadband` | Widest | Dense, continuous impulse noise where suppression matters more than fidelity. Most aggressive gating; expect some softening of the wanted signal. |
### Choosing a threshold
The threshold controls how aggressively the blanker suppresses impulses.
@@ -540,39 +850,44 @@ the running average signal level.
### Web UI
When the server reports noise-blanker support, two controls appear in the
When the server reports noise-blanker support, these controls appear in the
**SDR Settings** row of the web interface:
- **Noise Blanker** checkbox — enables or disables the blanker in real time.
The **N** keyboard shortcut toggles it too.
- **NB Threshold** number input (1100) with a **Set** button — adjusts the
detection threshold. Press Enter or click Set to apply.
detection sensitivity. Press Enter or click Set to apply.
- **NB profile** selector — chooses the profile (Spike / Ignition / Powerline /
Broadband). Changing it applies immediately.
Both controls stay hidden until the server sends filter state containing NB
The controls stay hidden until the server sends filter state containing NB
fields, so they only appear when connected to an SDR backend.
### HTTP API
```
POST /set_sdr_noise_blanker?enabled=true&threshold=10
POST /set_sdr_noise_blanker?enabled=true&threshold=10&profile=spike
```
| Parameter | Type | Required | Description |
|-------------|--------|----------|-------------|
| `enabled` | bool | yes | `true` or `false` |
| `threshold` | float | yes | Value between 1 and 100 |
| `profile` | string | no | `spike` (default), `ignition`, `powerline`, or `broadband` |
### How it works
The blanker runs on every IQ block (4096 samples) *before* the mixer stage in
the DSP pipeline:
The blanker runs on every IQ block *before* the mixer stage in the DSP pipeline,
one sample at a time:
1. For each sample, compute magnitude² (`re² + im²`).
2. Compare against `threshold² × mean_sq` (the exponentially-smoothed running
mean of magnitude²).
3. If the sample exceeds the threshold, replace it with the previous clean
sample.
4. Otherwise, update the running mean with smoothing factor α = 1/128 and store
the sample as the last clean value.
1. Emit the sample from the look-ahead delay line and ingest the fresh one.
2. Compute the fresh sample's power (`re² + im²`) and compare it against
`threshold² × noise_floor`.
3. If it exceeds the threshold, hold the gate closed for the profile's blank
window; the fresh sample reaches the output a few samples later, by which
time the gate has fully ramped to zero — so the leading edge is removed.
4. Otherwise, update the noise-floor estimate (skipped while blanking) and let
the gate ramp back open.
Because the blanker operates on raw IQ before frequency translation, it removes
impulse noise across the entire captured bandwidth regardless of the tuned
+38
View File
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# systemd *user* unit for the trx-rs radio client and web frontend.
#
# Install with `script/install.sh` (which substitutes @BINDIR@ and copies this
# into ~/.config/systemd/user/), or by hand:
# sed "s|@BINDIR@|$HOME/.local/bin|" trx-client.service \
# > ~/.config/systemd/user/trx-client.service
# systemctl --user daemon-reload
# systemctl --user enable --now trx-client.service
#
# Reads the combined config at ~/.config/trx-rs/trx-rs.toml ([trx-client]
# section) and connects to the server (default 127.0.0.1:4530). The web UI
# defaults to http://127.0.0.1:8080.
[Unit]
Description=trx-rs radio client and web frontend
Documentation=https://github.com/stanislawgrams/trx-rs
# Wait for networking and, when it is enabled, the local server. After= only
# orders units already in the transaction: it deliberately does not start a
# disabled trx-server, since the client may connect to a remote server instead.
Wants=network-online.target
After=network-online.target trx-server.service
[Service]
Type=simple
ExecStart=@BINDIR@/trx-client --config %h/.config/trx-rs/trx-rs.toml
Restart=on-failure
RestartSec=2
# Shuts down cleanly on SIGINT (see trx-server.service for the rationale).
KillSignal=SIGINT
TimeoutStopSec=15
NoNewPrivileges=true
[Install]
WantedBy=default.target
+38
View File
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# systemd *user* unit for the trx-rs radio server.
#
# Install with `script/install.sh` (which substitutes @BINDIR@ and copies this
# into ~/.config/systemd/user/), or by hand:
# sed "s|@BINDIR@|$HOME/.local/bin|" trx-server.service \
# > ~/.config/systemd/user/trx-server.service
# systemctl --user daemon-reload
# systemctl --user enable --now trx-server.service
#
# Reads the combined config at ~/.config/trx-rs/trx-rs.toml ([trx-server]
# section). Serial (/dev/ttyUSB*) and audio access require your user to be in
# the `dialout` and `audio` groups — a user unit cannot grant them itself.
[Unit]
Description=trx-rs radio server (CAT/SDR backend)
Documentation=https://github.com/stanislawgrams/trx-rs
# The server binds its control/audio TCP listeners and may connect to networked
# rigs and reporting services, so do not start it before networking is ready.
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
ExecStart=@BINDIR@/trx-server --config %h/.config/trx-rs/trx-rs.toml
Restart=on-failure
RestartSec=2
# Both binaries shut down cleanly on SIGINT (the server drains audio and exits
# 0); systemd's default SIGTERM is not handled, so signal SIGINT instead.
KillSignal=SIGINT
TimeoutStopSec=15
NoNewPrivileges=true
[Install]
WantedBy=default.target
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Build trx-rs in release mode and install the binaries system-wide, seed a
# per-user config, and install systemd *user* services to run the server and
# client. Idempotent: existing config is never overwritten.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
BINARIES=(trx-server trx-client trx-configurator)
# --- Defaults (override via flags or environment) ---------------------------
PREFIX="${PREFIX:-$HOME/.local}"
BINDIR="${BINDIR:-}" # derived from PREFIX unless set explicitly
NO_SDR=0
DO_BUILD=1
DO_SYSTEMD=1
ENABLE_NOW=0
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/trx-rs"
CONFIG_FILE="$CONFIG_DIR/trx-rs.toml"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
usage() {
cat <<EOF
Usage: script/install.sh [options]
Build trx-rs (release) and install it.
Options:
--prefix DIR Install prefix; binaries go to DIR/bin (default: \$HOME/.local)
--bindir DIR Install binaries directly to DIR (overrides --prefix)
--no-sdr Build without SoapySDR support (--no-default-features)
--no-build Skip cargo build; install existing target/release binaries
--no-systemd Do not install the systemd user services
--enable-now Enable and start the user services immediately after install
-h, --help Show this help
Environment:
PREFIX, BINDIR Same as the matching flags.
Installs:
binaries -> ${BINDIR:-$PREFIX/bin} (uses sudo if not writable)
config -> $CONFIG_FILE (from trx-rs.toml.example, only if absent)
services -> $UNIT_DIR/{trx-server,trx-client}.service
EOF
}
# --- Parse args -------------------------------------------------------------
while [ $# -gt 0 ]; do
case "$1" in
--prefix) PREFIX="$2"; shift 2 ;;
--bindir) BINDIR="$2"; shift 2 ;;
--no-sdr) NO_SDR=1; shift ;;
--no-build) DO_BUILD=0; shift ;;
--no-systemd) DO_SYSTEMD=0; shift ;;
--enable-now) ENABLE_NOW=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown option '$1'" >&2; usage >&2; exit 2 ;;
esac
done
[ -n "$BINDIR" ] || BINDIR="$PREFIX/bin"
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; }
# Return 0 (true) if writing into $1 needs elevated privileges.
need_sudo() {
local dir="$1"
while [ ! -e "$dir" ]; do dir="$(dirname "$dir")"; done
[ -w "$dir" ] && return 1 || return 0
}
# --- Build ------------------------------------------------------------------
if [ "$DO_BUILD" -eq 1 ]; then
build_args=(build --release --manifest-path "$PROJECT_ROOT/Cargo.toml")
for b in "${BINARIES[@]}"; do build_args+=(-p "$b"); done
if [ "$NO_SDR" -eq 1 ]; then
# --no-default-features only affects trx-server; harmless elsewhere.
build_args+=(-p trx-server --no-default-features)
log "Building (release, no SDR)…"
else
log "Building (release, with SDR)…"
fi
cargo "${build_args[@]}"
fi
# --- Install binaries -------------------------------------------------------
SUDO=""
if need_sudo "$BINDIR"; then
SUDO="sudo"
log "Installing binaries to $BINDIR (using sudo)…"
else
log "Installing binaries to $BINDIR"
fi
for b in "${BINARIES[@]}"; do
src="$PROJECT_ROOT/target/release/$b"
if [ ! -x "$src" ]; then
echo "error: $src not found (build failed or --no-build with no prior build?)" >&2
exit 1
fi
$SUDO install -Dm755 "$src" "$BINDIR/$b"
log "installed $BINDIR/$b"
done
# --- Seed per-user config ---------------------------------------------------
if [ -e "$CONFIG_FILE" ]; then
log "Config already present, leaving it untouched: $CONFIG_FILE"
else
mkdir -p "$CONFIG_DIR"
install -m600 "$PROJECT_ROOT/trx-rs.toml.example" "$CONFIG_FILE"
log "Seeded config from example: $CONFIG_FILE"
warn "Edit $CONFIG_FILE for your rig before starting the services."
fi
# --- systemd user services --------------------------------------------------
if [ "$DO_SYSTEMD" -eq 1 ]; then
mkdir -p "$UNIT_DIR"
for unit in trx-server trx-client; do
sed "s|@BINDIR@|$BINDIR|g" \
"$PROJECT_ROOT/packaging/systemd/$unit.service" \
> "$UNIT_DIR/$unit.service"
log "installed $UNIT_DIR/$unit.service"
done
if command -v systemctl >/dev/null 2>&1; then
systemctl --user daemon-reload || warn "systemctl --user daemon-reload failed"
if [ "$ENABLE_NOW" -eq 1 ]; then
log "Enabling and starting user services…"
systemctl --user enable --now trx-server.service trx-client.service
warn "Run 'loginctl enable-linger $USER' to keep services running after logout."
else
cat <<EOF
Next steps:
1. Edit your config: \$EDITOR $CONFIG_FILE
2. Start the services: systemctl --user enable --now trx-server trx-client
3. Watch the logs: journalctl --user -u trx-server -u trx-client -f
4. Run after logout: loginctl enable-linger $USER
Web UI (default): http://127.0.0.1:8080
EOF
fi
else
warn "systemctl not found; units copied but not reloaded."
fi
fi
log "Done."
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Reverse script/install.sh: stop and remove the systemd user services and the
# installed binaries. User config (~/.config/trx-rs) is left in place unless
# --purge is given.
set -euo pipefail
BINARIES=(trx-server trx-client trx-configurator)
PREFIX="${PREFIX:-$HOME/.local}"
BINDIR="${BINDIR:-}"
PURGE=0
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/trx-rs"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
usage() {
cat <<EOF
Usage: script/uninstall.sh [options]
Options:
--prefix DIR Install prefix binaries were installed under (default: \$HOME/.local)
--bindir DIR Directory binaries were installed to (overrides --prefix)
--purge Also delete the user config directory ($CONFIG_DIR)
-h, --help Show this help
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--prefix) PREFIX="$2"; shift 2 ;;
--bindir) BINDIR="$2"; shift 2 ;;
--purge) PURGE=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown option '$1'" >&2; usage >&2; exit 2 ;;
esac
done
[ -n "$BINDIR" ] || BINDIR="$PREFIX/bin"
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
need_sudo() {
local dir="$1"
while [ ! -e "$dir" ]; do dir="$(dirname "$dir")"; done
[ -w "$dir" ] && return 1 || return 0
}
# --- Stop and remove user services -----------------------------------------
if command -v systemctl >/dev/null 2>&1; then
log "Stopping and disabling user services…"
systemctl --user disable --now trx-client.service trx-server.service 2>/dev/null || true
fi
for unit in trx-client trx-server; do
if [ -e "$UNIT_DIR/$unit.service" ]; then
rm -f "$UNIT_DIR/$unit.service"
log "removed $UNIT_DIR/$unit.service"
fi
done
if command -v systemctl >/dev/null 2>&1; then
systemctl --user daemon-reload || true
fi
# --- Remove binaries --------------------------------------------------------
SUDO=""
if need_sudo "$BINDIR"; then SUDO="sudo"; fi
for b in "${BINARIES[@]}"; do
if [ -e "$BINDIR/$b" ]; then
$SUDO rm -f "$BINDIR/$b"
log "removed $BINDIR/$b"
fi
done
# --- Optionally purge config ------------------------------------------------
if [ "$PURGE" -eq 1 ]; then
rm -rf "$CONFIG_DIR"
log "purged $CONFIG_DIR"
else
log "Left config in place: $CONFIG_DIR (use --purge to delete)"
fi
log "Done."
+13 -1
View File
@@ -45,8 +45,20 @@ impl WefaxConfig {
60.0 / lpm as f32
}
/// Samples per line at the internal sample rate.
/// Samples per line at the internal sample rate (rounded to an integer;
/// use [`Self::samples_per_line_f64`] for drift-free line slicing).
pub fn samples_per_line(lpm: u16, sample_rate: u32) -> usize {
(Self::line_duration_s(lpm) * sample_rate as f32).round() as usize
}
/// Exact (fractional) samples per line at the internal sample rate.
///
/// The line period rarely lands on an integer number of samples
/// (e.g. 120 LPM at 11 025 Hz is 5512.5 samples). Slicing on the rounded
/// integer accumulates a fractional-sample error every line, which shows
/// up as a slow horizontal slant over a tall image. Line boundaries are
/// instead computed from this exact value so the error never accumulates.
pub fn samples_per_line_f64(lpm: u16, sample_rate: u32) -> f64 {
60.0 / f64::from(lpm) * f64::from(sample_rate)
}
}
+86 -5
View File
@@ -38,10 +38,24 @@ const SIGNAL_DETECT_WINDOWS: u32 = 6;
/// Real WEFAX content typically shows r > 0.5 between adjacent lines.
const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2;
/// Number of consecutive uncorrelated scan lines that trigger auto-finalize
/// while receiving. At 120 LPM this is 15 s; at 60 LPM it's 30 s. Modelled on
/// fldigi's line-to-line correlation check for automatic stop.
const LINE_CORR_NOISE_LINES: u32 = 30;
/// Number of consecutive uncorrelated scan lines that mark the end of a
/// transmission (carrier truly gone) and trigger auto-finalize. This must be
/// long enough to ride through ordinary HF fading (QSB), which routinely
/// decorrelates adjacent lines for several seconds without the transmission
/// having ended. At 120 LPM this is ~60 s; at 60 LPM ~120 s.
///
/// A short window here is what previously chopped a single chart into many
/// PNG "chunks": a 15 s fade tripped the watchdog, the image was finalized,
/// and the still-present carrier immediately re-started a fresh image. Real
/// WEFAX decoders (fldigi) keep one continuous image per APT cycle up to a
/// large line cap and only stop on the APT stop tone or genuine signal loss.
const LINE_CORR_NOISE_LINES: u32 = 120;
/// Hard cap on lines in a single image. A 120 LPM chart runs ~10 min
/// (~1200 lines); this cap (≈25 min at 120 LPM) only bounds memory if a
/// session is left running on an open carrier. On reaching it the image is
/// finalized and the decoder waits for a fresh APT start.
const MAX_IMAGE_LINES: u32 = 3000;
/// Maximum number of scan-line-equivalent sample windows to wait for phasing
/// lock before falling through to Receiving. Typical WEFAX phasing lasts
@@ -107,6 +121,12 @@ pub struct WefaxDecoder {
/// the decoder falls through to Receiving so a noisy or partial
/// phasing signal doesn't wedge the state machine.
phasing_samples: u64,
/// Whether a reception has already been auto-started from bare signal
/// variance during this session. After the first image, a new one is only
/// started by an APT start tone — this stops the trailing noise / carrier
/// that follows one chart from immediately auto-starting another image
/// (the mechanism that fragmented a transmission into many chunks).
auto_start_used: bool,
/// Current rig dial frequency in Hz (for image filenames).
freq_hz: u64,
/// Current rig mode name (for image filenames).
@@ -135,6 +155,7 @@ impl WefaxDecoder {
signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2),
low_corr_lines: 0,
phasing_samples: 0,
auto_start_used: false,
freq_hz: 0,
mode: String::new(),
}
@@ -204,7 +225,13 @@ impl WefaxDecoder {
// Fallback: detect active WEFAX signal by luminance variance.
// Like fldigi's "strong image signal" detection — if we see
// sustained modulated signal, auto-start receiving with defaults.
if self.state == State::Idle {
//
// Only ever taken once per session: it exists to catch a user
// tuning in mid-image with no APT start. After the first image,
// a new reception requires an APT start tone, so the residual
// carrier / noise that trails a finished chart cannot silently
// spawn a second image (which is what produced many chunks).
if self.state == State::Idle && !self.auto_start_used {
self.signal_detect_buf.extend_from_slice(&luminance);
let window_size = INTERNAL_RATE as usize / 2;
while self.signal_detect_buf.len() >= window_size {
@@ -340,6 +367,14 @@ impl WefaxDecoder {
break;
}
// Bound memory on an open carrier: finalize and wait
// for a fresh APT start rather than growing forever.
if count >= MAX_IMAGE_LINES {
debug!(lines = count, "WEFAX: max image lines — finalizing");
carrier_lost = true;
break;
}
// Emit progress event.
if self.config.emit_progress && count % PROGRESS_INTERVAL == 0 {
let line_data =
@@ -402,6 +437,7 @@ impl WefaxDecoder {
self.signal_detect_buf.clear();
self.low_corr_lines = 0;
self.phasing_samples = 0;
self.auto_start_used = false;
events
}
@@ -434,6 +470,7 @@ impl WefaxDecoder {
fn transition_to_start_detected(&mut self, ioc: u16) -> WefaxEvent {
let ioc = self.config.ioc.unwrap_or(ioc);
debug!(ioc, "WEFAX: APT start detected");
self.auto_start_used = true;
self.state = State::StartDetected { ioc };
self.reception_start_ms = Some(
std::time::SystemTime::now()
@@ -463,6 +500,7 @@ impl WefaxDecoder {
self.image = Some(ImageAssembler::new(ppl));
self.tone_detector.reset();
self.low_corr_lines = 0;
self.auto_start_used = true;
self.state = State::Receiving { ioc, lpm };
self.state_event("Receiving", ioc, lpm)
}
@@ -596,4 +634,47 @@ mod tests {
dec.reset();
assert_eq!(dec.state, State::Idle);
}
/// Regression test for the over-chunking bug: once a session has produced
/// an image, the trailing carrier / noise must not silently auto-start a
/// second image. Only an APT start tone (or an explicit reset) may begin a
/// new reception after the first.
#[test]
fn variance_auto_start_only_fires_once_per_session() {
let mut dec = WefaxDecoder::new(11025, WefaxConfig::default());
// A transition rate that matches no APT tone → drives the variance
// based "strong signal" auto-start rather than a tone detection.
let strong = generate_apt_start(100.0, 11025, 4.0);
dec.process_samples(&strong);
assert!(
matches!(dec.state, State::Receiving { .. }),
"strong signal should auto-start one image, got {:?}",
dec.state
);
assert!(dec.auto_start_used);
// Simulate the image ending on carrier loss / stop (finalize → idle)
// WITHOUT an operator reset.
dec.transition_to_idle();
assert_eq!(dec.state, State::Idle);
// The still-present carrier must NOT spawn a second image.
dec.process_samples(&generate_apt_start(100.0, 11025, 4.0));
assert_eq!(
dec.state,
State::Idle,
"trailing signal must not re-auto-start a fresh image"
);
// An explicit reset re-arms mid-image auto-start.
dec.reset();
assert!(!dec.auto_start_used);
dec.process_samples(&generate_apt_start(100.0, 11025, 4.0));
assert!(
matches!(dec.state, State::Receiving { .. }),
"reset should re-arm variance auto-start, got {:?}",
dec.state
);
}
}
+54 -9
View File
@@ -12,8 +12,8 @@ use crate::config::WefaxConfig;
/// Line slicer for WEFAX image assembly.
pub struct LineSlicer {
/// Samples per line at the internal sample rate.
samples_per_line: usize,
/// Exact (fractional) samples per line at the internal sample rate.
samples_per_line: f64,
/// Pixels per line (IOC × π).
pixels_per_line: usize,
/// Phase offset in samples from the phasing detector.
@@ -22,22 +22,37 @@ pub struct LineSlicer {
buffer: Vec<f32>,
/// Whether we have aligned to the phase offset yet.
aligned: bool,
/// Index of the next line to emit. Boundaries are derived from this
/// against the exact fractional line length so rounding never accumulates.
line_index: u64,
}
impl LineSlicer {
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self {
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
let samples_per_line = WefaxConfig::samples_per_line_f64(lpm, sample_rate);
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
Self {
samples_per_line,
pixels_per_line,
phase_offset,
buffer: Vec::with_capacity(samples_per_line * 2),
buffer: Vec::with_capacity(samples_per_line as usize * 2),
aligned: false,
line_index: 0,
}
}
/// Number of samples in line `n`, from the exact fractional line length.
///
/// Boundaries are `round(n · spl)`; the per-line length is the difference
/// of successive boundaries, so lengths alternate (e.g. 5513/5512 for
/// 120 LPM at 11 025 Hz) with no cumulative drift.
fn line_len(&self, n: u64) -> usize {
let start = (n as f64 * self.samples_per_line).round() as u64;
let end = ((n + 1) as f64 * self.samples_per_line).round() as u64;
(end - start) as usize
}
/// Feed luminance samples and extract complete image lines.
///
/// Returns a vector of completed lines, each as a `Vec<u8>` of
@@ -56,12 +71,18 @@ impl LineSlicer {
}
// Extract complete lines (single drain at the end to avoid O(n²)).
// Line boundaries follow the exact fractional line length so the
// sample clock stays locked over a tall image (no accumulating slant).
let mut offset = 0;
while offset + self.samples_per_line <= self.buffer.len() {
let line_samples = &self.buffer[offset..offset + self.samples_per_line];
let pixels = self.resample_line(line_samples);
lines.push(pixels);
offset += self.samples_per_line;
loop {
let len = self.line_len(self.line_index);
if offset + len > self.buffer.len() {
break;
}
let line_samples = &self.buffer[offset..offset + len];
lines.push(self.resample_line(line_samples));
offset += len;
self.line_index += 1;
}
if offset > 0 {
self.buffer.drain(..offset);
@@ -77,6 +98,7 @@ impl LineSlicer {
pub fn reset(&mut self) {
self.buffer.clear();
self.aligned = false;
self.line_index = 0;
}
/// Resample a line's worth of luminance samples to the target pixel count
@@ -129,6 +151,29 @@ mod tests {
assert!(lines[0].iter().all(|&p| p == 255));
}
#[test]
fn slicer_no_cumulative_drift() {
// 120 LPM at 11 025 Hz is 5512.5 samples/line — not an integer. Slicing
// on the rounded value (5513) would lose a line every ~11 000 lines and
// slant the image; the fractional boundaries must not accumulate error.
let lpm = 120;
let ioc = 576;
let sr = 11025;
let spl = WefaxConfig::samples_per_line_f64(lpm, sr);
assert_ne!(spl.fract(), 0.0, "test premise: spl is fractional");
let mut slicer = LineSlicer::new(lpm, ioc, sr, 0);
let total = (spl * 1000.0).round() as usize;
let samples = vec![1.0f32; total];
let lines = slicer.process(&samples);
assert_eq!(
lines.len(),
1000,
"exactly 1000 lines should fit in {} samples with no drift",
total
);
}
#[test]
fn slicer_linear_interpolation() {
let lpm = 120;
+5 -1
View File
@@ -77,8 +77,12 @@ pub struct ToneDetector {
impl ToneDetector {
pub fn new(sample_rate: u32) -> Self {
// APT start/stop tones are transmitted for ~5 s (WMO), so requiring a
// 2 s sustain costs no real detection latency while sharply cutting
// false positives from busy image content that momentarily produces a
// 300/450/675-transitions-per-second rate.
let window_size = (sample_rate / 2) as usize; // ~0.5 s window
let min_sustain_s = 1.0; // fldigi uses 2 consecutive half-second windows
let min_sustain_s = 2.0;
let window_duration_s = window_size as f32 / sample_rate as f32;
let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32;
-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" }
File diff suppressed because it is too large Load Diff
+150 -13
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
@@ -162,11 +252,17 @@ async fn async_init() -> DynResult<AppState> {
// Set HTTP frontend authentication config
frontend_runtime.http_auth.enabled = cfg.frontends.http.auth.enabled;
frontend_runtime.http_auth.rx_passphrase = cfg.frontends.http.auth.rx_passphrase.clone();
frontend_runtime.http_auth.control_passphrase =
cfg.frontends.http.auth.control_passphrase.clone();
frontend_runtime.http_auth.tx_access_control_enabled =
cfg.frontends.http.auth.tx_access_control_enabled;
frontend_runtime.http_auth.users_file = cfg.frontends.http.auth.users_file.clone();
frontend_runtime.http_auth.bootstrap_admin_username =
cfg.frontends.http.auth.bootstrap_admin_username.clone();
frontend_runtime.http_auth.bootstrap_admin_password =
cfg.frontends.http.auth.bootstrap_admin_password.clone();
frontend_runtime.http_auth.bootstrap_read_enabled =
cfg.frontends.http.auth.bootstrap_read_enabled;
frontend_runtime.http_auth.bootstrap_read_username =
cfg.frontends.http.auth.bootstrap_read_username.clone();
frontend_runtime.http_auth.bootstrap_read_password =
cfg.frontends.http.auth.bootstrap_read_password.clone();
frontend_runtime.http_auth.session_ttl_secs = cfg.frontends.http.auth.session_ttl().as_secs();
frontend_runtime.http_auth.cookie_secure = cfg.frontends.http.auth.cookie_secure;
frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site {
@@ -189,6 +285,10 @@ async fn async_init() -> DynResult<AppState> {
.http
.decode_history_retention_min_by_rig
.clone();
frontend_runtime.http_ui.logbook_path = cfg.logbook.path.clone();
// The callsign the station is on the air with, which every contact is
// logged under and which the operator defaults to.
frontend_runtime.http_ui.station_callsign = cfg.general.callsign.clone();
// Resolve remote entries: CLI --url > [[remotes]] > legacy [remote] > error
let resolved_remotes: Vec<RemoteEntry> = if let Some(ref url) = cli.url {
@@ -198,10 +298,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 +366,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 +494,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 +514,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(),
+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())),
+18 -6
View File
@@ -257,9 +257,12 @@ impl Default for DecodeHistoryContext {
/// HTTP authentication configuration.
pub struct HttpAuthConfig {
pub enabled: bool,
pub rx_passphrase: Option<String>,
pub control_passphrase: Option<String>,
pub tx_access_control_enabled: bool,
pub users_file: String,
pub bootstrap_admin_username: Option<String>,
pub bootstrap_admin_password: Option<String>,
pub bootstrap_read_enabled: bool,
pub bootstrap_read_username: String,
pub bootstrap_read_password: Option<String>,
pub session_ttl_secs: u64,
pub cookie_secure: bool,
pub cookie_same_site: String,
@@ -271,9 +274,12 @@ impl Default for HttpAuthConfig {
fn default() -> Self {
Self {
enabled: false,
rx_passphrase: None,
control_passphrase: None,
tx_access_control_enabled: true,
users_file: "trx-http-users.json".to_string(),
bootstrap_admin_username: None,
bootstrap_admin_password: None,
bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
session_ttl_secs: 480 * 60,
cookie_secure: false,
cookie_same_site: "Lax".to_string(),
@@ -292,6 +298,10 @@ pub struct HttpUiConfig {
pub bandplan_region: String,
pub decode_history_retention_min: u64,
pub decode_history_retention_min_by_rig: HashMap<String, u64>,
/// Where the station log is kept; `None` takes the default.
pub logbook_path: Option<String>,
/// The callsign contacts are logged under, and the operator by default.
pub station_callsign: Option<String>,
}
impl Default for HttpUiConfig {
@@ -305,6 +315,8 @@ impl Default for HttpUiConfig {
bandplan_region: "iaru_r1".to_string(),
decode_history_retention_min: 24 * 60,
decode_history_retention_min_by_rig: HashMap::new(),
logbook_path: None,
station_callsign: None,
}
}
}
@@ -11,6 +11,8 @@ build = "build.rs"
[dependencies]
trx-core = { path = "../../../trx-core" }
trx-frontend = { path = ".." }
trx-logbook = { path = "../../trx-logbook" }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
trx-protocol = { path = "../../../../src/trx-protocol" }
tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] }
@@ -26,7 +28,11 @@ flate2 = { workspace = true }
brotli = "7"
rand = "0.8"
hex = "0.4"
argon2 = "0.5"
pickledb = "0.5"
dirs = "6"
uuid = { workspace = true }
ts-rs = "12.0.1"
[dev-dependencies]
tempfile = "3"
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore,
hostState
@@ -140,18 +144,22 @@ function aisLatestByVessel(messages) {
}
return Array.from(byMmsi.values());
}
function aisRigMessages() {
return forActiveRig(aisMessageHistory);
}
function updateAisSummary() {
const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
}
const vessels = aisLatestByVessel(aisMessageHistory);
const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
if (aisVesselCountEl) {
const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
}
if (aisLatestSeenEl) {
const latest = aisMessageHistory[0];
const latest = rigMessages[0];
if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet";
} else {
@@ -222,7 +230,9 @@ function updateAisBar() {
updateAisSummary();
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
const recent = aisMessageHistory.filter(
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)
);
const messages = aisLatestByVessel(recent).slice(0, 8);
if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none";
@@ -267,7 +277,7 @@ function renderAisHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of aisMessageHistory) {
for (const message of aisRigMessages()) {
fragment.appendChild(renderAisRow(message));
}
aisMessagesEl.replaceChildren(fragment);
@@ -299,10 +309,10 @@ function normalizeServerAisMessage(msg) {
}
function onServerAisBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -342,8 +352,9 @@ if (aisFilterInput) {
});
}
function onServerAis(msg) {
if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg));
const message = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
}
updateAisSummary();
window.trxPluginRuntime.registerDecoder({
@@ -353,6 +364,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
rerender: () => {
updateAisBar();
renderAisHistory();
},
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
@@ -1,3 +1,20 @@
import {
AUTH_ADMIN_ROLES,
AUTH_ROLES,
AUTH_ROLE_LABELS,
changeOwnPassword,
createUser,
deleteUser,
fetchAuthSession,
hasAccountControls,
hasAuthRole,
listUsers,
login,
logout,
normalizeAuthRoles,
updateUser
} from "./chunk-PISLBJGN.js";
// src/webgl-renderer.ts
(function initTrxWebGl(global) {
"use strict";
@@ -633,9 +650,12 @@ function elementById(id) {
compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" },
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" }
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
// Working the bands: the log is the task and the radio is the instrument,
// so this one opens on the logbook with the controls a keystroke away.
ham: { label: "Ham radio", unavailable: "Ham radio needs a rig that can transmit", advanced: true, audio: true, scheduler: false, preferredTab: "logbook", capability: "ham" }
};
const layoutCapabilities = { broadcast: false, digital: false };
const layoutCapabilities = { broadcast: false, digital: false, ham: false };
let activeRigId = null;
const layoutSections = [
{ id: "advanced-radio-controls", key: "advanced" },
@@ -885,7 +905,7 @@ function elementById(id) {
if (restoreFocus) more.focus();
};
api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return;
const item = document.createElement("button");
@@ -1065,6 +1085,9 @@ var runtime = {
plugin.prune();
return true;
},
rerenderAll() {
for (const plugin of decoders.values()) plugin.rerender?.();
},
syncMapAll() {
for (const plugin of decoders.values()) plugin.syncMap?.();
},
@@ -1308,60 +1331,6 @@ async function loadDecoderRegistry(onLoaded) {
bridge.decoderRegistry = decoderRegistry;
bridge.onDecoderRegistryReady = onDecoderRegistryReady;
// src/api/auth.ts
function decodeAuthSession(value) {
if (typeof value !== "object" || value === null) {
throw new TypeError("The authentication response is malformed");
}
const session = value;
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.role !== void 0 && session.role !== "rx" && session.role !== "control") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded = { authenticated: session.authenticated };
if (session.role !== void 0) decoded.role = session.role;
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
var authDisabledSession = {
authenticated: true,
role: "control",
auth_disabled: true
};
async function fetchAuthSession() {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false };
return decodeAuthSession(await response.json());
} catch (error) {
console.error("Auth check failed:", error);
return { authenticated: false };
}
}
async function login(passphrase) {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase })
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Login failed");
}
return decodeAuthSession(await response.json());
}
async function logout() {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
}
// src/core/format.ts
function formatDuration(milliseconds) {
const seconds = Math.floor(milliseconds / 1e3);
@@ -1527,8 +1496,10 @@ function decodeCbor(buffer) {
var TAB_ORDER = [
"main",
"bookmarks",
"logbook",
"digital-modes",
"map",
"satellites",
"statistics",
"recorder",
"settings",
@@ -1537,8 +1508,10 @@ var TAB_ORDER = [
var TAB_PATHS = {
main: "/",
bookmarks: "/bookmarks",
logbook: "/logbook",
"digital-modes": "/digital-modes",
map: "/map",
satellites: "/satellites",
statistics: "/statistics",
recorder: "/recorder",
settings: "/settings",
@@ -1756,8 +1729,10 @@ var pluginGroups = {
],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
logbook: ["/logbook.js"],
recorder: [],
settings: ["/vchan.js", "/scheduler.js"]
};
@@ -1782,7 +1757,7 @@ async function loadPlugins(group) {
for (const path of pluginGroups[group]) await loadPlugin(path);
}
async function loadEagerPlugins() {
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
await Promise.all(["digital-modes", "bookmarks", "logbook", "settings"].map(loadPlugins));
}
async function loadPluginsForTab(tab) {
await loadPlugins(tab);
@@ -1850,33 +1825,67 @@ function isVchanRdsEntry(value) {
return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number");
}
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null;
var authRoles = [];
var authUsername = null;
var authEnabled = true;
function setAuthRoles(roles) {
authRoles = normalizeAuthRoles(roles);
}
function hasAuthRole2(role) {
return hasAuthRole(authRoles, role);
}
function buildRoleChoices(selected) {
const element = document.createElement("span");
element.className = "auth-role-choices";
const inputs = AUTH_ROLES.map((value) => {
const label = document.createElement("label");
label.className = "auth-role-choice";
const input = document.createElement("input");
input.type = "checkbox";
input.value = value;
input.checked = selected.includes(value);
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
element.append(label);
return { input, value };
});
inputs.forEach(({ input, value }) => {
input.addEventListener("change", () => {
if (!input.checked) return;
if (value === "guest") {
const administrator = inputs.find((choice) => choice.value === "administrator");
if (administrator) administrator.input.checked = false;
} else if (value === "administrator") {
const guest = inputs.find((choice) => choice.value === "guest");
if (guest) guest.input.checked = false;
}
});
});
return { element, inputs };
}
async function checkAuthStatus() {
return fetchAuthSession();
}
async function authLogin(passphrase) {
return login(passphrase);
async function authLogin(username, password) {
return login(username, password);
}
async function authLogout() {
try {
await logout();
authRole = null;
setAuthRoles([]);
authUsername = null;
disconnect();
setDecodeHistoryOverlayVisible(false);
requiredElement("content").style.display = "none";
requiredElement("loading").style.display = "none";
requiredElement("auth-passphrase").value = "";
requiredElement("auth-password").value = "";
updateAuthUI();
const authStatus = await checkAuthStatus();
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
} catch (e) {
console.error("Logout failed:", e);
showAuthError("Logout failed");
}
}
function showAuthGate(allowGuest = false) {
function showAuthGate() {
if (!authEnabled) return;
setDecodeHistoryOverlayVisible(false);
requiredElement("loading").style.display = "none";
@@ -1893,10 +1902,6 @@ function showAuthGate(allowGuest = false) {
document.querySelectorAll(".tab-panel").forEach((panel) => {
panel.style.display = "none";
});
const guestBtn2 = document.getElementById("auth-guest-btn");
if (guestBtn2) {
guestBtn2.style.display = allowGuest ? "block" : "none";
}
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === "main");
});
@@ -1932,20 +1937,30 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn2 = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) {
if (badge) badge.style.display = "none";
if (headerAuthBtn2) headerAuthBtn2.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess();
return;
}
if (authRole) {
if (authRoles.length > 0) {
const canManageAccount = hasAccountControls(authRoles);
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
if (!canManageAccount && accountTab?.classList.contains("active")) {
const panel = document.getElementById("subtab-settings-account");
if (panel) panel.style.display = "none";
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
}
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Logout";
headerAuthBtn2.style.display = "block";
}
} else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none";
if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Login";
@@ -1955,31 +1970,34 @@ function updateAuthUI() {
syncTopBarAccess();
}
function applyAuthRestrictions() {
if (!authRole) return;
if (authRole === "rx") {
if (authRoles.length === 0) return;
if (!hasAuthRole2("transmit")) {
const pttBtn2 = document.getElementById("ptt-btn");
const txLimitInput2 = document.getElementById("tx-limit");
const txLimitBtn2 = document.getElementById("tx-limit-btn");
const txAudioBtn2 = document.getElementById("tx-audio-btn");
const txLimitRow2 = document.getElementById("tx-limit-row");
if (pttBtn2) pttBtn2.disabled = true;
if (txAudioBtn2) txAudioBtn2.disabled = true;
if (txLimitBtn2) txLimitBtn2.disabled = true;
if (txLimitInput2) txLimitInput2.disabled = true;
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
}
if (!hasAuthRole2("control")) {
const powerBtn2 = document.getElementById("power-btn");
const lockBtn2 = document.getElementById("lock-btn");
const freqInput = document.getElementById("freq");
const centerFreqInput = document.getElementById("center-freq");
const modeSelect = document.getElementById("mode");
const txLimitInput2 = document.getElementById("tx-limit");
const txLimitBtn2 = document.getElementById("tx-limit-btn");
const txAudioBtn2 = document.getElementById("tx-audio-btn");
const txLimitRow2 = document.getElementById("tx-limit-row");
const jogUp = document.getElementById("jog-up");
const jogDown = document.getElementById("jog-down");
const jogButtons = document.querySelectorAll(".jog-step button");
const vfoButtons = document.querySelectorAll("#vfo-picker button");
if (pttBtn2) pttBtn2.disabled = true;
if (powerBtn2) powerBtn2.disabled = true;
if (lockBtn2) lockBtn2.disabled = true;
if (txAudioBtn2) txAudioBtn2.disabled = true;
if (txLimitBtn2) txLimitBtn2.disabled = true;
if (freqInput) freqInput.disabled = true;
if (centerFreqInput) centerFreqInput.disabled = true;
if (modeSelect) modeSelect.disabled = true;
if (txLimitInput2) txLimitInput2.disabled = true;
vfoButtons.forEach((btn) => btn.disabled = true);
const jogWheel2 = document.getElementById("jog-wheel");
if (jogUp) jogUp.disabled = true;
@@ -2016,7 +2034,6 @@ function applyAuthRestrictions() {
btn.disabled = true;
}
});
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
}
}
function applyCapabilities(caps) {
@@ -2246,20 +2263,21 @@ window.applyDecodeHistoryRetention = function() {
}
};
function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole;
const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector(".header-rig-switch");
if (tabBar) tabBar.style.display = "";
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main";
btn.style.display = !loggedOut || isMain ? "" : "none";
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole2("write");
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
btn.disabled = false;
});
if (rigSwitch) {
rigSwitch.style.display = loggedOut ? "none" : "";
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole2("control") || lastRigIds.length === 0;
}
}
var overviewDrawPending = false;
@@ -2895,13 +2913,13 @@ function applyRigList(activeRigId, rigIds, displayNames) {
}
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
const disableSwitch = lastRigIds.length === 0 || !hasAuthRole2("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
if (rigListChanged) {
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.populateScopePicker();
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
}
@@ -2944,7 +2962,10 @@ function refreshOperatorLayoutCapabilities() {
));
window.trxUi?.setLayoutCapabilities({
broadcast: rigModes.some((modes) => modes.includes("WFM")),
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode)))
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))),
// A logbook is for contacts made, so the layout built around it is offered
// where a rig can transmit.
ham: serverRigs.some((rig) => rig?.tx === true)
});
}
function showHint(msg, duration) {
@@ -3552,6 +3573,13 @@ function positionRdsPsOverlay() {
function resetRdsDisplay() {
updateRdsPsOverlay(primaryRds);
}
function refreshDecodeBars() {
window.updateAisBar?.();
window.updateVdesBar?.();
window.updateAprsBar?.();
window.updateFt8Bar?.();
window.updateCwBar?.();
}
function resetDecoderStateOnRigSwitch() {
primaryRds = null;
vchanRdsById = /* @__PURE__ */ new Map();
@@ -3566,6 +3594,8 @@ function resetDecoderStateOnRigSwitch() {
const el = document.getElementById(id);
if (el) el.textContent = "--";
});
refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
}
function resetWfmStereoIndicator() {
if (!wfmStFlagEl) return;
@@ -3699,10 +3729,19 @@ function visibleBandwidthSpecs(freqHz = lastFreqHz, mode = modeEl ? modeEl.value
}
return [{ centerHz: freqHz, widthHz: currentBandwidthHz }];
}
function digSidebandDirection() {
if (sdrDigSidebandPolicy === "usb") return 1;
if (sdrDigSidebandPolicy === "lsb") return -1;
if (typeof lastFreqHz === "number" && isFiniteNumber(lastFreqHz)) {
return lastFreqHz >= DIG_AUTO_SIDEBAND_THRESHOLD_HZ ? 1 : -1;
}
return 1;
}
function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") {
const modeUpper = String(mode || "").toUpperCase();
if (modeUpper === "LSB" || modeUpper === "CWR") return -1;
if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1;
if (modeUpper === "DIG") return sdrDigSidebandSupported ? digSidebandDirection() : 1;
if (modeUpper === "USB" || modeUpper === "CW") return 1;
return 0;
}
function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") {
@@ -4221,9 +4260,16 @@ function formatSignal(sUnits) {
return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`;
}
function setDisabled(disabled) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
const controlDisabled = disabled || authEnabled && !hasAuthRole2("control");
const transmitDisabled = disabled || authEnabled && !hasAuthRole2("transmit");
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
if (el) el.disabled = controlDisabled;
});
[pttBtn, txLimitInput, txLimitBtn].forEach((el) => {
if (el) el.disabled = transmitDisabled;
});
const transmitAudio = document.getElementById("tx-audio-btn");
if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs;
syncModePicker();
}
var serverVersion = null;
@@ -4486,7 +4532,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return;
if (authRole === "rx") {
if (!hasAuthRole2("control")) {
showHint("Read-only session — link not applied", 2500);
return;
}
@@ -4720,6 +4766,25 @@ function render(update) {
sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold));
}
}
if (typeof update.filter.sdr_nb_profile === "string") {
sdrNbProfile = update.filter.sdr_nb_profile;
if (sdrNbProfileWrapEl) sdrNbProfileWrapEl.style.display = "";
if (sdrNbProfileEl && document.activeElement !== sdrNbProfileEl) {
sdrNbProfileEl.value = update.filter.sdr_nb_profile;
}
}
}
if (typeof update.filter.sdr_dig_sideband === "string") {
sdrDigSidebandSupported = true;
const prevPolicy = sdrDigSidebandPolicy;
sdrDigSidebandPolicy = update.filter.sdr_dig_sideband;
if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) {
sdrDigSidebandEl.value = update.filter.sdr_dig_sideband;
}
updateWfmControls();
if (prevPolicy !== sdrDigSidebandPolicy && lastSpectrumData) {
scheduleSpectrumDraw();
}
}
}
if (typeof update.show_sdr_gain_control === "boolean") {
@@ -4785,10 +4850,7 @@ function render(update) {
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
}
if (window.updateAisBar) window.updateAisBar();
if (window.updateVdesBar) window.updateVdesBar();
if (window.updateAprsBar) window.updateAprsBar();
if (window.updateFt8Bar) window.updateFt8Bar();
refreshDecodeBars();
for (const d of decoderRegistry) {
if (d.activation !== "toggle") continue;
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
@@ -5207,7 +5269,7 @@ async function postPath(path, options = {}) {
}
const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) {
authRole = null;
setAuthRoles([]);
if (es) es.close();
showAuthGate();
throw new Error("Authentication required");
@@ -5232,7 +5294,7 @@ async function switchRigFromSelect(selectEl) {
showHint("No rig selected", 1500);
return;
}
if (authRole === "rx") {
if (!hasAuthRole2("control")) {
showHint("Control role required", 1500);
return;
}
@@ -5844,9 +5906,15 @@ function _initMapWhenReady() {
}
function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate();
return;
}
if (authEnabled && name === "logbook" && !hasAuthRole2("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return;
}
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5873,7 +5941,11 @@ function navigateToTab(name, options = {}) {
updateTabHistory(name, replaceHistory);
}
scheduleSpectrumLayout();
void loadPluginsForTab(name).catch((error) => {
if (leavingSatellites) window.clearSatPredictionDom?.();
void loadPluginsForTab(name).then(() => {
if (name === "satellites") window.refreshSatPredictions?.();
flushPendingDecodeStats();
}).catch((error) => {
console.error(error);
});
if (name === "map") {
@@ -5955,11 +6027,11 @@ window.addEventListener("resize", () => {
scheduleSpectrumLayout();
});
async function initializeApp() {
showAuthGate(false);
showAuthGate();
const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
authRole = "control";
setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate();
updateAuthUI();
connect();
@@ -5970,7 +6042,8 @@ async function initializeApp() {
return;
}
if (authStatus.authenticated) {
authRole = authStatus.role ?? null;
setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null;
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -5980,30 +6053,191 @@ async function initializeApp() {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} else {
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
}
}
var settingsUiReady = false;
function initSettingsUI() {
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
}
async function refreshUserManagement() {
const section = document.getElementById("user-management");
const tab = document.getElementById("settings-users-tab");
if (!section || !tab) return;
const canManageUsers = authEnabled && hasAuthRole2("administrator");
tab.style.display = canManageUsers ? "" : "none";
if (!canManageUsers) {
const panel = document.getElementById("subtab-settings-users");
if (panel) panel.style.display = "none";
if (tab.classList.contains("active")) {
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
}
return;
}
const list = requiredElement("user-list");
try {
const users = await listUsers();
const enabledAdminCount = users.filter((user) => user.enabled && hasAuthRole(user.roles, "administrator")).length;
list.replaceChildren(...users.map((user) => {
const row = document.createElement("div");
row.className = "sch-row";
row.style.cssText = "display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:.4rem 0";
const name = document.createElement("strong");
name.textContent = user.username;
name.style.minWidth = "10rem";
if (!user.enabled) name.textContent += " (disabled)";
const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
const enabledLabel = document.createElement("label");
enabledLabel.className = "auth-role-choice";
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = user.enabled;
enabled.disabled = user.username === authUsername;
if (enabled.disabled) enabled.title = "You cannot disable your current account";
enabledLabel.append(enabled, " Enabled");
const isOnlyAdmin = user.enabled && hasAuthRole(user.roles, "administrator") && enabledAdminCount === 1;
const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input;
const guestInput = roleInputs.find((item) => item.value === "guest")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
administratorInput.title = "The final administrator cannot be demoted";
}
if (isOnlyAdmin && guestInput) {
guestInput.disabled = true;
guestInput.title = "The final administrator cannot become a Guest";
}
if (isOnlyAdmin) {
enabled.disabled = true;
enabled.title = "The final enabled administrator cannot be disabled";
}
const password = document.createElement("input");
password.type = "password";
password.placeholder = "New password (8+ characters)";
password.autocomplete = "new-password";
password.className = "auth-input";
password.minLength = 8;
password.maxLength = 1024;
const syncPasswordAccess = () => {
const guestActive = guestInput?.checked === true;
password.disabled = guestActive;
password.title = guestActive ? "Password changes are unavailable while the Guest role is active" : "";
if (guestActive) password.value = "";
};
roleInputs.forEach(({ input }) => input.addEventListener("change", syncPasswordAccess));
syncPasswordAccess();
const save = document.createElement("button");
save.type = "button";
save.textContent = "Save";
save.addEventListener("click", async () => {
const changes = {
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
enabled: enabled.checked
};
if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes));
});
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "Remove";
remove.className = "danger";
remove.disabled = user.username === authUsername || isOnlyAdmin;
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
remove.addEventListener("click", async () => {
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
await runUserOperation(() => deleteUser(user.username));
}
});
row.append(name, enabledLabel, roles, password, save, remove);
return row;
}));
} catch (error) {
showUserManagementError(error);
}
}
function showUserManagementError(error) {
const element = document.getElementById("user-management-error");
if (!element) return;
element.textContent = error instanceof Error ? error.message : String(error);
element.style.display = "block";
}
async function runUserOperation(operation) {
try {
await operation();
const error = document.getElementById("user-management-error");
if (error) error.style.display = "none";
await refreshUserManagement();
} catch (reason) {
showUserManagementError(reason);
}
}
var createRoleContainer = document.getElementById("user-create-roles");
if (createRoleContainer) {
const { element } = buildRoleChoices(["read"]);
element.id = createRoleContainer.id;
createRoleContainer.replaceWith(element);
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const username = requiredElement("user-create-username");
const password = requiredElement("user-create-password");
const enabled = requiredElement("user-create-enabled");
const roles = Array.from(document.querySelectorAll("#user-create-roles input[type=checkbox]"));
void runUserOperation(async () => {
await createUser(username.value, password.value, roles.filter((input) => input.checked).map((input) => input.value), enabled.checked);
username.value = "";
password.value = "";
enabled.checked = true;
roles.forEach((input) => {
input.checked = input.value === "read";
});
});
});
document.getElementById("account-password-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
const currentPassword = requiredElement("account-current-password");
const newPassword = requiredElement("account-new-password");
const confirmPassword = requiredElement("account-confirm-password");
const error = requiredElement("account-password-error");
const submit = form.querySelector('button[type="submit"]');
if (newPassword.value !== confirmPassword.value) {
error.textContent = "New passwords do not match";
error.style.display = "block";
return;
}
if (submit) submit.disabled = true;
void changeOwnPassword(currentPassword.value, newPassword.value).then(async () => {
form.reset();
error.style.display = "none";
await authLogout();
showHint("Password changed. Sign in again.", 3e3);
}).catch((reason) => {
error.textContent = reason instanceof Error ? reason.message : String(reason);
error.style.display = "block";
}).finally(() => {
if (submit) submit.disabled = false;
});
});
requiredElement("auth-form").addEventListener("submit", async (e) => {
e.preventDefault();
const passphraseEl = requiredElement("auth-passphrase");
const passphrase = passphraseEl.value;
const usernameEl = requiredElement("auth-username");
const passwordEl = requiredElement("auth-password");
const btn = requiredElement("auth-form").querySelector("button[type=submit]");
if (!btn) return;
btn.disabled = true;
btn.textContent = "Logging in...";
try {
const result = await authLogin(passphrase);
authRole = result.role ?? null;
passphraseEl.value = "";
const result = await authLogin(usernameEl.value, passwordEl.value);
setAuthRoles(result.roles);
authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -6013,37 +6247,22 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} catch (err) {
showAuthError("Invalid passphrase");
showAuthError("Invalid username or password");
console.error("Login error:", err);
} finally {
btn.disabled = false;
btn.textContent = "Login";
}
});
var guestBtn = document.getElementById("auth-guest-btn");
if (guestBtn) {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement("auth-passphrase").value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
connect();
connectDecode();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
});
}
var headerAuthBtn = document.getElementById("header-auth-btn");
if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => {
if (authRole) {
if (authRoles.length > 0) {
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
await authLogout();
}
} else {
showAuthGate(false);
showAuthGate();
}
});
}
@@ -6081,8 +6300,8 @@ Object.defineProperties(trxState, {
authEnabled: { get() {
return authEnabled;
} },
authRole: { get() {
return authRole;
authRoles: { get() {
return authRoles;
} },
decoderRegistry: { get() {
return decoderRegistry;
@@ -6255,7 +6474,9 @@ Object.defineProperties(trxState, {
} }
});
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
void loadEagerPlugins().catch((error) => {
void loadEagerPlugins().then(() => {
if (settingsUiReady) initSettingsUI();
}).catch((error) => {
console.error(error);
});
void initializeApp();
@@ -6320,9 +6541,6 @@ function _wireSubTabBar(bar) {
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
});
}
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
window.clearSatPredictionDom();
}
});
}
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
@@ -6433,7 +6651,15 @@ var sdrNbEnabledEl = document.getElementById("sdr-nb-enabled");
var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold");
var sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set");
var sdrNbProfileWrapEl = document.getElementById("sdr-nb-profile-wrap");
var sdrNbProfileEl = document.getElementById("sdr-nb-profile");
var sdrNbSupported = false;
var sdrNbProfile = "spike";
var sdrDigSidebandWrapEl = document.getElementById("sdr-dig-sideband-wrap");
var sdrDigSidebandEl = document.getElementById("sdr-dig-sideband");
var sdrDigSidebandSupported = false;
var sdrDigSidebandPolicy = "auto";
var DIG_AUTO_SIDEBAND_THRESHOLD_HZ = 1e7;
fetch("/audio", { method: "GET" }).then((r) => {
if (r.status === 404) audioRow.style.display = "none";
}).catch(() => {
@@ -6803,7 +7029,7 @@ function submitSdrNbState() {
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
postPath(
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}&profile=${encodeURIComponent(sdrNbProfile)}`
).catch(() => {
});
}
@@ -6812,6 +7038,16 @@ if (sdrNbEnabledEl) {
submitSdrNbState();
});
}
if (sdrNbProfileEl) {
sdrNbProfileEl.addEventListener("change", () => {
const profile = sdrNbProfileEl.value || "spike";
if (profile !== "spike" && profile !== "ignition" && profile !== "powerline" && profile !== "broadband") {
return;
}
sdrNbProfile = profile;
submitSdrNbState();
});
}
function submitSdrNbThreshold() {
if (!sdrNbThresholdEl) return;
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
@@ -6829,11 +7065,28 @@ if (sdrNbThresholdEl) {
}
});
}
function submitSdrDigSideband() {
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
const policy = sdrDigSidebandEl.value || "auto";
if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
sdrDigSidebandPolicy = policy;
if (lastSpectrumData) scheduleSpectrumDraw();
postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {
});
}
if (sdrDigSidebandEl) {
sdrDigSidebandEl.addEventListener("change", () => {
submitSdrDigSideband();
});
}
function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none";
if (sdrDigSidebandWrapEl) {
sdrDigSidebandWrapEl.style.display = sdrDigSidebandSupported && mode === "DIG" ? "" : "none";
}
}
if (!hasWebCodecs) {
rxAudioBtn.disabled = true;
@@ -7155,6 +7408,10 @@ function startTxAudio() {
void stopTxAudio();
return;
}
if (authEnabled && !hasAuthRole2("transmit")) {
audioStatus.textContent = "Transmit role required";
return;
}
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
@@ -7606,11 +7863,35 @@ var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
"sstv",
"sstv_progress"
]);
var pendingDecodeStats = [];
var PENDING_DECODE_STATS_MAX = 5e4;
function recordDecodeStat(kind, rig, tsMs) {
const stats = window.trx.modules.map;
if (stats) {
stats.statsRecordDecode(kind, rig, tsMs);
return;
}
pendingDecodeStats.push({ kind, rig, tsMs });
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
}
}
function flushPendingDecodeStats() {
const stats = window.trx.modules.map;
if (!stats || pendingDecodeStats.length === 0) return;
for (const entry of pendingDecodeStats.splice(0)) {
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
}
stats.scheduleStatsRender();
}
function scheduleStatsRenderIfLoaded() {
window.trx.modules.map?.scheduleStatsRender();
}
function dispatchDecodeMessage(msg, skipStats = false) {
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender();
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
scheduleStatsRenderIfLoaded();
}
}
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
@@ -7656,9 +7937,9 @@ function restoreDecodeHistoryGroup(kind, messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (!IMAGE_DECODE_KINDS.has(kind)) {
for (const msg of messages) {
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
}
window.trx.modules.map?.scheduleStatsRender();
scheduleStatsRenderIfLoaded();
}
window.trxPluginRuntime.restore(kind, messages);
}
@@ -4,7 +4,11 @@ import {
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
} from "./chunk-REYSUJQ4.js";
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore,
hostState
@@ -82,20 +86,25 @@ function aprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(aprsFilterText);
}
function aprsRigPackets() {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets() {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(aprsFilterMatch);
}
function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets();
if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
}
if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0];
const latest = rigPackets[0];
if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -130,6 +139,9 @@ function renderAprsRow(pkt, isFresh) {
},
onCopy: (text) => {
void copyAprsCoords(text);
},
onLog: (call, gridsquare) => {
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? void 0, decoder: "aprs" });
}
});
}
@@ -153,7 +165,9 @@ function updateAprsBar() {
if (!aprsBarOverlay) return;
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
const okFrames = aprsPacketHistory.filter(
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id)
);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
@@ -215,11 +229,11 @@ function normalizeServerAprsPacket(pkt) {
}
function onServerAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -277,8 +291,9 @@ if (aprsFilterInput) {
});
}
function onServerAprs(pkt) {
if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
const packet = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
}
renderAprsHistory();
window.trxPluginRuntime.registerDecoder({
@@ -288,6 +303,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
rerender: () => {
updateAprsBar();
renderAprsHistory();
},
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostState
} from "./chunk-KL66PICH.js";
@@ -13,15 +16,17 @@ var bgdWindow = window;
return d.id;
});
}
let backgroundDecodeRole = null;
let backgroundDecodeRoles = [];
let currentRigId = null;
let currentConfig = null;
let bookmarkList = [];
let statusInterval = null;
let bgdDirty = false;
function initBackgroundDecode(rigId, role) {
backgroundDecodeRole = role;
currentRigId = rigId || null;
let statusByBookmark = /* @__PURE__ */ new Map();
let lastStatus = null;
function initBackgroundDecode(rigId, roles) {
backgroundDecodeRoles = roles;
currentRigId = rigId || hostState.lastActiveRigId || null;
if (currentRigId) loadBackgroundDecode();
startStatusPolling();
}
@@ -108,7 +113,7 @@ var bgdWindow = window;
}
setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist();
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
const isControl = isControlRole();
const panel = document.getElementById("background-decode-panel");
if (panel) {
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
@@ -119,6 +124,10 @@ var bgdWindow = window;
const resetBtn = document.getElementById("background-decode-reset-btn");
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
syncSaveButton();
}
function currentFilterText() {
return document.getElementById("bgd-bookmark-filter")?.value ?? "";
}
function renderBookmarkChecklist(filterText = "") {
const container = document.getElementById("bgd-bookmark-checklist");
@@ -134,20 +143,49 @@ var bgdWindow = window;
return text.indexOf(filter) >= 0;
}) : all;
if (filtered.length === 0) {
container.innerHTML = '<div class="bgd-checklist-empty">' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "</div>";
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
renderSelectionSummary();
return;
}
filtered.forEach(function(bookmark) {
const row = document.createElement("label");
row.className = "bgd-checklist-row";
row.dataset.bmId = bookmark.id;
const decoders = bookmarkDecoderKinds(bookmark);
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
row.innerHTML = '<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span>";
const selected = selectedIds.has(bookmark.id);
if (selected) row.classList.add("is-selected");
row.innerHTML = '<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz)) + '<span class="bgd-checklist-mode">' + escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span></span>" + stateBadgeHtml(bookmark.id, selected);
row.querySelector("input")?.addEventListener("change", function(e) {
onChecklistToggle(bookmark.id, e.currentTarget.checked);
});
container.appendChild(row);
});
renderSelectionSummary();
}
function emptyListText(supportedCount) {
if (supportedCount > 0) return "No bookmark matches that filter.";
if (bookmarkList.length > 0) {
return "None of your bookmarks name a decoder that can run in the background. Give one a decoder on the Bookmarks tab to list it here.";
}
return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
}
function stateBadgeHtml(bookmarkId, selected) {
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
const entry = statusByBookmark.get(bookmarkId);
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '"><span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
}
function renderSelectionSummary() {
const el = document.getElementById("bgd-selection-summary");
if (!el) return;
const selected = currentConfig?.bookmark_ids.length ?? 0;
if (selected === 0) {
el.textContent = "Nothing selected — background decoding is idle.";
return;
}
const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
el.textContent = currentConfig?.enabled ? `${noun}, ${String(active)} decoding now.` : `${noun}. Switch Enabled on to start decoding them.`;
}
function onChecklistToggle(bookmarkId, checked) {
if (!currentConfig) {
@@ -182,7 +220,7 @@ var bgdWindow = window;
}).catch(function(err) {
showToast(`Save failed: ${errorMessage(err)}`, true);
}).finally(function() {
if (btn) btn.disabled = false;
syncSaveButton();
});
}
async function resetBackgroundDecode() {
@@ -210,59 +248,83 @@ var bgdWindow = window;
});
}
function renderStatus(status) {
const card = document.getElementById("background-decode-status-card");
if (!card) return;
const entries = status.entries ?? [];
if (!entries.length) {
card.textContent = "No background decode bookmarks configured.";
lastStatus = status;
statusByBookmark = new Map(
(status.entries ?? []).filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0).map((entry) => [entry.bookmark_id, entry])
);
renderSpanSummary();
renderBookmarkChecklist(currentFilterText());
}
function renderSpanSummary() {
const el = document.getElementById("bgd-span-summary");
if (!el) return;
const status = lastStatus;
if (!status) {
el.textContent = "";
return;
}
const summary = [];
if (status.active_rig) {
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
} else {
summary.push("This rig is not currently selected for audio.");
if (!status.active_rig) {
el.textContent = "This rig is not the one playing audio.";
el.dataset.tone = "warn";
return;
}
const centre = typeof status.center_hz === "number" && Number.isFinite(status.center_hz) ? formatFreq(status.center_hz) : null;
const half = typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0 ? formatFreq(status.sample_rate / 2) : null;
el.dataset.tone = "";
el.textContent = centre && half ? `Span ${centre} ±${half}` : centre ? `Centre ${centre}` : "";
}
function stateHelp(state) {
switch (state) {
case "active":
return "Decoding on a hidden channel.";
case "out_of_span":
return "Outside the span the rig is tuned across, so it cannot be heard from here.";
case "waiting_for_spectrum":
return "Waiting for the first spectrum frame from the rig.";
case "waiting_for_user":
return "Nobody is listening to this rig, so no audio is being pulled.";
case "missing_bookmark":
return "The bookmark this was selected from is gone.";
case "no_supported_decoders":
return "No decoder that runs in the background can decode this bookmark.";
case "disabled":
return "Background decoding is switched off.";
case "handled_by_scheduler":
case "scheduler_has_control":
return "The scheduler is running this bookmark instead.";
case "handled_by_virtual_channel":
return "A virtual channel is already on this frequency.";
case "pending":
return "Selected, and not started yet — save to apply.";
default:
return "Selected, but not decoding.";
}
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
html += '<div class="bgd-status-list">';
entries.forEach(function(entry) {
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
const parts = [];
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
if (entry.mode) parts.push(entry.mode);
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
parts.push(entry.decoder_kinds.join("/").toUpperCase());
}
html += '<div class="bgd-status-row"><div><div class="bgd-status-name">' + escHtml(name) + '</div><div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div></div><div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '"><svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' + escHtml(prettyState(entry.state)) + "</div></div>";
});
html += "</div>";
card.innerHTML = html;
}
function prettyState(state) {
switch (state) {
case "active":
return "✓ Active";
return "Decoding";
case "out_of_span":
return "Out of span";
return "Out of span";
case "waiting_for_spectrum":
return "Waiting";
return "Waiting for spectrum";
case "waiting_for_user":
return "△ No user";
return "Nobody listening";
case "missing_bookmark":
return "✗ Missing";
return "Bookmark gone";
case "no_supported_decoders":
return "✗ Unsupported";
return "No decoder";
case "disabled":
return "△ Disabled";
return "Off";
case "handled_by_scheduler":
return "△ Scheduler";
case "scheduler_has_control":
return "Scheduler";
return "Scheduler has it";
case "handled_by_virtual_channel":
return "△ VChan";
return "On a channel";
case "pending":
return "Not saved";
default:
return "△ Inactive";
return "Idle";
}
}
function setCheckbox(id, value) {
@@ -285,13 +347,21 @@ var bgdWindow = window;
function markBgdDirty() {
if (bgdDirty) return;
bgdDirty = true;
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.add("sch-dirty");
syncSaveButton();
}
function clearBgdDirty() {
bgdDirty = false;
syncSaveButton();
}
function syncSaveButton() {
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.remove("sch-dirty");
if (!btn) return;
btn.classList.toggle("sch-dirty", bgdDirty);
btn.disabled = !bgdDirty || !isControlRole();
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
}
function isControlRole() {
return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
}
function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast");
@@ -311,7 +381,7 @@ var bgdWindow = window;
return bm.id;
});
currentConfig.bookmark_ids = ids;
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
renderBookmarkChecklist(currentFilterText());
markBgdDirty();
}
function deselectAllBookmarks() {
@@ -319,7 +389,7 @@ var bgdWindow = window;
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
currentConfig.bookmark_ids = [];
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
renderBookmarkChecklist(currentFilterText());
markBgdDirty();
}
function wireBackgroundDecodeEvents() {
@@ -334,7 +404,9 @@ var bgdWindow = window;
if (enabledCb && !enabledCb._wired) {
enabledCb._wired = true;
enabledCb.addEventListener("change", function() {
if (currentConfig) currentConfig.enabled = enabledCb.checked;
markBgdDirty();
renderBookmarkChecklist(currentFilterText());
});
}
const selectAllBtn = document.getElementById("bgd-select-all-btn");
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostCore,
hostState
@@ -42,7 +45,7 @@ function bmEsc(str) {
return d.innerHTML;
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control";
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
}
function bmSyncAccess() {
const canCtrl = bmCanControl();
@@ -211,6 +214,12 @@ function bmBuildDecoderCheckboxes() {
container.appendChild(label);
});
}
function bmSyncDigSidebandVisibility() {
const label = bmOptionalEl("bm-dig-sideband-label");
if (!label) return;
const mode = (bmEl("bm-mode").value || "").trim().toUpperCase();
label.style.display = mode === "DIG" ? "" : "none";
}
function bmOpenForm(bm) {
const wrap = bmEl("bm-form-wrap");
if (!wrap) return;
@@ -224,7 +233,9 @@ function bmOpenForm(bm) {
bmEl("bm-locator").value = bm ? bm.locator || "" : "";
bmEl("bm-category-input").value = bm ? bm.category || "" : "";
bmEl("bm-comment").value = bm ? bm.comment || "" : "";
bmEl("bm-dig-sideband").value = bm ? bm.dig_sideband || "" : "";
bmWriteDecoders(bm?.decoders ?? []);
bmSyncDigSidebandVisibility();
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex";
bmEl("bm-name").focus();
@@ -260,6 +271,7 @@ async function bmSave(e) {
const category = bmEl("bm-category-input").value.trim();
const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders();
const dig_sideband = mode.toUpperCase() === "DIG" ? bmEl("bm-dig-sideband").value || null : null;
const formError = bmEl("bm-form-error");
if (formError) formError.textContent = "";
if (!name || !Number.isFinite(freq_hz) || !mode) {
@@ -276,7 +288,8 @@ async function bmSave(e) {
locator: locator || null,
category,
comment,
decoders
decoders,
dig_sideband
};
try {
let resp;
@@ -341,6 +354,12 @@ function bmApply(bm) {
}
const tunePromise = (async () => {
await bridge.trx.modules.vchan?.takeSchedulerControl();
if ((bm.mode || "").toUpperCase() === "DIG" && bm.dig_sideband) {
const p = bm.dig_sideband.toLowerCase();
if (p === "auto" || p === "usb" || p === "lsb") {
await hostCore.postPath("/set_sdr_dig_sideband?policy=" + encodeURIComponent(p));
}
}
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) {
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
@@ -593,6 +612,7 @@ function bmPopulateScopePicker() {
bmEl("bm-form").addEventListener("submit", (event) => {
void bmSave(event);
});
bmEl("bm-mode").addEventListener("input", bmSyncDigSidebandVisibility);
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
const formWrap = bmEl("bm-form-wrap");
if (formWrap) {
@@ -0,0 +1,136 @@
// src/api/auth.ts
var AUTH_ROLES = ["guest", "read", "control", "transmit", "write", "administrator"];
var AUTH_ADMIN_ROLES = AUTH_ROLES.filter((role) => role !== "guest");
var AUTH_ROLE_LABELS = {
guest: "Guest",
read: "Read",
control: "Control",
transmit: "Transmit",
write: "Write",
administrator: "Administrator"
};
function isAuthRole(value) {
return typeof value === "string" && AUTH_ROLES.includes(value);
}
function normalizeAuthRoles(roles) {
return AUTH_ROLES.filter((role) => roles.includes(role));
}
function hasAuthRole(roles, required) {
return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("guest") || required === "read" && (roles.includes("control") || roles.includes("transmit"));
}
function hasAccountControls(roles) {
return roles.length > 0 && !roles.includes("guest");
}
function decodeRoles(value, context) {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
throw new TypeError(`${context} has invalid roles`);
}
return normalizeAuthRoles(value);
}
function decodeAuthSession(value) {
if (typeof value !== "object" || value === null) {
throw new TypeError("The authentication response is malformed");
}
const session = value;
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded = {
authenticated: session.authenticated,
roles: decodeRoles(session.roles, "The authentication response")
};
if (session.username !== void 0) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
}
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
var authDisabledSession = {
authenticated: true,
roles: [...AUTH_ADMIN_ROLES],
auth_disabled: true
};
async function fetchAuthSession() {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json());
} catch (error) {
console.error("Auth check failed:", error);
return { authenticated: false, roles: [] };
}
}
async function login(username, password) {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Login failed");
}
return decodeAuthSession(await response.json());
}
async function userRequest(path, init) {
const response = await fetch(path, init);
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || `User operation failed (${response.status})`);
}
return response;
}
async function listUsers() {
const value = await userRequest("/auth/users").then((response) => response.json());
if (!Array.isArray(value) || !value.every((user) => {
if (typeof user !== "object" || user === null) return false;
const record = user;
return typeof record.username === "string" && typeof record.enabled === "boolean" && Array.isArray(record.roles) && record.roles.every(isAuthRole);
})) {
throw new TypeError("The user list response is malformed");
}
return value.map((user) => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
}
async function createUser(username, password, roles, enabled = true) {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
}
async function updateUser(username, changes) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
async function changeOwnPassword(currentPassword, newPassword) {
await userRequest("/auth/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
});
}
async function deleteUser(username) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
async function logout() {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
}
export {
AUTH_ROLES,
AUTH_ADMIN_ROLES,
AUTH_ROLE_LABELS,
normalizeAuthRoles,
hasAuthRole,
hasAccountControls,
fetchAuthSession,
login,
listUsers,
createUser,
updateUser,
changeOwnPassword,
deleteUser,
logout
};
@@ -214,6 +214,16 @@ function summarizeAprsPayload(packet) {
const text = info.replace(/^[>;<?]/, "").trim();
return text.length ? text : null;
}
function maidenheadForLatLon(lat, lon) {
const adjustedLon = lon + 180;
const adjustedLat = lat + 90;
const field = String.fromCharCode(65 + Math.floor(adjustedLon / 20)) + String.fromCharCode(65 + Math.floor(adjustedLat / 10));
const square = String(Math.floor(adjustedLon % 20 / 2)) + String(Math.floor(adjustedLat % 10));
const subLon = adjustedLon % 2 * 60 / 5;
const subLat = adjustedLat % 1 * 60 / 2.5;
const sub = String.fromCharCode(97 + Math.floor(subLon)) + String.fromCharCode(97 + Math.floor(subLat));
return `${field}${square}${sub}`.toUpperCase();
}
function renderAprsPacketRow(packet, options = {}) {
const row = document.createElement("details");
row.className = "aprs-packet";
@@ -224,7 +234,7 @@ function renderAprsPacketRow(packet, options = {}) {
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.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>` + (options.onLog && packet.srcCall ? '<button class="aprs-inline-btn" type="button" data-aprs-log="1">Log</button>' : "") + `</div></div>`;
row.querySelectorAll("[data-aprs-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
@@ -233,6 +243,15 @@ function renderAprsPacketRow(packet, options = {}) {
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon);
});
});
const logButton = row.querySelector("[data-aprs-log]");
if (logButton) {
logButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const grid = packet.lat != null && packet.lon != null ? maidenheadForLatLon(packet.lat, packet.lon) : null;
options.onLog?.(packet.srcCall ?? "", grid);
});
}
const copyButton = row.querySelector("[data-aprs-copy]");
if (copyButton) {
copyButton.addEventListener("click", (event) => {
@@ -0,0 +1,18 @@
import {
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/active-rig.ts
function isActiveRigDecode(rigId) {
const activeRigId = hostState.lastActiveRigId;
if (!activeRigId || !rigId) return true;
return rigId === activeRigId;
}
function forActiveRig(items) {
return items.filter((item) => isActiveRigDecode(item.rig_id));
}
export {
isActiveRigDecode,
forActiveRig
};
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -148,14 +152,33 @@ function initializeFtxDecoder(config) {
second: "2-digit"
});
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
if (station) {
const log = document.createElement("button");
log.type = "button";
log.className = "ft8-log-btn";
log.textContent = "Log";
log.title = `Start a log entry for ${station}`;
log.addEventListener("click", (event) => {
event.stopPropagation();
const details = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
bridge.logContact?.({
call: station,
gridsquare: details.find((detail) => detail.station === station)?.grid,
decoder: id
});
});
row.appendChild(log);
}
return row;
};
const render = () => {
prune();
if (!messagesElement) return;
const rigMessages = forActiveRig(history);
const fragment = document.createDocumentFragment();
let count = 0;
for (const message of history) {
for (const message of rigMessages) {
if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message));
@@ -163,20 +186,26 @@ function initializeFtxDecoder(config) {
}
messagesElement.replaceChildren(fragment);
};
const normalize = (message) => {
const plotLocator = (message) => {
const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
if (grids.length === 0) return;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) {
bridge.mapAddLocator?.(raw, grids, id, station, {
...message,
freq_hz: frequency ?? message.freq_hz,
locator_details: locatorDetails
});
}
bridge.mapAddLocator?.(raw, grids, id, station, {
...message,
freq_hz: frequency ?? message.freq_hz,
locator_details: locatorDetails
});
};
const normalize = (message) => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
return {
// The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band.
rig_id: message.rig_id ?? null,
receiver: bridge.getDecodeRigMeta?.() ?? null,
ts_ms: message.ts_ms,
snr_db: message.snr_db,
@@ -188,7 +217,9 @@ function initializeFtxDecoder(config) {
};
const receiveBatch = (messages) => {
if (messages.length === 0) return;
if (status) status.textContent = "Receiving";
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
status.textContent = "Receiving";
}
history = messages.map(normalize).reverse().concat(history);
prune();
bridge.setFt8FamilyBarDecoder?.(id);
@@ -202,7 +233,7 @@ function initializeFtxDecoder(config) {
bridge.clearMapMarkersByType?.(id);
};
const barFrames = () => {
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5 && isActiveRigDecode(message.rig_id)).slice(0, 8);
let html = "";
for (const message of recent) {
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
@@ -226,7 +257,15 @@ function initializeFtxDecoder(config) {
prune();
render();
},
reset
reset,
rerender: () => {
bridge.updateFt8Bar?.();
render();
},
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => {
for (const message of [...history].reverse()) plotLocator(message);
}
});
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
const updatePeriod = () => {
@@ -1,3 +1,6 @@
import {
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -25,7 +28,7 @@ var CW_BAR_LINE_GAP_MS = 5e3;
var cwLastAppendTime = 0;
var cwTonePickerRaf = null;
var cwBarHistory = [];
var cwBarCurrentLine = null;
var cwBarCurrentLines = /* @__PURE__ */ new Map();
var cwBarDismissedAtMs = 0;
var cwAutoLocalOverride = null;
function escapeCwHtml(input) {
@@ -50,20 +53,22 @@ cwWindow.applyCwAutoUiFromServer = function(enabled) {
if (cwAutoLocalOverride !== null) return;
applyCwAutoUi(enabled);
};
function cwBarFlushCurrentLine() {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine);
function cwBarFlushCurrentLine(key) {
const line = cwBarCurrentLines.get(key);
cwBarCurrentLines.delete(key);
if (line?.text.trim()) {
cwBarHistory.unshift(line);
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
}
cwBarCurrentLine = null;
}
function updateCwBar() {
if (!cwBarOverlay) return;
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
const isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
const inProgress = [...cwBarCurrentLines.values()].filter((l) => l.text && isActiveRigDecode(l.rigId)).sort((a, b) => b.tsMs - a.tsMs);
const liveLines = [...inProgress, ...recent];
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none";
@@ -308,7 +313,7 @@ function resetCwHistoryView() {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBarHistory = [];
cwBarCurrentLine = null;
cwBarCurrentLines.clear();
updateCwBar();
drawCwTonePicker();
}
@@ -324,8 +329,9 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
})();
});
function onServerCw(evt) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl && forSelectedRig) {
const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
const line = document.createElement("div");
@@ -346,25 +352,29 @@ function onServerCw(evt) {
}
if (evt.text) {
const now = Date.now();
const rigId = evt.rig_id ?? null;
const key = rigId ?? "";
if (evt.text === "\n") {
cwBarFlushCurrentLine();
cwBarFlushCurrentLine(key);
} else {
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine();
let line = cwBarCurrentLines.get(key);
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine(key);
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
cwBarCurrentLines.set(key, line);
}
cwBarCurrentLine.text += evt.text;
cwBarCurrentLine.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
line.text += evt.text;
line.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
}
updateCwBar();
}
if (cwSignalIndicator) {
if (cwSignalIndicator && forSelectedRig) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
}
if (!cwAutoInput || cwAutoInput.checked) {
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm));
}
@@ -389,7 +399,14 @@ cwWindow.trxPluginRuntime.registerDecoder({
id: "cw",
onMessage: onServerCw,
restore: restoreCwHistory,
reset: resetCwHistoryView
reset: resetCwHistoryView,
// The copied text of a rig that is no longer on screen cannot be unpicked
// from the pane, so the switch starts the new rig's stream from empty.
rerender: () => {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
updateCwBar();
}
});
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution();
@@ -1,6 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-O2Y7YEVQ.js";
} from "./chunk-WU5EWJX4.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft2.ts
@@ -1,6 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-O2Y7YEVQ.js";
} from "./chunk-WU5EWJX4.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft4.ts
@@ -2,7 +2,8 @@ import {
initializeFt8FamilyBar,
initializeFtxDecoder,
installFtxCompatibilityHelpers
} from "./chunk-O2Y7YEVQ.js";
} from "./chunk-WU5EWJX4.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft8.ts
@@ -4,7 +4,11 @@ import {
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
} from "./chunk-REYSUJQ4.js";
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore,
hostState
@@ -67,21 +71,26 @@ function hfAprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(hfAprsFilterText);
}
function hfAprsRigPackets() {
return forActiveRig(hfAprsPacketHistory);
}
function hfAprsVisiblePackets() {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(hfAprsFilterMatch);
}
var collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
}
if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0];
const latest = rigPackets[0];
if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -140,17 +149,32 @@ function resetHfAprsHistoryView() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
}
function pruneHfAprsHistoryView() {
pruneHfAprsPacketHistory();
renderHfAprsHistory();
}
function plotHfAprsPacket(pkt) {
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
hfAprsWindow.aprsMapAddStation(
pkt.srcCall ?? "",
pkt.lat,
pkt.lon,
pkt.info ?? "",
pkt.symbolTable,
pkt.symbolCode,
pkt,
"hf_aprs"
);
}
function addHfAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender();
}
function normalizeServerHfAprsPacket(pkt) {
@@ -158,13 +182,14 @@ function normalizeServerHfAprsPacket(pkt) {
}
function onServerHfAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized = [];
for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotHfAprsPacket(next);
normalized.push(next);
}
normalized.reverse();
@@ -227,8 +252,9 @@ if (hfAprsFilterInput) {
});
}
function onServerHfAprs(pkt) {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
const packet = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
}
renderHfAprsHistory();
window.trxPluginRuntime.registerDecoder({
@@ -237,5 +263,10 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerHfAprsBatch,
restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView
prune: pruneHfAprsHistoryView,
rerender: renderHfAprsHistory,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry);
}
});
@@ -0,0 +1,449 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/logbook.ts
var bridge = window;
var el = (id) => document.getElementById(id);
var form = el("log-entry-form");
var callInput = el("log-call");
var freqInput = el("log-freq");
var modeInput = el("log-mode");
var rstSentInput = el("log-rst-sent");
var rstRcvdInput = el("log-rst-rcvd");
var gridInput = el("log-grid");
var nameInput = el("log-name");
var commentInput = el("log-comment");
var operatorInput = el("log-operator");
var rowsBody = el("log-rows");
var summaryEl = el("log-summary");
var workedEl = el("log-worked-before");
var clockEl = el("log-clock");
var stationCallEl = el("log-station-callsign");
var stationRigEl = el("log-station-rig-name");
var stationGridEl = el("log-station-grid");
var filterCall = el("log-filter-call");
var filterBand = el("log-filter-band");
var filterMode = el("log-filter-mode");
var importBtn = el("log-import-btn");
var importFile = el("log-import-file");
var exportLink = el("log-export-btn");
var clearBtn = el("log-clear-btn");
var saveBtn = el("log-save-btn");
var contestIdInput = el("log-contest-id");
var stxInput = el("log-stx");
var srxInput = el("log-srx");
var statisticsRows = el("log-statistics-rows");
var cabrilloContest = el("log-cbr-contest");
var cabrilloCallsign = el("log-cbr-callsign");
var cabrilloOperator = el("log-cbr-operator");
var cabrilloPower = el("log-cbr-power");
var cabrilloScore = el("log-cbr-score");
var cabrilloExport = el("log-cbr-export");
var entryStartedAt = null;
var entryRigId = null;
var entryRigName = null;
var entryGrid = null;
var qsos = [];
var workedRequest = 0;
function canWriteLogbook() {
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
}
function notify(message, kind) {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0);
else hostCore.showHint(message, 2e3);
}
async function getJson(path) {
const response = await fetch(path);
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
return await response.json();
}
function formatFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "";
return String(Number((hz / 1e6).toFixed(6)));
}
function parseFreq(text) {
const value = Number(text.trim().replace(/\s+/g, "").replace(",", "."));
if (!Number.isFinite(value) || value <= 0) return null;
return value < 1e5 ? Math.round(value * 1e6) : Math.round(value);
}
function numberOrNull(text) {
const trimmed = (text ?? "").trim();
if (!trimmed || !/^\d+$/.test(trimmed)) return null;
const value = Number(trimmed);
return Number.isSafeInteger(value) ? value : null;
}
function utcDate(iso) {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
}
function utcTime(iso) {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19);
}
async function openEntry(seed = {}) {
const params = new URLSearchParams();
if (hostState.lastActiveRigId) params.set("remote", hostState.lastActiveRigId);
if (seed.decoder) params.set("decoder", seed.decoder);
if (seed.call) params.set("call", seed.call);
if (seed.gridsquare) params.set("gridsquare", seed.gridsquare);
try {
const prefill = await getJson(`/api/logbook/prefill?${params.toString()}`);
applyPrefill(prefill);
} catch (error) {
console.error("logbook prefill failed", error);
}
}
function applyPrefill(prefill) {
entryStartedAt = prefill.started_at;
entryRigId = prefill.rig_id;
entryRigName = prefill.my_rig;
if (freqInput) freqInput.value = formatFreq(prefill.freq_hz);
if (modeInput) modeInput.value = prefill.submode ?? prefill.mode;
if (callInput && prefill.call) callInput.value = prefill.call;
if (gridInput && prefill.gridsquare) gridInput.value = prefill.gridsquare;
if (stationRigEl) stationRigEl.textContent = prefill.my_rig ?? "no rig";
showClock(prefill);
updateWorkedBefore();
}
function showClock(prefill) {
if (!clockEl) return;
const time = utcTime(prefill.started_at);
const drift = Math.abs(Date.now() - prefill.epoch_ms);
clockEl.textContent = drift > 1e3 ? `${time}Z · your clock is ${(drift / 1e3).toFixed(0)}s out` : `${time}Z`;
clockEl.classList.toggle("is-adrift", drift > 1e3);
}
function resetEntry() {
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) {
if (input) input.value = "";
}
if (workedEl) workedEl.textContent = "";
void openEntry();
}
async function submitEntry(event) {
event.preventDefault();
const call = callInput?.value.trim() ?? "";
if (!call) {
notify("A contact needs a callsign", "error");
callInput?.focus();
return;
}
const freqHz = parseFreq(freqInput?.value ?? "");
if (freqHz == null) {
notify("A contact needs a frequency", "error");
freqInput?.focus();
return;
}
const typedMode = (modeInput?.value ?? "").trim().toUpperCase();
const isSideband = typedMode === "USB" || typedMode === "LSB";
const body = {
// The server stamps the time; this is the one it gave when the entry
// opened, so a contact logged five minutes later keeps the time it started.
started_at: entryStartedAt,
call,
freq_hz: freqHz,
mode: isSideband ? "SSB" : typedMode,
submode: isSideband ? typedMode : null,
rst_sent: rstSentInput?.value ?? null,
rst_rcvd: rstRcvdInput?.value ?? null,
gridsquare: gridInput?.value ?? null,
name: nameInput?.value ?? null,
comment: commentInput?.value ?? null,
contest_id: contestIdInput?.value ?? null,
// The exchange is a number when it is a serial and a word when it is a
// zone or a section; both are kept, and the log writes whichever it has.
stx: numberOrNull(stxInput?.value),
stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null,
srx: numberOrNull(srxInput?.value),
srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null,
station_callsign: stationCallEl?.textContent?.trim() ?? null,
operator: operatorInput?.value ?? null,
my_gridsquare: entryGrid,
my_rig: entryRigName,
rig_id: entryRigId
};
if (saveBtn) saveBtn.disabled = true;
try {
const response = await fetch("/api/logbook", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (!response.ok) {
const detail = await response.json().catch(() => ({}));
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
}
notify(`${call} logged`);
const sent = numberOrNull(stxInput?.value);
resetEntry();
if (stxInput && sent != null) stxInput.value = String(sent + 1);
await refreshLog();
} catch (error) {
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}
function updateWorkedBefore() {
if (!workedEl) return;
const call = callInput?.value.trim() ?? "";
if (call.length < 3) {
workedEl.textContent = "";
return;
}
const request = ++workedRequest;
void getJson(
`/api/logbook/worked/${encodeURIComponent(call)}`
).then((answer) => {
if (request !== workedRequest || !workedEl) return;
if (answer.worked.length === 0) {
workedEl.textContent = "Not worked before";
workedEl.classList.remove("is-worked");
return;
}
const where = answer.worked.map((entry) => `${entry.band} ${entry.mode}`).join(", ");
workedEl.textContent = `Worked before: ${where}`;
workedEl.classList.add("is-worked");
}).catch(() => {
});
}
function currentQuery() {
const params = new URLSearchParams();
const call = filterCall?.value.trim();
if (call) params.set("call", call);
if (filterBand?.value) params.set("band", filterBand.value);
if (filterMode?.value) params.set("mode", filterMode.value);
return params.toString();
}
async function refreshLog() {
try {
const query = currentQuery();
const answer = await getJson(
`/api/logbook${query ? `?${query}` : ""}`
);
qsos = answer.qsos;
renderRows();
renderFilterOptions();
if (summaryEl) {
summaryEl.textContent = query ? `${String(qsos.length)} of ${String(answer.total)} contacts` : `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
}
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
await refreshStatistics();
} catch (error) {
console.error("logbook read failed", error);
}
}
async function refreshStatistics() {
if (!statisticsRows) return;
try {
const answer = await getJson("/api/logbook/statistics");
if (answer.bands.length === 0) {
statisticsRows.innerHTML = '<tr><td colspan="4" class="log-empty">Nothing worked yet.</td></tr>';
return;
}
const fragment = document.createDocumentFragment();
for (const band of answer.bands) {
const row = document.createElement("tr");
for (const value of [band.band, band.contacts, band.stations, band.confirmed]) {
const cell = document.createElement("td");
cell.textContent = String(value);
row.appendChild(cell);
}
fragment.appendChild(row);
}
statisticsRows.replaceChildren(fragment);
} catch (error) {
console.error("logbook statistics failed", error);
}
}
function syncCabrilloLink() {
if (!cabrilloExport) return;
const params = new URLSearchParams();
const contest = cabrilloContest?.value.trim();
if (contest) {
params.set("contest", contest);
}
const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? "");
if (callsign) params.set("callsign", callsign);
if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value);
if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value);
const score = numberOrNull(cabrilloScore?.value);
if (score != null) params.set("claimed_score", String(score));
const operator = operatorInput?.value.trim();
if (operator) params.set("operators", operator);
cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`;
}
function renderRows() {
if (!rowsBody) return;
if (qsos.length === 0) {
rowsBody.innerHTML = '<tr><td colspan="10" class="log-empty">No contacts yet. Work someone and log them here, or bring a log in with Import ADIF.</td></tr>';
return;
}
const fragment = document.createDocumentFragment();
for (const qso of qsos) {
const row = document.createElement("tr");
row.dataset.qsoId = qso.id;
const cells = [
utcDate(qso.started_at),
utcTime(qso.started_at),
qso.call,
qso.band ?? "",
qso.submode ?? qso.mode,
qso.rst_sent ?? "",
qso.rst_rcvd ?? "",
qso.gridsquare ?? "",
qso.my_rig ?? "",
qso.confirmed ? "✓" : ""
];
for (const [index, value] of cells.entries()) {
const cell = document.createElement("td");
cell.textContent = value ?? "";
if (index === 2) cell.className = "log-cell-call";
row.appendChild(cell);
}
const actions = document.createElement("td");
if (!canWriteLogbook()) {
row.appendChild(actions);
fragment.appendChild(row);
continue;
}
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = "log-row-btn";
confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm";
confirm.title = qso.confirmed ? "Mark this contact as not confirmed" : "Mark this contact confirmed by QSL";
confirm.addEventListener("click", () => {
void setConfirmed(qso, !qso.confirmed);
});
actions.appendChild(confirm);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "log-row-btn";
remove.textContent = "Delete";
remove.setAttribute("aria-label", `Delete the contact with ${qso.call}`);
remove.addEventListener("click", () => {
void deleteQso(qso);
});
actions.appendChild(remove);
row.appendChild(actions);
fragment.appendChild(row);
}
rowsBody.replaceChildren(fragment);
}
function renderFilterOptions() {
for (const [select, values] of [
[filterBand, [...new Set(qsos.map((q) => q.band).filter((b) => !!b))]],
[filterMode, [...new Set(qsos.map((q) => q.submode ?? q.mode).filter(Boolean))]]
]) {
if (!select) continue;
const chosen = select.value;
const known = new Set([...select.options].map((option) => option.value));
for (const value of [...values].sort()) {
if (known.has(value)) continue;
select.add(new Option(value, value));
}
select.value = chosen;
}
}
async function setConfirmed(qso, confirmed) {
try {
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...qso,
// A card is a card: this is the paper one, and an electronic
// confirmation the log already holds is left where it is.
qsl_rcvd: confirmed ? "Y" : "N"
})
});
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
await refreshLog();
} catch (error) {
notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
async function deleteQso(qso) {
const confirmed = await bridge.trxUi.confirm({
title: "Delete this contact?",
message: `${qso.call} on ${qso.band ?? formatFreq(qso.freq_hz)} will be removed from the log.`,
confirmLabel: "Delete"
});
if (!confirmed) return;
try {
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { method: "DELETE" });
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
await refreshLog();
} catch (error) {
notify(`Could not delete: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
async function importAdif(file) {
try {
const response = await fetch("/api/logbook/import", { method: "POST", body: await file.arrayBuffer() });
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
const outcome = await response.json();
const parts = [`${String(outcome.added)} added`];
if (outcome.duplicate > 0) parts.push(`${String(outcome.duplicate)} already held`);
if (outcome.rejected.length > 0) parts.push(`${String(outcome.rejected.length)} not readable`);
notify(parts.join(", "));
await refreshLog();
} catch (error) {
notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
function renderStation() {
const callsign = hostState.ownerCallsign ?? "";
if (stationCallEl) stationCallEl.textContent = callsign || "no callsign set";
if (operatorInput && !operatorInput.value) operatorInput.value = callsign;
if (stationGridEl) {
const grid = hostState.serverLat != null && hostState.serverLon != null ? hostCore.latLonToMaidenhead(hostState.serverLat, hostState.serverLon) : "";
entryGrid = grid || null;
stationGridEl.textContent = grid;
}
}
form?.addEventListener("submit", (event) => {
void submitEntry(event);
});
clearBtn?.addEventListener("click", resetEntry);
callInput?.addEventListener("input", updateWorkedBefore);
for (const control of [filterCall, filterBand, filterMode]) {
control?.addEventListener("input", () => {
void refreshLog();
});
control?.addEventListener("change", () => {
void refreshLog();
});
}
for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) {
control?.addEventListener("input", syncCabrilloLink);
control?.addEventListener("change", syncCabrilloLink);
}
importBtn?.addEventListener("click", () => {
importFile?.click();
});
importFile?.addEventListener("change", () => {
const file = importFile.files?.[0];
if (file) void importAdif(file);
importFile.value = "";
});
if (canWriteLogbook()) {
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation();
if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
}
syncCabrilloLink();
if (canWriteLogbook()) void openEntry();
void refreshLog();
@@ -1,6 +1,6 @@
import {
aprsSymbolSprite
} from "./chunk-OPEIVJGD.js";
} from "./chunk-REYSUJQ4.js";
// src/map-core.ts
function mapEl(id) {
@@ -60,7 +60,7 @@ var mapWindow = window;
const decodeContactPaths = /* @__PURE__ */ new Map();
let selectedMapQsoKey = null;
const mapMarkers = /* @__PURE__ */ new Set();
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
const MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
@@ -128,6 +128,12 @@ var mapWindow = window;
}
return trimmed;
}
function aprsEntrySource(entry) {
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
}
function aprsStationKey(call, source) {
return source === "aprs" ? call : `${source}:${call}`;
}
function refreshAprsTrack(call, entry) {
if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
@@ -149,7 +155,7 @@ var mapWindow = window;
lineJoin: "round",
interactive: false
});
track.__trxType = "aprs";
track.__trxType = aprsEntrySource(entry);
track._aprsCall = call;
entry.track = track;
}
@@ -381,6 +387,7 @@ var mapWindow = window;
}
function mapSourceLabel(type) {
if (type === "bookmark") return "Bookmarks";
if (type === "hf_aprs") return "HF APRS";
return String(type || "").toUpperCase();
}
function locatorFilterColor(type) {
@@ -396,6 +403,7 @@ var mapWindow = window;
if (type === "vdes") return "#a78bfa";
if (type === "sat") return "#f59e0b";
if (type === "aprs") return "#00d17f";
if (type === "hf_aprs") return "#fb7185";
return locatorFilterColor(type);
}
function bandForHz(hz) {
@@ -931,10 +939,8 @@ var mapWindow = window;
}
}
for (const entry of stationMarkers.values()) {
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
availableSources.add("aprs");
break;
}
if (!entry?.visibleInHistoryWindow) continue;
availableSources.add(aprsEntrySource(entry));
}
const bandMap = /* @__PURE__ */ new Map();
for (const entry of locatorMarkers.values()) {
@@ -966,7 +972,7 @@ var mapWindow = window;
for (const key of Array.from(mapLocatorFilter.bands)) {
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
}
const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
const sourceItems = ["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
key,
label: mapSourceLabel(key),
color: mapSourceColor(key),
@@ -1028,9 +1034,10 @@ var mapWindow = window;
}
return parts.join(" ").toLowerCase();
}
if (type === "aprs") {
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(call);
if (type === "aprs" || type === "hf_aprs") {
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(key);
const call = entry?.call ?? key;
const info = entry?.info ? String(entry.info) : "";
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
return `${call} ${info} ${pktRaw}`.toLowerCase();
@@ -1200,9 +1207,10 @@ var mapWindow = window;
}
};
mapWindow.clearMapMarkersByType = function(type) {
if (type === "aprs") {
if (type === "aprs" || type === "hf_aprs") {
selectedAprsTrackCall = null;
stationMarkers.forEach((entry) => {
stationMarkers.forEach((entry, key) => {
if (aprsEntrySource(entry) !== type) return;
if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker);
@@ -1211,8 +1219,8 @@ var mapWindow = window;
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
mapMarkers.delete(entry.track);
}
stationMarkers.delete(key);
});
stationMarkers.clear();
return;
}
if (type === "ais") {
@@ -1448,13 +1456,15 @@ var mapWindow = window;
if (!ll) return;
const entry = stationMarkers.get(marker._aprsCall);
if (!entry) return;
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
const source = aprsEntrySource(entry);
const call = entry.call ?? String(marker._aprsCall);
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
refreshAprsTrack(String(marker._aprsCall), entry);
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
entry.track.addTo(aprsMap);
}
selectedAprsTrackCall = String(marker._aprsCall);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
return;
}
if (marker._aisMmsi) {
@@ -1848,28 +1858,33 @@ var mapWindow = window;
}
return null;
}
function _aprsAddMarkerToMap(call, entry) {
function _aprsAddMarkerToMap(key, entry) {
if (!aprsMap || entry.lat == null || entry.lon == null) return;
refreshAprsTrack(call, entry);
refreshAprsTrack(key, entry);
const source = aprsEntrySource(entry);
const call = entry.call ?? key;
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
const color = mapSourceColor(source);
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
radius: 6,
color: "#00d17f",
fillColor: "#00d17f",
color,
fillColor: color,
fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(popupContent);
marker.__trxType = "aprs";
marker.__trxType = source;
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
marker._aprsCall = call;
marker._aprsCall = key;
entry.marker = marker;
mapMarkers.add(marker);
}
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
const nextPoint = [lat, lon];
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
const existing = stationMarkers.get(call);
const stationSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
const key = aprsStationKey(call, stationSource);
const existing = stationMarkers.get(key);
if (existing) {
existing.pkt = pkt;
existing.lat = lat;
@@ -1888,7 +1903,8 @@ var mapWindow = window;
} else if (prevPoint) {
prevPoint.tsMs = tsMs;
}
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
existing.call = call;
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -1899,7 +1915,8 @@ var mapWindow = window;
track: null,
trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint],
type: "aprs",
type: stationSource,
call,
pkt,
lat,
lon,
@@ -1908,9 +1925,9 @@ var mapWindow = window;
symbolCode,
rigIds: new Set(msgRigId ? [msgRigId] : [])
};
stationMarkers.set(call, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
stationMarkers.set(key, entry);
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
if (aprsMap) scheduleDecodeMapMaintenance();
}
};
@@ -8,7 +8,6 @@ var satDom = {
status: document.getElementById("sat-status"),
liveView: document.getElementById("sat-live-view"),
historyView: document.getElementById("sat-history-view"),
predictionsView: document.getElementById("sat-predictions-view"),
liveLatest: document.getElementById("sat-live-latest"),
historyList: document.getElementById("sat-history-list"),
historyCount: document.getElementById("sat-history-count"),
@@ -17,29 +16,12 @@ var satDom = {
typeFilter: document.getElementById("sat-type-filter"),
lrptState: document.getElementById("sat-lrpt-state"),
viewLiveBtn: document.getElementById("sat-view-live"),
viewHistoryBtn: document.getElementById("sat-view-history"),
viewPredBtn: document.getElementById("sat-view-predictions"),
predFilter: document.getElementById("sat-pred-filter"),
predMinEl: document.getElementById("sat-pred-min-el"),
predCategory: document.getElementById("sat-pred-category"),
predCurrentList: document.getElementById("sat-pred-current-list"),
predUpcomingList: document.getElementById("sat-pred-list"),
predCurrentSec: document.getElementById("sat-pred-current-section"),
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
predStatus: document.getElementById("sat-pred-status")
viewHistoryBtn: document.getElementById("sat-view-history")
};
var satImageHistory = [];
var SAT_MAX_IMAGES = 100;
var SAT_PRED_PAGE_SIZE = 50;
var satPredShowAll = false;
var satFilterText = "";
var satActiveView = "live";
var satPredData = [];
var satPredFilterText = "";
var satPredMinEl = 0;
var satPredCategory = "all";
var satPredSatCount = 0;
var satPredCountdownTimer = null;
function scheduleSatUi(key, job) {
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
satWindow.trxScheduleUiFrameJob(key, job);
@@ -48,37 +30,19 @@ function scheduleSatUi(key, job) {
job();
}
function switchSatView(view) {
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
satActiveView = view;
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
if (leavingPredictions) clearPredictionDom();
if (view === "history") {
renderSatHistoryTable();
} else if (view === "predictions") {
satPredShowAll = false;
void loadSatPredictions();
}
if (view === "history") renderSatHistoryTable();
}
function clearPredictionDom() {
stopCountdownTimer();
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
}
satWindow.clearSatPredictionDom = clearPredictionDom;
satDom.viewLiveBtn?.addEventListener("click", () => {
switchSatView("live");
});
satDom.viewHistoryBtn?.addEventListener("click", () => {
switchSatView("history");
});
satDom.viewPredBtn?.addEventListener("click", () => {
switchSatView("predictions");
});
var lastSatLrptOn = null;
satWindow.updateSatLiveState = function(update) {
if (!satDom.lrptState) return;
@@ -275,200 +239,6 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click",
}
})();
});
function azToCardinal(deg) {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms) {
const d = new Date(ms);
const now = /* @__PURE__ */ new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s) {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms) {
const totalSec = Math.max(0, Math.floor(ms / 1e3));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg) {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
function stopCountdownTimer() {
if (satPredCountdownTimer) {
clearInterval(satPredCountdownTimer);
satPredCountdownTimer = null;
}
}
function startCountdownTimer(container) {
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
satPredCountdownTimer = setInterval(() => {
if (satActiveView !== "predictions") {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
renderSatPredictions(getFilteredPredictions());
}
}, 1e3);
}
function buildCurrentPassRow(pass, now) {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function buildUpcomingPassRow(pass) {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function getFilteredPredictions() {
let items = satPredData;
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
return items;
}
function applyPredFilters() {
renderSatPredictions(getFilteredPredictions());
}
var satPredictionFilter = satDom.predFilter;
satPredictionFilter?.addEventListener("input", () => {
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
applyPredFilters();
});
var satPredictionMinElevation = satDom.predMinEl;
satPredictionMinElevation?.addEventListener("change", () => {
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
applyPredFilters();
});
var satPredictionCategory = satDom.predCategory;
satPredictionCategory?.addEventListener("change", () => {
satPredCategory = satPredictionCategory.value;
applyPredFilters();
});
function renderSatPredictions(passes, error) {
stopCountdownTimer();
if (error) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
if (satDom.predCurrentList) {
if (current.length === 0) {
satDom.predCurrentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
satDom.predCurrentList.replaceChildren(frag);
}
}
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
if (satDom.predUpcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
moreRow.addEventListener("click", () => {
satPredShowAll = true;
renderSatPredictions(getFilteredPredictions());
});
frag.appendChild(moreRow);
}
satDom.predUpcomingList.replaceChildren(frag);
}
if (satDom.predStatus) {
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
satDom.predStatus.textContent = text;
}
if (current.length > 0 && satActiveView === "predictions") {
startCountdownTimer(satDom.predCurrentList);
}
}
async function loadSatPredictions() {
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…";
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
satPredSatCount = data.satellite_count || 0;
if (data.error) {
satPredData = [];
renderSatPredictions([], data.error);
} else {
satPredData = data.passes || [];
renderSatPredictions(getFilteredPredictions());
}
} catch (error) {
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
satWindow.satShowOnMap = function(south, west, north, east) {
if (typeof satWindow.enableMapSourceFilter === "function") {
satWindow.enableMapSourceFilter("sat");
@@ -0,0 +1,228 @@
// src/plugins/satellite-predictions.ts
var predWindow = window;
var dom = {
page: document.getElementById("tab-satellites"),
filter: document.getElementById("sat-pred-filter"),
minElevation: document.getElementById("sat-pred-min-el"),
category: document.getElementById("sat-pred-category"),
currentList: document.getElementById("sat-pred-current-list"),
upcomingList: document.getElementById("sat-pred-list"),
currentSection: document.getElementById("sat-pred-current-section"),
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
status: document.getElementById("sat-pred-status")
};
var PAGE_SIZE = 50;
var predShowAll = false;
var predData = [];
var predFilterText = "";
var predMinEl = 0;
var predCategory = "all";
var predSatCount = 0;
var predCountdownTimer = null;
function isPageVisible() {
return !!dom.page && dom.page.style.display !== "none";
}
function azToCardinal(deg) {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms) {
const d = new Date(ms);
const now = /* @__PURE__ */ new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s) {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms) {
const totalSec = Math.max(0, Math.floor(ms / 1e3));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg) {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
function stopCountdownTimer() {
if (predCountdownTimer) {
clearInterval(predCountdownTimer);
predCountdownTimer = null;
}
}
function startCountdownTimer(container) {
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
predCountdownTimer = setInterval(() => {
if (!isPageVisible()) {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
render(filtered());
}
}, 1e3);
}
function buildCurrentPassRow(pass, now) {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function buildUpcomingPassRow(pass) {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function filtered() {
let items = predData;
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
return items;
}
function applyFilters() {
render(filtered());
}
var filterInput = dom.filter;
filterInput?.addEventListener("input", () => {
predFilterText = filterInput.value.trim().toUpperCase();
applyFilters();
});
var minElevationSelect = dom.minElevation;
minElevationSelect?.addEventListener("change", () => {
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
applyFilters();
});
var categorySelect = dom.category;
categorySelect?.addEventListener("change", () => {
predCategory = categorySelect.value;
applyFilters();
});
function render(passes, error) {
stopCountdownTimer();
if (error) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
if (dom.currentList) {
if (current.length === 0) {
dom.currentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
dom.currentList.replaceChildren(frag);
}
}
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
if (dom.upcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
moreRow.addEventListener("click", () => {
predShowAll = true;
render(filtered());
});
frag.appendChild(moreRow);
}
dom.upcomingList.replaceChildren(frag);
}
if (dom.status) {
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
if (predSatCount > 0) text += ` · ${predSatCount} satellites tracked`;
dom.status.textContent = text;
}
if (current.length > 0 && isPageVisible()) {
startCountdownTimer(dom.currentList);
}
}
async function load() {
if (dom.status) dom.status.textContent = "Loading predictions…";
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
predSatCount = data.satellite_count || 0;
if (data.error) {
predData = [];
render([], data.error);
} else {
predData = data.passes || [];
render(filtered());
}
} catch (error) {
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
function clearPredictionDom() {
stopCountdownTimer();
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
}
predWindow.clearSatPredictionDom = clearPredictionDom;
predWindow.refreshSatPredictions = function() {
predShowAll = false;
void load();
};
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostState
} from "./chunk-KL66PICH.js";
@@ -15,7 +18,7 @@ function schedulerOptionalEl(id) {
}
(function() {
"use strict";
let schedulerRole = null;
let schedulerRoles = [];
let currentRigId = null;
let currentConfig = null;
let currentSchedulerStatus = null;
@@ -25,8 +28,8 @@ function schedulerOptionalEl(id) {
let schedulerStepPending = false;
let schEntryEditIdx = null;
let schedulerDirty = false;
function initScheduler(rigId, role) {
schedulerRole = role;
function initScheduler(rigId, roles) {
schedulerRoles = roles;
currentRigId = rigId || null;
if (currentRigId) loadScheduler();
startStatusPolling();
@@ -272,7 +275,7 @@ function schedulerOptionalEl(id) {
const nextBtn = schedulerEl("scheduler-next-btn");
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled = schedulerRole === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
const enabled = hasAuthRole(schedulerRoles, "control") && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
prevBtn.disabled = !enabled;
nextBtn.disabled = !enabled;
const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active";
@@ -354,7 +357,7 @@ function schedulerOptionalEl(id) {
const panel = schedulerEl("scheduler-panel");
if (!panel) return;
const mode = currentConfig && currentConfig.mode || "disabled";
const isControl = schedulerRole === "control";
const isControl = hasAuthRole(schedulerRoles, "control");
setSelected("scheduler-mode-select", mode);
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
const controlRow = document.querySelector(".scheduler-control-row");
@@ -1220,8 +1223,8 @@ function schedulerOptionalEl(id) {
markDirty: markSchedulerDirty
};
schedulerWindow.trx.modules.scheduler = schedulerService;
if (hostState.authRole != null) {
initScheduler(hostState.lastActiveRigId, hostState.authRole);
if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents();
}
})();
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -111,6 +115,7 @@ function paintRow(line, rgb) {
liveRows = Math.max(liveRows, line + 1);
}
function onProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state;
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
@@ -131,15 +136,20 @@ function onImage(msg) {
sstvHistory.push(image);
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory();
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) {
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"}${image.lines ?? 0} lines`;
}
scheduleUi("sstv-latest", renderLatestCard);
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
}
function sstvRigImages() {
return forActiveRig(sstvHistory);
}
function renderLatestCard() {
if (!sstvDom.liveLatest) return;
const latest = sstvHistory[sstvHistory.length - 1];
const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
if (!latest) {
sstvDom.liveLatest.innerHTML = "";
return;
@@ -160,8 +170,9 @@ function renderLatestCard() {
</div>`;
}
function filteredHistory() {
const rigImages = sstvRigImages();
const text = filterText.trim().toLowerCase();
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice();
const matching = text ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : rigImages;
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching;
@@ -241,7 +252,11 @@ sstvWindow.trxPluginRuntime.registerDecoder({
onMessage: onImage,
restore: restoreHistory,
prune: renderHistoryTable,
reset: resetHistoryView
reset: resetHistoryView,
rerender: () => {
renderLatestCard();
renderHistoryTable();
}
});
sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress",
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -60,17 +64,21 @@ function vdesHexPreview(rawBytes) {
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
}
function vdesRigMessages() {
return forActiveRig(vdesMessageHistory);
}
function updateVdesSummary() {
pruneVdesMessageHistory();
if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText();
}
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) {
const count = vdesMessageHistory.length;
const count = rigMessages.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
}
if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0];
const latest = rigMessages[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
}
}
@@ -137,7 +145,7 @@ function updateVdesBar() {
updateVdesSummary();
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)).slice(0, 6);
if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = "";
@@ -182,7 +190,7 @@ function renderVdesHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) {
for (const message of vdesRigMessages()) {
fragment.appendChild(renderVdesRow(message));
}
vdesMessagesEl.replaceChildren(fragment);
@@ -209,10 +217,10 @@ function normalizeServerVdesMessage(msg) {
}
function onServerVdesBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -251,8 +259,8 @@ function plotVdesMessage(msg) {
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next);
plotVdesMessage(next);
}
@@ -269,6 +277,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
rerender: () => {
updateVdesBar();
renderVdesHistory();
},
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -102,13 +106,17 @@ function paintLine(lineBytes) {
wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++;
}
function wefaxRigImages() {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return;
if (wefaxImageHistory.length === 0) {
const rigImages = wefaxRigImages();
if (rigImages.length === 0) {
wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
return;
}
const img = wefaxImageHistory[0];
const img = rigImages[0];
if (!img) return;
const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
@@ -129,7 +137,7 @@ function renderWefaxLatestCard() {
wefaxDom.liveLatest.innerHTML = html;
}
function getWefaxFilteredHistory() {
let items = wefaxImageHistory;
let items = wefaxRigImages();
if (wefaxFilterText) {
items = items.filter(function(i) {
const haystack = [
@@ -173,7 +181,7 @@ function renderWefaxHistoryTable() {
}
wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) {
const total = wefaxImageHistory.length;
const total = wefaxRigImages().length;
const shown = items.length;
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
}
@@ -208,6 +216,7 @@ function addWefaxImage(msg) {
}
}
function onServerWefaxProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state && !msg.line_data) {
if (wefaxDom.status) {
wefaxDom.status.textContent = msg.state;
@@ -234,6 +243,7 @@ function onServerWefaxProgress(msg) {
}
function onServerWefax(msg) {
addWefaxImage(msg);
if (!isActiveRigDecode(msg.rig_id)) return;
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
if (wefaxDom.status) {
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
@@ -328,7 +338,11 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerWefax,
restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView
reset: resetWefaxHistoryView,
rerender: () => {
renderWefaxLatestCard();
renderWefaxHistoryTable();
}
});
wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress",
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -64,10 +68,10 @@ function renderWsprRow(msg) {
function renderWsprHistory() {
pruneWsprMessageHistory();
if (!wsprMessagesEl) return;
const rigMessages = forActiveRig(wsprMessageHistory);
const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
const message = wsprMessageHistory[i];
if (message) fragment.appendChild(renderWsprRow(message));
for (const message of rigMessages) {
fragment.appendChild(renderWsprRow(message));
}
wsprMessagesEl.replaceChildren(fragment);
}
@@ -90,6 +94,8 @@ function normalizeServerWsprMessage(msg) {
station,
rfHz,
history: {
rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms,
snr_db: msg.snr_db,
@@ -101,16 +107,11 @@ function normalizeServerWsprMessage(msg) {
}
function onServerWsprBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
});
}
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
plotWsprLocator(msg);
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
@@ -250,15 +251,19 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
}
})();
});
function onServerWspr(msg) {
if (wsprStatus) wsprStatus.textContent = "Receiving";
function plotWsprLocator(msg) {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
});
}
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...rfHz === null ? {} : { freq_hz: rfHz }
});
}
function onServerWspr(msg) {
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg);
plotWsprLocator(msg);
addWsprMessage(next.history);
}
wsprWindow.trxPluginRuntime.registerDecoder({
@@ -267,5 +272,10 @@ wsprWindow.trxPluginRuntime.registerDecoder({
onBatch: onServerWsprBatch,
restore: onServerWsprBatch,
prune: pruneWsprHistoryView,
reset: resetWsprHistoryView
reset: resetWsprHistoryView,
rerender: renderWsprHistory,
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => {
for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message);
}
});
@@ -22,8 +22,10 @@ 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-logbook" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 2h8a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M5.5 2v12"/><path d="M7.5 5.5h3M7.5 8h3M7.5 10.5h2"/></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,6 +54,10 @@ 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="logbook">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-logbook"/></svg>
<span class="tab-label">Logbook</span>
</button>
<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>
@@ -63,6 +69,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<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>
@@ -113,13 +123,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="auth-gate" class="auth-gate" style="display:none;">
<div class="auth-gate-head">
<div class="auth-gate-title">Access Required</div>
<div class="auth-gate-sub">Enter passphrase to continue</div>
<div class="auth-gate-sub">Sign in to continue</div>
</div>
<form id="auth-form" class="auth-form">
<input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" />
<input type="text" id="auth-username" class="auth-input" placeholder="Username" autocomplete="username" required />
<input type="password" id="auth-password" class="auth-input" placeholder="Password" autocomplete="current-password" maxlength="1024" required />
<button type="submit" class="auth-submit">Login</button>
</form>
<button id="auth-guest-btn" type="button" class="auth-guest" style="display: none;">Continue as Guest</button>
<div id="auth-error" class="auth-error" style="display: none;"></div>
<div id="auth-role" class="auth-role" style="display: none;"></div>
</div>
@@ -375,6 +385,23 @@ SPDX-License-Identifier: GPL-2.0-or-later
</label>
<button id="sdr-nb-threshold-set" type="button" class="wfm-inline-btn">Set</button>
</div>
<label class="wfm-control" id="sdr-nb-profile-wrap" style="display:none;">
<span class="wfm-control-label" title="Matches the blanker to the interference: Spike = sharp bursts, Ignition = engine/PWM, Powerline = mains buzz, Broadband = dense noise.">NB profile</span>
<select id="sdr-nb-profile" class="status-input">
<option value="spike">Spike</option>
<option value="ignition">Ignition</option>
<option value="powerline">Powerline</option>
<option value="broadband">Broadband</option>
</select>
</label>
<label class="wfm-control" id="sdr-dig-sideband-wrap" style="display:none;">
<span class="wfm-control-label" title="Sideband used to demodulate DIG. Auto = USB &ge; 10 MHz, LSB below.">DIG sideband</span>
<select id="sdr-dig-sideband" class="status-input">
<option value="auto">Auto (by band)</option>
<option value="usb">USB</option>
<option value="lsb">LSB</option>
</select>
</label>
</div>
</div>
<div class="full-row label-below-row" id="vchan-row">
@@ -475,6 +502,192 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
</div>
<div id="tab-logbook" class="tab-panel" style="display:none;">
<div id="logbook-panel" class="log-panel">
<!-- Station: who the contacts are logged under, and from where. Shown
once rather than typed into every entry. -->
<div class="log-station">
<div class="log-station-identity">
<span class="log-station-call" id="log-station-callsign">--</span>
<label class="log-station-operator">
<span>Operator</span>
<input type="text" id="log-operator" class="status-input" autocomplete="off"
spellcheck="false" aria-label="Operator callsign" />
</label>
</div>
<div class="log-station-rig">
<span id="log-station-rig-name">--</span>
<span id="log-station-grid" class="log-station-grid"></span>
<span id="log-clock" class="log-clock" aria-live="polite"></span>
</div>
</div>
<!-- Entry: opens pre-filled with the six fields the radio and the clock
can answer for. Everything else is the operator's. -->
<form id="log-entry-form" class="log-entry" autocomplete="off">
<div class="log-entry-grid">
<label class="log-field log-field-call">
<span>Callsign</span>
<input type="text" id="log-call" class="status-input" required
spellcheck="false" aria-describedby="log-worked-before" />
</label>
<label class="log-field">
<span>Frequency</span>
<input type="text" id="log-freq" class="status-input" inputmode="decimal" />
</label>
<label class="log-field log-field-narrow">
<span>Mode</span>
<input type="text" id="log-mode" class="status-input" spellcheck="false" />
</label>
<label class="log-field log-field-narrow">
<span>RST sent</span>
<input type="text" id="log-rst-sent" class="status-input" spellcheck="false" />
</label>
<label class="log-field log-field-narrow">
<span>RST rcvd</span>
<input type="text" id="log-rst-rcvd" class="status-input" spellcheck="false" />
</label>
<label class="log-field log-field-narrow">
<span>Locator</span>
<input type="text" id="log-grid" class="status-input" spellcheck="false" />
</label>
<label class="log-field">
<span>Name</span>
<input type="text" id="log-name" class="status-input" />
</label>
<label class="log-field">
<span>Comment</span>
<input type="text" id="log-comment" class="status-input" />
</label>
</div>
<details class="log-contest" id="log-contest-block">
<summary>Contest exchange</summary>
<div class="log-entry-grid">
<label class="log-field">
<span>Contest</span>
<input type="text" id="log-contest-id" class="status-input" spellcheck="false"
placeholder="CQ-WW-SSB" />
</label>
<label class="log-field log-field-narrow">
<span>Serial sent</span>
<input type="text" id="log-stx" class="status-input" inputmode="numeric" />
</label>
<label class="log-field log-field-narrow">
<span>Received</span>
<input type="text" id="log-srx" class="status-input" />
</label>
</div>
</details>
<div class="log-entry-actions">
<span id="log-worked-before" class="log-worked" aria-live="polite"></span>
<span class="log-entry-buttons">
<button type="button" id="log-clear-btn" class="log-secondary-btn">Clear</button>
<button type="submit" id="log-save-btn" class="log-primary-btn">Log contact</button>
</span>
</div>
</form>
<!-- The log itself. -->
<div class="log-list-toolbar">
<input type="text" id="log-filter-call" class="status-input" placeholder="Callsign&hellip;"
aria-label="Filter by callsign" />
<select id="log-filter-band" class="status-input" aria-label="Filter by band">
<option value="">All bands</option>
</select>
<select id="log-filter-mode" class="status-input" aria-label="Filter by mode">
<option value="">All modes</option>
</select>
<span class="log-toolbar-spacer"></span>
<a id="log-export-btn" class="log-secondary-btn" href="/api/logbook/export.adi"
download>Export ADIF</a>
<button type="button" id="log-import-btn" class="log-secondary-btn">Import ADIF</button>
<input type="file" id="log-import-file" accept=".adi,.adif,text/plain" hidden />
</div>
<div class="log-table-wrap">
<table class="log-table">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Time</th>
<th scope="col">Callsign</th>
<th scope="col">Band</th>
<th scope="col">Mode</th>
<th scope="col">Sent</th>
<th scope="col">Rcvd</th>
<th scope="col">Locator</th>
<th scope="col">Rig</th>
<th scope="col">QSL</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody id="log-rows"></tbody>
</table>
</div>
<div id="log-summary" class="log-summary" aria-live="polite"></div>
<details class="log-report" id="log-statistics-block">
<summary>Bands worked</summary>
<div class="log-table-wrap">
<table class="log-table">
<thead>
<tr>
<th scope="col">Band</th>
<th scope="col">Contacts</th>
<th scope="col">Stations</th>
<th scope="col">Confirmed</th>
</tr>
</thead>
<tbody id="log-statistics-rows"></tbody>
</table>
</div>
</details>
<details class="log-report" id="log-contest-export-block">
<summary>Contest entry (Cabrillo)</summary>
<p class="log-report-note">
Contest logs are submitted in Cabrillo and rejected in anything else.
Only the contacts of the contest named here go into the entry.
</p>
<div class="log-entry-grid">
<label class="log-field">
<span>Contest</span>
<input type="text" id="log-cbr-contest" class="status-input" spellcheck="false"
placeholder="CQ-WW-SSB" />
</label>
<label class="log-field">
<span>Callsign</span>
<input type="text" id="log-cbr-callsign" class="status-input" spellcheck="false" />
</label>
<label class="log-field">
<span>Operators</span>
<select id="log-cbr-operator" class="status-input">
<option value="SINGLE-OP">Single operator</option>
<option value="MULTI-OP">Multi operator</option>
<option value="CHECKLOG">Checklog</option>
</select>
</label>
<label class="log-field">
<span>Power</span>
<select id="log-cbr-power" class="status-input">
<option value="LOW">Low</option>
<option value="HIGH">High</option>
<option value="QRP">QRP</option>
</select>
</label>
<label class="log-field log-field-narrow">
<span>Claimed score</span>
<input type="text" id="log-cbr-score" class="status-input" inputmode="numeric" />
</label>
</div>
<div class="log-entry-actions">
<span class="log-entry-buttons">
<a id="log-cbr-export" class="log-secondary-btn" href="/api/logbook/export.cbr"
download>Export Cabrillo</a>
</span>
</div>
</details>
</div>
</div>
<div id="tab-bookmarks" class="tab-panel" style="display:none;">
<div class="bm-toolbar">
<select id="bm-scope-picker" class="status-input" aria-label="Bookmark scope">
@@ -525,6 +738,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
<label class="bm-label">Bandwidth (Hz)
<input type="number" id="bm-bw" class="status-input" min="0" placeholder="optional" />
</label>
<label class="bm-label" id="bm-dig-sideband-label" style="display:none;">DIG sideband
<select id="bm-dig-sideband" class="status-input">
<option value="">Global setting</option>
<option value="auto">Auto (by band)</option>
<option value="usb">USB</option>
<option value="lsb">LSB</option>
</select>
</label>
<label class="bm-label">Locator
<input type="text" id="bm-locator" class="status-input" maxlength="6" placeholder="e.g. JO93" />
</label>
@@ -918,7 +1139,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">
@@ -960,50 +1180,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">
@@ -1117,6 +1293,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</template>
</div>
<div id="tab-satellites" class="tab-panel" style="display:none;">
<h2 class="section-heading">Satellites</h2>
<div class="ft8-controls">
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
<select id="sat-pred-category" class="sat-sort-select">
<option value="all">All</option>
<option value="weather">Weather</option>
<option value="amateur">Ham Radio</option>
<option value="other">Other</option>
</select>
<select id="sat-pred-min-el" class="sat-sort-select">
<option value="0">All passes</option>
<option value="10">Min 10°</option>
<option value="20">Min 20°</option>
<option value="45">Min 45°</option>
</select>
</div>
<!-- Current passes -->
<div id="sat-pred-current-section">
<div class="sat-pred-section-title">Currently receivable</div>
<div class="sat-pred-header sat-pred-header-current">
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-time">AOS Start</span>
<span class="sat-pred-col-time">AOS End</span>
<span class="sat-pred-col-countdown">Time left</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-current-list"></div>
</div>
<!-- Upcoming passes -->
<div id="sat-pred-upcoming-section">
<div class="sat-pred-section-title">Upcoming passes</div>
<div class="sat-pred-header">
<span class="sat-pred-col-time">AOS (UTC)</span>
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-dur">Duration</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-list"></div>
</div>
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions&hellip;</small>
</div>
<div id="tab-statistics" class="tab-panel" style="display:none;">
<h2 class="section-heading">Statistics</h2>
<template id="tmpl-statistics">
@@ -1265,6 +1486,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
<button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button>
<button class="sub-tab" data-subtab="settings-history">History</button>
<button id="settings-account-tab" class="sub-tab" data-subtab="settings-account" style="display:none;">Account</button>
<button id="settings-users-tab" class="sub-tab" data-subtab="settings-users" style="display:none;">Users</button>
</div>
<div id="subtab-settings-scheduler" class="sub-tab-panel">
<div id="scheduler-panel" class="sch-panel">
@@ -1462,30 +1685,31 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
<div id="background-decode-panel" class="sch-panel">
<div class="sch-toast" id="background-decode-toast" role="alert" aria-live="polite" style="display:none;"></div>
<!-- Now Playing status card (moved to top) -->
<div class="now-playing-card">
<div id="background-decode-status-card" class="sch-status-card">No background decode bookmarks configured.</div>
</div>
<div class="sch-section">
<div class="sch-section-title">Configuration</div>
<div class="sch-row">
<label class="sch-label bgd-toggle-wrap">Background decode
<span class="bgd-toggle-row">
<input type="checkbox" id="background-decode-enabled" />
<span>Enable hidden background decoder channels</span>
</span>
<div class="sch-section-title">Background Decode</div>
<p class="bgd-intro">
Decodes saved bookmarks on hidden channels while you work another band.
Needs an SDR rig, and a bookmark is only decoded while it falls inside
the span the rig is tuned across.
</p>
<div class="bgd-controls">
<label class="bgd-enable">
<input type="checkbox" id="background-decode-enabled" />
<span>Enabled</span>
</label>
<span id="bgd-span-summary" class="bgd-span" aria-live="polite"></span>
</div>
<div class="sch-row" style="flex-direction:column;gap:0.5rem;">
<label class="sch-label" style="min-width:100%;">Bookmarks
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter" placeholder="Filter bookmarks..." />
</label>
<div class="bgd-list-toolbar">
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter"
placeholder="Filter bookmarks&hellip;" aria-label="Filter bookmarks" />
<div class="bgd-select-actions">
<button type="button" id="bgd-select-all-btn" class="bgd-select-btn" aria-label="Select all bookmarks">Select All</button>
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Deselect all bookmarks">Deselect All</button>
<button type="button" id="bgd-select-all-btn" class="bgd-select-btn" aria-label="Select every bookmark">All</button>
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Select no bookmarks">None</button>
</div>
<div id="bgd-bookmark-checklist" class="bgd-checklist"></div>
</div>
<div id="bgd-bookmark-checklist" class="bgd-checklist" role="group"
aria-label="Bookmarks decoded in the background"></div>
<div id="bgd-selection-summary" class="bgd-summary" aria-live="polite"></div>
</div>
<div class="sch-actions">
@@ -1553,6 +1777,34 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
</div>
<div id="subtab-settings-account" class="sub-tab-panel" style="display:none;">
<div class="settings-card">
<h3>Change password</h3>
<form id="account-password-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
<input id="account-current-password" class="auth-input" type="password" placeholder="Current password" autocomplete="current-password" maxlength="1024" required />
<input id="account-new-password" class="auth-input" type="password" placeholder="New password (8+ characters)" autocomplete="new-password" minlength="8" maxlength="1024" required />
<input id="account-confirm-password" class="auth-input" type="password" placeholder="Confirm new password" autocomplete="new-password" minlength="8" maxlength="1024" required />
<button type="submit" class="auth-submit">Change password</button>
</form>
<div id="account-password-error" class="auth-error" role="alert" aria-live="polite" style="display:none;"></div>
<p class="settings-note">Changing your password signs out every session for this account.</p>
</div>
</div>
<div id="subtab-settings-users" class="sub-tab-panel" style="display:none;">
<div id="user-management">
<div class="settings-card">
<form id="user-create-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
<input id="user-create-username" class="auth-input" placeholder="Username" autocomplete="off" required />
<input id="user-create-password" class="auth-input" type="password" placeholder="Password (8+ characters)" autocomplete="new-password" minlength="8" maxlength="1024" required />
<span id="user-create-roles" class="auth-role-choices"></span>
<label class="auth-role-choice"><input id="user-create-enabled" type="checkbox" checked /> Enabled</label>
<button type="submit" class="auth-submit">Add user</button>
</form>
<div id="user-management-error" class="auth-error" style="display:none;"></div>
<div id="user-list" style="margin-top:.75rem;"></div>
</div>
</div>
</div>
</div>
<div id="tab-about" class="tab-panel" style="display:none;">
<h2 class="section-heading">About</h2>
@@ -196,8 +196,7 @@ body {
font-size: var(--fs-base);
box-sizing: border-box;
}
.auth-submit,
.auth-guest {
.auth-submit {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: var(--radius-md);
@@ -213,16 +212,14 @@ body {
font-weight: 700;
}
.auth-submit:hover:not(:disabled) { background: var(--accent-green-hover); }
.auth-guest {
background: var(--btn-bg);
color: var(--text);
border: 1px solid var(--border-light);
font-weight: 600;
margin-top: var(--space-4);
}
.auth-guest:hover:not(:disabled) { background: var(--btn-hover-bg); }
.auth-error { color: var(--accent-red); font-size: var(--fs-sm); margin-top: var(--space-4); }
.auth-role { margin-top: var(--space-4); color: var(--text-muted); font-size: var(--fs-sm); }
#user-management .auth-input { width: auto; min-width: 9rem; flex: 1 1 10rem; margin-bottom: 0; }
#user-management .auth-submit { width: auto; }
#user-management .auth-role-choices { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
#user-management .auth-role-choice { display: inline-flex; align-items: center; gap: .2rem; white-space: nowrap; }
.settings-note { color: var(--text-muted); font-size: .85rem; margin: .75rem 0 0; }
#user-management button { padding: 0.55rem 0.75rem; }
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
#tab-main .label > span {
@@ -3816,11 +3813,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
@media (max-width: 760px) {
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
}
/* One navigation model at every width. Statistics, Recorder, Settings and
* About are occasional destinations: they live behind More rather than
* competing with the operating tabs for the row and then scrolling out of
/* One navigation model at every width. Satellites, Statistics, Recorder,
* Settings and About are occasional destinations: they live behind More rather
* than competing with the operating tabs for the row and then scrolling out of
* reach. The mobile layout already grouped them this way; the desktop strip
* now matches it, which is why the tab strip no longer needs to scroll. */
.tab-bar-nav .tab[data-tab="satellites"],
.tab-bar-nav .tab[data-tab="statistics"],
.tab-bar-nav .tab[data-tab="recorder"],
.tab-bar-nav .tab[data-tab="settings"],
@@ -4030,8 +4028,9 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
}
.tab-icon, .tab-more-icon { display: block; }
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
.tab[data-tab="satellites"], .tab[data-tab="statistics"],
.tab[data-tab="recorder"], .tab[data-tab="settings"],
.tab[data-tab="about"] { display: none; }
.mobile-more-btn { display: flex; }
.mobile-more-menu {
position: fixed;
@@ -5587,18 +5586,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
#sch-sat-form-cancel:hover {
background: var(--border);
}
.bgd-toggle-wrap {
min-width: 18rem;
flex: 1 1 20rem;
}
.bgd-toggle-row {
display: inline-flex;
align-items: center;
gap: 0.55rem;
min-height: var(--control-height);
color: var(--text);
font-weight: 500;
}
.bgd-add-row {
display: flex;
gap: 0.55rem;
@@ -5622,48 +5609,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--text-muted);
font-size: 0.78rem;
}
.bgd-status-list {
display: grid;
gap: 0.65rem;
}
.bgd-status-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 0.75rem;
border: 1px solid var(--border-light);
border-radius: 0.55rem;
background: color-mix(in srgb, var(--card-bg) 74%, transparent);
}
.bgd-status-name {
font-weight: 600;
}
.bgd-status-meta {
color: var(--text-muted);
font-size: 0.82rem;
}
.bgd-status-state {
align-self: center;
white-space: nowrap;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--accent-green);
}
.bgd-status-state[data-state="out_of_span"],
.bgd-status-state[data-state="waiting_for_spectrum"],
.bgd-status-state[data-state="waiting_for_user"],
.bgd-status-state[data-state="scheduler_has_control"],
.bgd-status-state[data-state="inactive"],
.bgd-status-state[data-state="handled_by_scheduler"],
.bgd-status-state[data-state="handled_by_virtual_channel"] {
color: var(--accent-yellow);
}
.bgd-status-state[data-state="missing_bookmark"],
.bgd-status-state[data-state="no_supported_decoders"] {
color: var(--accent-red);
}
/* ── "Now Playing" status card (top of scheduler & bgd panels) ──── */
.now-playing-card {
border-left: 3px solid var(--accent-green);
@@ -5879,18 +5824,65 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--text-muted);
opacity: 0.7;
}
.bgd-intro {
margin: 0 0 0.9rem;
max-width: 62ch;
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.45;
}
/* The switch and what the rig can hear, on one line: the second is the reason
a selected bookmark may not be decoding, so it belongs beside the first. */
.bgd-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.9rem;
}
.bgd-enable {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
cursor: pointer;
}
.bgd-span {
color: var(--text-muted);
font-size: 0.82rem;
}
.bgd-span[data-tone="warn"] {
color: var(--text-warn, #b7791f);
}
/* The filter and the bulk buttons sit on the list they act on, and stay on it
when the panel narrows. */
.bgd-list-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.bgd-list-toolbar .bgd-checklist-filter {
flex: 1 1 auto;
min-width: 0;
}
.bgd-checklist {
max-height: 16rem;
max-height: 22rem;
overflow-y: auto;
border: 1px solid var(--border-light);
border-radius: 0.4rem;
background: var(--bg-secondary);
}
/* One row per bookmark: pick it here, and read what it is doing here too. */
.bgd-checklist-row {
display: flex;
display: grid;
grid-template-columns: auto minmax(6rem, 1fr) auto auto;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.65rem;
padding: 0.5rem 0.65rem;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
font-size: 0.85rem;
@@ -5902,28 +5894,83 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.bgd-checklist-row:hover {
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
}
.bgd-checklist-row.is-selected {
background: color-mix(in srgb, var(--accent-green) 8%, transparent);
}
.bgd-checklist-row input[type="checkbox"] {
flex-shrink: 0;
}
.bgd-checklist-name {
font-weight: 600;
color: var(--text);
}
.bgd-checklist-meta {
color: var(--text-muted);
font-size: 0.78rem;
margin-left: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bgd-checklist-meta {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
color: var(--text-muted);
font-size: 0.78rem;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.bgd-checklist-mode {
color: var(--text-muted);
opacity: 0.85;
}
/* A dot and a word. The dot carries the state, so the word can stay a word. */
.bgd-state {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
min-width: 9.5rem;
color: var(--text-muted);
font-size: 0.76rem;
white-space: nowrap;
}
.bgd-state-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: currentcolor;
opacity: 0.55;
flex-shrink: 0;
}
.bgd-state[data-state="active"] {
color: var(--accent-green);
font-weight: 600;
}
.bgd-state[data-state="active"] .bgd-state-dot {
opacity: 1;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-green) 22%, transparent);
}
.bgd-state[data-state="missing_bookmark"],
.bgd-state[data-state="no_supported_decoders"] {
color: var(--color-error, #c0392b);
}
.bgd-state[data-state="unselected"] {
min-width: 0;
}
.bgd-checklist-empty {
padding: 0.75rem;
padding: 1rem 0.75rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.5;
}
/* ── Select All / Deselect All buttons ────────────────────────────── */
/* What the selection adds up to, under the list it describes. */
.bgd-summary {
margin-top: 0.5rem;
color: var(--text-muted);
font-size: 0.8rem;
}
/* ── Select all / none buttons ─────────────────────────────────────── */
.bgd-select-actions {
display: flex;
flex-shrink: 0;
gap: 0.5rem;
}
.bgd-select-btn {
@@ -5941,15 +5988,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
background: var(--card-bg);
color: var(--text);
}
/* ── SVG State Dot Badges ─────────────────────────────────────────── */
.bgd-state-dot {
width: 8px;
height: 8px;
display: inline-block;
vertical-align: middle;
margin-right: 4px;
fill: currentColor;
}
@media (max-width: 600px) {
.channel-scheduler-controls {
flex-direction: column;
@@ -5990,8 +6028,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.bm-page-summary {
text-align: center;
}
.bgd-add-row,
.bgd-status-row {
.bgd-add-row {
flex-direction: column;
align-items: stretch;
}
@@ -6001,8 +6038,17 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.interleave-ring-label {
max-width: 8rem;
}
.bgd-checklist-meta {
margin-left: 0;
/* Two lines rather than four columns: the name and its state stay legible
when there is no room for the frequency beside them. */
.bgd-checklist-row {
grid-template-columns: auto minmax(0, 1fr);
row-gap: 0.2rem;
}
.bgd-checklist-meta,
.bgd-state {
grid-column: 2;
justify-content: flex-start;
min-width: 0;
font-size: 0.75rem;
}
}
@@ -6339,3 +6385,280 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
}
#tab-statistics > .section-heading { margin-bottom: 0; }
/* =========================================================================
Logbook
========================================================================= */
.log-panel {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
/* Who the contacts are logged under, and from where: shown once, not typed
into every entry. */
.log-station {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.6rem 0.85rem;
border: 1px solid var(--border-light);
border-radius: 0.5rem;
background: var(--bg-secondary);
}
.log-station-identity {
display: flex;
align-items: center;
gap: 0.85rem;
min-width: 0;
}
.log-station-call {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text);
}
.log-station-operator {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: var(--text-muted);
}
.log-station-operator input {
width: 7rem;
text-transform: uppercase;
}
.log-station-rig {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.82rem;
color: var(--text-muted);
min-width: 0;
}
.log-station-grid {
font-variant-numeric: tabular-nums;
}
.log-clock {
font-variant-numeric: tabular-nums;
padding: 0.1rem 0.45rem;
border-radius: 0.3rem;
background: color-mix(in srgb, var(--card-bg) 70%, transparent);
}
/* A browser clock that disagrees with the radio's is worth saying out loud:
the times in a log are the one thing that cannot be corrected later. */
.log-clock.is-adrift {
color: var(--accent-yellow, #b7791f);
font-weight: 600;
}
.log-entry {
border: 1px solid var(--border-light);
border-radius: 0.5rem;
padding: 0.85rem;
background: var(--bg-secondary);
}
.log-entry-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
gap: 0.6rem 0.75rem;
}
.log-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
font-size: 0.75rem;
color: var(--text-muted);
}
.log-field input {
min-width: 0;
}
.log-field-call {
grid-column: span 2;
}
.log-field-call input {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.log-field-narrow {
max-width: 10rem;
}
.log-entry-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.75rem;
}
.log-entry-buttons {
display: inline-flex;
gap: 0.5rem;
margin-left: auto;
}
.log-worked {
font-size: 0.8rem;
color: var(--text-muted);
}
.log-worked.is-worked {
color: var(--accent-green);
font-weight: 600;
}
.log-primary-btn {
background: var(--accent-green);
color: #fff;
border: none;
border-radius: 0.35rem;
padding: 0.4rem 1.1rem;
font-weight: 600;
cursor: pointer;
}
.log-secondary-btn {
display: inline-flex;
align-items: center;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 0.35rem;
color: var(--text-muted);
padding: 0.35rem 0.75rem;
font-size: 0.82rem;
text-decoration: none;
cursor: pointer;
}
.log-secondary-btn:hover {
background: var(--card-bg);
color: var(--text);
}
.log-list-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.log-list-toolbar .status-input {
max-width: 11rem;
}
.log-toolbar-spacer {
flex: 1 1 auto;
}
.log-table-wrap {
overflow-x: auto;
border: 1px solid var(--border-light);
border-radius: 0.5rem;
}
.log-table {
width: 100%;
border-collapse: collapse;
font-size: 0.83rem;
}
.log-table th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 0.5rem 0.65rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-light);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
white-space: nowrap;
}
.log-table td {
padding: 0.4rem 0.65rem;
border-bottom: 1px solid color-mix(in srgb, var(--border-light) 60%, transparent);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.log-table tr:last-child td {
border-bottom: none;
}
.log-table tbody tr:hover {
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
}
.log-cell-call {
font-weight: 700;
letter-spacing: 0.04em;
font-variant-numeric: normal;
}
.log-row-btn {
background: transparent;
border: 1px solid var(--border-light);
border-radius: 0.3rem;
color: var(--text-muted);
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
.log-row-btn:hover {
color: var(--text);
background: var(--card-bg);
}
.log-empty {
padding: 1.5rem 0.75rem;
text-align: center;
color: var(--text-muted);
white-space: normal;
}
.log-summary {
color: var(--text-muted);
font-size: 0.8rem;
}
/* The ham layout works the bands: the broadcast furniture goes, and the entry
form is the thing the panel opens on. */
body[data-operator-layout="ham"] .controls-col-wfm { display: none !important; }
body[data-operator-layout="ham"] #rds-panel { display: none !important; }
@media (max-width: 700px) {
.log-station,
.log-entry-actions {
flex-direction: column;
align-items: stretch;
}
.log-entry-buttons {
margin-left: 0;
}
.log-field-call {
grid-column: span 2;
}
.log-list-toolbar .status-input {
max-width: none;
flex: 1 1 8rem;
}
}
/* Contest exchange and the reports below the log: folded away, because most
operating is not a contest and most sessions do not export one. */
.log-contest,
.log-report {
border: 1px solid var(--border-light);
border-radius: 0.5rem;
background: var(--bg-secondary);
padding: 0.5rem 0.85rem;
}
.log-contest {
margin-top: 0.75rem;
}
.log-contest > summary,
.log-report > summary {
cursor: pointer;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.log-contest[open] > summary,
.log-report[open] > summary {
margin-bottom: 0.65rem;
}
.log-report-note {
margin: 0 0 0.65rem;
max-width: 62ch;
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1.45;
}
@@ -12,9 +12,13 @@ use trx_core::rig::{
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
RigVfoEntry,
};
use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel};
use trx_core::{
DecoderConfig, DigSidebandPolicy, NoiseBlankerProfile, RdsData, RigFilterState, RigMode,
RigSnapshot, WfmDenoiseLevel,
};
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
use trx_frontend_http::server::api::FrontendMeta;
use trx_frontend_http::server::auth::AuthRole;
use trx_protocol::{DecoderActivation, DecoderDescriptor};
use ts_rs::{Config, TS};
@@ -48,6 +52,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
export!(RigStatus);
export!(DecoderConfig);
export!(WfmDenoiseLevel);
export!(DigSidebandPolicy);
export!(NoiseBlankerProfile);
export!(RigFilterState);
export!(RdsData);
export!(SpectrumData);
@@ -56,6 +62,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
export!(RigListItem);
export!(RigListResponse);
export!(FrontendMeta);
export!(AuthRole);
export!(DecoderActivation);
export!(DecoderDescriptor);
@@ -30,8 +30,10 @@ await build({
"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"),
logbook: path.join(sourceDir, "plugins", "logbook.ts"),
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
"map-core": path.join(sourceDir, "map-core.ts"),
screenshot: path.join(sourceDir, "screenshot.ts"),
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs && node tests/account-management.mjs && node tests/transmit-role.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -2,11 +2,51 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
export type AuthRole = "rx" | "control";
import type { AuthRole } from "./generated.js";
export type { AuthRole };
export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "transmit", "write", "administrator"];
export const AUTH_ADMIN_ROLES: readonly AuthRole[] = AUTH_ROLES.filter(role => role !== "guest");
export const AUTH_ROLE_LABELS: Readonly<Record<AuthRole, string>> = {
guest: "Guest",
read: "Read",
control: "Control",
transmit: "Transmit",
write: "Write",
administrator: "Administrator",
};
export function isAuthRole(value: unknown): value is AuthRole {
return typeof value === "string" && (AUTH_ROLES as readonly string[]).includes(value);
}
export function normalizeAuthRoles(roles: readonly AuthRole[]): AuthRole[] {
return AUTH_ROLES.filter(role => roles.includes(role));
}
export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boolean {
return roles.includes("administrator")
|| roles.includes(required)
|| required === "read" && roles.includes("guest")
|| required === "read" && (roles.includes("control") || roles.includes("transmit"));
}
export function hasAccountControls(roles: readonly AuthRole[]): boolean {
return roles.length > 0 && !roles.includes("guest");
}
function decodeRoles(value: unknown, context: string): AuthRole[] {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
throw new TypeError(`${context} has invalid roles`);
}
return normalizeAuthRoles(value);
}
export interface AuthSession {
authenticated: boolean;
role?: AuthRole;
roles: AuthRole[];
username?: string;
auth_disabled?: boolean;
}
@@ -18,21 +58,24 @@ function decodeAuthSession(value: unknown): AuthSession {
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.role !== undefined && session.role !== "rx" && session.role !== "control") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded: AuthSession = { authenticated: session.authenticated };
if (session.role !== undefined) decoded.role = session.role;
const decoded: AuthSession = {
authenticated: session.authenticated,
roles: decodeRoles(session.roles, "The authentication response"),
};
if (session.username !== undefined) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
}
if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
const authDisabledSession: AuthSession = {
authenticated: true,
role: "control",
roles: [...AUTH_ADMIN_ROLES],
auth_disabled: true,
};
@@ -40,19 +83,19 @@ export async function fetchAuthSession(): Promise<AuthSession> {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false };
if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json());
} catch (error: unknown) {
console.error("Auth check failed:", error);
return { authenticated: false };
return { authenticated: false, roles: [] };
}
}
export async function login(passphrase: string): Promise<AuthSession> {
export async function login(username: string, password: string): Promise<AuthSession> {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase }),
body: JSON.stringify({ username, password }),
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
@@ -62,6 +105,52 @@ export async function login(passphrase: string): Promise<AuthSession> {
return decodeAuthSession(await response.json());
}
export interface ManagedUser { username: string; roles: AuthRole[]; enabled: boolean }
async function userRequest(path: string, init?: RequestInit): Promise<Response> {
const response = await fetch(path, init);
if (!response.ok) {
const payload = await response.json().catch(() => ({})) as { error?: string };
throw new Error(payload.error || `User operation failed (${response.status})`);
}
return response;
}
export async function listUsers(): Promise<ManagedUser[]> {
const value: unknown = await userRequest("/auth/users").then(response => response.json());
if (!Array.isArray(value) || !value.every((user: unknown) => {
if (typeof user !== "object" || user === null) return false;
const record = user as Record<string, unknown>;
return typeof record.username === "string"
&& typeof record.enabled === "boolean"
&& Array.isArray(record.roles)
&& record.roles.every(isAuthRole);
})) {
throw new TypeError("The user list response is malformed");
}
return (value as ManagedUser[]).map(user => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
}
export async function createUser(username: string, password: string, roles: AuthRole[], enabled = true): Promise<void> {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
}
export async function updateUser(username: string, changes: { password?: string; roles?: AuthRole[]; enabled?: boolean }): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
export async function changeOwnPassword(currentPassword: string, newPassword: string): Promise<void> {
await userRequest("/auth/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
}
export async function deleteUser(username: string): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
export async function logout(): Promise<void> {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
@@ -55,7 +55,21 @@ export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabl
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
export type DigSidebandPolicy = "auto" | "usb" | "lsb";
export type NoiseBlankerProfile = "spike" | "ignition" | "powerline" | "broadband";
export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null,
/**
* Current noise-blanker tuning profile (SDR backends only). Surfaces in the
* UI as the advanced-controls "NB profile" selector.
*/
sdr_nb_profile?: NoiseBlankerProfile | null,
/**
* Current DIG sideband policy (SDR backends only). Surfaces in the UI as
* the advanced-controls "DIG sideband" selector.
*/
sdr_dig_sideband?: DigSidebandPolicy | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
/**
* Co-Channel Interference level (0100 scale).
*/
@@ -78,6 +92,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>,
/**
@@ -119,6 +137,8 @@ export type RigListResponse = { active_remote: string | null, rigs: Array<RigLis
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, };
export type AuthRole = "guest" | "read" | "control" | "transmit" | "write" | "administrator";
export type DecoderActivation = "mode_bound" | "toggle";
export type DecoderDescriptor = {
@@ -21,6 +21,17 @@ import {
fetchAuthSession,
login,
logout,
listUsers,
createUser,
updateUser,
deleteUser,
changeOwnPassword,
AUTH_ADMIN_ROLES,
AUTH_ROLES,
AUTH_ROLE_LABELS,
hasAccountControls,
hasAuthRole as rolesInclude,
normalizeAuthRoles,
} from "./api/auth.js";
import {
formatByteSize as recorderFormatSize,
@@ -189,8 +200,8 @@ interface TrxModules {
reverseGeocodeLocation(lat: number, lon: number, grid: string): void;
bandForHz(frequencyHz: number): unknown;
};
scheduler?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
backgroundDecode?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
scheduler?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
backgroundDecode?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
bookmarks?: {
readonly overlayList: readonly Bookmark[];
readonly overlayRevision: number;
@@ -226,7 +237,7 @@ interface TrxState {
readonly initialMapZoom: number;
readonly decodeHistoryRetentionMin: number;
readonly authEnabled: boolean;
readonly authRole: AuthRole | null;
readonly authRoles: readonly AuthRole[];
readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null;
readonly primaryRds: RdsData | null;
@@ -265,7 +276,7 @@ interface TrxUi {
confirm(options?: { title?: string; message?: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>;
notify(message: string, options?: { kind?: "info" | "error" | "success" | "warning"; duration?: number; action?: { label?: string; run(): void } | null }): HTMLDivElement;
setActiveRig(rigId: string | null): void;
setLayoutCapabilities(capabilities?: Partial<Record<"broadcast" | "digital", boolean>>): void;
setLayoutCapabilities(capabilities?: Partial<Record<"broadcast" | "digital" | "ham", boolean>>): void;
setButtonState(element: HTMLButtonElement | null, options?: { active?: boolean; activeLabel?: string; inactiveLabel?: string; busy?: boolean; disabled?: boolean }): void;
closeMobileOverlays?(restoreFocus?: boolean): void;
syncSelectedTab(container: HTMLElement | null, selected: Element): void;
@@ -385,12 +396,14 @@ declare global {
refreshCwTonePicker?(): void;
updateFt8RfDisplay?(): void;
clearSatPredictionDom?(): void;
refreshSatPredictions?(): void;
syncWefaxToggle?(enabled: boolean): void;
syncSstvToggle?(enabled: boolean): void;
updateAisBar?(value?: number): void;
updateVdesBar?(value?: number): void;
updateAprsBar?(value?: number): void;
updateFt8Bar?(value?: number): void;
updateCwBar?(value?: number): void;
updateSatLiveState?(value: unknown): void;
applyCwAutoUi?(enabled: boolean): void;
applyCwAutoUiFromServer?(enabled: boolean): void;
@@ -404,40 +417,76 @@ declare global {
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
// --- Authentication ---
let authRole: AuthRole | null = null;
let authRoles: AuthRole[] = [];
let authUsername: string | null = null;
let authEnabled = true;
function setAuthRoles(roles: readonly AuthRole[]) {
authRoles = normalizeAuthRoles(roles);
}
function hasAuthRole(role: AuthRole) {
return rolesInclude(authRoles, role);
}
function buildRoleChoices(selected: readonly AuthRole[]) {
const element = document.createElement("span");
element.className = "auth-role-choices";
const inputs = AUTH_ROLES.map((value) => {
const label = document.createElement("label");
label.className = "auth-role-choice";
const input = document.createElement("input");
input.type = "checkbox";
input.value = value;
input.checked = selected.includes(value);
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
element.append(label);
return { input, value };
});
inputs.forEach(({ input, value }) => {
input.addEventListener("change", () => {
if (!input.checked) return;
if (value === "guest") {
const administrator = inputs.find(choice => choice.value === "administrator");
if (administrator) administrator.input.checked = false;
} else if (value === "administrator") {
const guest = inputs.find(choice => choice.value === "guest");
if (guest) guest.input.checked = false;
}
});
});
return { element, inputs };
}
async function checkAuthStatus() {
return fetchAuthSession();
}
async function authLogin(passphrase: string) {
return login(passphrase);
async function authLogin(username: string, password: string) {
return login(username, password);
}
async function authLogout() {
try {
await logout();
authRole = null;
setAuthRoles([]);
authUsername = null;
// Disconnect and show auth gate without page reload
disconnect();
setDecodeHistoryOverlayVisible(false);
requiredElement("content").style.display = "none";
requiredElement("loading").style.display = "none";
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
requiredElement<HTMLInputElement>("auth-password").value = "";
updateAuthUI();
// Check if guest mode is available after logout
const authStatus = await checkAuthStatus();
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
} catch (e) {
console.error("Logout failed:", e);
showAuthError("Logout failed");
}
}
function showAuthGate(allowGuest = false) {
function showAuthGate() {
if (!authEnabled) return;
setDecodeHistoryOverlayVisible(false);
requiredElement("loading").style.display = "none";
@@ -457,12 +506,6 @@ function showAuthGate(allowGuest = false) {
panel.style.display = "none";
});
// Show guest button if guest mode is available
const guestBtn = document.getElementById("auth-guest-btn");
if (guestBtn) {
guestBtn.style.display = allowGuest ? "block" : "none";
}
document.querySelectorAll<HTMLElement>(".tab-bar .tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === "main");
});
@@ -504,22 +547,32 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) {
if (badge) badge.style.display = "none";
if (headerAuthBtn) headerAuthBtn.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess();
return;
}
if (authRole) {
if (authRoles.length > 0) {
const canManageAccount = hasAccountControls(authRoles);
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
if (!canManageAccount && accountTab?.classList.contains("active")) {
const panel = document.getElementById("subtab-settings-account");
if (panel) panel.style.display = "none";
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
}
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn) {
headerAuthBtn.textContent = "Logout";
headerAuthBtn.style.display = "block";
}
} else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none";
if (headerAuthBtn) {
headerAuthBtn.textContent = "Login";
@@ -530,37 +583,40 @@ function updateAuthUI() {
}
function applyAuthRestrictions() {
if (!authRole) return;
if (authRoles.length === 0) return;
// Disable TX/PTT/frequency/mode/VFO controls for rx role
if (authRole === "rx") {
if (!hasAuthRole("transmit")) {
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null;
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
const txLimitRow = document.getElementById("tx-limit-row");
if (pttBtn) pttBtn.disabled = true;
if (txAudioBtn) txAudioBtn.disabled = true;
if (txLimitBtn) txLimitBtn.disabled = true;
if (txLimitInput) txLimitInput.disabled = true;
if (txLimitRow) txLimitRow.style.opacity = "0.5";
}
// General tuning and receive-side controls require Control.
if (!hasAuthRole("control")) {
const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
const freqInput = document.getElementById("freq") as HTMLInputElement | null;
const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | null;
const modeSelect = document.getElementById("mode") as HTMLSelectElement | null;
const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null;
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
const txLimitRow = document.getElementById("tx-limit-row");
const jogUp = document.getElementById("jog-up") as HTMLButtonElement | null;
const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button");
const vfoButtons = document.querySelectorAll<HTMLButtonElement>("#vfo-picker button");
// Disable TX buttons
if (pttBtn) pttBtn.disabled = true;
if (powerBtn) powerBtn.disabled = true;
if (lockBtn) lockBtn.disabled = true;
if (txAudioBtn) txAudioBtn.disabled = true;
if (txLimitBtn) txLimitBtn.disabled = true;
// Disable frequency/mode inputs
if (freqInput) freqInput.disabled = true;
if (centerFreqInput) centerFreqInput.disabled = true;
if (modeSelect) modeSelect.disabled = true;
if (txLimitInput) txLimitInput.disabled = true;
// Disable VFO selector
vfoButtons.forEach(btn => btn.disabled = true);
@@ -604,9 +660,6 @@ function applyAuthRestrictions() {
btn.disabled = true;
}
});
// Hide TX-specific UI but keep controls visible (disabled)
if (txLimitRow) txLimitRow.style.opacity = "0.5";
}
}
@@ -865,14 +918,15 @@ window.applyDecodeHistoryRetention = function() {
};
function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole;
const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector<HTMLElement>(".header-rig-switch");
if (tabBar) tabBar.style.display = "";
document.querySelectorAll<HTMLButtonElement>(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main";
btn.style.display = !loggedOut || isMain ? "" : "none";
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole("write");
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
btn.disabled = false;
});
@@ -881,7 +935,7 @@ function syncTopBarAccess() {
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0;
}
}
@@ -1462,13 +1516,17 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
}
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
// Told every time, not only when the list changes: these modules load lazily
// and can miss the one telling there was, which left them holding no rig and
// showing an empty panel until the operator switched rigs. Both ignore a rig
// they already have.
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
if (rigListChanged) {
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.populateScopePicker();
void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
}
@@ -1516,6 +1574,9 @@ function refreshOperatorLayoutCapabilities() {
window.trxUi?.setLayoutCapabilities({
broadcast: rigModes.some((modes) => modes.includes("WFM")),
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))),
// A logbook is for contacts made, so the layout built around it is offered
// where a rig can transmit.
ham: serverRigs.some((rig) => rig?.tx === true),
});
}
@@ -2206,6 +2267,17 @@ function resetRdsDisplay() {
updateRdsPsOverlay(primaryRds);
}
// The mini views over the waterfall show only what the rig on screen heard, so
// they have to be repainted whenever that rig — or its mode — changes, not just
// when the next decode happens to arrive.
function refreshDecodeBars() {
window.updateAisBar?.();
window.updateVdesBar?.();
window.updateAprsBar?.();
window.updateFt8Bar?.();
window.updateCwBar?.();
}
function resetDecoderStateOnRigSwitch() {
// RDS
primaryRds = null;
@@ -2225,6 +2297,12 @@ function resetDecoderStateOnRigSwitch() {
const el = document.getElementById(id);
if (el) el.textContent = "--";
});
// The decode stream is not rig-scoped, so the histories behind the mini views
// and the decoder panels survive the switch: repaint both for the rig now
// selected instead of leaving the outgoing rig's traffic on screen.
refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
}
function resetWfmStereoIndicator() {
@@ -2372,10 +2450,26 @@ function visibleBandwidthSpecs(freqHz: number | null = lastFreqHz, mode = modeEl
return [{ centerHz: freqHz, widthHz: currentBandwidthHz }];
}
// Resolve the DIG passband direction from the current policy and dial
// frequency, mirroring the backend's demodulator resolution so the spectrum
// overlay lands on the same sideband that is actually being demodulated.
function digSidebandDirection() {
if (sdrDigSidebandPolicy === "usb") return 1;
if (sdrDigSidebandPolicy === "lsb") return -1;
// "auto": USB at/above the threshold, LSB below.
if (typeof lastFreqHz === "number" && isFiniteNumber(lastFreqHz)) {
return lastFreqHz >= DIG_AUTO_SIDEBAND_THRESHOLD_HZ ? 1 : -1;
}
return 1;
}
function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") {
const modeUpper = String(mode || "").toUpperCase();
if (modeUpper === "LSB" || modeUpper === "CWR") return -1;
if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1;
// On SDR the DIG sideband is configurable; resolve it. On other backends
// keep the historical upper-sideband overlay.
if (modeUpper === "DIG") return sdrDigSidebandSupported ? digSidebandDirection() : 1;
if (modeUpper === "USB" || modeUpper === "CW") return 1;
return 0;
}
@@ -3003,9 +3097,16 @@ function formatSignal(sUnits: number) {
}
function setDisabled(disabled: boolean) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
const controlDisabled = disabled || (authEnabled && !hasAuthRole("control"));
const transmitDisabled = disabled || (authEnabled && !hasAuthRole("transmit"));
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
if (el) el.disabled = controlDisabled;
});
[pttBtn, txLimitInput, txLimitBtn].forEach((el) => {
if (el) el.disabled = transmitDisabled;
});
const transmitAudio = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs;
syncModePicker();
}
@@ -3326,7 +3427,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link: TuneLink) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return;
if (authRole === "rx") {
if (!hasAuthRole("control")) {
showHint("Read-only session — link not applied", 2500);
return;
}
@@ -3595,6 +3696,26 @@ function render(update: AppUpdate) {
sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold));
}
}
if (typeof update.filter.sdr_nb_profile === "string") {
sdrNbProfile = update.filter.sdr_nb_profile;
if (sdrNbProfileWrapEl) sdrNbProfileWrapEl.style.display = "";
if (sdrNbProfileEl && document.activeElement !== sdrNbProfileEl) {
sdrNbProfileEl.value = update.filter.sdr_nb_profile;
}
}
}
if (typeof update.filter.sdr_dig_sideband === "string") {
sdrDigSidebandSupported = true;
const prevPolicy = sdrDigSidebandPolicy;
sdrDigSidebandPolicy = update.filter.sdr_dig_sideband;
if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) {
sdrDigSidebandEl.value = update.filter.sdr_dig_sideband;
}
updateWfmControls();
// Repaint the passband overlay if the DIG sideband just changed.
if (prevPolicy !== sdrDigSidebandPolicy && lastSpectrumData) {
scheduleSpectrumDraw();
}
}
}
if (typeof update.show_sdr_gain_control === "boolean") {
@@ -3675,10 +3796,7 @@ function render(update: AppUpdate) {
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
}
if (window.updateAisBar) window.updateAisBar();
if (window.updateVdesBar) window.updateVdesBar();
if (window.updateAprsBar) window.updateAprsBar();
if (window.updateFt8Bar) window.updateFt8Bar();
refreshDecodeBars();
// Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong.
for (const d of decoderRegistry) {
if (d.activation !== "toggle") continue;
@@ -4139,7 +4257,7 @@ async function postPath(path: string, options: PostOptions = {}) {
const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) {
// Not authenticated - return to login
authRole = null;
setAuthRoles([]);
if (es) es.close();
showAuthGate();
throw new Error("Authentication required");
@@ -4168,7 +4286,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
showHint("No rig selected", 1500);
return;
}
if (authRole === "rx") {
if (!hasAuthRole("control")) {
showHint("Control role required", 1500);
return;
}
@@ -4849,9 +4967,15 @@ function _initMapWhenReady() {
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate();
return;
}
if (authEnabled && name === "logbook" && !hasAuthRole("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return;
}
const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
@@ -4889,7 +5013,15 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
updateTabHistory(name, replaceHistory);
}
scheduleSpectrumLayout();
void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); });
// Its countdowns tick every second, so the page stops when it is not on screen.
if (leavingSatellites) window.clearSatPredictionDom?.();
void loadPluginsForTab(name).then(() => {
// Passes go stale while the page is closed, so each visit reloads them.
// The first visit is what imports the module, which renders on its own.
if (name === "satellites") window.refreshSatPredictions?.();
// The map module owns the decode log; this is the moment it can exist.
flushPendingDecodeStats();
}).catch((error: unknown) => { console.error(error); });
if (name === "map") {
_initMapWhenReady();
}
@@ -4976,12 +5108,12 @@ window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
// --- Auth startup sequence ---
async function initializeApp() {
showAuthGate(false);
showAuthGate();
const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
authRole = "control";
setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate();
updateAuthUI();
connect();
@@ -4994,7 +5126,8 @@ async function initializeApp() {
if (authStatus.authenticated) {
// User has valid session
authRole = authStatus.role ?? null;
setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null;
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -5004,36 +5137,196 @@ async function initializeApp() {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} else {
// No valid session - show auth gate
// Guest button is shown if guest mode is available (role granted without auth)
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
}
}
// Whether the session got far enough to wire the settings panels up, so the
// plugins can be wired again if they turn up after it did.
let settingsUiReady = false;
function initSettingsUI() {
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
}
async function refreshUserManagement() {
const section = document.getElementById("user-management");
const tab = document.getElementById("settings-users-tab");
if (!section || !tab) return;
const canManageUsers = authEnabled && hasAuthRole("administrator");
tab.style.display = canManageUsers ? "" : "none";
if (!canManageUsers) {
const panel = document.getElementById("subtab-settings-users");
if (panel) panel.style.display = "none";
if (tab.classList.contains("active")) {
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
}
return;
}
const list = requiredElement("user-list");
try {
const users = await listUsers();
const enabledAdminCount = users.filter(user => user.enabled && rolesInclude(user.roles, "administrator")).length;
list.replaceChildren(...users.map((user) => {
const row = document.createElement("div");
row.className = "sch-row";
row.style.cssText = "display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:.4rem 0";
const name = document.createElement("strong");
name.textContent = user.username;
name.style.minWidth = "10rem";
if (!user.enabled) name.textContent += " (disabled)";
const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
const enabledLabel = document.createElement("label");
enabledLabel.className = "auth-role-choice";
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = user.enabled;
enabled.disabled = user.username === authUsername;
if (enabled.disabled) enabled.title = "You cannot disable your current account";
enabledLabel.append(enabled, " Enabled");
const isOnlyAdmin = user.enabled
&& rolesInclude(user.roles, "administrator")
&& enabledAdminCount === 1;
const administratorInput = roleInputs.find(item => item.value === "administrator")?.input;
const guestInput = roleInputs.find(item => item.value === "guest")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
administratorInput.title = "The final administrator cannot be demoted";
}
if (isOnlyAdmin && guestInput) {
guestInput.disabled = true;
guestInput.title = "The final administrator cannot become a Guest";
}
if (isOnlyAdmin) {
enabled.disabled = true;
enabled.title = "The final enabled administrator cannot be disabled";
}
const password = document.createElement("input");
password.type = "password"; password.placeholder = "New password (8+ characters)"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8; password.maxLength = 1024;
const syncPasswordAccess = () => {
const guestActive = guestInput?.checked === true;
password.disabled = guestActive;
password.title = guestActive ? "Password changes are unavailable while the Guest role is active" : "";
if (guestActive) password.value = "";
};
roleInputs.forEach(({ input }) => input.addEventListener("change", syncPasswordAccess));
syncPasswordAccess();
const save = document.createElement("button"); save.type = "button"; save.textContent = "Save";
save.addEventListener("click", async () => {
const changes: { roles?: AuthRole[]; password?: string; enabled?: boolean } = {
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
enabled: enabled.checked,
};
if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes));
});
const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "Remove"; remove.className = "danger";
remove.disabled = user.username === authUsername || isOnlyAdmin;
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
remove.addEventListener("click", async () => {
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
await runUserOperation(() => deleteUser(user.username));
}
});
row.append(name, enabledLabel, roles, password, save, remove);
return row;
}));
} catch (error) {
showUserManagementError(error);
}
}
function showUserManagementError(error: unknown) {
const element = document.getElementById("user-management-error");
if (!element) return;
element.textContent = error instanceof Error ? error.message : String(error);
element.style.display = "block";
}
async function runUserOperation(operation: () => Promise<void>) {
try {
await operation();
const error = document.getElementById("user-management-error");
if (error) error.style.display = "none";
await refreshUserManagement();
} catch (reason) {
showUserManagementError(reason);
}
}
const createRoleContainer = document.getElementById("user-create-roles");
if (createRoleContainer) {
const { element } = buildRoleChoices(["read"]);
element.id = createRoleContainer.id;
createRoleContainer.replaceWith(element);
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const username = requiredElement<HTMLInputElement>("user-create-username");
const password = requiredElement<HTMLInputElement>("user-create-password");
const enabled = requiredElement<HTMLInputElement>("user-create-enabled");
const roles = Array.from(document.querySelectorAll<HTMLInputElement>("#user-create-roles input[type=checkbox]"));
void runUserOperation(async () => {
await createUser(username.value, password.value, roles.filter(input => input.checked).map(input => input.value as AuthRole), enabled.checked);
username.value = ""; password.value = "";
enabled.checked = true;
roles.forEach(input => { input.checked = input.value === "read"; });
});
});
document.getElementById("account-password-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const currentPassword = requiredElement<HTMLInputElement>("account-current-password");
const newPassword = requiredElement<HTMLInputElement>("account-new-password");
const confirmPassword = requiredElement<HTMLInputElement>("account-confirm-password");
const error = requiredElement("account-password-error");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (newPassword.value !== confirmPassword.value) {
error.textContent = "New passwords do not match";
error.style.display = "block";
return;
}
if (submit) submit.disabled = true;
void changeOwnPassword(currentPassword.value, newPassword.value)
.then(async () => {
form.reset();
error.style.display = "none";
await authLogout();
showHint("Password changed. Sign in again.", 3000);
})
.catch((reason: unknown) => {
error.textContent = reason instanceof Error ? reason.message : String(reason);
error.style.display = "block";
})
.finally(() => {
if (submit) submit.disabled = false;
});
});
// Setup auth form
requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => {
e.preventDefault();
const passphraseEl = requiredElement<HTMLInputElement>("auth-passphrase");
const passphrase = passphraseEl.value;
const usernameEl = requiredElement<HTMLInputElement>("auth-username");
const passwordEl = requiredElement<HTMLInputElement>("auth-password");
const btn = requiredElement<HTMLFormElement>("auth-form").querySelector<HTMLButtonElement>("button[type=submit]");
if (!btn) return;
btn.disabled = true;
btn.textContent = "Logging in...";
try {
const result = await authLogin(passphrase);
authRole = result.role ?? null;
passphraseEl.value = "";
const result = await authLogin(usernameEl.value, passwordEl.value);
setAuthRoles(result.roles);
authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -5043,7 +5336,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} catch (err) {
showAuthError("Invalid passphrase");
showAuthError("Invalid username or password");
console.error("Login error:", err);
} finally {
btn.disabled = false;
@@ -5051,35 +5344,18 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
}
});
// Setup guest button
const guestBtn = document.getElementById("auth-guest-btn") as HTMLButtonElement | null;
if (guestBtn) {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
connect();
connectDecode();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
});
}
// Setup header auth button (Login/Logout)
const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null;
if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => {
if (authRole) {
if (authRoles.length > 0) {
// Logged in - show logout confirmation
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
await authLogout();
}
} else {
// Not logged in - show auth gate
showAuthGate(false);
showAuthGate();
}
});
}
@@ -5101,7 +5377,7 @@ Object.defineProperties(trxState, {
initialMapZoom: { get() { return initialMapZoom; } },
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
authEnabled: { get() { return authEnabled; } },
authRole: { get() { return authRole; } },
authRoles: { get() { return authRoles; } },
decoderRegistry: { get() { return decoderRegistry; } },
sseSessionId: { get() { return sseSessionId; } },
primaryRds: { get() { return primaryRds; } },
@@ -5164,7 +5440,15 @@ window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules
// Load plugin scripts now that window.trx is populated. Dynamic scripts are
// async so they must not be created before the namespace they depend on exists.
void loadEagerPlugins().catch((error: unknown) => { console.error(error); });
//
// Wire the settings panels again once they are here: the session can be up
// before these modules finish importing, and the wiring at that point silently
// skipped whichever had not arrived. A panel that missed it had no rig, no
// data, and dead buttons — for the rest of the session, since nothing came
// back to it. Both entry points are safe to run twice.
void loadEagerPlugins()
.then(() => { if (settingsUiReady) initSettingsUI(); })
.catch((error: unknown) => { console.error(error); });
// Start the app
void initializeApp();
@@ -5241,10 +5525,6 @@ function _wireSubTabBar(bar: WiredElement) {
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
});
}
// Clear SAT prediction DOM when leaving the SAT tab to reduce node count.
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
window.clearSatPredictionDom();
}
});
}
document.querySelectorAll<WiredElement>(".sub-tab-bar").forEach(_wireSubTabBar);
@@ -5366,7 +5646,20 @@ const sdrNbEnabledEl = document.getElementById("sdr-nb-enabled") as HTMLInputEle
const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
const sdrNbThresholdEl = document.getElementById("sdr-nb-threshold") as HTMLInputElement | null;
const sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set") as HTMLButtonElement | null;
const sdrNbProfileWrapEl = document.getElementById("sdr-nb-profile-wrap");
const sdrNbProfileEl = document.getElementById("sdr-nb-profile") as HTMLSelectElement | null;
let sdrNbSupported = false;
// Current NB profile, mirrored from server filter state; sent alongside every
// enable/threshold change (including the quick toggle) so it is preserved.
let sdrNbProfile = "spike";
const sdrDigSidebandWrapEl = document.getElementById("sdr-dig-sideband-wrap");
const sdrDigSidebandEl = document.getElementById("sdr-dig-sideband") as HTMLSelectElement | null;
let sdrDigSidebandSupported = false;
// Current DIG sideband policy ("auto" | "usb" | "lsb"), mirrored from server
// filter state. Drives the spectrum passband overlay direction for DIG.
let sdrDigSidebandPolicy = "auto";
// Matches DigSidebandPolicy::AUTO_THRESHOLD_HZ on the backend.
const DIG_AUTO_SIDEBAND_THRESHOLD_HZ = 10_000_000;
// Hide audio row if audio is not configured on the server
fetch("/audio", { method: "GET" }).then((r) => {
@@ -5793,7 +6086,7 @@ function submitSdrNbState() {
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
postPath(
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`,
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}&profile=${encodeURIComponent(sdrNbProfile)}`,
).catch(() => {});
}
if (sdrNbEnabledEl) {
@@ -5801,6 +6094,21 @@ if (sdrNbEnabledEl) {
submitSdrNbState();
});
}
if (sdrNbProfileEl) {
sdrNbProfileEl.addEventListener("change", () => {
const profile = sdrNbProfileEl.value || "spike";
if (
profile !== "spike" &&
profile !== "ignition" &&
profile !== "powerline" &&
profile !== "broadband"
) {
return;
}
sdrNbProfile = profile;
submitSdrNbState();
});
}
function submitSdrNbThreshold() {
if (!sdrNbThresholdEl) return;
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
@@ -5818,6 +6126,21 @@ if (sdrNbThresholdEl) {
}
});
}
function submitSdrDigSideband() {
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
const policy = sdrDigSidebandEl.value || "auto";
if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
// Optimistically move the passband overlay to the chosen sideband so the
// display responds instantly, before the server round-trip confirms it.
sdrDigSidebandPolicy = policy;
if (lastSpectrumData) scheduleSpectrumDraw();
postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {});
}
if (sdrDigSidebandEl) {
sdrDigSidebandEl.addEventListener("change", () => {
submitSdrDigSideband();
});
}
function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
@@ -5825,6 +6148,11 @@ function updateWfmControls() {
// The row holds only these two, so it goes with them — an empty one would
// still take a track and a gap in the tray, and draw its divider.
if (modeControlsRow) modeControlsRow.style.display = (mode === "WFM" || mode === "SAM") ? "" : "none";
// DIG sideband selector is only meaningful in DIG mode on an SDR backend.
if (sdrDigSidebandWrapEl) {
sdrDigSidebandWrapEl.style.display =
sdrDigSidebandSupported && mode === "DIG" ? "" : "none";
}
}
// Show compatibility warning for non-Chromium browsers
@@ -6142,6 +6470,10 @@ function stopRxAudio() {
function startTxAudio() {
if (txActive) { void stopTxAudio(); return; }
if (authEnabled && !hasAuthRole("transmit")) {
audioStatus.textContent = "Transmit role required";
return;
}
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
@@ -6578,11 +6910,49 @@ const IMAGE_DECODE_KINDS = new Set([
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
]);
// The decode log the Statistics page counts lives in the map module, which is
// lazy: it arrives when the Map or Statistics tab is first opened, long after
// the history replayed and the live decodes started coming. Recording into a
// module that is not there yet dropped every one of them, and nothing replayed
// them afterwards -- so the page opened empty and only filled in from decodes
// heard after it, which is why it took a reload (landing on the tab, so the
// module loads at startup) to show the whole picture. Hold them here until
// the module arrives, then hand them over.
interface PendingDecodeStat { kind: string; rig: string | null; tsMs: number | undefined }
const pendingDecodeStats: PendingDecodeStat[] = [];
// The module's own log keeps 50k entries; there is no point holding more here.
const PENDING_DECODE_STATS_MAX = 50_000;
function recordDecodeStat(kind: string, rig: string | null, tsMs?: number) {
const stats = window.trx.modules.map;
if (stats) {
stats.statsRecordDecode(kind, rig, tsMs);
return;
}
pendingDecodeStats.push({ kind, rig, tsMs });
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
}
}
function flushPendingDecodeStats() {
const stats = window.trx.modules.map;
if (!stats || pendingDecodeStats.length === 0) return;
for (const entry of pendingDecodeStats.splice(0)) {
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
}
stats.scheduleStatsRender();
}
function scheduleStatsRenderIfLoaded() {
window.trx.modules.map?.scheduleStatsRender();
}
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender();
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
scheduleStatsRenderIfLoaded();
}
}
@@ -6632,9 +7002,9 @@ function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
// Record statistics for restored history messages.
if (!IMAGE_DECODE_KINDS.has(kind)) {
for (const msg of messages) {
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
}
window.trx.modules.map?.scheduleStatsRender();
scheduleStatsRenderIfLoaded();
}
window.trxPluginRuntime.restore(kind, messages);
}
@@ -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", "logbook", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
] as const;
export type TabName = typeof TAB_ORDER[number];
@@ -11,8 +11,10 @@ export type TabName = typeof TAB_ORDER[number];
export const TAB_PATHS: Readonly<Record<TabName, string>> = {
main: "/",
bookmarks: "/bookmarks",
logbook: "/logbook",
"digital-modes": "/digital-modes",
map: "/map",
satellites: "/satellites",
statistics: "/statistics",
recorder: "/recorder",
settings: "/settings",
@@ -31,7 +31,7 @@ function mapEl(id: string): MapElement {
return element as MapElement;
}
type DecoderSource = "ais" | "vdes" | "aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
type DecoderSource = "ais" | "vdes" | "aprs" | "hf_aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
interface TrackPoint { lat: number; lon: number; tsMs: number }
interface DecodeDetail {
station?: string | null; source?: string | null; target?: string | null;
@@ -83,6 +83,9 @@ interface MapEntry {
bookmarks?: Array<Record<string, unknown>>;
bounds?: Leaflet.LatLngBoundsExpression;
symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
/** Callsign as heard. The map key qualifies it with the source, so that a
* station worked on both 144 MHz and 30 m keeps one marker per band. */
call?: string;
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
pathKey?: string; sourceGrid?: string; targetGrid?: string;
from?: LatLon; to?: LatLon;
@@ -171,7 +174,7 @@ interface MapWindow {
clearMapMarkersByType?(type: DecoderSource): void;
navigateToAprsMap?(lat: number, lon: number): void;
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage): void;
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage, source?: "aprs" | "hf_aprs"): void;
aisMapAddVessel?(message: MapMessage): void;
vdesMapAddPoint?(message: MapMessage): void;
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
@@ -228,7 +231,7 @@ const mapWindow = window as unknown as MapWindow;
const decodeContactPaths = new Map<string, MapEntry>();
let selectedMapQsoKey: string | null = null;
const mapMarkers = new Set<TrxLayer>();
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
/** Chip key that clears a selection rather than naming a band or a source. */
const MAP_FILTER_ALL_KEY = "__all";
@@ -302,6 +305,18 @@ const mapWindow = window as unknown as MapWindow;
return trimmed;
}
/** The source a station marker belongs to, defaulting to VHF APRS for
* entries stored before HF APRS had a source of its own. */
function aprsEntrySource(entry: MapEntry | null | undefined): DecoderSource {
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
}
/** Map key for a station: the callsign alone on VHF, source-qualified on HF,
* so one station heard on both bands keeps a marker for each. */
function aprsStationKey(call: string, source: DecoderSource): string {
return source === "aprs" ? call : `${source}:${call}`;
}
function refreshAprsTrack(call: string, entry: MapEntry): void {
if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
@@ -323,7 +338,7 @@ const mapWindow = window as unknown as MapWindow;
lineJoin: "round",
interactive: false,
}) as unknown as TrxLayer;
track.__trxType = "aprs";
track.__trxType = aprsEntrySource(entry);
track._aprsCall = call;
entry.track = track;
}
@@ -575,6 +590,7 @@ const mapWindow = window as unknown as MapWindow;
function mapSourceLabel(type: DecoderSource): string {
if (type === "bookmark") return "Bookmarks";
if (type === "hf_aprs") return "HF APRS";
return String(type || "").toUpperCase();
}
@@ -594,6 +610,8 @@ const mapWindow = window as unknown as MapWindow;
if (type === "vdes") return "#a78bfa";
if (type === "sat") return "#f59e0b";
if (type === "aprs") return "#00d17f";
// Far enough from the VHF green to tell the two apart at a glance.
if (type === "hf_aprs") return "#fb7185";
return locatorFilterColor(type);
}
@@ -1188,10 +1206,8 @@ const mapWindow = window as unknown as MapWindow;
}
}
for (const entry of stationMarkers.values()) {
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
availableSources.add("aprs");
break;
}
if (!entry?.visibleInHistoryWindow) continue;
availableSources.add(aprsEntrySource(entry));
}
const bandMap = new Map<string, FilterChip>();
for (const entry of locatorMarkers.values()) {
@@ -1225,7 +1241,7 @@ const mapWindow = window as unknown as MapWindow;
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
}
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
.filter((key) => availableSources.has(key))
.map((key) => ({
key,
@@ -1296,9 +1312,10 @@ const mapWindow = window as unknown as MapWindow;
}
return parts.join(" ").toLowerCase();
}
if (type === "aprs") {
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(call);
if (type === "aprs" || type === "hf_aprs") {
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(key);
const call = entry?.call ?? key;
const info = entry?.info ? String(entry.info) : "";
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
return `${call} ${info} ${pktRaw}`.toLowerCase();
@@ -1496,9 +1513,10 @@ const mapWindow = window as unknown as MapWindow;
};
mapWindow.clearMapMarkersByType = function(type) {
if (type === "aprs") {
if (type === "aprs" || type === "hf_aprs") {
selectedAprsTrackCall = null;
stationMarkers.forEach((entry) => {
stationMarkers.forEach((entry, key) => {
if (aprsEntrySource(entry) !== type) return;
if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker);
@@ -1507,8 +1525,8 @@ const mapWindow = window as unknown as MapWindow;
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
mapMarkers.delete(entry.track);
}
stationMarkers.delete(key);
});
stationMarkers.clear();
return;
}
@@ -1781,13 +1799,15 @@ const mapWindow = window as unknown as MapWindow;
if (!ll) return;
const entry = stationMarkers.get(marker._aprsCall);
if (!entry) return;
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
const source = aprsEntrySource(entry);
const call = entry.call ?? String(marker._aprsCall);
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
refreshAprsTrack(String(marker._aprsCall), entry);
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
entry.track.addTo(aprsMap);
}
selectedAprsTrackCall = String(marker._aprsCall);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
return;
}
@@ -2270,28 +2290,33 @@ const mapWindow = window as unknown as MapWindow;
return null;
}
function _aprsAddMarkerToMap(call: string, entry: MapEntry): void {
function _aprsAddMarkerToMap(key: string, entry: MapEntry): void {
if (!aprsMap || entry.lat == null || entry.lon == null) return;
refreshAprsTrack(call, entry);
refreshAprsTrack(key, entry);
const source = aprsEntrySource(entry);
const call = entry.call ?? key;
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
const color = mapSourceColor(source);
const marker = (icon
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
: L.circleMarker([entry.lat, entry.lon], {
radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8
radius: 6, color, fillColor: color, fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
marker.__trxType = "aprs";
marker.__trxType = source;
marker.__trxRigIds = entry.rigIds || new Set();
marker._aprsCall = call;
marker._aprsCall = key;
entry.marker = marker;
mapMarkers.add(marker);
}
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
const nextPoint: Leaflet.LatLngTuple = [lat, lon];
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
const existing = stationMarkers.get(call);
const stationSource: DecoderSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
const key = aprsStationKey(call, stationSource);
const existing = stationMarkers.get(key);
if (existing) {
existing.pkt = pkt;
existing.lat = lat;
@@ -2310,7 +2335,8 @@ const mapWindow = window as unknown as MapWindow;
} else if (prevPoint) {
prevPoint.tsMs = tsMs;
}
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
existing.call = call;
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -2321,7 +2347,8 @@ const mapWindow = window as unknown as MapWindow;
track: null,
trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint],
type: "aprs",
type: stationSource,
call,
pkt,
lat,
lon,
@@ -2330,9 +2357,9 @@ const mapWindow = window as unknown as MapWindow;
symbolCode,
rigIds: new Set(msgRigId ? [msgRigId] : []),
};
stationMarkers.set(call, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
stationMarkers.set(key, entry);
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
if (aprsMap) scheduleDecodeMapMaintenance();
}
};
@@ -2,7 +2,9 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
type PluginGroup =
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
| "bookmarks" | "logbook" | "recorder" | "settings";
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
@@ -15,8 +17,10 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
],
"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"],
logbook: ["/logbook.js"],
recorder: [],
settings: ["/vchan.js", "/scheduler.js"],
};
@@ -44,7 +48,7 @@ async function loadPlugins(group: string): Promise<void> {
}
export async function loadEagerPlugins(): Promise<void> {
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
await Promise.all(["digital-modes", "bookmarks", "logbook", "settings"].map(loadPlugins));
}
export async function loadPluginsForTab(tab: string): Promise<void> {
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
plugin.prune();
return true;
},
rerenderAll() { for (const plugin of decoders.values()) plugin.rerender?.(); },
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
clearQueued() { queued.clear(); },
hasDecoder: (id) => decoders.has(id),
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
// The decode stream is not rig-scoped: every rig a client is connected to sends
// its decodes to every browser, each frame naming the rig that heard it (the
// client stamps `rig_id` as it leaves the audio connection). The map wants all
// of them — it has its own filter, and plotting only one rig would empty it —
// but the radio page and the decoder panels describe one rig at a time: the one
// the operator selected, whose spectrum is on screen and whose audio is
// playing. A frame a background rig copied on another band belongs to neither
// picture, so both filter on this.
export function isActiveRigDecode(rigId: string | null | undefined): boolean {
const activeRigId = hostState.lastActiveRigId;
// Before the rig list has arrived, and for a decode that reached the browser
// without a rig of its own, there is nothing to disagree with.
if (!activeRigId || !rigId) return true;
return rigId === activeRigId;
}
/** The decodes of `items` that the selected rig heard, in their original order. */
export function forActiveRig<T extends { rig_id?: string | null | undefined }>(items: T[]): T[] {
return items.filter((item) => isActiveRigDecode(item.rig_id));
}
@@ -6,6 +6,7 @@ import { hostCore, hostState } from "./host.js";
export {};
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface AisMessage {
@@ -190,20 +191,27 @@ function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
return Array.from(byMmsi.values());
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function aisRigMessages(): AisMessage[] {
return forActiveRig(aisMessageHistory);
}
function updateAisSummary() {
const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
}
const vessels = aisLatestByVessel(aisMessageHistory);
const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
if (aisVesselCountEl) {
const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
}
if (aisLatestSeenEl) {
const latest = aisMessageHistory[0];
const latest = rigMessages[0];
if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet";
} else {
@@ -314,7 +322,9 @@ function updateAisBar() {
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
const recent = aisMessageHistory.filter(
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id),
);
const messages = aisLatestByVessel(recent).slice(0, 8);
if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none";
@@ -368,7 +378,7 @@ function renderAisHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of aisMessageHistory) {
for (const message of aisRigMessages()) {
fragment.appendChild(renderAisRow(message));
}
aisMessagesEl.replaceChildren(fragment);
@@ -407,10 +417,11 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage {
function onServerAisBatch(messages: AisMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized: AisMessage[] = [];
for (const msg of messages) {
const next = normalizeServerAisMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -452,8 +463,9 @@ if (aisFilterInput) {
}
function onServerAis(msg: AisMessage): void {
if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg));
const message = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
}
updateAisSummary();
@@ -464,6 +476,7 @@ updateAisSummary();
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
rerender: () => { updateAisBar(); renderAisHistory(); },
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
});
@@ -324,6 +324,21 @@ export interface AprsRowOptions {
onMap?: (lat: number, lon: number) => void;
/** Puts a frame's coordinates on the clipboard. */
onCopy?: (text: string, button: HTMLElement) => void;
/** Opens a log entry for the station that sent the frame. */
onLog?: (call: string, gridsquare: string | null) => void;
}
/** A position as a six-character locator, which is what a log wants. */
function maidenheadForLatLon(lat: number, lon: number): string {
const adjustedLon = lon + 180;
const adjustedLat = lat + 90;
const field = String.fromCharCode(65 + Math.floor(adjustedLon / 20))
+ String.fromCharCode(65 + Math.floor(adjustedLat / 10));
const square = String(Math.floor((adjustedLon % 20) / 2)) + String(Math.floor(adjustedLat % 10));
const subLon = ((adjustedLon % 2) * 60) / 5;
const subLat = ((adjustedLat % 1) * 60) / 2.5;
const sub = String.fromCharCode(97 + Math.floor(subLon)) + String.fromCharCode(97 + Math.floor(subLat));
return `${field}${square}${sub}`.toUpperCase();
}
export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement {
@@ -370,6 +385,7 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions
(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>` +
(options.onLog && packet.srcCall ? '<button class="aprs-inline-btn" type="button" data-aprs-log="1">Log</button>' : "") +
`</div>` +
`</div>`;
@@ -381,6 +397,17 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number);
});
});
const logButton = row.querySelector<HTMLElement>("[data-aprs-log]");
if (logButton) {
logButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const grid = packet.lat != null && packet.lon != null
? maidenheadForLatLon(packet.lat, packet.lon)
: null;
options.onLog?.(packet.srcCall ?? "", grid);
});
}
const copyButton = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyButton) {
copyButton.addEventListener("click", (event) => {
@@ -4,6 +4,7 @@
import { hostCore, hostState } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import {
aprsAgeText,
aprsPacketCategory,
@@ -22,6 +23,7 @@ interface AprsBridge {
aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
getDecodeRigMeta?: () => unknown;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
updateAprsBar?: () => void;
clearAprsBar?: () => void;
closeAprsBar?: () => void;
@@ -106,21 +108,29 @@ function aprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(aprsFilterText);
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function aprsRigPackets(): AprsPacket[] {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets(): AprsPacket[] {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(aprsFilterMatch);
}
function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets();
if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
}
if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0];
const latest = rigPackets[0];
if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -155,6 +165,9 @@ function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
distance: aprsDistanceText(pkt),
onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); },
onCopy: (text) => { void copyAprsCoords(text); },
onLog: (call, gridsquare) => {
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? undefined, decoder: "aprs" });
},
});
}
@@ -179,7 +192,9 @@ function updateAprsBar() {
if (!aprsBarOverlay) return;
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
const okFrames = aprsPacketHistory.filter(
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id),
);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
@@ -256,11 +271,12 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -324,8 +340,9 @@ if (aprsFilterInput) {
// --- Server-side APRS decode handler ---
function onServerAprs(pkt: AprsPacket): void {
if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
const packet = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
}
renderAprsHistory();
@@ -336,6 +353,7 @@ renderAprsHistory();
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
rerender: () => { updateAprsBar(); renderAprsHistory(); },
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
});
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
export {};
@@ -44,7 +45,7 @@ interface BackgroundBridge {
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
}
interface BackgroundDecodeService {
initialize(rigId: string | null, role: string | null): void;
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
wireEvents(): void;
setRig(rigId: string | null): void;
}
@@ -60,16 +61,23 @@ const bgdWindow = window as unknown as BackgroundBridge;
.map(function (d) { return d.id; });
}
let backgroundDecodeRole: string | null = null;
let backgroundDecodeRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null;
let currentConfig: BackgroundDecodeConfig | null = null;
let bookmarkList: Bookmark[] = [];
let statusInterval: ReturnType<typeof setInterval> | null = null;
let bgdDirty = false;
/** Last polled state per bookmark, so a row can say what it is doing. */
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
let lastStatus: BackgroundDecodeStatus | null = null;
function initBackgroundDecode(rigId: string | null, role: string | null): void {
backgroundDecodeRole = role;
currentRigId = rigId || null;
function initBackgroundDecode(rigId: string | null, roles: readonly AuthRole[]): void {
backgroundDecodeRoles = roles;
// The panel used to take whatever rig it was handed at load and wait to be
// told again. Loading before the rig list arrives handed it null, and the
// next telling only came when the operator switched rigs, so the panel sat
// empty and silent. The host knows the rig; ask it.
currentRigId = rigId || hostState.lastActiveRigId || null;
if (currentRigId) loadBackgroundDecode();
startStatusPolling();
}
@@ -171,7 +179,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist();
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
const isControl = isControlRole();
const panel = document.getElementById("background-decode-panel");
if (panel) {
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
@@ -182,6 +190,11 @@ const bgdWindow = window as unknown as BackgroundBridge;
const resetBtn = document.getElementById("background-decode-reset-btn");
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
syncSaveButton();
}
function currentFilterText(): string {
return (document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value ?? "";
}
function renderBookmarkChecklist(filterText = ""): void {
@@ -203,26 +216,65 @@ const bgdWindow = window as unknown as BackgroundBridge;
: all;
if (filtered.length === 0) {
container.innerHTML = '<div class="bgd-checklist-empty">' +
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
'</div>';
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
renderSelectionSummary();
return;
}
filtered.forEach(function (bookmark) {
const row = document.createElement("label");
row.className = "bgd-checklist-row";
row.dataset.bmId = bookmark.id;
const decoders = bookmarkDecoderKinds(bookmark);
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
const selected = selectedIds.has(bookmark.id);
if (selected) row.classList.add("is-selected");
row.innerHTML =
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
'<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
'<span class="bgd-checklist-meta">'
+ escHtml(formatFreq(bookmark.freq_hz)) + '<span class="bgd-checklist-mode">'
+ escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span></span>'
+ stateBadgeHtml(bookmark.id, selected);
row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) {
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
});
container.appendChild(row);
});
renderSelectionSummary();
}
/** Why the list is empty, in the terms the operator can act on. */
function emptyListText(supportedCount: number): string {
if (supportedCount > 0) return "No bookmark matches that filter.";
if (bookmarkList.length > 0) {
return "None of your bookmarks name a decoder that can run in the background."
+ " Give one a decoder on the Bookmarks tab to list it here.";
}
return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
}
/** The live state of a selected bookmark, as the row's own badge. */
function stateBadgeHtml(bookmarkId: string, selected: boolean): string {
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
const entry = statusByBookmark.get(bookmarkId);
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '">'
+ '<span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
}
function renderSelectionSummary(): void {
const el = document.getElementById("bgd-selection-summary");
if (!el) return;
const selected = currentConfig?.bookmark_ids.length ?? 0;
if (selected === 0) {
el.textContent = "Nothing selected — background decoding is idle.";
return;
}
const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
el.textContent = currentConfig?.enabled
? `${noun}, ${String(active)} decoding now.`
: `${noun}. Switch Enabled on to start decoding them.`;
}
function onChecklistToggle(bookmarkId: string, checked: boolean): void {
@@ -260,7 +312,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
showToast(`Save failed: ${errorMessage(err)}`, true);
})
.finally(function () {
if (btn) btn.disabled = false;
syncSaveButton();
});
}
@@ -295,58 +347,77 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
function renderStatus(status: BackgroundDecodeStatus): void {
const card = document.getElementById("background-decode-status-card");
if (!card) return;
const entries = status.entries ?? [];
if (!entries.length) {
card.textContent = "No background decode bookmarks configured.";
return;
}
const summary = [];
if (status.active_rig) {
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
} else {
summary.push("This rig is not currently selected for audio.");
}
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
html += '<div class="bgd-status-list">';
entries.forEach(function (entry) {
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
const parts = [];
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
if (entry.mode) parts.push(entry.mode);
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
parts.push(entry.decoder_kinds.join("/").toUpperCase());
}
html +=
'<div class="bgd-status-row">' +
'<div>' +
'<div class="bgd-status-name">' + escHtml(name) + '</div>' +
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
'</div>' +
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' +
'<svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' +
escHtml(prettyState(entry.state)) + '</div>' +
'</div>';
});
html += "</div>";
card.innerHTML = html;
lastStatus = status;
statusByBookmark = new Map(
(status.entries ?? [])
.filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0)
.map((entry) => [entry.bookmark_id as string, entry]),
);
renderSpanSummary();
// Rows carry the state, so the list repaints — keeping the filter the
// operator typed and the selection they have not saved yet.
renderBookmarkChecklist(currentFilterText());
}
/** What the rig is listening across, which is what decides whether a
* selected bookmark can be decoded at all. */
function renderSpanSummary(): void {
const el = document.getElementById("bgd-span-summary");
if (!el) return;
const status = lastStatus;
if (!status) {
el.textContent = "";
return;
}
if (!status.active_rig) {
el.textContent = "This rig is not the one playing audio.";
el.dataset.tone = "warn";
return;
}
const centre = typeof status.center_hz === "number" && Number.isFinite(status.center_hz)
? formatFreq(status.center_hz)
: null;
const half = typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0
? formatFreq(status.sample_rate / 2)
: null;
el.dataset.tone = "";
el.textContent = centre && half ? `Span ${centre} ±${half}` : centre ? `Centre ${centre}` : "";
}
/** The one-line reason behind a state, for the row's tooltip. */
function stateHelp(state: string | undefined): string {
switch (state) {
case "active": return "Decoding on a hidden channel.";
case "out_of_span": return "Outside the span the rig is tuned across, so it cannot be heard from here.";
case "waiting_for_spectrum": return "Waiting for the first spectrum frame from the rig.";
case "waiting_for_user": return "Nobody is listening to this rig, so no audio is being pulled.";
case "missing_bookmark": return "The bookmark this was selected from is gone.";
case "no_supported_decoders": return "No decoder that runs in the background can decode this bookmark.";
case "disabled": return "Background decoding is switched off.";
case "handled_by_scheduler":
case "scheduler_has_control": return "The scheduler is running this bookmark instead.";
case "handled_by_virtual_channel": return "A virtual channel is already on this frequency.";
case "pending": return "Selected, and not started yet — save to apply.";
default: return "Selected, but not decoding.";
}
}
// The dot beside the word already says whether this is running, so the
// words no longer carry a tick or a triangle of their own.
function prettyState(state: string | undefined): string {
switch (state) {
case "active": return "\u2713 Active";
case "out_of_span": return "\u25B3 Out of span";
case "waiting_for_spectrum": return "\u25B3 Waiting";
case "waiting_for_user": return "\u25B3 No user";
case "missing_bookmark": return "\u2717 Missing";
case "no_supported_decoders": return "\u2717 Unsupported";
case "disabled": return "\u25B3 Disabled";
case "handled_by_scheduler": return "\u25B3 Scheduler";
case "scheduler_has_control": return "\u25B3 Scheduler";
case "handled_by_virtual_channel": return "\u25B3 VChan";
default: return "\u25B3 Inactive";
case "active": return "Decoding";
case "out_of_span": return "Out of span";
case "waiting_for_spectrum": return "Waiting for spectrum";
case "waiting_for_user": return "Nobody listening";
case "missing_bookmark": return "Bookmark gone";
case "no_supported_decoders": return "No decoder";
case "disabled": return "Off";
case "handled_by_scheduler":
case "scheduler_has_control": return "Scheduler has it";
case "handled_by_virtual_channel": return "On a channel";
case "pending": return "Not saved";
default: return "Idle";
}
}
@@ -380,14 +451,25 @@ const bgdWindow = window as unknown as BackgroundBridge;
function markBgdDirty() {
if (bgdDirty) return;
bgdDirty = true;
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.add("sch-dirty");
syncSaveButton();
}
function clearBgdDirty() {
bgdDirty = false;
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.remove("sch-dirty");
syncSaveButton();
}
/** Save offers itself only when there is something to save. */
function syncSaveButton() {
const btn = document.getElementById("background-decode-save-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.classList.toggle("sch-dirty", bgdDirty);
btn.disabled = !bgdDirty || !isControlRole();
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
}
function isControlRole(): boolean {
return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
}
function showToast(msg: string, isError: boolean): void {
@@ -407,7 +489,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
const ids = supportedBookmarks().map(function (bm) { return bm.id; });
currentConfig.bookmark_ids = ids;
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
renderBookmarkChecklist(currentFilterText());
markBgdDirty();
}
@@ -416,7 +498,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
currentConfig.bookmark_ids = [];
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
renderBookmarkChecklist(currentFilterText());
markBgdDirty();
}
@@ -432,7 +514,11 @@ const bgdWindow = window as unknown as BackgroundBridge;
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
if (enabledCb && !enabledCb._wired) {
enabledCb._wired = true;
enabledCb.addEventListener("change", function () { markBgdDirty(); });
enabledCb.addEventListener("change", function () {
if (currentConfig) currentConfig.enabled = enabledCb.checked;
markBgdDirty();
renderBookmarkChecklist(currentFilterText());
});
}
const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {};
@@ -18,6 +19,8 @@ interface Bookmark {
category?: string | null;
comment?: string | null;
decoders?: string[];
/** DIG sideband override: "auto" | "usb" | "lsb". Empty/absent = global. */
dig_sideband?: string | null;
scope?: string;
}
@@ -100,7 +103,8 @@ function bmEsc(str: unknown): string {
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control";
return !hostState.authEnabled
|| hasAuthRole(hostState.authRoles, "write");
}
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -319,6 +323,14 @@ function bmBuildDecoderCheckboxes() {
});
}
/** Show the DIG-sideband override selector only when the mode field is DIG. */
function bmSyncDigSidebandVisibility(): void {
const label = bmOptionalEl("bm-dig-sideband-label");
if (!label) return;
const mode = (bmEl("bm-mode").value || "").trim().toUpperCase();
label.style.display = mode === "DIG" ? "" : "none";
}
function bmOpenForm(bm: Bookmark | null): void {
const wrap = bmEl("bm-form-wrap");
if (!wrap) return;
@@ -336,7 +348,9 @@ function bmOpenForm(bm: Bookmark | null): void {
bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
bmEl("bm-dig-sideband").value = bm ? (bm.dig_sideband || "") : "";
bmWriteDecoders(bm?.decoders ?? []);
bmSyncDigSidebandVisibility();
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex";
@@ -381,6 +395,9 @@ async function bmSave(e: Event): Promise<void> {
const category = bmEl("bm-category-input").value.trim();
const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders();
// Only meaningful for DIG; stored empty otherwise so it never overrides.
const dig_sideband =
mode.toUpperCase() === "DIG" ? (bmEl("bm-dig-sideband").value || null) : null;
const formError = bmEl("bm-form-error");
if (formError) formError.textContent = "";
@@ -401,6 +418,7 @@ async function bmSave(e: Event): Promise<void> {
category,
comment,
decoders,
dig_sideband,
};
try {
@@ -475,6 +493,16 @@ function bmApply(bm: Bookmark): void {
const tunePromise = (async () => {
await bridge.trx.modules.vchan?.takeSchedulerControl();
// For a DIG bookmark carrying a sideband override, set the global DIG
// policy first so the backend resolves the intended sideband when DIG is
// applied. "auto" is honoured explicitly; an empty override is left alone.
if ((bm.mode || "").toUpperCase() === "DIG" && bm.dig_sideband) {
const p = bm.dig_sideband.toLowerCase();
if (p === "auto" || p === "usb" || p === "lsb") {
await hostCore.postPath("/set_sdr_dig_sideband?policy=" + encodeURIComponent(p));
}
}
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) {
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
@@ -768,6 +796,9 @@ function bmPopulateScopePicker() {
// Form submit
bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); });
// Reveal the DIG-sideband override only while the mode field reads DIG.
bmEl("bm-mode").addEventListener("input", bmSyncDigSidebandVisibility);
// Form cancel
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js";
import { isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
@@ -20,8 +21,11 @@ interface CwRenderer {
drawPoints(points: number[], size: number, color: Rgba): void;
}
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number }
interface CwEvent { rig_id?: string | null; text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
interface CwLine {
rigId: string | null; tsMs: number; ts: string; text: string;
wpm: number | null; tone_hz: number | null; lastMs: number;
}
interface CwToneRange {
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
toneSpanHz: number; lowerSideband: boolean; mode: string;
@@ -66,7 +70,10 @@ const CW_BAR_LINE_GAP_MS = 5000;
let cwLastAppendTime = 0;
let cwTonePickerRaf: number | null = null;
let cwBarHistory: CwLine[] = [];
let cwBarCurrentLine: CwLine | null = null;
// One line in progress per rig, keyed by rig id ("" for a decode that names no
// rig). Two rigs copying at the same time each fill their own line instead of
// braiding their characters into one.
const cwBarCurrentLines = new Map<string, CwLine>();
let cwBarDismissedAtMs = 0;
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
// acknowledged). While set, server-state updates must not override the
@@ -101,12 +108,13 @@ cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
applyCwAutoUi(enabled);
};
function cwBarFlushCurrentLine(): void {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine);
function cwBarFlushCurrentLine(key: string): void {
const line = cwBarCurrentLines.get(key);
cwBarCurrentLines.delete(key);
if (line?.text.trim()) {
cwBarHistory.unshift(line);
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
}
cwBarCurrentLine = null;
}
function updateCwBar(): void {
@@ -114,9 +122,12 @@ function updateCwBar(): void {
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
const isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
// Prepend the in-progress line so characters appear immediately
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
// Prepend the in-progress lines so characters appear immediately
const inProgress = [...cwBarCurrentLines.values()]
.filter((l) => l.text && isActiveRigDecode(l.rigId))
.sort((a, b) => b.tsMs - a.tsMs);
const liveLines = [...inProgress, ...recent];
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none";
@@ -402,7 +413,7 @@ function resetCwHistoryView(): void {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBarHistory = [];
cwBarCurrentLine = null;
cwBarCurrentLines.clear();
updateCwBar();
drawCwTonePicker();
}
@@ -421,8 +432,13 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
// --- Server-side CW decode handler ---
function onServerCw(evt: CwEvent): void {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
// The panel is a single stream of copied text: characters from a background
// rig do not belong in it at all, and interleaving them would make both
// rigs unreadable. The bar histories below keep every rig, each on its own
// line, and pick out the selected rig when they paint.
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl && forSelectedRig) {
// Append decoded text to output
const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
@@ -445,25 +461,29 @@ function onServerCw(evt: CwEvent): void {
// Bar history accumulation (regardless of pause state)
if (evt.text) {
const now = Date.now();
const rigId = evt.rig_id ?? null;
const key = rigId ?? "";
if (evt.text === "\n") {
cwBarFlushCurrentLine();
cwBarFlushCurrentLine(key);
} else {
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine();
let line = cwBarCurrentLines.get(key);
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine(key);
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
cwBarCurrentLines.set(key, line);
}
cwBarCurrentLine.text += evt.text;
cwBarCurrentLine.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
line.text += evt.text;
line.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
}
updateCwBar();
}
if (cwSignalIndicator) {
if (cwSignalIndicator && forSelectedRig) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
}
if (!cwAutoInput || cwAutoInput.checked) {
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm));
}
@@ -491,6 +511,13 @@ cwWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerCw,
restore: restoreCwHistory,
reset: resetCwHistoryView,
// The copied text of a rig that is no longer on screen cannot be unpicked
// from the pane, so the switch starts the new rig's stream from empty.
rerender: () => {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
updateCwBar();
},
});
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
@@ -15,6 +16,7 @@ export interface FtxMessage {
snr_db?: number | undefined;
dt_s?: number | undefined;
freq_hz?: number | undefined;
rig_id?: string | null | undefined;
receiver?: unknown;
[key: string]: unknown;
}
@@ -47,6 +49,8 @@ interface FtxBridge {
updateFt8Bar?: () => void;
registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
/** Opens a log entry from a decode. Pre-fills it; never logs by itself. */
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
trxUi: ConfirmApi;
clearFt8Bar?: () => void;
closeFt8Bar?: () => void;
@@ -222,14 +226,37 @@ export function initializeFtxDecoder(config: FtxConfig): void {
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
// A decode is not a contact: this opens an entry with what was heard in it
// and leaves the logging to the operator.
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
if (station) {
const log = document.createElement("button");
log.type = "button";
log.className = "ft8-log-btn";
log.textContent = "Log";
log.title = `Start a log entry for ${station}`;
log.addEventListener("click", (event) => {
event.stopPropagation();
const details = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
bridge.logContact?.({
call: station,
gridsquare: details.find((detail) => detail.station === station)?.grid,
decoder: id,
});
});
row.appendChild(log);
}
return row;
};
const render = (): void => {
prune();
if (!messagesElement) return;
// The history holds every rig's decodes, for the map; the panel is about
// the rig the operator is working.
const rigMessages = forActiveRig(history);
const fragment = document.createDocumentFragment();
let count = 0;
for (const message of history) {
for (const message of rigMessages) {
if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message));
@@ -237,20 +264,32 @@ export function initializeFtxDecoder(config: FtxConfig): void {
}
messagesElement.replaceChildren(fragment);
};
const normalize = (message: FtxMessage): FtxMessage => {
/** Hands a decode's grid squares to the map, if the map module is loaded yet.
* Split out of normalize so a decode can be replayed onto a map that
* arrived later: the module is lazy, and everything decoded before it
* loaded had nowhere to go. */
const plotLocator = (message: FtxMessage): void => {
const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0
? locatorDetails.map(({ grid }) => grid)
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
if (grids.length === 0) return;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
// Already an RF frequency on a replay, an audio offset on arrival; the
// conversion only fires below 100 kHz, so it is right either way.
const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) {
bridge.mapAddLocator?.(raw, grids, id, station, {
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
});
}
bridge.mapAddLocator?.(raw, grids, id, station, {
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
});
};
const normalize = (message: FtxMessage): FtxMessage => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
return {
// The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band.
rig_id: message.rig_id ?? null,
receiver: bridge.getDecodeRigMeta?.() ?? null,
ts_ms: message.ts_ms,
snr_db: message.snr_db,
@@ -262,7 +301,10 @@ export function initializeFtxDecoder(config: FtxConfig): void {
};
const receiveBatch = (messages: FtxMessage[]): void => {
if (messages.length === 0) return;
if (status) status.textContent = "Receiving";
// "Receiving" is the panel's own status, and the panel is the rig's.
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
status.textContent = "Receiving";
}
history = messages.map(normalize).reverse().concat(history);
prune();
bridge.setFt8FamilyBarDecoder?.(id);
@@ -277,7 +319,8 @@ export function initializeFtxDecoder(config: FtxConfig): void {
};
const barFrames = (): BarFrames => {
const recent = history
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000)
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000
&& isActiveRigDecode(message.rig_id))
.slice(0, 8);
let html = "";
for (const message of recent) {
@@ -299,6 +342,9 @@ export function initializeFtxDecoder(config: FtxConfig): void {
restore: receiveBatch,
prune: () => { prune(); render(); },
reset,
rerender: () => { bridge.updateFt8Bar?.(); render(); },
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => { for (const message of [...history].reverse()) plotLocator(message); },
});
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
@@ -4,6 +4,7 @@
import { hostCore, hostState } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import {
aprsAgeText,
aprsPacketCategory,
@@ -19,6 +20,12 @@ interface HfAprsBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
navigateToAprsMap?: (lat: number, lon: number) => void;
aprsMapAddStation?: (
call: string, lat: number, lon: number, info: string,
symbolTable: string | null | undefined, symbolCode: string | null | undefined,
packet: AprsPacket, source: "aprs" | "hf_aprs",
) => void;
clearMapMarkersByType?: (type: string) => void;
getDecodeRigMeta?: () => unknown;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
@@ -90,23 +97,31 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(hfAprsFilterText);
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function hfAprsRigPackets(): AprsPacket[] {
return forActiveRig(hfAprsPacketHistory);
}
function hfAprsVisiblePackets(): AprsPacket[] {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(hfAprsFilterMatch);
}
const collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
}
if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0];
const latest = rigPackets[0];
if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -168,6 +183,7 @@ function resetHfAprsHistoryView(): void {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
}
function pruneHfAprsHistoryView(): void {
@@ -175,6 +191,17 @@ function pruneHfAprsHistoryView(): void {
renderHfAprsHistory();
}
/** Hands a positioned packet to the map, if the map module is loaded yet.
* HF traffic goes on as its own source: it is a different band and a
* different path, and the map filter offers it separately. */
function plotHfAprsPacket(pkt: AprsPacket): void {
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
hfAprsWindow.aprsMapAddStation(
pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "",
pkt.symbolTable, pkt.symbolCode, pkt, "hf_aprs",
);
}
function addHfAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
@@ -183,6 +210,8 @@ function addHfAprsPacket(pkt: AprsPacket): void {
hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender();
}
@@ -192,13 +221,15 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerHfAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = [];
for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotHfAprsPacket(next);
normalized.push(next);
}
normalized.reverse();
@@ -266,8 +297,9 @@ if (hfAprsFilterInput) {
// --- Server-side HF APRS decode handler ---
function onServerHfAprs(pkt: AprsPacket): void {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
const packet = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
}
renderHfAprsHistory();
@@ -278,4 +310,7 @@ renderHfAprsHistory();
restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView,
rerender: renderHfAprsHistory,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry); },
});
@@ -11,6 +11,8 @@
// feature bundles from re-deriving it — and from drifting back to bare `window`
// properties, which the module graph no longer publishes.
import type { AuthRole } from "../api/auth.js";
export interface HostDecoderDescriptor {
id: string;
label: string;
@@ -22,8 +24,10 @@ export interface HostDecoderDescriptor {
export interface HostState {
readonly serverLat: number | null;
readonly serverLon: number | null;
/** The callsign this station is on the air with, from the client config. */
readonly ownerCallsign: string | null;
readonly authEnabled: boolean;
readonly authRole: string | null;
readonly authRoles: readonly AuthRole[];
readonly lastActiveRigId: string | null;
readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record<string, string>;
@@ -43,6 +47,7 @@ export interface HostCore {
armOptimisticFrequency(frequencyHz: number): void;
escapeMapHtml(value: string): string;
haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
latLonToMaidenhead(lat: number, lon: number): string;
showHint(message: string, durationMs?: number): void;
formatFreqForStep(frequencyHz: number, stepHz: number): string;
refreshFreqDisplay(): void;
@@ -0,0 +1,577 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The station log: one panel that opens an entry, keeps the log, and takes
// ADIF in and out.
//
// Times come from the server, never from here. The browser may be a phone in
// another timezone with a clock nobody has checked, and a log is only worth
// keeping if the times in it are the radio's.
import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {};
interface Qso {
id: string;
started_at: string;
call: string;
freq_hz: number;
band?: string | null;
mode: string;
submode?: string | null;
rst_sent?: string | null;
rst_rcvd?: string | null;
gridsquare?: string | null;
name?: string | null;
qth?: string | null;
comment?: string | null;
station_callsign?: string | null;
operator?: string | null;
my_gridsquare?: string | null;
my_rig?: string | null;
rig_id?: string | null;
contest_id?: string | null;
stx?: number | null;
srx?: number | null;
srx_string?: string | null;
qsl_rcvd?: string | null;
lotw_qsl_rcvd?: string | null;
confirmed?: boolean;
}
interface BandStatistics {
band: string;
contacts: number;
stations: number;
confirmed: number;
}
interface Prefill {
started_at: string;
epoch_ms: number;
freq_hz: number;
band: string | null;
mode: string;
submode: string | null;
rig_id: string | null;
my_rig: string | null;
call: string | null;
gridsquare: string | null;
}
interface LogbookBridge {
trxUi: {
confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean>;
notify?(message: string, options?: { kind?: string }): void;
};
navigateToTab?: (name: string) => void;
/** Opens the panel with an entry started from a decode. */
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
}
const bridge = window as unknown as LogbookBridge;
const el = <T extends HTMLElement>(id: string): T | null => document.getElementById(id) as T | null;
const form = el<HTMLFormElement>("log-entry-form");
const callInput = el<HTMLInputElement>("log-call");
const freqInput = el<HTMLInputElement>("log-freq");
const modeInput = el<HTMLInputElement>("log-mode");
const rstSentInput = el<HTMLInputElement>("log-rst-sent");
const rstRcvdInput = el<HTMLInputElement>("log-rst-rcvd");
const gridInput = el<HTMLInputElement>("log-grid");
const nameInput = el<HTMLInputElement>("log-name");
const commentInput = el<HTMLInputElement>("log-comment");
const operatorInput = el<HTMLInputElement>("log-operator");
const rowsBody = el<HTMLTableSectionElement>("log-rows");
const summaryEl = el("log-summary");
const workedEl = el("log-worked-before");
const clockEl = el("log-clock");
const stationCallEl = el("log-station-callsign");
const stationRigEl = el("log-station-rig-name");
const stationGridEl = el("log-station-grid");
const filterCall = el<HTMLInputElement>("log-filter-call");
const filterBand = el<HTMLSelectElement>("log-filter-band");
const filterMode = el<HTMLSelectElement>("log-filter-mode");
const importBtn = el<HTMLButtonElement>("log-import-btn");
const importFile = el<HTMLInputElement>("log-import-file");
const exportLink = el<HTMLAnchorElement>("log-export-btn");
const clearBtn = el<HTMLButtonElement>("log-clear-btn");
const saveBtn = el<HTMLButtonElement>("log-save-btn");
const contestIdInput = el<HTMLInputElement>("log-contest-id");
const stxInput = el<HTMLInputElement>("log-stx");
const srxInput = el<HTMLInputElement>("log-srx");
const statisticsRows = el<HTMLTableSectionElement>("log-statistics-rows");
const cabrilloContest = el<HTMLInputElement>("log-cbr-contest");
const cabrilloCallsign = el<HTMLInputElement>("log-cbr-callsign");
const cabrilloOperator = el<HTMLSelectElement>("log-cbr-operator");
const cabrilloPower = el<HTMLSelectElement>("log-cbr-power");
const cabrilloScore = el<HTMLInputElement>("log-cbr-score");
const cabrilloExport = el<HTMLAnchorElement>("log-cbr-export");
/** The time the open entry was started, as the server gave it. */
let entryStartedAt: string | null = null;
/** The rig the open entry belongs to, likewise. */
let entryRigId: string | null = null;
let entryRigName: string | null = null;
let entryGrid: string | null = null;
let qsos: Qso[] = [];
let workedRequest = 0;
function canWriteLogbook(): boolean {
return !hostState.authEnabled
|| hasAuthRole(hostState.authRoles, "write");
}
function notify(message: string, kind?: string): void {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined);
else hostCore.showHint(message, 2000);
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path);
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
return await response.json() as T;
}
function formatFreq(hz: number): string {
if (!Number.isFinite(hz) || hz <= 0) return "";
// Through Number, not a trailing-zero trim: trimming "20.000000" as a string
// walks back through the point and leaves "2".
return String(Number((hz / 1e6).toFixed(6)));
}
/** What the operator typed, in Hz: MHz unless it is plainly already Hz. */
function parseFreq(text: string): number | null {
const value = Number(text.trim().replace(/\s+/g, "").replace(",", "."));
if (!Number.isFinite(value) || value <= 0) return null;
return value < 100_000 ? Math.round(value * 1e6) : Math.round(value);
}
/** A serial, or null when the exchange is a word rather than a number. */
function numberOrNull(text: string | undefined): number | null {
const trimmed = (text ?? "").trim();
if (!trimmed || !/^\d+$/.test(trimmed)) return null;
const value = Number(trimmed);
return Number.isSafeInteger(value) ? value : null;
}
function utcDate(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
}
function utcTime(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19);
}
// ── The entry ───────────────────────────────────────────────────────────────
/** Open an entry, pre-filled with the six fields the radio and clock answer. */
async function openEntry(
seed: { call?: string; gridsquare?: string | undefined; decoder?: string } = {},
): Promise<void> {
const params = new URLSearchParams();
if (hostState.lastActiveRigId) params.set("remote", hostState.lastActiveRigId);
if (seed.decoder) params.set("decoder", seed.decoder);
if (seed.call) params.set("call", seed.call);
if (seed.gridsquare) params.set("gridsquare", seed.gridsquare);
try {
const prefill = await getJson<Prefill>(`/api/logbook/prefill?${params.toString()}`);
applyPrefill(prefill);
} catch (error: unknown) {
console.error("logbook prefill failed", error);
}
}
function applyPrefill(prefill: Prefill): void {
entryStartedAt = prefill.started_at;
entryRigId = prefill.rig_id;
entryRigName = prefill.my_rig;
if (freqInput) freqInput.value = formatFreq(prefill.freq_hz);
if (modeInput) modeInput.value = prefill.submode ?? prefill.mode;
if (callInput && prefill.call) callInput.value = prefill.call;
if (gridInput && prefill.gridsquare) gridInput.value = prefill.gridsquare;
if (stationRigEl) stationRigEl.textContent = prefill.my_rig ?? "no rig";
showClock(prefill);
updateWorkedBefore();
}
/** The entry's time, and whether this browser agrees with it. */
function showClock(prefill: Prefill): void {
if (!clockEl) return;
const time = utcTime(prefill.started_at);
const drift = Math.abs(Date.now() - prefill.epoch_ms);
clockEl.textContent = drift > 1000
? `${time}Z · your clock is ${(drift / 1000).toFixed(0)}s out`
: `${time}Z`;
clockEl.classList.toggle("is-adrift", drift > 1000);
}
/** Clear the entry and open a fresh one. */
function resetEntry(): void {
// The contest and the serial sent stay: they belong to the session, not to
// the contact just logged.
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) {
if (input) input.value = "";
}
if (workedEl) workedEl.textContent = "";
void openEntry();
}
async function submitEntry(event: Event): Promise<void> {
event.preventDefault();
const call = callInput?.value.trim() ?? "";
if (!call) {
notify("A contact needs a callsign", "error");
callInput?.focus();
return;
}
const freqHz = parseFreq(freqInput?.value ?? "");
if (freqHz == null) {
notify("A contact needs a frequency", "error");
freqInput?.focus();
return;
}
const typedMode = (modeInput?.value ?? "").trim().toUpperCase();
// USB and LSB are how a rig says SSB; the log wants the mode with the
// sideband beside it, which is what other loggers read.
const isSideband = typedMode === "USB" || typedMode === "LSB";
const body = {
// The server stamps the time; this is the one it gave when the entry
// opened, so a contact logged five minutes later keeps the time it started.
started_at: entryStartedAt,
call,
freq_hz: freqHz,
mode: isSideband ? "SSB" : typedMode,
submode: isSideband ? typedMode : null,
rst_sent: rstSentInput?.value ?? null,
rst_rcvd: rstRcvdInput?.value ?? null,
gridsquare: gridInput?.value ?? null,
name: nameInput?.value ?? null,
comment: commentInput?.value ?? null,
contest_id: contestIdInput?.value ?? null,
// The exchange is a number when it is a serial and a word when it is a
// zone or a section; both are kept, and the log writes whichever it has.
stx: numberOrNull(stxInput?.value),
stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null,
srx: numberOrNull(srxInput?.value),
srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null,
station_callsign: stationCallEl?.textContent?.trim() ?? null,
operator: operatorInput?.value ?? null,
my_gridsquare: entryGrid,
my_rig: entryRigName,
rig_id: entryRigId,
};
if (saveBtn) saveBtn.disabled = true;
try {
const response = await fetch("/api/logbook", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
const detail = await response.json().catch(() => ({})) as { error?: string };
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
}
notify(`${call} logged`);
// A contest runs on serials: the next one is this one plus one, so it is
// not retyped forty times an hour.
const sent = numberOrNull(stxInput?.value);
resetEntry();
if (stxInput && sent != null) stxInput.value = String(sent + 1);
await refreshLog();
} catch (error: unknown) {
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}
/** Whether this station has been worked, and on what. */
function updateWorkedBefore(): void {
if (!workedEl) return;
const call = callInput?.value.trim() ?? "";
if (call.length < 3) {
workedEl.textContent = "";
return;
}
const request = ++workedRequest;
void getJson<{ worked: { band: string; mode: string }[] }>(
`/api/logbook/worked/${encodeURIComponent(call)}`,
).then((answer) => {
// A slower answer to an earlier callsign must not overwrite a later one.
if (request !== workedRequest || !workedEl) return;
if (answer.worked.length === 0) {
workedEl.textContent = "Not worked before";
workedEl.classList.remove("is-worked");
return;
}
const where = answer.worked.map((entry) => `${entry.band} ${entry.mode}`).join(", ");
workedEl.textContent = `Worked before: ${where}`;
workedEl.classList.add("is-worked");
}).catch(() => { /* the log will still take the contact */ });
}
// ── The log ─────────────────────────────────────────────────────────────────
function currentQuery(): string {
const params = new URLSearchParams();
const call = filterCall?.value.trim();
if (call) params.set("call", call);
if (filterBand?.value) params.set("band", filterBand.value);
if (filterMode?.value) params.set("mode", filterMode.value);
return params.toString();
}
async function refreshLog(): Promise<void> {
try {
const query = currentQuery();
const answer = await getJson<{ total: number; qsos: Qso[] }>(
`/api/logbook${query ? `?${query}` : ""}`,
);
qsos = answer.qsos;
renderRows();
renderFilterOptions();
if (summaryEl) {
summaryEl.textContent = query
? `${String(qsos.length)} of ${String(answer.total)} contacts`
: `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
}
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
await refreshStatistics();
} catch (error: unknown) {
console.error("logbook read failed", error);
}
}
/** What has been worked and confirmed, band by band. */
async function refreshStatistics(): Promise<void> {
if (!statisticsRows) return;
try {
const answer = await getJson<{ bands: BandStatistics[] }>("/api/logbook/statistics");
if (answer.bands.length === 0) {
statisticsRows.innerHTML = '<tr><td colspan="4" class="log-empty">Nothing worked yet.</td></tr>';
return;
}
const fragment = document.createDocumentFragment();
for (const band of answer.bands) {
const row = document.createElement("tr");
for (const value of [band.band, band.contacts, band.stations, band.confirmed]) {
const cell = document.createElement("td");
cell.textContent = String(value);
row.appendChild(cell);
}
fragment.appendChild(row);
}
statisticsRows.replaceChildren(fragment);
} catch (error: unknown) {
console.error("logbook statistics failed", error);
}
}
/** The Cabrillo link, carrying the header the operator filled in. */
function syncCabrilloLink(): void {
if (!cabrilloExport) return;
const params = new URLSearchParams();
const contest = cabrilloContest?.value.trim();
if (contest) {
// Only that contest's contacts belong in the entry.
params.set("contest", contest);
}
const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? "");
if (callsign) params.set("callsign", callsign);
if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value);
if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value);
const score = numberOrNull(cabrilloScore?.value);
if (score != null) params.set("claimed_score", String(score));
const operator = operatorInput?.value.trim();
if (operator) params.set("operators", operator);
cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`;
}
function renderRows(): void {
if (!rowsBody) return;
if (qsos.length === 0) {
rowsBody.innerHTML =
'<tr><td colspan="10" class="log-empty">No contacts yet. Work someone and log them here,'
+ " or bring a log in with Import ADIF.</td></tr>";
return;
}
const fragment = document.createDocumentFragment();
for (const qso of qsos) {
const row = document.createElement("tr");
row.dataset.qsoId = qso.id;
const cells: (string | null | undefined)[] = [
utcDate(qso.started_at),
utcTime(qso.started_at),
qso.call,
qso.band ?? "",
qso.submode ?? qso.mode,
qso.rst_sent ?? "",
qso.rst_rcvd ?? "",
qso.gridsquare ?? "",
qso.my_rig ?? "",
qso.confirmed ? "✓" : "",
];
for (const [index, value] of cells.entries()) {
const cell = document.createElement("td");
cell.textContent = value ?? "";
if (index === 2) cell.className = "log-cell-call";
row.appendChild(cell);
}
const actions = document.createElement("td");
if (!canWriteLogbook()) {
row.appendChild(actions);
fragment.appendChild(row);
continue;
}
// Confirming is the commonest edit a log gets, so it is a button rather
// than a form: a card arrives, and the contact counts towards an award.
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = "log-row-btn";
confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm";
confirm.title = qso.confirmed
? "Mark this contact as not confirmed"
: "Mark this contact confirmed by QSL";
confirm.addEventListener("click", () => { void setConfirmed(qso, !qso.confirmed); });
actions.appendChild(confirm);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "log-row-btn";
remove.textContent = "Delete";
remove.setAttribute("aria-label", `Delete the contact with ${qso.call}`);
remove.addEventListener("click", () => { void deleteQso(qso); });
actions.appendChild(remove);
row.appendChild(actions);
fragment.appendChild(row);
}
rowsBody.replaceChildren(fragment);
}
/** Bands and modes the log actually holds, so the filters offer only those. */
function renderFilterOptions(): void {
for (const [select, values] of [
[filterBand, [...new Set(qsos.map((q) => q.band).filter((b): b is string => !!b))]],
[filterMode, [...new Set(qsos.map((q) => q.submode ?? q.mode).filter(Boolean))]],
] as const) {
if (!select) continue;
const chosen = select.value;
const known = new Set([...select.options].map((option) => option.value));
for (const value of [...values].sort()) {
if (known.has(value)) continue;
select.add(new Option(value, value));
}
select.value = chosen;
}
}
/** Record, or withdraw, the other station's confirmation. */
async function setConfirmed(qso: Qso, confirmed: boolean): Promise<void> {
try {
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...qso,
// A card is a card: this is the paper one, and an electronic
// confirmation the log already holds is left where it is.
qsl_rcvd: confirmed ? "Y" : "N",
}),
});
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
await refreshLog();
} catch (error: unknown) {
notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
async function deleteQso(qso: Qso): Promise<void> {
const confirmed = await bridge.trxUi.confirm({
title: "Delete this contact?",
message: `${qso.call} on ${qso.band ?? formatFreq(qso.freq_hz)} will be removed from the log.`,
confirmLabel: "Delete",
});
if (!confirmed) return;
try {
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { method: "DELETE" });
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
await refreshLog();
} catch (error: unknown) {
notify(`Could not delete: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
async function importAdif(file: File): Promise<void> {
try {
const response = await fetch("/api/logbook/import", { method: "POST", body: await file.arrayBuffer() });
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
const outcome = await response.json() as { added: number; duplicate: number; rejected: string[] };
const parts = [`${String(outcome.added)} added`];
if (outcome.duplicate > 0) parts.push(`${String(outcome.duplicate)} already held`);
if (outcome.rejected.length > 0) parts.push(`${String(outcome.rejected.length)} not readable`);
notify(parts.join(", "));
await refreshLog();
} catch (error: unknown) {
notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
// ── Station line ────────────────────────────────────────────────────────────
function renderStation(): void {
const callsign = hostState.ownerCallsign ?? "";
if (stationCallEl) stationCallEl.textContent = callsign || "no callsign set";
if (operatorInput && !operatorInput.value) operatorInput.value = callsign;
if (stationGridEl) {
const grid = hostState.serverLat != null && hostState.serverLon != null
? hostCore.latLonToMaidenhead(hostState.serverLat, hostState.serverLon)
: "";
entryGrid = grid || null;
stationGridEl.textContent = grid;
}
}
// ── Wiring ──────────────────────────────────────────────────────────────────
form?.addEventListener("submit", (event) => { void submitEntry(event); });
clearBtn?.addEventListener("click", resetEntry);
callInput?.addEventListener("input", updateWorkedBefore);
for (const control of [filterCall, filterBand, filterMode]) {
control?.addEventListener("input", () => { void refreshLog(); });
control?.addEventListener("change", () => { void refreshLog(); });
}
for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) {
control?.addEventListener("input", syncCabrilloLink);
control?.addEventListener("change", syncCabrilloLink);
}
importBtn?.addEventListener("click", () => { importFile?.click(); });
importFile?.addEventListener("change", () => {
const file = importFile.files?.[0];
if (file) void importAdif(file);
importFile.value = "";
});
/** Start an entry from a decode, and show the operator where it went. */
if (canWriteLogbook()) {
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation();
if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
}
syncCabrilloLink();
if (canWriteLogbook()) void openEntry();
void refreshLog();
@@ -9,6 +9,10 @@ export interface DecoderPlugin<TMessage = unknown> {
restore?(messages: TMessage[]): void;
reset?(): void;
prune?(): void;
/** Repaint the plugin's panel from what it already holds. Called when the
* selected rig changes: the panels show one rig's traffic, and the decodes
* behind them arrived long before the switch. */
rerender?(): void;
/** Replay everything the plugin is holding onto the map. Called when the map
* module attaches, which can happen long after the decodes arrived. */
syncMap?(): void;
@@ -22,6 +26,7 @@ export interface TrxPluginRuntime {
reset(id: string): boolean;
resetAll(): void;
prune(id: string): boolean;
rerenderAll(): void;
syncMapAll(): void;
clearQueued(): void;
hasDecoder(id: string): boolean;
@@ -4,13 +4,12 @@
import { hostCore } from "./host.js";
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate } from "./satellite-types";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
type SatelliteView = "live" | "history" | "predictions";
type SatelliteView = "live" | "history";
interface SatelliteBridge {
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
clearSatPredictionDom?: () => void;
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
addSatMapOverlay?: (image: SatelliteImage) => void;
clearSatMapOverlays?: () => void;
@@ -25,14 +24,14 @@ const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow;
// --- SAT Plugin ---
// Live view: decoder state, latest image card
// History view: filterable table of all decoded images
// Predictions view: next 24 h passes for ham satellites
//
// Pass predictions live on their own page; see satellite-predictions.ts.
// ── DOM references (cached once) ───────────────────────────────────
const satDom = {
status: document.getElementById("sat-status"),
liveView: document.getElementById("sat-live-view"),
historyView: document.getElementById("sat-history-view"),
predictionsView: document.getElementById("sat-predictions-view"),
liveLatest: document.getElementById("sat-live-latest"),
historyList: document.getElementById("sat-history-list"),
historyCount: document.getElementById("sat-history-count"),
@@ -42,30 +41,13 @@ const satDom = {
lrptState: document.getElementById("sat-lrpt-state"),
viewLiveBtn: document.getElementById("sat-view-live"),
viewHistoryBtn: document.getElementById("sat-view-history"),
viewPredBtn: document.getElementById("sat-view-predictions"),
predFilter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
predMinEl: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
predCategory: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
predCurrentList: document.getElementById("sat-pred-current-list"),
predUpcomingList: document.getElementById("sat-pred-list"),
predCurrentSec: document.getElementById("sat-pred-current-section"),
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
predStatus: document.getElementById("sat-pred-status"),
};
// ── State ───────────────────────────────────────────────────────────
let satImageHistory: SatelliteImage[] = [];
const SAT_MAX_IMAGES = 100;
const SAT_PRED_PAGE_SIZE = 50;
let satPredShowAll = false;
let satFilterText = "";
let satActiveView: SatelliteView = "live";
let satPredData: SatellitePass[] = [];
let satPredFilterText = "";
let satPredMinEl = 0;
let satPredCategory = "all";
let satPredSatCount = 0;
let satPredCountdownTimer: ReturnType<typeof setInterval> | null = null;
// ── UI scheduler helper ─────────────────────────────────────────────
function scheduleSatUi(key: string, job: () => void): void {
@@ -78,33 +60,16 @@ function scheduleSatUi(key: string, job: () => void): void {
// ── View switching ──────────────────────────────────────────────────
function switchSatView(view: SatelliteView): void {
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
satActiveView = view;
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
if (leavingPredictions) clearPredictionDom();
if (view === "history") {
renderSatHistoryTable();
} else if (view === "predictions") {
satPredShowAll = false;
void loadSatPredictions();
}
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
if (view === "history") renderSatHistoryTable();
}
function clearPredictionDom() {
stopCountdownTimer();
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
}
satWindow.clearSatPredictionDom = clearPredictionDom;
satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); });
// ── Live view: decoder state ────────────────────────────────────────
let lastSatLrptOn: boolean | null = null;
@@ -335,234 +300,6 @@ document
}
})(); });
// ── Predictions: helpers ────────────────────────────────────────────
function azToCardinal(deg: number): string {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms: number): string {
const d = new Date(ms);
const now = new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s: number): string {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms: number): string {
const totalSec = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg: number): string {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
// ── Predictions: countdown timer management ─────────────────────────
function stopCountdownTimer() {
if (satPredCountdownTimer) {
clearInterval(satPredCountdownTimer);
satPredCountdownTimer = null;
}
}
function startCountdownTimer(container: HTMLElement | null): void {
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
satPredCountdownTimer = setInterval(() => {
if (satActiveView !== "predictions") {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
renderSatPredictions(getFilteredPredictions());
}
}, 1000);
}
// ── Predictions: row builders ───────────────────────────────────────
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
// ── Predictions: filter state ───────────────────────────────────────
function getFilteredPredictions(): SatellitePass[] {
let items = satPredData;
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
return items;
}
function applyPredFilters() {
renderSatPredictions(getFilteredPredictions());
}
const satPredictionFilter = satDom.predFilter;
satPredictionFilter?.addEventListener("input", () => {
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
applyPredFilters();
});
const satPredictionMinElevation = satDom.predMinEl;
satPredictionMinElevation?.addEventListener("change", () => {
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
applyPredFilters();
});
const satPredictionCategory = satDom.predCategory;
satPredictionCategory?.addEventListener("change", () => {
satPredCategory = satPredictionCategory.value;
applyPredFilters();
});
// ── Predictions: main render ────────────────────────────────────────
function renderSatPredictions(passes: SatellitePass[], error?: string): void {
stopCountdownTimer();
if (error) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
// ── Current passes ──
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
if (satDom.predCurrentList) {
if (current.length === 0) {
satDom.predCurrentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
satDom.predCurrentList.replaceChildren(frag);
}
}
// ── Upcoming passes ──
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
if (satDom.predUpcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
moreRow.addEventListener("click", () => {
satPredShowAll = true;
renderSatPredictions(getFilteredPredictions());
});
frag.appendChild(moreRow);
}
satDom.predUpcomingList.replaceChildren(frag);
}
// ── Status ──
if (satDom.predStatus) {
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`;
satDom.predStatus.textContent = text;
}
// ── Countdown timer ──
if (current.length > 0 && satActiveView === "predictions") {
startCountdownTimer(satDom.predCurrentList);
}
}
// ── Predictions: data loading ───────────────────────────────────────
async function loadSatPredictions(): Promise<void> {
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json() as SatellitePassResponse;
satPredSatCount = data.satellite_count || 0;
if (data.error) {
satPredData = [];
renderSatPredictions([], data.error);
} else {
satPredData = data.passes || [];
renderSatPredictions(getFilteredPredictions());
}
} catch (error: unknown) {
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
// ── Navigate to map centered on satellite image bounds ──────────────
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
if (typeof satWindow.enableMapSourceFilter === "function") {
@@ -0,0 +1,290 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import type { SatellitePass, SatellitePassResponse } from "./satellite-types";
// --- Satellite pass predictions ---
//
// Its own page rather than a third view inside the weather-satellite decoder:
// when a bird comes over is a planning question, and it was buried under
// Digital modes beside decoders it has nothing to do with.
interface PredictionsBridge {
clearSatPredictionDom?: () => void;
refreshSatPredictions?: () => void;
}
const predWindow = window as unknown as PredictionsBridge;
// ── DOM references (cached once) ───────────────────────────────────
const dom = {
page: document.getElementById("tab-satellites"),
filter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
minElevation: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
category: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
currentList: document.getElementById("sat-pred-current-list"),
upcomingList: document.getElementById("sat-pred-list"),
currentSection: document.getElementById("sat-pred-current-section"),
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
status: document.getElementById("sat-pred-status"),
};
// ── State ───────────────────────────────────────────────────────────
const PAGE_SIZE = 50;
let predShowAll = false;
let predData: SatellitePass[] = [];
let predFilterText = "";
let predMinEl = 0;
let predCategory = "all";
let predSatCount = 0;
let predCountdownTimer: ReturnType<typeof setInterval> | null = null;
/** The countdowns tick once a second, so they stop when nobody is looking. */
function isPageVisible(): boolean {
return !!dom.page && dom.page.style.display !== "none";
}
// ── Predictions: helpers ────────────────────────────────────────────
function azToCardinal(deg: number): string {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms: number): string {
const d = new Date(ms);
const now = new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s: number): string {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms: number): string {
const totalSec = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg: number): string {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
// ── Predictions: countdown timer management ─────────────────────────
function stopCountdownTimer() {
if (predCountdownTimer) {
clearInterval(predCountdownTimer);
predCountdownTimer = null;
}
}
function startCountdownTimer(container: HTMLElement | null): void {
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
predCountdownTimer = setInterval(() => {
if (!isPageVisible()) {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
render(filtered());
}
}, 1000);
}
// ── Predictions: row builders ───────────────────────────────────────
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
// ── Predictions: filter state ───────────────────────────────────────
function filtered(): SatellitePass[] {
let items = predData;
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
return items;
}
function applyFilters() {
render(filtered());
}
const filterInput = dom.filter;
filterInput?.addEventListener("input", () => {
predFilterText = filterInput.value.trim().toUpperCase();
applyFilters();
});
const minElevationSelect = dom.minElevation;
minElevationSelect?.addEventListener("change", () => {
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
applyFilters();
});
const categorySelect = dom.category;
categorySelect?.addEventListener("change", () => {
predCategory = categorySelect.value;
applyFilters();
});
// ── Predictions: main render ────────────────────────────────────────
function render(passes: SatellitePass[], error?: string): void {
stopCountdownTimer();
if (error) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
// ── Current passes ──
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
if (dom.currentList) {
if (current.length === 0) {
dom.currentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
dom.currentList.replaceChildren(frag);
}
}
// ── Upcoming passes ──
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
if (dom.upcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
moreRow.addEventListener("click", () => {
predShowAll = true;
render(filtered());
});
frag.appendChild(moreRow);
}
dom.upcomingList.replaceChildren(frag);
}
// ── Status ──
if (dom.status) {
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
if (predSatCount > 0) text += ` \u00B7 ${predSatCount} satellites tracked`;
dom.status.textContent = text;
}
// ── Countdown timer ──
if (current.length > 0 && isPageVisible()) {
startCountdownTimer(dom.currentList);
}
}
// ── Predictions: data loading ───────────────────────────────────────
async function load(): Promise<void> {
if (dom.status) dom.status.textContent = "Loading predictions\u2026";
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json() as SatellitePassResponse;
predSatCount = data.satellite_count || 0;
if (data.error) {
predData = [];
render([], data.error);
} else {
predData = data.passes || [];
render(filtered());
}
} catch (error: unknown) {
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
// ── Page lifecycle ──────────────────────────────────────────────────
function clearPredictionDom(): void {
stopCountdownTimer();
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
}
predWindow.clearSatPredictionDom = clearPredictionDom;
/**
* Called by the router each time the Satellites page is opened, including the
* visit that imports this module so loading is driven from one place only.
*/
predWindow.refreshSatPredictions = function () {
predShowAll = false;
void load();
};
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js";
import type { AuthRole } from "../api/auth.js";
export type SchedulerMode = "disabled" | "grayline" | "time_span";
@@ -60,7 +61,7 @@ export interface SchedulerStatus {
}
export interface SchedulerService {
initialize(rigId: string | null, role: string | null): void;
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
destroy(): void;
setRig(rigId: string | null): void;
wireEvents(): void;
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
import type {
ScheduleEntry,
@@ -43,7 +44,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// -------------------------------------------------------------------------
// State
// -------------------------------------------------------------------------
let schedulerRole: string | null = null;
let schedulerRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null;
let currentConfig: SchedulerConfig | null = null;
let currentSchedulerStatus: SchedulerStatus | null = null;
@@ -58,8 +59,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
function initScheduler(rigId: string | null, role: string | null): void {
schedulerRole = role;
function initScheduler(rigId: string | null, roles: readonly AuthRole[]): void {
schedulerRoles = roles;
currentRigId = rigId || null;
if (currentRigId) loadScheduler();
startStatusPolling();
@@ -356,7 +357,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled =
schedulerRole === "control" &&
hasAuthRole(schedulerRoles, "control") &&
!!currentRigId &&
!schedulerStepPending &&
state.activeEntries.length > 1;
@@ -466,7 +467,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled";
const isControl = schedulerRole === "control";
const isControl = hasAuthRole(schedulerRoles, "control");
// Mode selector
setSelected("scheduler-mode-select", mode);
@@ -1574,8 +1575,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// When loaded eagerly, initSettingsUI() in app.js calls initScheduler();
// when loaded lazily (e.g. settings tab click after boot), the app has
// already passed that point, so we must self-initialize here.
if (hostState.authRole != null) {
initScheduler(hostState.lastActiveRigId, hostState.authRole);
if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents();
}
})();
@@ -14,11 +14,13 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
interface SstvImage {
rig_id?: string | null;
ts_ms?: number;
vis?: number;
mode?: string;
@@ -32,6 +34,7 @@ interface SstvImage {
}
interface SstvProgress {
rig_id?: string | null;
state?: string;
mode?: string;
width?: number;
@@ -185,6 +188,9 @@ function paintRow(line: number, rgb: Uint8Array): void {
// ── Server messages ─────────────────────────────────────────────────
function onProgress(msg: SstvProgress): void {
// The live canvas draws one picture at a time: a background rig's rows would
// paint straight over the one being watched.
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state;
// A state update carries the geometry; a row update carries only the row.
@@ -208,6 +214,9 @@ function onImage(msg: SstvImage): void {
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory();
// Kept in the history either way, so switching to that rig finds it there.
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) {
sstvDom.status.textContent = image.complete
? `Received ${image.mode ?? "picture"}`
@@ -219,9 +228,15 @@ function onImage(msg: SstvImage): void {
// ── Rendering ───────────────────────────────────────────────────────
/** The pictures the selected rig decoded, oldest first, as the history is kept. */
function sstvRigImages(): SstvImage[] {
return forActiveRig(sstvHistory);
}
function renderLatestCard(): void {
if (!sstvDom.liveLatest) return;
const latest = sstvHistory[sstvHistory.length - 1];
const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
if (!latest) {
sstvDom.liveLatest.innerHTML = "";
return;
@@ -245,10 +260,12 @@ function renderLatestCard(): void {
}
function filteredHistory(): SstvImage[] {
// Every rig's pictures are kept; the panel shows the selected rig's.
const rigImages = sstvRigImages();
const text = filterText.trim().toLowerCase();
const matching = text
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
: sstvHistory.slice();
? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
: rigImages;
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching;
@@ -348,6 +365,7 @@ sstvWindow.trxPluginRuntime.registerDecoder({
restore: restoreHistory,
prune: renderHistoryTable,
reset: resetHistoryView,
rerender: () => { renderLatestCard(); renderHistoryTable(); },
});
sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress",
@@ -6,6 +6,7 @@ import { hostCore } from "./host.js";
export {};
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface VdesMessage {
@@ -116,17 +117,24 @@ function vdesHexPreview(rawBytes: number[] | undefined): string {
.toUpperCase();
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function vdesRigMessages(): VdesMessage[] {
return forActiveRig(vdesMessageHistory);
}
function updateVdesSummary() {
pruneVdesMessageHistory();
if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText();
}
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) {
const count = vdesMessageHistory.length;
const count = rigMessages.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
}
if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0];
const latest = rigMessages[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
}
}
@@ -228,7 +236,9 @@ function updateVdesBar() {
updateVdesSummary();
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
const messages = vdesMessageHistory
.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id))
.slice(0, 6);
if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = "";
@@ -278,7 +288,7 @@ function renderVdesHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) {
for (const message of vdesRigMessages()) {
fragment.appendChild(renderVdesRow(message));
}
vdesMessagesEl.replaceChildren(fragment);
@@ -309,10 +319,11 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
function onServerVdesBatch(messages: VdesMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized: VdesMessage[] = [];
for (const msg of messages) {
const next = normalizeServerVdesMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -354,8 +365,8 @@ function plotVdesMessage(msg: VdesMessage): void {
}
function onServerVdes(msg: VdesMessage): void {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next);
plotVdesMessage(next);
}
@@ -374,6 +385,7 @@ updateVdesSummary();
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
rerender: () => { updateVdesBar(); renderVdesHistory(); },
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
});
@@ -4,11 +4,13 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
interface WefaxImage {
rig_id?: string | null;
ts_ms?: number;
ioc?: number;
lpm?: number;
@@ -20,6 +22,7 @@ interface WefaxImage {
}
interface WefaxProgress {
rig_id?: string | null;
state?: string;
line_data?: string;
line_count?: number;
@@ -156,15 +159,21 @@ function paintLine(lineBytes: Uint8Array): void {
}
// ── Live view: latest image card ────────────────────────────────────
/** The pictures the selected rig decoded, newest first. */
function wefaxRigImages(): WefaxImage[] {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return;
if (wefaxImageHistory.length === 0) {
const rigImages = wefaxRigImages();
if (rigImages.length === 0) {
wefaxDom.liveLatest.innerHTML =
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
return;
}
const img = wefaxImageHistory[0];
const img = rigImages[0];
if (!img) return;
const ts = img._ts || '--';
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
@@ -193,7 +202,8 @@ function renderWefaxLatestCard() {
// ── History view: table ─────────────────────────────────────────────
function getWefaxFilteredHistory() {
let items = wefaxImageHistory;
// Every rig's pictures are kept; the panel shows the selected rig's.
let items = wefaxRigImages();
if (wefaxFilterText) {
items = items.filter(function (i) {
@@ -253,7 +263,7 @@ function renderWefaxHistoryTable() {
wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) {
const total = wefaxImageHistory.length;
const total = wefaxRigImages().length;
const shown = items.length;
wefaxDom.historyCount.textContent =
total === 0
@@ -298,6 +308,9 @@ function addWefaxImage(msg: WefaxImage): void {
// ── SSE event handlers (public API) ─────────────────────────────────
function onServerWefaxProgress(msg: WefaxProgress): void {
// The live canvas draws one picture at a time: a background rig's lines
// would paint straight over the one being watched.
if (!isActiveRigDecode(msg.rig_id)) return;
// State-only update (no image data): show decoder state in status.
if (msg.state && !msg.line_data) {
if (wefaxDom.status) {
@@ -331,6 +344,8 @@ function onServerWefaxProgress(msg: WefaxProgress): void {
function onServerWefax(msg: WefaxImage): void {
addWefaxImage(msg);
// Kept in the history either way, so switching to that rig finds it there.
if (!isActiveRigDecode(msg.rig_id)) return;
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
if (wefaxDom.status) {
@@ -438,6 +453,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView,
rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); },
});
wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress",
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
@@ -15,6 +16,10 @@ interface WsprMessage {
snr_db?: number | undefined;
dt_s?: number | undefined;
freq_hz?: number | undefined;
rig_id?: string | null | undefined;
/** RF frequency the spot was heard on, kept so a map replay does not
* recompute it against wherever the dial has moved to since. */
_rfHz?: number | null | undefined;
receiver?: unknown;
[key: string]: unknown;
}
@@ -102,10 +107,12 @@ function renderWsprRow(msg: WsprMessage): HTMLDivElement {
function renderWsprHistory(): void {
pruneWsprMessageHistory();
if (!wsprMessagesEl) return;
// The history holds every rig's decodes, for the map; the panel is about the
// rig the operator is working.
const rigMessages = forActiveRig(wsprMessageHistory);
const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
const message = wsprMessageHistory[i];
if (message) fragment.appendChild(renderWsprRow(message));
for (const message of rigMessages) {
fragment.appendChild(renderWsprRow(message));
}
wsprMessagesEl.replaceChildren(fragment);
}
@@ -130,6 +137,8 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
station,
rfHz,
history: {
rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms,
snr_db: msg.snr_db,
@@ -142,16 +151,12 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
function onServerWsprBatch(messages: WsprMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized: WsprMessage[] = [];
for (const msg of messages) {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
});
}
// "Receiving" is the panel's own status, and the panel is the rig's.
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
plotWsprLocator(msg);
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
@@ -311,15 +316,24 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
})();
});
function onServerWspr(msg: WsprMessage): void {
if (wsprStatus) wsprStatus.textContent = "Receiving";
/** Hands a spot's grid squares to the map, if the map module is loaded yet.
* The module is lazy, so a spot heard before it arrived has to be replayable. */
function plotWsprLocator(msg: WsprMessage): void {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
});
}
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
// A replayed spot carries the frequency it was heard on; a fresh one has it
// worked out from the dial it just arrived against.
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(rfHz === null ? {} : { freq_hz: rfHz }),
});
}
function onServerWspr(msg: WsprMessage): void {
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg);
plotWsprLocator(msg);
addWsprMessage(next.history);
}
@@ -330,4 +344,7 @@ wsprWindow.trxPluginRuntime.registerDecoder({
restore: onServerWsprBatch,
prune: pruneWsprHistoryView,
reset: resetWsprHistoryView,
rerender: renderWsprHistory,
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => { for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message); },
});
@@ -24,8 +24,8 @@ interface ButtonStateOptions {
disabled?: boolean;
}
type TabKind = "primary" | "secondary";
type LayoutCapability = "broadcast" | "digital";
type LayoutName = "compact" | "broadcast" | "digital" | "full";
type LayoutCapability = "broadcast" | "digital" | "ham";
type LayoutName = "compact" | "broadcast" | "digital" | "full" | "ham";
interface OperatorLayout {
label: string;
unavailable?: string;
@@ -191,8 +191,11 @@ function elementById<T extends HTMLElement>(id: string): T {
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
// Working the bands: the log is the task and the radio is the instrument,
// so this one opens on the logbook with the controls a keystroke away.
ham: { label: "Ham radio", unavailable: "Ham radio needs a rig that can transmit", advanced: true, audio: true, scheduler: false, preferredTab: "logbook", capability: "ham" },
};
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false };
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false, ham: false };
let activeRigId: string | null = null;
// A layout seeds the collapsible sections; it does not hold them there.
@@ -499,7 +502,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");
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document */
const ALL_ROLES = ["read", "control", "transmit", "write", "administrator"];
const fixture = await startWebFixture({
authSession: {
authenticated: true,
username: "admin",
roles: ALL_ROLES,
auth_disabled: false,
},
users: [
{ username: "admin", roles: ALL_ROLES, enabled: true },
{ username: "guest", roles: ["guest"], enabled: true },
{ username: "listener", roles: ["read"], enabled: false },
],
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-settings").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").waitFor({ state: "visible" });
await page.locator("#settings-users-tab").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").click();
assert.equal(await page.locator("#account-password-form").isVisible(), true);
assert.equal(await page.locator("#subtab-settings-users").isVisible(), false);
await page.locator("#settings-users-tab").click();
await page.locator("#user-list").getByText("listener (disabled)").waitFor();
assert.equal(await page.locator("#user-create-form").isVisible(), true);
const state = await page.evaluate(() => {
const rows = [...document.querySelectorAll("#user-list > .sch-row")];
const rowFor = (username) => rows.find((row) => row.querySelector("strong")?.textContent.startsWith(username));
const admin = rowFor("admin");
const guest = rowFor("guest");
const listener = rowFor("listener");
const role = (row, value) => row?.querySelector(`input[value="${value}"]`);
return {
createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value),
adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled,
adminRoleLocked: role(admin, "administrator")?.disabled,
adminGuestLocked: role(admin, "guest")?.disabled,
adminRemoveLocked: admin?.querySelector("button.danger")?.disabled,
guestPasswordLocked: guest?.querySelector('input[type="password"]')?.disabled,
listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.checked,
listenerRead: role(listener, "read")?.checked,
};
});
assert.deepEqual(state.createRoles, ["guest", ...ALL_ROLES]);
assert.equal(state.adminEnabledLocked, true);
assert.equal(state.adminRoleLocked, true);
assert.equal(state.adminGuestLocked, true);
assert.equal(state.adminRemoveLocked, true);
assert.equal(state.guestPasswordLocked, true);
assert.equal(state.listenerEnabled, false);
assert.equal(state.listenerRead, true);
const listenerRow = page.locator("#user-list > .sch-row").filter({ hasText: "listener" });
const listenerPassword = listenerRow.locator('input[type="password"]');
await listenerRow.locator('input[value="guest"]').check();
assert.equal(await listenerPassword.isDisabled(), true);
assert.equal(await listenerRow.locator('input[value="read"]').isChecked(), true);
await listenerRow.locator('input[value="control"]').check();
assert.equal(await listenerRow.locator('input[value="guest"]').isChecked(), true);
assert.equal(await listenerPassword.isDisabled(), true);
await listenerRow.locator('input[value="guest"]').uncheck();
assert.equal(await listenerPassword.isDisabled(), false);
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
const guestFixture = await startWebFixture({
authSession: {
authenticated: true,
username: "guest",
roles: ["guest"],
auth_disabled: false,
},
});
const guestBrowser = await startBrowser(chromium);
try {
await guestBrowser.page.goto(`${guestFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await guestBrowser.page.locator("#tab-settings").waitFor({ state: "visible" });
assert.equal(await guestBrowser.page.locator("#settings-account-tab").isVisible(), false);
assert.equal(await guestBrowser.page.locator("#settings-users-tab").isVisible(), false);
assert.deepEqual(guestBrowser.runtimeErrors, []);
} finally {
await guestBrowser.browser.close();
await guestFixture.close();
}
@@ -0,0 +1,348 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The radio page and the digital modes page both describe one rig: the one the
// operator selected. Every rig a client is connected to feeds the same decode
// stream, so both the mini views over the waterfall and the panels behind them
// filter on the rig that heard each decode. The map is the exception and keeps
// all of them — it has its own rig filter — which is why the histories these
// views read from are never pruned by rig.
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
class ElementFixture {
constructor(value = "") {
this.children = [];
this.innerHTML = "";
this.textContent = "";
this.value = value;
this.style = {};
this.dataset = {};
this.classList = { add() {}, remove() {}, toggle() {} };
}
appendChild(child) { this.children.push(child); return child; }
removeChild(child) { this.children.splice(this.children.indexOf(child), 1); }
replaceChildren(...nodes) { this.children = nodes.flatMap((node) => node.children ?? [node]); }
addEventListener() {}
setAttribute() {}
querySelector() { return null; }
querySelectorAll() { return []; }
get firstChild() { return this.children[0] ?? null; }
get lastElementChild() { return this.children.at(-1) ?? null; }
get scrollHeight() { return this.children.length; }
}
/** A document whose named elements exist and whose unknown ones do not. */
function createDocument(elements) {
return {
documentElement: {},
getElementById: (id) => elements.get(id) ?? null,
querySelector: () => null,
querySelectorAll: () => [],
createElement: () => new ElementFixture(),
createDocumentFragment: () => new ElementFixture(),
};
}
async function runPlugin(entry, { window, document: doc, extras = {} }) {
const context = vm.createContext({
window,
document: doc,
navigator: {},
requestAnimationFrame(callback) { callback(); return 1; },
getComputedStyle: () => ({ getPropertyValue: () => "" }),
Date, Number, String, Math, Array, Map, Set, Reflect, console,
...extras,
});
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL(`../src/plugins/${entry}.ts`, import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
return context;
}
test("the APRS mini view and panel both keep to the active rig", async () => {
const overlay = new ElementFixture();
const packets = new ElementFixture();
const totals = new ElementFixture();
const elements = new Map([
["aprs-bar-overlay", overlay],
["aprs-packets", packets],
["aprs-total-count", totals],
["mode", new ElementFixture("PKT")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
aprsMapAddStation: () => {},
};
await runPlugin("aprs", { window, document: createDocument(elements) });
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
assert.match(overlay.innerHTML, /SP1AAA/, "the active rig's frame is missing from the mini view");
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's frame reached the mini view");
assert.equal(packets.children.length, 1, "the APRS panel listed a background rig's frame");
assert.equal(totals.textContent, "1 total", `the APRS panel counted "${totals.textContent}"`);
});
test("the APRS mini view shows every frame until a rig is known", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["aprs-bar-overlay", overlay],
["mode", new ElementFixture("PKT")],
]);
const window = {
...createHost(),
trxUi: { confirm: async () => true },
aprsMapAddStation: () => {},
};
await runPlugin("aprs", { window, document: createDocument(elements) });
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB" });
assert.match(overlay.innerHTML, /SP1AAA/);
assert.match(overlay.innerHTML, /SP2BBB/);
});
test("the AIS mini view keeps to the active rig", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["ais-bar-overlay", overlay],
["mode", new ElementFixture("AIS")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
aisMapAddVessel: () => {},
};
await runPlugin("ais", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
assert.match(overlay.innerHTML, /NEARBY/);
assert.doesNotMatch(overlay.innerHTML, /ELSEWHERE/, "a background rig's vessel reached the mini view");
});
test("the VDES mini view keeps to the active rig", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["vdes-bar-overlay", overlay],
["mode", new ElementFixture("VDES")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
vdesMapAddPoint: () => {},
};
await runPlugin("vdes", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
assert.match(overlay.innerHTML, /SP1AAA/);
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's burst reached the mini view");
});
test("the CW mini view keeps to the active rig and does not braid two rigs into a line", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["cw-bar-overlay", overlay],
["cw-output", new ElementFixture()],
["mode", new ElementFixture("CW")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
addEventListener() {},
};
await runPlugin("cw", { window, document: createDocument(elements) });
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("cw", { text: "DX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
assert.match(overlay.innerHTML, /CQ SP1AAA/, "the active rig's line was broken up or lost");
assert.doesNotMatch(overlay.innerHTML, /DX/, "a background rig's characters reached the mini view");
});
test("the FT8 mini view keeps to the active rig", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["ft8-bar-overlay", overlay],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 7_074_000,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
setInterval() { return 1; },
};
await runPlugin("ft8", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
assert.match(overlay.innerHTML, /SP1AAA/);
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view");
});
// ── The digital modes panels ───────────────────────────────────────────────
// The same rule one page over: a panel lists the selected rig's decodes only.
test("the AIS panel lists the active rig's vessels only", async () => {
const messages = new ElementFixture();
const count = new ElementFixture();
const elements = new Map([
["ais-messages", messages],
["ais-vessel-count", count],
["mode", new ElementFixture("AIS")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
aisMapAddVessel: () => {},
};
await runPlugin("ais", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the AIS panel listed a background rig's vessel");
assert.equal(count.textContent, "1 vessel", `the panel counted "${count.textContent}"`);
});
test("the VDES panel lists the active rig's bursts only", async () => {
const messages = new ElementFixture();
const count = new ElementFixture();
const elements = new Map([
["vdes-messages", messages],
["vdes-frame-count", count],
["mode", new ElementFixture("VDES")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
vdesMapAddPoint: () => {},
};
await runPlugin("vdes", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the VDES panel listed a background rig's burst");
assert.equal(count.textContent, "1 burst", `the panel counted "${count.textContent}"`);
});
test("the FT8 panel lists the active rig's decodes only", async () => {
const messages = new ElementFixture();
const elements = new Map([
["ft8-messages", messages],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 7_074_000,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
setInterval() { return 1; },
};
await runPlugin("ft8", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the FT8 panel listed a background rig's decode");
});
test("the WSPR panel lists the active rig's spots only", async () => {
const messages = new ElementFixture();
const elements = new Map([
["wspr-messages", messages],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 14_095_600,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
};
await runPlugin("wspr", {
window,
document: createDocument(elements),
extras: { setInterval: () => 1 },
});
const now = Date.now();
window.trxPluginRuntime.dispatch("wspr", { message: "SP1AAA JO91 30", freq_hz: 1_500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("wspr", { message: "SP2BBB JO94 27", freq_hz: 1_520, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the WSPR panel listed a background rig's spot");
});
test("the CW pane copies the active rig only", async () => {
const output = new ElementFixture();
const elements = new Map([
["cw-output", output],
["cw-bar-overlay", new ElementFixture()],
["mode", new ElementFixture("CW")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
addEventListener() {},
};
await runPlugin("cw", { window, document: createDocument(elements) });
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("cw", { text: "XX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
const copied = output.children.map((line) => line.textContent).join("");
assert.equal(copied, "CQ SP1AAA", `the pane copied "${copied}"`);
});
test("switching rigs repaints the panels through the runtime", async () => {
const packets = new ElementFixture();
const elements = new Map([
["aprs-packets", packets],
["aprs-bar-overlay", new ElementFixture()],
["mode", new ElementFixture("PKT")],
]);
// A mutable host: the rig picker writes lastActiveRigId, and the panels read
// it as they paint.
const host = createHost({ state: { lastActiveRigId: "rig-a" } });
let activeRigId = "rig-a";
Object.defineProperty(host.trx.state, "lastActiveRigId", { get: () => activeRigId });
const window = { ...host, trxUi: { confirm: async () => true }, aprsMapAddStation: () => {} };
await runPlugin("aprs", { window, document: createDocument(elements) });
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
assert.equal(packets.children.length, 1);
activeRigId = "rig-b";
window.trxPluginRuntime.rerenderAll();
assert.equal(packets.children.length, 1, "the panel did not follow the switch");
const shown = packets.children.map((row) => row.innerHTML).join("");
assert.match(shown, /SP2BBB/, "the panel still shows the rig that was switched away from");
assert.doesNotMatch(shown, /SP1AAA/);
});
@@ -0,0 +1,61 @@
// 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 { bundleEntry } from "./bundle-entry.mjs";
const source = await bundleEntry(new URL("../src/api/auth.ts", import.meta.url), "AuthApi");
function loadAuth(fetch) {
const context = vm.createContext({ fetch, console });
new vm.Script(source).runInContext(context);
return context.AuthApi;
}
test("role policy centralizes Guest and separates Control from Transmit", () => {
const auth = loadAuth(async () => { throw new Error("unused"); });
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "transmit", "write", "administrator"]);
assert.equal(auth.hasAuthRole(["guest"], "read"), true);
assert.equal(auth.hasAccountControls(["guest"]), false);
assert.equal(auth.hasAccountControls(["read"]), true);
assert.equal(auth.hasAuthRole(["control"], "read"), true);
assert.equal(auth.hasAuthRole(["transmit"], "read"), true);
assert.equal(auth.hasAuthRole(["control"], "transmit"), false);
assert.equal(auth.hasAuthRole(["control"], "write"), false);
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
});
test("auth responses normalize roles and require the managed-account lifecycle state", async () => {
const replies = new Map([
["/auth/session", { authenticated: true, roles: ["write", "read", "write"], username: "alice" }],
["/auth/users", [{ username: "alice", roles: ["write", "read"], enabled: false }]],
]);
const auth = loadAuth(async (url) => ({
ok: true,
status: 200,
json: async () => replies.get(String(url)),
}));
assert.deepEqual(Array.from((await auth.fetchAuthSession()).roles), ["read", "write"]);
assert.deepEqual(Array.from((await auth.listUsers())[0].roles), ["read", "write"]);
assert.equal((await auth.listUsers())[0].enabled, false);
});
test("changing a password sends current and replacement credentials", async () => {
let request;
const auth = loadAuth(async (url, init) => {
request = { url, init };
return { ok: true, status: 200, json: async () => ({}) };
});
await auth.changeOwnPassword("old-password", "new-password");
assert.equal(request.url, "/auth/account/password");
assert.equal(request.init.method, "PATCH");
assert.deepEqual(JSON.parse(request.init.body), {
current_password: "old-password",
new_password: "new-password",
});
});
@@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The background decode panel: one list that both picks the bookmarks and says
// what each one is doing. It used to be two lists of the same bookmarks — one
// to choose from, one to read state off — and it filled neither until the
// operator switched rigs, because it took whatever rig it was handed at load
// and that was nothing yet.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document */
const BOOKMARKS = [
{ id: "bm-ft8-20", name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoders: ["ft8"] },
{ id: "bm-ft8-40", name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoders: ["ft8"] },
{ id: "bm-ft4-20", name: "FT4 20 m", freq_hz: 14_080_000, mode: "USB", decoders: ["ft4"] },
{ id: "bm-aprs", name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoders: ["aprs"] },
// Nothing that runs in the background can decode CW, so it must not be offered.
{ id: "bm-cw", name: "CW practice", freq_hz: 7_030_000, mode: "CW", decoders: ["cw"] },
];
const backgroundDecode = {
config: { remote: "rig-a", enabled: true, bookmark_ids: ["bm-ft8-20", "bm-ft8-40", "bm-aprs"] },
status: {
active_rig: true,
center_hz: 14_100_000,
sample_rate: 2_400_000,
entries: [
{ bookmark_id: "bm-ft8-20", bookmark_name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "active" },
{ bookmark_id: "bm-ft8-40", bookmark_name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "out_of_span" },
{ bookmark_id: "bm-aprs", bookmark_name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoder_kinds: ["aprs"], state: "handled_by_scheduler" },
],
},
};
const readRows = () => page.evaluate(() => {
const rows = [...document.querySelectorAll("#bgd-bookmark-checklist .bgd-checklist-row")];
return rows.map((row) => ({
name: row.querySelector(".bgd-checklist-name")?.textContent ?? "",
checked: row.querySelector("input[type=checkbox]")?.checked ?? false,
state: row.querySelector(".bgd-state")?.dataset.state ?? null,
stateText: row.querySelector(".bgd-state")?.textContent?.trim() ?? "",
}));
});
const openPanel = async () => {
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-settings").waitFor({ state: "visible" });
await page.waitForTimeout(2000);
await page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
await page.waitForTimeout(1200);
};
const fixture = await startWebFixture({ spectrum: true, bookmarks: BOOKMARKS, backgroundDecode });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.setViewportSize({ width: 1400, height: 950 });
await openPanel();
// Nothing was switched, clicked or reloaded: the panel asked the host which
// rig it is on and filled itself.
const rows = await readRows();
assert.deepEqual(rows.map((row) => row.name), ["FT8 20 m", "FT8 40 m", "FT4 20 m", "APRS 2 m"],
"the list does not hold the bookmarks a background channel can decode");
assert.deepEqual(rows.map((row) => row.checked), [true, true, false, true],
"the saved selection is not reflected in the list");
// Each row carries its own state, so there is no second list to consult.
assert.deepEqual(rows.map((row) => row.state),
["active", "out_of_span", "unselected", "handled_by_scheduler"],
"the rows do not carry the state of the bookmark they name");
assert.match(rows[0].stateText, /Decoding/);
assert.match(rows[1].stateText, /Out of span/);
// What the rig can hear, and what the selection adds up to.
const context = await page.evaluate(() => ({
span: document.getElementById("bgd-span-summary")?.textContent?.trim(),
summary: document.getElementById("bgd-selection-summary")?.textContent?.trim(),
}));
assert.match(context.span, /14\.1 MHz/, `the span reads "${context.span}"`);
assert.match(context.summary, /3 bookmarks selected, 1 decoding now\./, `the summary reads "${context.summary}"`);
// Save has nothing to do until something changes, and says so again once it
// has been done.
const saveState = () => page.evaluate(() => {
const btn = document.getElementById("background-decode-save-btn");
return { disabled: btn.disabled, dirty: btn.classList.contains("sch-dirty") };
});
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save offers itself with nothing to save");
await page.locator('#bgd-bookmark-checklist .bgd-checklist-row:nth-child(3) input').click();
await page.waitForTimeout(300);
assert.deepEqual(await saveState(), { disabled: false, dirty: true }, "Save stayed inert after a change");
await page.locator("#background-decode-save-btn").click();
await page.waitForTimeout(1000);
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save stayed live after saving");
// The selection reached the server and comes back on the next load.
await openPanel();
const saved = await readRows();
assert.deepEqual(saved.map((row) => row.checked), [true, true, true, true],
"the newly selected bookmark did not survive a reload");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
// With no bookmarks at all the panel says what to do about it rather than
// showing an empty box.
const emptyFixture = await startWebFixture({ spectrum: true, bookmarks: [] });
const empty = await startBrowser(chromium);
try {
await empty.page.setViewportSize({ width: 1400, height: 950 });
await empty.page.goto(`${emptyFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await empty.page.locator("#tab-settings").waitFor({ state: "visible" });
await empty.page.waitForTimeout(2000);
await empty.page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
await empty.page.waitForTimeout(1000);
const text = await empty.page.evaluate(() =>
document.getElementById("bgd-bookmark-checklist")?.textContent?.trim() ?? "");
assert.match(text, /No bookmarks yet/, `the empty list reads "${text}"`);
const summary = await empty.page.evaluate(() =>
document.getElementById("bgd-selection-summary")?.textContent?.trim() ?? "");
assert.match(summary, /Nothing selected/, `the summary reads "${summary}"`);
} finally {
await empty.browser.close();
await emptyFixture.close();
}
console.log("background decode panel tests passed");
@@ -40,7 +40,7 @@ test("background decode loads configuration for the explicitly selected rig", as
const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
new vm.Script(source).runInContext(context);
window.trx.modules.backgroundDecode.initialize("rig/a", "control");
window.trx.modules.backgroundDecode.initialize("rig/a", ["administrator"]);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(requested.includes("/background-decode/rig%2Fa"));
assert.ok(requested.includes("/bookmarks"));
@@ -32,7 +32,7 @@ function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = {
authEnabled: false,
authRole: "control",
authRoles: ["read", "control", "transmit", "write", "administrator"],
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
@@ -157,7 +157,7 @@ test("bookmark controls follow the host authentication state", async () => {
if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id);
};
const { window } = hostFixture({ authEnabled: true, authRole: "rx" });
const { window } = hostFixture({ authEnabled: true, authRoles: ["read"] });
const context = vm.createContext({
window,
document: documentFixture(element),
@@ -171,7 +171,7 @@ test("bookmark controls follow the host authentication state", async () => {
assert.equal(element("bm-add-btn").style.display, "none");
window.trx.state.authRole = "control";
window.trx.state.authRoles = ["read", "write"];
await window.trx.modules.bookmarks.fetch("");
assert.equal(element("bm-add-btn").style.display, "");
});
@@ -22,9 +22,25 @@ const BEACON = {
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
// A second rig listening in the background, on its own band. Both pages
// describe the rig on screen, so its traffic belongs in neither the panel nor
// the mini view — only on the map, which shows the whole station.
// HF APRS travels a different band and a different path from the VHF list, so
// it goes on the map under a source of its own that the filter can hide.
const HF_BEACON = {
type: "hf_aprs", src_call: "SP5HF-7", dest_call: "APRS", path: "WIDE2-2", info: "HF beacon",
packet_type: "position", crc_ok: true, lat: 50.06, lon: 19.94,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
const OTHER_RIG_VESSEL = {
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
lat: 51.92, lon: 4.48, rig_id: "rig-b",
};
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" });
const fixture = await startWebFixture({
spectrum: true, decodes: [VESSEL, BEACON, HF_BEACON, OTHER_RIG_VESSEL], mode: "AIS",
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
@@ -41,11 +57,14 @@ try {
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false,
aisRows: document.getElementById("ais-messages")?.children.length ?? 0,
mapLoaded: !!window.trx.modules.map,
}));
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
assert.equal(panels.otherRig, false, "the AIS panel listed a background rig's vessel");
// The mini view rides over the waterfall on the radio page.
await page.locator('.tab[data-tab="main"]').click();
@@ -56,11 +75,13 @@ try {
shown: getComputedStyle(bar).display !== "none",
pins: bar.querySelectorAll(".aprs-bar-pin").length,
names: bar.textContent.includes("NEDERLAND"),
otherRig: bar.textContent.includes("ELDERBERRY"),
};
});
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
assert.equal(miniView.names, true, "the mini view does not name the vessel");
assert.equal(miniView.otherRig, false, "the mini view shows a background rig's vessel");
// Following the pin: the map opens, on the vessel. This is the path that was
// broken for every decoder — the module that owned the navigation had not
@@ -89,8 +110,41 @@ try {
: Object.keys(collection ?? {}).length);
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
});
assert.ok(markers.ais > 0, "the vessel never reached the map");
assert.ok(markers.stations > 0, "the APRS station never reached the map");
// Both APRS stations are on it, each under its own source: the HF one used
// to be dropped entirely, since the HF list never plotted anything.
const aprsSources = await page.evaluate(() => {
const entries = [...(window.trx.modules.map?.stationMarkers ?? new Map()).entries()];
return entries.map(([key, entry]) => `${entry?.type ?? "?"}:${entry?.call ?? key}`).sort();
});
assert.deepEqual(aprsSources, ["aprs:SP2SJG-9", "hf_aprs:SP5HF-7"],
`the map holds ${JSON.stringify(aprsSources)}`);
// And the filter offers HF APRS on a chip of its own, next to the VHF one.
await page.locator('#map-locator-phase .map-locator-phase-btn[data-phase="type"]').click();
await page.waitForTimeout(400);
const chips = await page.evaluate(() => [...document.querySelectorAll("#map-locator-choice-filter .map-locator-chip")]
.map((chip) => chip.dataset.filterKey));
assert.ok(chips.includes("aprs") && chips.includes("hf_aprs"),
`the Show chips are ${JSON.stringify(chips)}`);
// Turning that chip off takes the HF station off the map and leaves the VHF
// one on: the two are filtered apart, which is the whole point of the split.
await page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="hf_aprs"]').click();
await page.waitForTimeout(500);
const shownAfterFilter = await page.evaluate(() => {
const map = window.trx.modules.map;
const onMap = [];
(map?.stationMarkers ?? new Map()).forEach((entry, key) => {
if (entry?.marker && map.aprsMap?.hasLayer(entry.marker)) onMap.push(entry?.call ?? key);
});
return onMap.sort();
});
assert.deepEqual(shownAfterFilter, ["SP2SJG-9"],
`hiding HF APRS left ${JSON.stringify(shownAfterFilter)} on the map`);
// The map is the whole station's view — the one place a background rig's
// traffic belongs — so both vessels are on it even though the panel and the
// mini view show only the selected rig's.
assert.equal(markers.ais, 2, `${markers.ais} of 2 vessels reached the map`);
assert.deepEqual(runtimeErrors, []);
} finally {
@@ -103,6 +157,8 @@ try {
// left the client on its retry path and the history path untested.
const HISTORY_AIS = 900;
const HISTORY_APRS = 300;
const HISTORY_FT8 = 12;
const HISTORY_WSPR = 8;
const historyFixture = await startWebFixture({
spectrum: true,
mode: "AIS",
@@ -112,6 +168,14 @@ const historyFixture = await startWebFixture({
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({
message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2,
freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({
message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5,
freq_hz: 1500 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
info: `history ${index}`, packet_type: "position", crc_ok: true,
@@ -199,6 +263,27 @@ try {
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
// The Statistics page counts the same history. Its decode log lives in the
// map module, which is lazy, so every decode that arrived before the module
// did used to be recorded into nothing at all — the page opened empty and
// only filled in from decodes heard afterwards, and it took a reload landing
// on the tab (module loaded at startup, before the history) to show the lot.
await replay.page.evaluate(() => window.navigateToTab("statistics"));
await replay.page.waitForTimeout(1500);
// The counters are written with toLocaleString(), so a four-figure count
// arrives as "1,220" — whichever separator the runner's locale picks. Read
// the digits rather than the formatting.
const counted = await replay.page.evaluate(() => {
const count = (id) => Number((document.getElementById(id)?.textContent ?? "").replace(/\D/g, ""));
return { decodes: count("stats-total-decodes"), grids: count("stats-unique-grids") };
});
assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR,
`the statistics counted ${counted.decodes} decodes`);
// Grid squares come from the FT8 and WSPR spots, which had no map replay of
// their own: the locators of everything heard before the map loaded were lost.
assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR,
`the statistics counted ${counted.grids} grid squares`);
assert.deepEqual(replay.runtimeErrors, []);
} finally {
await replay.browser.close();
@@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
serverLat: null,
serverLon: null,
authEnabled: false,
authRole: "control",
authRoles: ["read", "control", "transmit", "write", "administrator"],
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
@@ -0,0 +1,225 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The station log: the panel opens an entry pre-filled from the radio and the
// server's clock, writes a contact, keeps it, and says whether the station has
// been worked before. And the ham layout, which is offered only where a rig can
// transmit -- a log is of contacts made.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, window, location */
const HELD = [
{
id: "q1", started_at: "2026-08-06T14:22:00Z", call: "DL3ABC", freq_hz: 14_074_000,
band: "20m", mode: "FT8", rst_sent: "-07", rst_rcvd: "-12", gridsquare: "JO31",
my_rig: "Primary fixture",
},
{
id: "q2", started_at: "2026-08-06T13:05:00Z", call: "SP9XYZ", freq_hz: 7_120_000,
band: "40m", mode: "SSB", submode: "LSB", rst_sent: "59", rst_rcvd: "57",
gridsquare: "JO90", my_rig: "Primary fixture",
},
];
const fixture = await startWebFixture({ spectrum: true, tx: true, logbook: [...HELD] });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
const readPanel = () => page.evaluate(() => ({
rows: [...(document.getElementById("log-rows")?.children ?? [])].map((row) => ({
call: row.querySelector(".log-cell-call")?.textContent ?? "",
cells: [...row.querySelectorAll("td")].map((cell) => cell.textContent),
})),
summary: document.getElementById("log-summary")?.textContent ?? "",
freq: document.getElementById("log-freq")?.value ?? "",
mode: document.getElementById("log-mode")?.value ?? "",
call: document.getElementById("log-call")?.value ?? "",
grid: document.getElementById("log-grid")?.value ?? "",
rig: document.getElementById("log-station-rig-name")?.textContent ?? "",
clock: document.getElementById("log-clock")?.textContent ?? "",
worked: document.getElementById("log-worked-before")?.textContent ?? "",
}));
try {
await page.setViewportSize({ width: 1400, height: 950 });
await page.goto(`${fixture.origin}/logbook`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-logbook").waitFor({ state: "visible" });
await page.waitForTimeout(2500);
// The log is there, newest first, with the band and mode of each contact.
const opened = await readPanel();
assert.deepEqual(opened.rows.map((row) => row.call), ["DL3ABC", "SP9XYZ"]);
assert.equal(opened.summary, "2 contacts");
// A phone contact shows the sideband it was worked on, not just "SSB".
assert.ok(opened.rows[1].cells.includes("LSB"), `the row reads ${JSON.stringify(opened.rows[1].cells)}`);
// The entry opened pre-filled: frequency and mode from the rig, the rig's
// name, and the time from the server.
assert.equal(opened.freq, "100", `the frequency pre-filled as "${opened.freq}"`);
assert.equal(opened.mode, "USB", `the mode pre-filled as "${opened.mode}"`);
assert.equal(opened.rig, "Primary fixture");
assert.match(opened.clock, /^\d{2}:\d{2}:\d{2}Z$/, `the clock reads "${opened.clock}"`);
// ...and nothing else was: a report is the operator's to give.
assert.equal(opened.call, "");
assert.equal(await page.evaluate(() => document.getElementById("log-rst-sent")?.value), "");
// Worked-before answers as the callsign is typed.
await page.locator("#log-call").fill("DL3ABC");
await page.waitForTimeout(600);
assert.match((await readPanel()).worked, /Worked before: 20m FT8/);
await page.locator("#log-call").fill("OZ1NEW");
await page.waitForTimeout(600);
assert.match((await readPanel()).worked, /Not worked before/);
// Logging writes the contact and clears the entry for the next one.
await page.locator("#log-rst-sent").fill("59");
await page.locator("#log-rst-rcvd").fill("57");
await page.locator("#log-save-btn").click();
await page.waitForTimeout(900);
const afterLogging = await readPanel();
assert.deepEqual(afterLogging.rows.map((row) => row.call), ["OZ1NEW", "DL3ABC", "SP9XYZ"]);
assert.equal(afterLogging.summary, "3 contacts");
assert.equal(afterLogging.call, "", "the entry kept the callsign after logging it");
assert.equal(afterLogging.freq, "100", "the entry did not re-open pre-filled");
// A decode row starts an entry rather than logging one: what was heard goes
// into the form, and the operator finishes it.
await page.evaluate(() => { window.navigateToTab("digital-modes"); });
await page.waitForTimeout(800);
await page.locator('#tab-digital-modes .sub-tab[data-subtab="ft8"]').click();
await page.waitForTimeout(400);
await page.evaluate(() => {
window.trxPluginRuntime.dispatch("ft8", {
message: "CQ SP2SJG JO94", snr_db: -7, dt_s: 0.2, freq_hz: 1200,
ts_ms: Date.now(), rig_id: "rig-a",
});
});
await page.waitForTimeout(600);
await page.locator("#ft8-messages .ft8-log-btn").first().click();
await page.waitForTimeout(900);
const fromDecode = await page.evaluate(() => ({
path: location.pathname,
call: document.getElementById("log-call")?.value ?? "",
grid: document.getElementById("log-grid")?.value ?? "",
mode: document.getElementById("log-mode")?.value ?? "",
rows: document.getElementById("log-rows")?.children.length ?? 0,
}));
assert.equal(fromDecode.path, "/logbook", `the Log button landed on ${fromDecode.path}`);
assert.equal(fromDecode.call, "SP2SJG", "the heard callsign did not reach the entry");
assert.equal(fromDecode.grid, "JO94", "the heard locator did not reach the entry");
// The decoder answers for the mode, because a rig in DIG cannot.
assert.equal(fromDecode.mode, "FT8", `the mode came through as "${fromDecode.mode}"`);
assert.equal(fromDecode.rows, 3, "a decode logged itself instead of opening an entry");
// The ham layout is on offer, because this rig can transmit.
const layouts = await page.evaluate(() =>
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
assert.ok(layouts.includes("ham"), `the layouts are ${JSON.stringify(layouts)}`);
// Choosing it the way the picker does. The <select> itself measures zero
// wide in a headless window, so Playwright will not click it; the change
// event is what the picker is wired to either way.
await page.evaluate(() => { window.navigateToTab("main"); });
await page.waitForTimeout(600);
await page.evaluate(() => {
const select = document.getElementById("operator-layout-select");
select.value = "ham";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
await page.waitForTimeout(900);
const inHamLayout = await page.evaluate(() => ({
layout: document.body.dataset.operatorLayout,
path: location.pathname,
}));
assert.equal(inHamLayout.layout, "ham");
assert.equal(inHamLayout.path, "/logbook", "the ham layout opened somewhere else");
// ── Contest working ─────────────────────────────────────────────────────
// The exchange belongs to the session: the contest and the serial sent stay
// between contacts, and the serial counts on by itself.
await page.evaluate(() => { window.navigateToTab("logbook"); });
await page.waitForTimeout(600);
await page.locator("#log-contest-block > summary").click();
await page.locator("#log-contest-id").fill("CQ-WW-SSB");
await page.locator("#log-stx").fill("1");
await page.locator("#log-call").fill("DL9CON");
await page.locator("#log-srx").fill("014");
await page.locator("#log-rst-sent").fill("59");
await page.locator("#log-rst-rcvd").fill("59");
await page.locator("#log-save-btn").click();
await page.waitForTimeout(900);
const afterContest = await page.evaluate(() => ({
contest: document.getElementById("log-contest-id")?.value ?? "",
stx: document.getElementById("log-stx")?.value ?? "",
srx: document.getElementById("log-srx")?.value ?? "",
call: document.getElementById("log-call")?.value ?? "",
}));
assert.equal(afterContest.contest, "CQ-WW-SSB", "the contest was cleared with the contact");
assert.equal(afterContest.stx, "2", `the serial sent went to "${afterContest.stx}"`);
assert.equal(afterContest.srx, "", "the received exchange was kept for the next station");
assert.equal(afterContest.call, "");
// The Cabrillo link carries the header, and names the contest so that only
// its contacts are entered.
await page.locator("#log-contest-export-block > summary").click();
await page.locator("#log-cbr-contest").fill("CQ-WW-SSB");
await page.locator("#log-cbr-score").fill("4242");
await page.waitForTimeout(300);
const cabrillo = await page.evaluate(() =>
document.getElementById("log-cbr-export")?.getAttribute("href") ?? "");
assert.match(cabrillo, /contest=CQ-WW-SSB/, `the Cabrillo link reads "${cabrillo}"`);
assert.match(cabrillo, /claimed_score=4242/, `the Cabrillo link reads "${cabrillo}"`);
const entry = await page.evaluate(async (href) => {
const response = await fetch(href);
return await response.text();
}, cabrillo);
assert.match(entry, /^START-OF-LOG: 3\.0/, entry);
assert.match(entry, /DL9CON/, "the contest contact is not in the entry");
assert.ok(!entry.includes("OZ1NEW"), "a contact outside the contest was entered");
// ── Confirmations ───────────────────────────────────────────────────────
// A card arrives: the contact is marked confirmed, and the per-band report
// counts it.
const confirmRow = page.locator("#log-rows tr", { hasText: "DL9CON" }).first();
await confirmRow.getByRole("button", { name: "Confirm" }).click();
await page.waitForTimeout(800);
await page.locator("#log-statistics-block > summary").click();
await page.waitForTimeout(400);
const confirmed = await page.evaluate(() => ({
marks: [...document.querySelectorAll("#log-rows tr")]
.map((row) => [...row.querySelectorAll("td")].at(-2)?.textContent ?? ""),
bands: [...document.querySelectorAll("#log-statistics-rows tr")]
.map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent)),
}));
assert.equal(confirmed.marks.filter((mark) => mark === "✓").length, 1,
`the QSL column reads ${JSON.stringify(confirmed.marks)}`);
const twenty = confirmed.bands.find((band) => band[0] === "20m");
assert.ok(twenty, `the bands are ${JSON.stringify(confirmed.bands)}`);
assert.equal(twenty[3], "1", `20m shows ${twenty[3]} confirmed`);
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
// Without a transmitter there is nothing to log, so the layout is not offered.
const listenerFixture = await startWebFixture({ spectrum: true, tx: false });
const listener = await startBrowser(chromium);
try {
await listener.page.setViewportSize({ width: 1400, height: 950 });
await listener.page.goto(`${listenerFixture.origin}/`, { waitUntil: "domcontentloaded" });
await listener.page.locator("#content").waitFor({ state: "visible" });
await listener.page.waitForTimeout(2000);
const layouts = await listener.page.evaluate(() =>
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
assert.ok(!layouts.includes("ham"), `a receiver was offered the ham layout: ${JSON.stringify(layouts)}`);
} finally {
await listener.browser.close();
await listenerFixture.close();
}
console.log("logbook tests passed");
@@ -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();
}
@@ -11,7 +11,7 @@ import { bundleEntry } from "./bundle-entry.mjs";
test("scheduler registers a typed module service without lifecycle globals", async () => {
// No role known yet: the entry registers its service and waits for the
// application to drive initialization.
const window = { ...createHost({ state: { authRole: null } }), trxUi: { confirm: async () => true } };
const window = { ...createHost({ state: { authEnabled: true, authRoles: [] } }), trxUi: { confirm: async () => true } };
const context = vm.createContext({
window,
document: {
@@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known
return elements.get(id);
};
const window = {
...createHost({ state: { authRole: "control", lastActiveRigId: "sdr" } }),
...createHost({ state: { authRoles: ["administrator"], lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true },
};
const context = vm.createContext({
@@ -9,6 +9,7 @@ import { readFile } from "node:fs/promises";
const indexPath = new URL("../../assets/web/index.html", import.meta.url);
const pluginLoaderPath = new URL("../src/plugin-loader.ts", import.meta.url);
const mapCorePath = new URL("../src/map-core.ts", import.meta.url);
const appPath = new URL("../src/app.ts", import.meta.url);
test("index loads one first-party application bootstrap", async () => {
const html = await readFile(indexPath, "utf8");
@@ -30,6 +31,30 @@ test("startup has no remote script or stylesheet dependencies", async () => {
);
});
test("administrator user management is a dedicated Settings sub-tab", async () => {
const [html, app] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(appPath, "utf8"),
]);
assert.match(html, /data-subtab="settings-users"/);
assert.match(html, /id="subtab-settings-users" class="sub-tab-panel"/);
assert.match(app, /authEnabled && hasAuthRole\("administrator"\)/);
assert.match(app, /settings-users-tab/);
});
test("account lifecycle controls include self-service passwords and enable state", async () => {
const [html, app] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(appPath, "utf8"),
]);
assert.match(html, /data-subtab="settings-account"/);
assert.match(html, /id="account-password-form"/);
assert.match(html, /id="user-create-enabled"/);
assert.match(app, /changeOwnPassword/);
assert.match(app, /hasAccountControls\(authRoles\)/);
assert.match(app, /enabledAdminCount/);
});
test("lazy frontend features use modules and local map symbols", async () => {
const [loader, map] = await Promise.all([
readFile(pluginLoaderPath, "utf8"),
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
async function controlState(page) {
return {
frequency: await page.locator("#freq").isDisabled(),
ptt: await page.locator("#ptt-btn").isDisabled(),
txAudio: await page.locator("#tx-audio-btn").isDisabled(),
txLimit: await page.locator("#tx-limit-btn").isDisabled(),
};
}
async function inspectRole(roles) {
const fixture = await startWebFixture({
tx: true,
authSession: {
authenticated: true,
username: "operator",
roles,
auth_disabled: false,
},
});
const session = await startBrowser(chromium);
try {
await session.page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
await session.page.locator("#content").waitFor({ state: "visible" });
await session.page.waitForTimeout(1200);
assert.deepEqual(session.runtimeErrors, []);
return await controlState(session.page);
} finally {
await session.browser.close();
await fixture.close();
}
}
assert.deepEqual(await inspectRole(["control"]), {
frequency: false,
ptt: true,
txAudio: true,
txLimit: true,
});
assert.deepEqual(await inspectRole(["transmit"]), {
frequency: true,
ptt: false,
txAudio: false,
txLimit: false,
});
@@ -27,6 +27,21 @@ const dial = () => page.evaluate(() => ({
path: window.location.pathname,
}));
// Typed, not filled. The app holds back its own refreshes of the frequency
// field from the first keystroke until Enter, so that a state update arriving
// mid-edit does not rewrite what is being typed. `fill()` sets the value
// without a keystroke, leaving the field unguarded: on a slow machine a state
// update could land between the fill and the Enter and put the old frequency
// back, and the Enter would then re-apply the frequency the radio was already
// on. Selecting first arms the guard before a single character changes.
async function tuneByHand(text) {
const field = page.locator("#freq");
await field.click();
await field.press("ControlOrMeta+a");
await field.pressSequentially(text);
await field.press("Enter");
}
try {
await page.setViewportSize({ width: 1500, height: 950 });
@@ -49,8 +64,7 @@ try {
// Tuning by hand rewrites the link. This is the part that makes the address
// bar shareable at any moment rather than only at load.
await page.locator("#freq").fill("7.040M");
await page.locator("#freq").press("Enter");
await tuneByHand("7.040M");
await page.waitForTimeout(1500);
const tuned = await dial();
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
@@ -59,8 +73,7 @@ try {
// Tuning is not navigation: a swept dial must not bury the back button.
const historyLength = await page.evaluate(() => window.history.length);
await page.locator("#freq").fill("7.100M");
await page.locator("#freq").press("Enter");
await tuneByHand("7.100M");
await page.waitForTimeout(1200);
assert.equal(await page.evaluate(() => window.history.length), historyLength,
"tuning pushed a history entry instead of replacing one");
@@ -34,7 +34,14 @@ const DECODER_REGISTRY = [
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true }));
].map((decoder) => ({
...decoder,
// Which decoders a background channel can run, as the server's own registry
// has it: the background decode panel offers a bookmark only if one of these
// can decode it, so marking them all false left that panel with nothing.
background_decode: ["ft8", "ft4", "ft2", "wspr", "ais", "aprs", "hf-aprs"].includes(decoder.id),
bookmark_selectable: true,
}));
const CONTENT_TYPES = new Map([
[".css", "text/css; charset=utf-8"],
@@ -132,9 +139,14 @@ export async function startWebFixture({
mode = "FM",
history = {},
bookmarks = [],
backgroundDecode = null,
logbook = [],
bandplan = {},
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
satPasses = null,
authSession = { authenticated: true, roles: ["read", "control", "transmit", "write", "administrator"], auth_disabled: true },
users = [],
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
@@ -222,7 +234,8 @@ export async function startWebFixture({
};
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/auth/session", authSession],
["/auth/users", users],
["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse],
["/status", status],
@@ -230,6 +243,7 @@ export async function startWebFixture({
["/bandplan.json", bandplan],
["/api/recorder/status", []],
["/api/recorder/files", []],
["/sat_passes", satPasses ?? { satellite_count: 0, passes: [] }],
]);
// Flat i8 bins: the shape does not matter, only that frames arrive so the
@@ -269,6 +283,158 @@ export async function startWebFixture({
response.writeHead(200).end();
return;
}
// The logbook: an in-memory station log, enough for the panel to open an
// entry, write it, read it back and export it.
if (url.pathname.startsWith("/api/logbook")) {
const tail = url.pathname.slice("/api/logbook".length);
if (tail === "/prefill") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
started_at: new Date().toISOString(),
epoch_ms: Date.now(),
freq_hz: status.status.freq.hz,
band: "20m",
mode: url.searchParams.get("decoder") === "ft8" ? "FT8" : "SSB",
submode: url.searchParams.get("decoder") === "ft8" ? null : "USB",
rig_id: url.searchParams.get("remote") ?? "rig-a",
my_rig: "Primary fixture",
call: url.searchParams.get("call"),
gridsquare: url.searchParams.get("gridsquare"),
}));
return;
}
if (tail.startsWith("/worked/")) {
const call = decodeURIComponent(tail.slice("/worked/".length)).toUpperCase();
const worked = logbook
.filter((qso) => qso.call === call)
.map((qso) => ({ band: qso.band, mode: qso.mode }));
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ call, worked }));
return;
}
if (tail === "/statistics") {
const bands = new Map();
for (const qso of logbook) {
const entry = bands.get(qso.band) ?? { band: qso.band, contacts: 0, stations: 0, confirmed: 0, calls: new Set() };
entry.contacts += 1;
entry.calls.add(qso.call);
if (qso.confirmed || qso.qsl_rcvd === "Y" || qso.lotw_qsl_rcvd === "Y") entry.confirmed += 1;
bands.set(qso.band, entry);
}
const list = [...bands.values()].map(({ calls, ...rest }) => ({ ...rest, stations: calls.size }));
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
contacts: logbook.length,
confirmed: list.reduce((total, band) => total + band.confirmed, 0),
bands: list,
}));
return;
}
if (tail === "/export.cbr") {
const contest = url.searchParams.get("contest");
const entered = contest ? logbook.filter((qso) => qso.contest_id === contest) : logbook;
response.writeHead(200, { "content-type": "text/plain" });
response.end(`START-OF-LOG: 3.0\nCONTEST: ${contest ?? ""}\n`
+ entered.map((qso) => `QSO: 14200 PH ${qso.call}\n`).join("")
+ "END-OF-LOG:\n");
return;
}
if (request.method === "PUT") {
const raw = await new Promise((resolve) => {
let text = "";
request.on("data", (chunk) => { text += chunk; });
request.on("end", () => resolve(text));
});
const input = JSON.parse(raw);
const id = tail.replace("/", "");
const held = logbook.find((qso) => qso.id === id);
if (held) {
Object.assign(held, input, { id, confirmed: input.qsl_rcvd === "Y" });
}
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(held ?? {}));
return;
}
if (tail === "/export.adi") {
const body = logbook
.map((qso) => `<CALL:${qso.call.length}>${qso.call}<EOR>\n`)
.join("");
response.writeHead(200, { "content-type": "text/plain" });
response.end(body);
return;
}
if (request.method === "POST" && tail === "") {
const raw = await new Promise((resolve) => {
let text = "";
request.on("data", (chunk) => { text += chunk; });
request.on("end", () => resolve(text));
});
const input = JSON.parse(raw);
const qso = {
id: `qso-${String(logbook.length + 1)}`,
started_at: input.started_at ?? new Date().toISOString(),
call: String(input.call).toUpperCase(),
freq_hz: input.freq_hz,
band: "20m",
mode: input.mode,
submode: input.submode,
rst_sent: input.rst_sent,
rst_rcvd: input.rst_rcvd,
gridsquare: input.gridsquare ? String(input.gridsquare).toUpperCase() : null,
my_rig: input.my_rig,
operator: input.operator,
contest_id: input.contest_id ?? null,
stx: input.stx ?? null,
srx: input.srx ?? null,
confirmed: input.qsl_rcvd === "Y" || input.lotw_qsl_rcvd === "Y",
};
logbook.unshift(qso);
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(qso));
return;
}
if (request.method === "DELETE") {
const id = tail.replace("/", "");
const index = logbook.findIndex((qso) => qso.id === id);
if (index >= 0) logbook.splice(index, 1);
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ deleted: index >= 0 }));
return;
}
const wanted = (url.searchParams.get("call") ?? "").toUpperCase();
const matching = wanted ? logbook.filter((qso) => qso.call.includes(wanted)) : logbook;
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ total: logbook.length, qsos: matching }));
return;
}
// Background decode: the panel reads its config, its status, and the
// bookmark list, and writes the config back.
if (url.pathname.startsWith("/background-decode/")) {
const isStatus = url.pathname.endsWith("/status");
if (isStatus) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(backgroundDecode?.status ?? { entries: [] }));
return;
}
if (request.method === "PUT") {
const body = await new Promise((resolve) => {
let raw = "";
request.on("data", (chunk) => { raw += chunk; });
request.on("end", () => resolve(raw));
});
try {
const parsed = JSON.parse(body);
if (backgroundDecode) backgroundDecode.config = parsed;
} catch { /* leave the config as it was */ }
}
if (request.method === "DELETE" && backgroundDecode) {
backgroundDecode.config = { remote: "rig-a", enabled: false, bookmark_ids: [] };
}
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(backgroundDecode?.config
?? { remote: "rig-a", enabled: false, bookmark_ids: [] }));
return;
}
if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote");
if (remote) {
@@ -10,7 +10,9 @@ use actix_web::{get, HttpRequest, HttpResponse, Responder};
use std::collections::HashMap;
use std::sync::OnceLock;
use super::{gz_cache_entry, static_asset_response, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES};
use super::{
gz_cache_entry, static_asset_response, AssetCaching, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES,
};
use crate::server::status;
// ---------------------------------------------------------------------------
@@ -62,49 +64,111 @@ define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css");
#[get("/")]
pub(crate) async fn index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/map")]
pub(crate) async fn map_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/digital-modes")]
pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/recorder")]
pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/settings")]
pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/about")]
pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/statistics")]
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/logbook")]
pub(crate) async fn logbook_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/satellites")]
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/bookmarks")]
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
static_asset_response(
&req,
"text/html; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
// ---------------------------------------------------------------------------
@@ -142,13 +206,13 @@ pub(crate) async fn logo() -> impl Responder {
#[get("/style.css")]
pub(crate) async fn style_css(req: HttpRequest) -> impl Responder {
let c = gz_style_css();
static_asset_response(&req, "text/css; charset=utf-8", c)
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
}
#[get("/themes.css")]
pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder {
let c = gz_themes_css();
static_asset_response(&req, "text/css; charset=utf-8", c)
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
}
// Generated filenames are supplied only by build.rs and resolved through this
@@ -172,7 +236,22 @@ pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path<String>) -
let Some(entry) = generated_asset_cache().get(filename.as_str()) else {
return HttpResponse::NotFound().finish();
};
static_asset_response(&req, content_type, entry)
static_asset_response(
&req,
content_type,
entry,
generated_asset_caching(&filename),
)
}
/// esbuild names shared chunks `chunk-<hash>.js` and leaves the entry points on
/// a fixed name, so only the chunks are safe to keep forever.
fn generated_asset_caching(filename: &str) -> AssetCaching {
if filename.starts_with("chunk-") {
AssetCaching::Immutable
} else {
AssetCaching::Revalidate
}
}
/// Serve a received SSTV picture out of the local cache.
@@ -215,7 +294,12 @@ fn cached_png(decoder: &str, filename: &str) -> HttpResponse {
#[get("/bandplan.json")]
pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder {
let c = gz_bandplan_json();
static_asset_response(&req, "application/json; charset=utf-8", c)
static_asset_response(
&req,
"application/json; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
// ---------------------------------------------------------------------------
@@ -233,7 +317,12 @@ pub(crate) async fn dseg14_classic_woff2() -> impl Responder {
#[get("/vendor/opus-decoder-0.7.11.min.js")]
pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
let c = gz_opus_decoder_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
static_asset_response(
&req,
"application/javascript; charset=utf-8",
c,
AssetCaching::Immutable,
)
}
// ---------------------------------------------------------------------------
@@ -243,13 +332,18 @@ pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
#[get("/vendor/leaflet.js")]
pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder {
let c = gz_leaflet_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
static_asset_response(
&req,
"application/javascript; charset=utf-8",
c,
AssetCaching::Revalidate,
)
}
#[get("/vendor/leaflet.css")]
pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder {
let c = gz_leaflet_css();
static_asset_response(&req, "text/css; charset=utf-8", c)
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
}
#[get("/vendor/marker-icon.png")]
@@ -349,6 +443,44 @@ mod tests {
assert!(!generated_asset_cache().contains_key("../app.js"));
}
/// A browser that visited before an upgrade must not keep the old page.
/// index.html, the stylesheets and the entry bundles all keep their URL
/// from one build to the next, so an immutable year-long policy on them
/// leaves a client running whatever it first downloaded — a fixed layout
/// stays broken in the browser that happened to cache it, and no reload
/// short of a forced one gets the fix.
#[test]
fn assets_that_keep_their_url_across_builds_are_revalidated() {
for name in ["app.js", "aprs.js", "map-core.js"] {
assert_eq!(
generated_asset_caching(name),
AssetCaching::Revalidate,
"{name} is served from the same URL after every build"
);
}
assert_eq!(
AssetCaching::Revalidate.header_value(),
"no-cache",
"revalidating assets must ask before they are reused"
);
}
/// Only the chunks carry a hash of their own bytes, so only they can be
/// kept forever.
#[test]
fn content_addressed_chunks_stay_immutable() {
let chunk = status::GENERATED_ASSETS
.iter()
.map(|(name, _)| *name)
.find(|name| name.starts_with("chunk-"))
.expect("the bundle splits into at least one shared chunk");
assert_eq!(generated_asset_caching(chunk), AssetCaching::Immutable);
assert!(
AssetCaching::Immutable.header_value().contains("immutable"),
"a hashed name is safe to keep"
);
}
#[test]
fn generated_asset_mime_types_are_restricted() {
assert_eq!(
@@ -9,7 +9,7 @@ use std::sync::Arc;
use actix_web::Error;
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse};
use super::{no_cache_response, request_accepts_html, require_control};
use super::{no_cache_response, request_accepts_html, require_write};
use crate::server::status;
// ============================================================================
@@ -37,6 +37,7 @@ pub struct BookmarkInput {
pub comment: Option<String>,
pub category: Option<String>,
pub decoders: Option<Vec<String>>,
pub dig_sideband: Option<String>,
}
/// A bookmark with its owning scope tag for the list response.
@@ -88,6 +89,17 @@ fn normalize_bookmark_locator(locator: Option<String>) -> Option<String> {
})
}
/// Normalize a DIG sideband override to `auto`/`usb`/`lsb`, or `None` when
/// absent or unrecognized (leaves the current policy untouched on apply).
fn normalize_dig_sideband(value: Option<String>) -> Option<String> {
value.and_then(|v| match v.trim().to_lowercase().as_str() {
"auto" => Some("auto".to_string()),
"usb" => Some("usb".to_string()),
"lsb" => Some("lsb".to_string()),
_ => None,
})
}
// ============================================================================
// Endpoints
// ============================================================================
@@ -165,7 +177,7 @@ pub async fn create_bookmark(
body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
if store.freq_taken(body.freq_hz, None) {
return Err(actix_web::error::ErrorConflict(
@@ -182,6 +194,7 @@ pub async fn create_bookmark(
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
dig_sideband: normalize_dig_sideband(body.dig_sideband.clone()),
};
if store.insert(&bm) {
Ok(HttpResponse::Created().json(bm))
@@ -201,7 +214,7 @@ pub async fn update_bookmark(
body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner();
if store.freq_taken(body.freq_hz, Some(&id)) {
@@ -219,6 +232,7 @@ pub async fn update_bookmark(
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
dig_sideband: normalize_dig_sideband(body.dig_sideband.clone()),
};
if store.upsert(&id, &bm) {
Ok(HttpResponse::Ok().json(bm))
@@ -235,7 +249,7 @@ pub async fn delete_bookmark(
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner();
if store.remove(&id) {
@@ -253,7 +267,7 @@ pub async fn batch_delete_bookmarks(
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let mut deleted = 0usize;
for id in &body.ids {
@@ -272,7 +286,7 @@ pub async fn batch_move_bookmarks(
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let from_store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref());
let mut moved = 0usize;

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