Compare commits

...
Author SHA1 Message Date
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
81 changed files with 8146 additions and 4089 deletions
+8 -1
View File
@@ -24,6 +24,12 @@ cargo test -p trx-core
./target/release/trx-server --print-config > trx-server.toml
./target/release/trx-client --print-config > trx-client.toml
# Validate a config without starting anything (reports every problem)
./target/release/trx-server --check-config --config trx-rs.toml
# Regenerate trx-rs.toml.example after changing a config struct
cargo run -p trx-config --example generate_example
# Run server
./target/release/trx-server --config trx-server.toml
# or via CLI args:
@@ -41,7 +47,8 @@ This is a Cargo workspace. All crates live under `src/`:
src/
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
trx-app/ # Shared application helpers (config paths, logging init)
trx-app/ # Shared application helpers (logging init, name normalization)
trx-config/ # Client + server config structs, loader, validators (~2,500 LOC)
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
trx-backend/ # Backend abstraction trait + factory + dummy
Generated
+32 -4
View File
@@ -2412,6 +2412,16 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.149"
@@ -3031,10 +3041,6 @@ dependencies = [
name = "trx-app"
version = "0.1.0"
dependencies = [
"dirs",
"serde",
"thiserror 2.0.18",
"toml",
"tracing",
"tracing-subscriber",
]
@@ -3115,6 +3121,7 @@ dependencies = [
"toml",
"tracing",
"trx-app",
"trx-config",
"trx-core",
"trx-frontend",
"trx-frontend-http",
@@ -3124,6 +3131,23 @@ dependencies = [
"uuid",
]
[[package]]
name = "trx-config"
version = "0.1.0"
dependencies = [
"dirs",
"serde",
"serde_ignored",
"tempfile",
"thiserror 2.0.18",
"toml",
"toml_edit 0.22.27",
"tracing",
"trx-core",
"trx-decode-log",
"trx-reporting",
]
[[package]]
name = "trx-configurator"
version = "0.1.0"
@@ -3132,13 +3156,16 @@ dependencies = [
"dialoguer",
"tempfile",
"tokio-serial",
"toml",
"toml_edit 0.22.27",
"trx-config",
]
[[package]]
name = "trx-core"
version = "0.1.0"
dependencies = [
"base64",
"flate2",
"reqwest",
"serde",
@@ -3294,6 +3321,7 @@ dependencies = [
"trx-app",
"trx-aprs",
"trx-backend",
"trx-config",
"trx-core",
"trx-cw",
"trx-decode-log",
+1
View File
@@ -18,6 +18,7 @@ members = [
"src/trx-core",
"src/trx-protocol",
"src/trx-app",
"src/trx-config",
"src/trx-reporting",
"src/trx-server",
"src/trx-server/trx-backend",
+10 -3
View File
@@ -93,13 +93,17 @@ The wizard walks you through rig selection, serial port detection, audio
settings, and frontend options, then writes `trx-server.toml` and
`trx-client.toml`.
Alternatively, generate example configs and edit them by hand:
Alternatively, copy `trx-rs.toml.example` — a commented example covering every
setting — and edit it by hand:
```bash
./target/release/trx-server --print-config > trx-server.toml
./target/release/trx-client --print-config > trx-client.toml
cp trx-rs.toml.example trx-rs.toml
./target/release/trx-server --check-config --config trx-rs.toml
```
`--check-config` reports everything wrong with a config without starting
anything. `--print-config` prints the same settings without comments.
### 4. Run
```bash
@@ -107,6 +111,9 @@ Alternatively, generate example configs and edit them by hand:
./target/release/trx-client --config trx-client.toml
```
A single `trx-rs.toml` can configure both: the server reads its `[trx-server]`
section and the client reads `[trx-client]`.
Open the configured HTTP frontend address in a browser (default `http://localhost:8080`).
## How It Works
+176 -22
View File
@@ -17,30 +17,61 @@ frontends.
## Configuration
Both `trx-server` and `trx-client` use TOML configuration files. Use
`--print-config` to generate a fully commented example.
Both `trx-server` and `trx-client` read TOML. The server takes its settings
from the `[trx-server]` section and the client from `[trx-client]`, so one
`trx-rs.toml` can configure both — or each may live in its own file with the
section header left off.
`trx-rs.toml.example` in the repository root is a complete, commented example
generated from the config definitions themselves. `--print-config` prints the
same settings without the comments.
### File Locations
**trx-server** lookup order:
1. `--config <FILE>`
2. `./trx-server.toml`
3. `~/.trx-server.toml`
4. `~/.config/trx-rs/server.toml`
5. `/etc/trx-rs/server.toml`
Both binaries use the same lookup order:
**trx-client** lookup order:
1. `--config <FILE>`
2. `./trx-client.toml`
3. `~/.config/trx-rs/client.toml`
4. `/etc/trx-rs/client.toml`
2. `./trx-rs.toml`
3. `~/.config/trx-rs/trx-rs.toml`
4. `/etc/trx-rs/trx-rs.toml`
CLI arguments override config file values.
### Environment Variables
### Checking a Config
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by
both server and client.
`--check-config` loads the file, reports every problem it finds — unknown keys,
invalid values, listeners fighting over a port — and exits without starting
anything:
```bash
trx-server --check-config --config trx-rs.toml
trx-client --check-config --config trx-rs.toml
```
Unknown keys are warnings by default, so a config written for a newer version
still runs on an older binary. `--strict-config` makes them fatal.
`trx-configurator --check <FILE>` runs the same checks.
### Environment Variables and Secrets
Any string in the config may reference an environment variable as `${VAR}`;
an unset variable is an error rather than an empty value.
Credentials can be kept out of the config entirely by pointing at a file
instead. Every secret has a `*_file` sibling — set one or the other, never
both:
| Inline key | File key | Contents |
|------------|----------|----------|
| `[listen.auth].tokens` | `tokens_file` | one token per line |
| `[[remotes]].auth.token` | `token_file` | the token |
| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase |
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
Blank lines and `#` comments are ignored in the list files. A config that holds
credentials inline and is readable by group or others is flagged at startup.
### Server Options
@@ -96,6 +127,7 @@ CLI arguments override config file values.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
| `tokens_file` | string | — | Read tokens from this file, one per line |
#### `[audio]`
@@ -121,6 +153,13 @@ When audio is enabled, at least one of `rx_enabled` or `tx_enabled` must be true
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
| `spectrum_fft_size` | usize | `1024` | Spectrum FFT bins; power of two, 1288192 |
| `spectrum_interval_ms` | u64 | `50` | How often a spectrum frame is pushed to subscribed clients |
Spectrum is the largest thing on the client connection. On a slow or
high-latency link, halving `spectrum_fft_size` halves the bytes per frame (at
half the frequency resolution) and raising `spectrum_interval_ms` sends fewer of
them; see [Spectrum over a slow link](#spectrum-over-a-slow-link).
#### `[sdr.gain]`
@@ -197,6 +236,29 @@ Notes:
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
`%MM%`, `%DD%` (UTC).
#### `[decoders]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | string[] | all decoders | Decoders to run for this rig |
| `output_dir` | string | `"$XDG_CACHE_HOME/trx-rs"` | Base directory for decoders that write images |
Valid decoder names: `aprs`, `aprs_hf`, `ais`, `cw`, `ft2`, `ft4`, `ft8`,
`lrpt`, `sstv`, `vdes`, `wefax`, `wspr` — the same names `[[sdr.channels]]`
uses. An unrecognised name is a config error.
Every decoder runs by default, which costs real CPU on a small machine. On a
station that only works digital modes, listing just what you use is worth it:
```toml
[decoders]
enabled = ["ft8", "ft4", "wspr"]
```
`sstv`, `wefax` and `lrpt` write images into a subdirectory of `output_dir`
named after the decoder. `ais` and `vdes` additionally require an SDR channel
configured to feed them.
#### Multi-Rig Configuration
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
@@ -240,12 +302,32 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|-------|------|---------|-------------|
| `url` | string | — | Server address (e.g. `localhost:4530`) |
| `poll_interval_ms` | u64 | `750` | State poll interval |
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
#### `[remote.auth]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `token` | string | — | Auth token (must not be empty if set) |
| `token_file` | string | — | Read the token from this file instead |
#### `[[remotes]]`
Preferred over the single `[remote]` section: one entry per rig, each mapping a
short name to a server and an optional server-side rig id.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `name` | string | — | Short name used everywhere in the client |
| `url` | string | — | Server address (`host:port`) |
| `rig_id` | string | — | Rig id on a multi-rig server |
| `auth.token` | string | — | Auth token |
| `auth.token_file` | string | — | Read the token from this file instead |
| `poll_interval_ms` | u64 | `750` | State poll interval |
The `name` is the key used by `default_rig_name`, `rigctl.rig_ports`,
`audio.rig_urls`, `audio.rig_ports` and `decode_history_retention_min_by_rig`.
A name in any of those maps that no remote answers to is a config error.
#### `[frontends.http]`
@@ -254,6 +336,31 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
| `enabled` | bool | `true` | Enable web UI |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `8080` | Bind port |
| `default_rig_name` | string | — | Remote selected on startup |
| `initial_map_zoom` | u8 | `10` | Starting zoom for the APRS map |
| `show_sdr_gain_control` | bool | `true` | Expose the RF gain control |
| `bandplan_enabled` | bool | `true` | Show the bandplan strip |
| `bandplan_region` | string | `"iaru_r1"` | `iaru_r1`, `iaru_r2`, or `iaru_r3` |
| `decode_history_retention_min` | u64 | `1440` | Decode history retention |
| `decode_history_retention_min_by_rig` | table | `{}` | Per-remote retention override |
| `spectrum_coverage_margin_hz` | u32 | `50000` | Centre-retune guard margin |
| `spectrum_usable_span_ratio` | f32 | `0.92` | Usable fraction of the sampled span |
#### `[frontends.http.auth]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Require a passphrase |
| `rx_passphrase` | string | — | Passphrase granting receive-only access |
| `rx_passphrase_file` | string | — | Read it from this file instead |
| `control_passphrase` | string | — | Passphrase granting full control |
| `control_passphrase_file` | string | — | Read it from this file instead |
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users |
| `session_ttl_min` | u64 | `480` | Session lifetime |
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
With `enabled = true`, at least one passphrase must be set.
#### `[frontends.rigctl]`
@@ -261,7 +368,11 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable Hamlib rigctl |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `4532` | Bind port |
| `rig_ports` | table | `{}` | Remote name → local port; one listener each |
One listener is started per `rig_ports` entry, each routing to its rig, so
`rig_ports` must name at least one remote when the frontend is enabled. The
older single `port` key and `--rigctl-port` are ignored.
#### `[frontends.http_json]`
@@ -271,13 +382,17 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
| `auth.tokens_file` | string | — | Read tokens from this file, one per line |
#### `[frontends.audio]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable audio client |
| `server_port` | u16 | `4531` | Server audio port |
| `server_url` | string | — | Audio endpoint for every remote |
| `rig_urls` | table | `{}` | Remote name → audio URL (wins over `server_url`) |
| `server_port` | u16 | `4531` | Fallback port when no URL is configured |
| `rig_ports` | table | `{}` | Remote name → port; superseded by `rig_urls` |
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
| `bridge.rx_output_device` | string | — | Local playback device |
| `bridge.tx_input_device` | string | — | Local capture device |
@@ -287,16 +402,55 @@ 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.
---
-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
+135 -8
View File
@@ -20,6 +20,7 @@ use tokio::task::JoinHandle;
use tracing::{error, info};
use trx_app::{init_logging, normalize_name};
use trx_config::ConfigFile;
use trx_core::audio::AudioStreamInfo;
use trx_core::decode::DecodedMessage;
@@ -50,6 +51,12 @@ struct Cli {
/// Print example configuration and exit
#[arg(long = "print-config")]
print_config: bool,
/// Treat unknown configuration keys as a fatal error
#[arg(long = "strict-config")]
strict_config: bool,
/// Validate the configuration and exit without starting anything
#[arg(long = "check-config")]
check_config: bool,
/// Remote server URL (host:port)
#[arg(short = 'u', long = "url")]
url: Option<String>,
@@ -74,7 +81,7 @@ struct Cli {
/// rigctl frontend listen address
#[arg(long = "rigctl-listen")]
rigctl_listen: Option<IpAddr>,
/// rigctl frontend listen port
/// Deprecated: ignored, use [frontends.rigctl].rig_ports
#[arg(long = "rigctl-port")]
rigctl_port: Option<u16>,
/// JSON TCP frontend listen address
@@ -109,6 +116,65 @@ async fn main() -> DynResult<()> {
Ok(())
}
/// `--check-config`: report everything wrong with the configuration and exit.
///
/// Unlike startup, this reports every problem it finds rather than stopping at
/// the first, so a config can be fixed in one pass. The file is checked as
/// written, without CLI overrides.
fn check_config(loaded: &trx_config::ConfigLoad<ClientConfig>) -> DynResult<()> {
match &loaded.path {
Some(path) => println!("{}", path.display()),
None => println!("(no config file found; checking built-in defaults)"),
}
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
warnings.extend(ClientConfig::deprecations(&loaded.present_keys));
let mut cfg = loaded.config.clone();
let mut errors = Vec::new();
if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) {
errors.push(e);
}
let cfg = &cfg;
let remotes = cfg.resolved_remotes();
if remotes.is_empty() {
warnings.push(
"no remotes configured; --url will be required at startup (add [[remotes]] entries)"
.to_string(),
);
}
errors.extend(cfg.validate_all());
if !remotes.is_empty() {
errors.extend(cfg.validate_resolved_all(&remotes));
}
for w in &warnings {
println!(" warning: {}", w);
}
for e in &errors {
println!(" error: {}", e);
}
if errors.is_empty() {
println!(
" OK: {} remote(s) configured: {}",
remotes.len(),
remotes
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if !warnings.is_empty() {
println!(" {} warning(s)", warnings.len());
}
Ok(())
} else {
Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
}
}
/// Holds the state needed after async initialization completes.
struct AppState {
shutdown_tx: watch::Sender<bool>,
@@ -135,20 +201,44 @@ async fn async_init() -> DynResult<AppState> {
std::process::exit(0);
}
let (cfg, config_path) = if let Some(ref path) = cli.config {
let cfg = ClientConfig::load_from_file(path)?;
(cfg, Some(path.clone()))
let loaded = if let Some(ref path) = cli.config {
ClientConfig::load_from_file(path)?
} else {
ClientConfig::load_from_default_paths()?
};
cfg.validate()
.map_err(|e| format!("Invalid client configuration: {}", e))?;
let config_path = loaded.path.clone();
init_logging(cfg.general.log_level.as_deref());
if cli.check_config {
match check_config(&loaded) {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
}
}
// Logging comes up before any config complaint so the warnings are visible.
init_logging(loaded.config.general.log_level.as_deref());
if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display());
}
loaded.report_unknown_keys(cli.strict_config)?;
loaded.report_deprecations();
if cli.rigctl_port.is_some() {
tracing::warn!(
"--rigctl-port is ignored; give each rig its own listener via \
[frontends.rigctl].rig_ports"
);
}
let mut cfg = loaded.config;
// Secrets configured as *_file are read before validation, so everything
// downstream sees resolved values.
cfg.resolve_secrets(config_path.as_deref())?;
cfg.validate()
.map_err(|e| format!("Invalid client configuration: {}", e))?;
frontend_runtime.http_auth.tokens = cfg
.frontends
@@ -198,10 +288,14 @@ async fn async_init() -> DynResult<AppState> {
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
vec![RemoteEntry {
spectrum_interval_ms: cfg.remote.spectrum_interval_ms,
name,
url: url.clone(),
rig_id,
auth: config::RemoteAuthConfig { token },
auth: config::RemoteAuthConfig {
token,
token_file: None,
},
poll_interval_ms,
}]
} else {
@@ -262,6 +356,30 @@ async fn async_init() -> DynResult<AppState> {
.http_json_listen
.unwrap_or(cfg.frontends.http_json.listen);
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
// Fold the CLI overrides back into the config so validation and the
// frontends agree on what is about to be bound.
cfg.frontends.http.enabled = frontends.iter().any(|f| f == "http");
cfg.frontends.rigctl.enabled = frontends.iter().any(|f| f == "rigctl");
cfg.frontends.http_json.enabled = frontends.iter().any(|f| f == "httpjson");
cfg.frontends.http.listen = http_listen;
cfg.frontends.http.port = http_port;
cfg.frontends.rigctl.listen = rigctl_listen;
cfg.frontends.http_json.listen = http_json_listen;
cfg.frontends.http_json.port = http_json_port;
// Second validation phase: the per-rig maps are keyed by remote short name,
// so they can only be checked once the remote list is final.
if cli.url.is_none() {
cfg.validate_resolved(&resolved_remotes)
.map_err(|e| format!("Invalid client configuration: {}", e))?;
} else {
// --url replaces the configured remotes outright, so only the socket
// checks still apply.
trx_config::shared::check_socket_conflicts(&cfg.bound_sockets())
.map_err(|e| format!("Invalid client configuration: {}", e))?;
}
let callsign = cli
.callsign
.clone()
@@ -366,6 +484,13 @@ async fn async_init() -> DynResult<AppState> {
.map(|e| e.poll_interval_ms)
.min()
.unwrap_or(750);
// Entries sharing a server share its connections, so the most frequent
// request wins: whoever wants spectrum fastest sets the rate.
let spectrum_interval = entries
.iter()
.map(|e| e.spectrum_interval_ms)
.min()
.unwrap_or_else(|| remote_client::DEFAULT_SPECTRUM_INTERVAL.as_millis() as u64);
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
for entry in entries {
@@ -379,6 +504,8 @@ async fn async_init() -> DynResult<AppState> {
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
rig_states: frontend_runtime.routing.rig_states.clone(),
poll_interval: Duration::from_millis(poll_interval),
spectrum_interval: Duration::from_millis(spectrum_interval),
spectrum_stream_unsupported: Arc::new(std::sync::atomic::AtomicBool::new(false)),
spectrum: frontend_runtime.spectrum.sender.clone(),
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
server_connected: frontend_runtime.routing.server_connected.clone(),
+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())),
@@ -1,3 +1,6 @@
import {
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
import {
hostCore,
hostState
@@ -222,7 +225,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";
@@ -885,7 +885,7 @@ function elementById(id) {
if (restoreFocus) more.focus();
};
api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return;
const item = document.createElement("button");
@@ -1529,6 +1529,7 @@ var TAB_ORDER = [
"bookmarks",
"digital-modes",
"map",
"satellites",
"statistics",
"recorder",
"settings",
@@ -1539,6 +1540,7 @@ var TAB_PATHS = {
bookmarks: "/bookmarks",
"digital-modes": "/digital-modes",
map: "/map",
satellites: "/satellites",
statistics: "/statistics",
recorder: "/recorder",
settings: "/settings",
@@ -1756,6 +1758,7 @@ var pluginGroups = {
],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
@@ -3552,6 +3555,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 +3576,7 @@ function resetDecoderStateOnRigSwitch() {
const el = document.getElementById(id);
if (el) el.textContent = "--";
});
refreshDecodeBars();
}
function resetWfmStereoIndicator() {
if (!wfmStFlagEl) return;
@@ -4785,10 +4796,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";
@@ -5844,6 +5852,7 @@ function _initMapWhenReady() {
}
function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
@@ -5873,7 +5882,10 @@ 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?.();
}).catch((error) => {
console.error(error);
});
if (name === "map") {
@@ -6320,9 +6332,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);
@@ -5,6 +5,9 @@ import {
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
import {
hostCore,
hostState
@@ -153,7 +156,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) {
@@ -0,0 +1,14 @@
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;
}
export {
isActiveRigDecode
};
@@ -1,3 +1,6 @@
import {
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -177,6 +180,9 @@ function initializeFtxDecoder(config) {
});
}
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,
@@ -202,7 +208,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);
@@ -1,3 +1,6 @@
import {
isActiveRigDecode
} from "./chunk-BQQXSNLC.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();
}
@@ -346,18 +351,22 @@ 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();
}
@@ -1,6 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-O2Y7YEVQ.js";
} from "./chunk-K3D6FOP5.js";
import "./chunk-BQQXSNLC.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft2.ts
@@ -1,6 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-O2Y7YEVQ.js";
} from "./chunk-K3D6FOP5.js";
import "./chunk-BQQXSNLC.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft4.ts
@@ -2,7 +2,8 @@ import {
initializeFt8FamilyBar,
initializeFtxDecoder,
installFtxCompatibilityHelpers
} from "./chunk-O2Y7YEVQ.js";
} from "./chunk-K3D6FOP5.js";
import "./chunk-BQQXSNLC.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft8.ts
@@ -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 {
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -137,7 +140,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 = "";
@@ -24,6 +24,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
@@ -63,6 +64,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>
@@ -918,7 +923,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="sat-view-bar">
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
<button id="sat-view-predictions" class="sat-view-btn" type="button">Predictions</button>
</div>
<!-- Live view -->
<div id="sat-live-view">
@@ -960,50 +964,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="sat-history-list"></div>
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
</div>
<!-- Predictions view -->
<div id="sat-predictions-view" style="display:none;">
<div class="ft8-controls">
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
<select id="sat-pred-category" class="sat-sort-select">
<option value="all">All</option>
<option value="weather">Weather</option>
<option value="amateur">Ham Radio</option>
<option value="other">Other</option>
</select>
<select id="sat-pred-min-el" class="sat-sort-select">
<option value="0">All passes</option>
<option value="10">Min 10°</option>
<option value="20">Min 20°</option>
<option value="45">Min 45°</option>
</select>
</div>
<!-- Current passes -->
<div id="sat-pred-current-section">
<div class="sat-pred-section-title">Currently receivable</div>
<div class="sat-pred-header sat-pred-header-current">
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-time">AOS Start</span>
<span class="sat-pred-col-time">AOS End</span>
<span class="sat-pred-col-countdown">Time left</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-current-list"></div>
</div>
<!-- Upcoming passes -->
<div id="sat-pred-upcoming-section">
<div class="sat-pred-section-title">Upcoming passes</div>
<div class="sat-pred-header">
<span class="sat-pred-col-time">AOS (UTC)</span>
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-dur">Duration</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-list"></div>
</div>
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions&hellip;</small>
</div>
</div>
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls">
@@ -1117,6 +1077,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</template>
</div>
<div id="tab-satellites" class="tab-panel" style="display:none;">
<h2 class="section-heading">Satellites</h2>
<div class="ft8-controls">
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
<select id="sat-pred-category" class="sat-sort-select">
<option value="all">All</option>
<option value="weather">Weather</option>
<option value="amateur">Ham Radio</option>
<option value="other">Other</option>
</select>
<select id="sat-pred-min-el" class="sat-sort-select">
<option value="0">All passes</option>
<option value="10">Min 10°</option>
<option value="20">Min 20°</option>
<option value="45">Min 45°</option>
</select>
</div>
<!-- Current passes -->
<div id="sat-pred-current-section">
<div class="sat-pred-section-title">Currently receivable</div>
<div class="sat-pred-header sat-pred-header-current">
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-time">AOS Start</span>
<span class="sat-pred-col-time">AOS End</span>
<span class="sat-pred-col-countdown">Time left</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-current-list"></div>
</div>
<!-- Upcoming passes -->
<div id="sat-pred-upcoming-section">
<div class="sat-pred-section-title">Upcoming passes</div>
<div class="sat-pred-header">
<span class="sat-pred-col-time">AOS (UTC)</span>
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-dur">Duration</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-list"></div>
</div>
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions&hellip;</small>
</div>
<div id="tab-statistics" class="tab-panel" style="display:none;">
<h2 class="section-heading">Statistics</h2>
<template id="tmpl-statistics">
@@ -3816,11 +3816,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
@media (max-width: 760px) {
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
}
/* One navigation model at every width. Statistics, Recorder, Settings and
* About are occasional destinations: they live behind More rather than
* competing with the operating tabs for the row and then scrolling out of
/* One navigation model at every width. Satellites, Statistics, Recorder,
* Settings and About are occasional destinations: they live behind More rather
* than competing with the operating tabs for the row and then scrolling out of
* reach. The mobile layout already grouped them this way; the desktop strip
* now matches it, which is why the tab strip no longer needs to scroll. */
.tab-bar-nav .tab[data-tab="satellites"],
.tab-bar-nav .tab[data-tab="statistics"],
.tab-bar-nav .tab[data-tab="recorder"],
.tab-bar-nav .tab[data-tab="settings"],
@@ -4030,8 +4031,9 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
}
.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;
@@ -30,6 +30,7 @@ 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"),
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && 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",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -78,6 +78,10 @@ export type RdsData = { pi?: number | null, program_service?: string | null, rad
export type SpectrumData = {
/**
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
*
* On the wire these are base64-encoded `i8` whole dBFS (see
* `spectrum_wire`), which is what the display draws anyway; the TypeScript
* type describes the decoded array the browser receives over SSE.
*/
bins: Array<number>,
/**
@@ -385,12 +385,14 @@ declare global {
refreshCwTonePicker?(): void;
updateFt8RfDisplay?(): void;
clearSatPredictionDom?(): void;
refreshSatPredictions?(): void;
syncWefaxToggle?(enabled: boolean): void;
syncSstvToggle?(enabled: boolean): void;
updateAisBar?(value?: number): void;
updateVdesBar?(value?: number): void;
updateAprsBar?(value?: number): void;
updateFt8Bar?(value?: number): void;
updateCwBar?(value?: number): void;
updateSatLiveState?(value: unknown): void;
applyCwAutoUi?(enabled: boolean): void;
applyCwAutoUiFromServer?(enabled: boolean): void;
@@ -2206,6 +2208,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 +2238,11 @@ 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
// survive the switch: drop the outgoing rig's frames from them now instead of
// leaving them on screen until the next state update.
refreshDecodeBars();
}
function resetWfmStereoIndicator() {
@@ -3675,10 +3693,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;
@@ -4849,6 +4864,7 @@ function _initMapWhenReady() {
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
@@ -4889,7 +4905,13 @@ 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?.();
}).catch((error: unknown) => { console.error(error); });
if (name === "map") {
_initMapWhenReady();
}
@@ -5241,10 +5263,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);
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
export const TAB_ORDER = [
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
"main", "bookmarks", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
] as const;
export type TabName = typeof TAB_ORDER[number];
@@ -13,6 +13,7 @@ export const TAB_PATHS: Readonly<Record<TabName, string>> = {
bookmarks: "/bookmarks",
"digital-modes": "/digital-modes",
map: "/map",
satellites: "/satellites",
statistics: "/statistics",
recorder: "/recorder",
settings: "/settings",
@@ -2,7 +2,9 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
type PluginGroup =
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
| "bookmarks" | "recorder" | "settings";
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
@@ -15,6 +17,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
// The decode panels are aggregate views on purpose: they list what every rig
// heard, and the history they restore from is not rig-scoped either. The mini
// views over the waterfall are not aggregates — they read as a caption for the
// spectrum underneath, so a frame a background rig copied on another band
// belongs to a different picture entirely. Each decode names the rig that
// heard it (the client stamps `rig_id` as the frame leaves the audio
// connection), which is all the overlays need to keep to the rig on screen.
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;
}
@@ -6,6 +6,7 @@ import { hostCore, hostState } from "./host.js";
export {};
import { isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface AisMessage {
@@ -314,7 +315,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";
@@ -4,6 +4,7 @@
import { hostCore, hostState } from "./host.js";
import { isActiveRigDecode } from "./active-rig.js";
import {
aprsAgeText,
aprsPacketCategory,
@@ -179,7 +180,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) {
@@ -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();
}
@@ -445,18 +456,22 @@ 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();
}
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js";
import { 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;
}
@@ -251,6 +253,9 @@ export function initializeFtxDecoder(config: FtxConfig): void {
});
}
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,
@@ -277,7 +282,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) {
@@ -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();
};
@@ -6,6 +6,7 @@ import { hostCore } from "./host.js";
export {};
import { isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface VdesMessage {
@@ -228,7 +229,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 = "";
@@ -499,7 +499,7 @@ function elementById<T extends HTMLElement>(id: string): T {
if (restoreFocus) more.focus();
};
api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return;
const item = document.createElement("button");
@@ -22,9 +22,18 @@ 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. Its traffic
// belongs in the panels, which aggregate every rig, but not in the mini view,
// which captions the spectrum of the rig on screen.
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, OTHER_RIG_VESSEL], mode: "AIS",
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
@@ -41,11 +50,13 @@ 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,
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, true, "the AIS panel dropped the 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 +67,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
@@ -0,0 +1,198 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The mini views over the waterfall caption the spectrum below them, so they
// show only what the rig on screen heard. The panels on the decoder tabs stay
// aggregate — every rig's traffic lands there — which is the distinction these
// tests pin down: a decode from a background rig belongs in the list and not in
// the overlay.
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 keeps to the active rig while the panel lists both", async () => {
const overlay = new ElementFixture();
const packets = new ElementFixture();
const elements = new Map([
["aprs-bar-overlay", overlay],
["aprs-packets", packets],
["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, 2, "the APRS panel dropped a frame it should still list");
});
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");
});
@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// Pass predictions used to be a third view inside the weather-satellite
// decoder, under Digital modes — a planning tool filed with the decoders it has
// nothing to do with. It is its own page now, so what this guards is that the
// page exists at its own address, renders passes, and that the decoder card no
// longer offers the view it gave up.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, window, getComputedStyle */
const HOUR = 3_600_000;
const now = Date.now();
const satPasses = {
satellite_count: 2,
passes: [
// In progress right now, so the page has a countdown to run.
{
satellite: "NOAA 19", category: "weather",
aos_ms: now - 4 * 60_000, los_ms: now + 6 * 60_000,
max_elevation_deg: 62.4, duration_s: 600,
azimuth_aos_deg: 10, azimuth_los_deg: 190,
},
{
satellite: "ISS", category: "amateur",
aos_ms: now + 2 * HOUR, los_ms: now + 2 * HOUR + 480_000,
max_elevation_deg: 18.2, duration_s: 480,
azimuth_aos_deg: 200, azimuth_los_deg: 20,
},
],
};
const fixture = await startWebFixture({ satPasses });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.setViewportSize({ width: 1500, height: 950 });
// The page is reachable at its own address, not through a decoder sub-view.
// #content is the radio panel on the main tab, so the readiness signal for a
// deep link is the destination panel itself.
await page.goto(`${fixture.origin}/satellites`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-satellites").waitFor({ state: "visible" });
await page.waitForFunction(
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
null,
{ timeout: 5000 },
);
const rendered = await page.evaluate(() => ({
current: document.getElementById("sat-pred-current-list").textContent,
upcoming: document.getElementById("sat-pred-list").textContent,
status: document.getElementById("sat-pred-status").textContent,
activeTab: document.querySelector(".tab-bar .tab.active")?.dataset.tab,
}));
assert.match(rendered.current, /NOAA 19/, `current passes read "${rendered.current}"`);
assert.match(rendered.upcoming, /ISS/, `upcoming passes read "${rendered.upcoming}"`);
assert.match(rendered.status, /1 active/, `status reads "${rendered.status}"`);
assert.equal(rendered.activeTab, "satellites", "the Satellites tab is the active one");
// A pass in progress counts down, so the seconds have to move on their own.
const firstTick = await page.evaluate(
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
await page.waitForTimeout(1600);
const secondTick = await page.evaluate(
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
assert.notEqual(firstTick, secondTick, `the countdown sat at ${firstTick}`);
// Leaving stops the countdown rather than leaving a timer running behind a
// hidden page.
await page.evaluate(() => { window.navigateToTab("main"); });
await page.waitForTimeout(300);
const afterLeaving = await page.evaluate(() => ({
visible: document.getElementById("tab-satellites").style.display,
rows: document.getElementById("sat-pred-current-list").childElementCount,
}));
assert.equal(afterLeaving.visible, "none", "the page stayed on screen after navigating away");
assert.equal(afterLeaving.rows, 0, "the countdown rows outlived the page");
// Coming back reloads rather than showing whatever was there before.
await page.evaluate(() => { window.navigateToTab("satellites"); });
await page.waitForFunction(
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
null,
{ timeout: 5000 },
);
// The page is an occasional destination, so it lives behind Tools rather than
// taking a slot in the operating strip — the same treatment Statistics,
// Recorder, Settings and About get.
const nav = await page.evaluate(() => ({
inStrip: getComputedStyle(document.querySelector('.tab-bar-nav .tab[data-tab="satellites"]')).display,
inTools: !!document.querySelector('#mobile-more-menu [data-navigate-tab="satellites"]'),
}));
assert.equal(nav.inStrip, "none", "Satellites took a slot in the operating strip");
assert.ok(nav.inTools, "Satellites is not reachable from Tools");
// The decoder card keeps Live and History, and no longer offers Predictions.
await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
const satCard = await page.evaluate(() => ({
predictionsButton: !!document.getElementById("sat-view-predictions"),
predictionsView: !!document.getElementById("sat-predictions-view"),
live: !!document.getElementById("sat-view-live"),
history: !!document.getElementById("sat-view-history"),
}));
assert.equal(satCard.predictionsButton, false, "the decoder card still offers Predictions");
assert.equal(satCard.predictionsView, false, "the old predictions view is still in the page");
assert.ok(satCard.live && satCard.history, "the decoder card lost Live/History");
assert.deepEqual(runtimeErrors, [], "the page threw while showing predictions");
console.log("satellite predictions page tests passed");
} finally {
await browser.close();
await fixture.close();
}
@@ -27,6 +27,21 @@ const dial = () => page.evaluate(() => ({
path: window.location.pathname,
}));
// 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");
@@ -135,6 +135,7 @@ export async function startWebFixture({
bandplan = {},
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
satPasses = null,
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
@@ -230,6 +231,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
@@ -101,6 +101,12 @@ pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/satellites")]
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/bookmarks")]
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
@@ -639,6 +639,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::recorder_index)
.service(assets::settings_index)
.service(assets::about_index)
.service(assets::satellites_index)
.service(assets::statistics_index)
.service(assets::bookmarks_index)
.service(assets::favicon)
+24
View File
@@ -0,0 +1,24 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
[package]
name = "trx-config"
version.workspace = true
edition = "2021"
license = "GPL-2.0-or-later"
[dependencies]
serde = { workspace = true, features = ["derive"] }
toml = { workspace = true }
tracing = { workspace = true }
dirs = "6"
thiserror = "2"
trx-core = { path = "../trx-core" }
trx-decode-log = { path = "../decoders/trx-decode-log" }
trx-reporting = { path = "../trx-reporting" }
serde_ignored = "0.1"
toml_edit = "0.22"
[dev-dependencies]
tempfile = "3"
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Regenerate `trx-rs.toml.example` from the config structs.
//!
//! Run from anywhere in the workspace:
//!
//! ```text
//! cargo run -p trx-config --example generate_example
//! ```
//!
//! A test in `trx_config::example` fails when the checked-in file no longer
//! matches, which is the reminder to run this.
use std::path::PathBuf;
fn main() -> std::io::Result<()> {
let target: PathBuf = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../trx-rs.toml.example")
});
std::fs::write(&target, trx_config::example::combined_example())?;
println!("Wrote {}", target.display());
Ok(())
}
File diff suppressed because it is too large Load Diff
+295
View File
@@ -0,0 +1,295 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Generating the example configuration from the config structs.
//!
//! `trx-rs.toml.example` used to be maintained by hand and had fallen years
//! behind the code — no `[[rigs]]`, no `[[remotes]]`, no `[timeouts]`, no
//! bandplan settings. It is now produced from the structs themselves, so a new
//! field appears in the example the moment it exists, and a test fails if the
//! checked-in copy drifts.
//!
//! Section comments come from the table below. A section without an entry is
//! still emitted — only its explanatory text is missing — so forgetting to add
//! one can never drop a setting from the example.
use toml_edit::{DocumentMut, Item};
use crate::{ClientConfig, ServerConfig};
const HEADER: &str = "\
# trx-rs example configuration
#
# Generated from the config structs; regenerate with:
# cargo run -p trx-config --example generate_example
#
# Both sections are optional: trx-server reads [trx-server], trx-client reads
# [trx-client], and either may live in its own file with the section header
# omitted. Any string may use ${ENV_VAR}, and credentials may be moved out of
# this file with the matching *_file keys.
#
# Check a config without starting anything:
# trx-server --check-config --config trx-rs.toml
# trx-client --check-config --config trx-rs.toml
";
/// Explanatory comments for config sections, keyed by dotted path.
const SECTION_COMMENTS: &[(&str, &str)] = &[
("trx-server", "Server: drives the radio hardware."),
(
"trx-server.general",
"Station identity. Coordinates feed PSKReporter and the map.",
),
(
"trx-server.rig",
"Single-rig layout. For several radios, delete this and use [[rigs]].",
),
(
"trx-server.rig.access",
"How to reach the radio: serial, tcp, or sdr.",
),
("trx-server.behavior", "CAT polling and retry behaviour."),
(
"trx-server.listen",
"JSON control listener that trx-client connects to.",
),
(
"trx-server.listen.auth",
"Tokens clients must present. Empty means no authentication.\n\
Use tokens_file = \"/etc/trx-rs/tokens\" to keep them out of this file.",
),
("trx-server.audio", "Opus audio stream for trx-client."),
(
"trx-server.decoders",
"Which decoders run. Trimming this list saves real CPU on small boxes.\n\
Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr.\n\
output_dir sets where sstv/wefax/lrpt write images (default: user cache dir).",
),
(
"trx-server.pskreporter",
"Report FT8/FT4/WSPR spots to pskreporter.info.",
),
(
"trx-server.aprsfi",
"Forward received APRS frames to APRS-IS.",
),
(
"trx-server.decode_logs",
"Write decodes to JSON Lines files.",
),
(
"trx-server.sdr",
"SoapySDR pipeline; used when [rig.access] type = \"sdr\".",
),
(
"trx-server.sdr.gain",
"\"auto\" for hardware AGC, or \"manual\".",
),
(
"trx-server.sdr.squelch",
"Software squelch on demodulated audio.",
),
(
"trx-server.sdr.noise_blanker",
"Impulse-noise suppression on the IQ stream.",
),
(
"trx-server.timeouts",
"Timeout and buffer tuning. The defaults suit most setups.",
),
("trx-client", "Client: exposes the radio to users."),
("trx-client.general", "Labels shown in the web UI."),
(
"trx-client.remote",
"Legacy single-remote form; prefer [[remotes]] below.",
),
(
"trx-client.frontends.http",
"Web UI. default_rig_name and the per-rig maps are keyed by the\n\
[[remotes]] name, not the server-side rig id.",
),
(
"trx-client.frontends.http.auth",
"Passphrase login for the web UI. rx_passphrase_file and\n\
control_passphrase_file keep the secrets out of this file.",
),
(
"trx-client.frontends.rigctl",
"Hamlib-compatible TCP interface, one listener per rig.",
),
(
"trx-client.frontends.http_json",
"JSON-over-TCP control interface.",
),
(
"trx-client.frontends.audio",
"Where to fetch the audio stream from.",
),
(
"trx-client.frontends.audio.bridge",
"Play RX audio on a local sound device and capture TX from one.",
),
];
/// Render the combined `trx-rs.toml.example` contents.
pub fn combined_example() -> String {
let mut doc = DocumentMut::new();
doc.decor_mut().set_prefix(HEADER);
doc.insert("trx-server", section_item(&ServerConfig::example_config()));
doc.insert("trx-client", section_item(&ClientConfig::example_config()));
// Each section was serialized on its own, so both carry table positions
// starting at zero and would otherwise render interleaved.
renumber_tables(&mut doc);
for (path, comment) in SECTION_COMMENTS {
annotate(&mut doc, path, comment);
}
doc.to_string()
}
/// Renumber every table so the document renders in tree order.
fn renumber_tables(doc: &mut DocumentMut) {
fn walk(item: &mut Item, next: &mut usize) {
match item {
Item::Table(table) => {
table.set_position(*next);
*next += 1;
for (_, child) in table.iter_mut() {
walk(child, next);
}
}
Item::ArrayOfTables(array) => {
for table in array.iter_mut() {
table.set_position(*next);
*next += 1;
for (_, child) in table.iter_mut() {
walk(child, next);
}
}
}
_ => {}
}
}
let mut next = 0;
for (_, item) in doc.as_table_mut().iter_mut() {
walk(item, &mut next);
}
}
/// Serialize one config into a toml_edit table.
fn section_item<T: serde::Serialize>(config: &T) -> Item {
let rendered = toml::to_string_pretty(config).unwrap_or_default();
let doc: DocumentMut = rendered.parse().expect("serialized config must re-parse");
Item::Table(doc.as_table().clone())
}
/// Attach a comment above the table at `path`, if it exists.
fn annotate(doc: &mut DocumentMut, path: &str, comment: &str) {
let mut item: Option<&mut Item> = None;
for segment in path.split('.') {
let next = match item {
None => doc.get_mut(segment),
Some(current) => current.as_table_mut().and_then(|t| t.get_mut(segment)),
};
match next {
Some(found) => item = Some(found),
None => return,
}
}
let Some(table) = item.and_then(|i| i.as_table_mut()) else {
return;
};
let body: String = comment
.lines()
.map(|line| format!("# {}\n", line.trim_start()))
.collect();
table.decor_mut().set_prefix(format!("\n{body}"));
}
#[cfg(test)]
mod tests {
use super::*;
/// The checked-in example must match what the structs produce, so a new
/// config field cannot land without showing up in the example.
#[test]
fn test_checked_in_example_is_up_to_date() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../trx-rs.toml.example")
.canonicalize()
.expect("example file must exist");
let on_disk = std::fs::read_to_string(&path).expect("example file must be readable");
assert_eq!(
on_disk,
combined_example(),
"trx-rs.toml.example is out of date; regenerate with \
`cargo run -p trx-config --example generate_example`"
);
}
/// Nothing in the example may be derived from the machine that generated
/// it: [decode_logs].dir defaults to the running user's cache directory,
/// which made the generated file differ between a developer's laptop and
/// CI, and the up-to-date test fail for everyone but its author.
#[test]
fn test_example_has_no_machine_specific_paths() {
let example = combined_example();
for dir in [dirs::home_dir(), dirs::cache_dir(), dirs::config_dir()]
.into_iter()
.flatten()
{
let dir = dir.to_string_lossy().into_owned();
assert!(
!example.contains(&dir),
"the example contains this machine's {dir}; pin the value in example_config()"
);
}
}
#[test]
fn test_example_loads_and_validates() {
let mut file = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut file, combined_example().as_bytes()).unwrap();
let server = ServerConfig::load_from_file(file.path()).expect("server section loads");
assert!(
server.unknown_keys.is_empty(),
"the generated example must not contain unknown keys: {:?}",
server.unknown_keys
);
server.config.validate().expect("server section validates");
let client = ClientConfig::load_from_file(file.path()).expect("client section loads");
assert!(
client.unknown_keys.is_empty(),
"the generated example must not contain unknown keys: {:?}",
client.unknown_keys
);
client.config.validate().expect("client section validates");
}
/// Every section that gained a comment must still exist under that path.
#[test]
fn test_section_comments_match_real_sections() {
let doc: DocumentMut = combined_example().parse().unwrap();
for (path, _) in SECTION_COMMENTS {
let mut item = None;
for segment in path.split('.') {
item = match item {
None => doc.get(segment),
Some(current) => current.as_table().and_then(|t| t.get(segment)),
};
assert!(
item.is_some(),
"commented section [{path}] no longer exists"
);
}
}
}
}
+252
View File
@@ -0,0 +1,252 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Loading a config section out of a TOML file.
//!
//! Two file shapes are accepted:
//!
//! - **Sectioned** — a combined `trx-rs.toml` with `[trx-server]` and/or
//! `[trx-client]` tables. This is what `--print-config` and
//! `trx-configurator` emit.
//! - **Bare** — a standalone file whose root *is* the section, i.e. `[general]`
//! and `[rig]` at the top level. Hand-written per-binary configs use this.
//!
//! A file that carries some other component's section but not ours is treated
//! as "section absent" rather than as a bare file, so loading a client-only
//! config with the server reports the missing section instead of silently
//! falling back to defaults.
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::path::{Path, PathBuf};
use thiserror::Error;
use std::collections::BTreeSet;
use crate::unknown::{describe, flatten_paths, UnknownKey};
/// Every section key that may appear at the root of a combined config file.
pub const SECTION_KEYS: &[&str] = &["trx-server", "trx-client"];
/// A loaded config plus what the loader noticed on the way in.
#[derive(Debug, Clone)]
pub struct ConfigLoad<T> {
/// The deserialized configuration.
pub config: T,
/// File the config came from; `None` when nothing was found and defaults
/// were used.
pub path: Option<PathBuf>,
/// Keys present in the file that no config field claimed.
pub unknown_keys: Vec<UnknownKey>,
/// Every key path the file actually set. Defaults are indistinguishable
/// from explicit values once deserialized, so deprecation checks need this.
pub present_keys: BTreeSet<String>,
}
impl<T: ConfigFile> ConfigLoad<T> {
/// Log a warning for every deprecated key the file sets.
pub fn report_deprecations(&self) {
for message in T::deprecations(&self.present_keys) {
tracing::warn!("{}", message);
}
}
}
impl<T> ConfigLoad<T> {
/// Log every unknown key as a warning. With `strict`, also return an error
/// so the caller can refuse to start.
pub fn report_unknown_keys(&self, strict: bool) -> Result<(), String> {
for key in &self.unknown_keys {
tracing::warn!("{}", key);
}
if strict && !self.unknown_keys.is_empty() {
return Err(format!(
"{} unknown config key(s); refusing to start because --strict-config is set",
self.unknown_keys.len()
));
}
Ok(())
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file {0}: {1}")]
ReadError(PathBuf, String),
#[error("Failed to parse config file {0}: {1}")]
ParseError(PathBuf, String),
}
/// Returns the default search paths for `trx-rs.toml`
/// (current directory → XDG config → /etc).
fn config_search_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("trx-rs.toml")];
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("trx-rs").join("trx-rs.toml"));
}
paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml"));
paths
}
/// Pick the table holding `key`'s settings out of a parsed document.
///
/// Returns the named section when present, the whole document when it carries
/// no section headers at all (a bare standalone file), or `None` when the file
/// is sectioned but has no section for `key`.
fn select_section(table: &toml::Table, key: &str) -> Option<toml::Value> {
if let Some(section) = table.get(key) {
return Some(section.clone());
}
let is_sectioned = SECTION_KEYS.iter().any(|k| table.contains_key(*k));
if is_sectioned {
return None;
}
Some(toml::Value::Table(table.clone()))
}
/// Extract and deserialize a named section from a TOML file.
///
/// Returns `Ok(Some((cfg, unknown_keys)))` when the section is present and
/// parses cleanly, `Ok(None)` when the section is absent, or `Err` on I/O /
/// parse failure.
type LoadedSection<T> = (T, Vec<UnknownKey>, BTreeSet<String>);
fn load_section_from_file<T: ConfigFile>(
path: &Path,
key: &str,
) -> Result<Option<LoadedSection<T>>, ConfigError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
let table: toml::Table = toml::from_str(&content)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
let Some(mut section) = select_section(&table, key) else {
return Ok(None);
};
// ${VAR} references are expanded before deserializing, so any string in the
// file can come from the environment.
crate::secrets::expand_env_vars(&mut section)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e))?;
let present_keys = flatten_paths(&section);
// Deserialize straight from the TOML value so serde applies every default,
// recording any key no field claimed.
let mut ignored: Vec<String> = Vec::new();
let cfg: T = serde_ignored::deserialize(section, |path| ignored.push(path.to_string()))
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
Ok(Some((
cfg,
describe(&ignored, &T::reference_value()),
present_keys,
)))
}
/// Trait for loading configuration from a `trx-rs.toml` section.
pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize {
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
fn section_key() -> &'static str;
/// Warnings for deprecated keys the file sets, given every key path present
/// in it. Defaults to none.
fn deprecations(_present_keys: &BTreeSet<String>) -> Vec<String> {
Vec::new()
}
/// A TOML rendering of a populated config, used to suggest corrections for
/// unknown keys. Implementations should fill in list-valued sections such
/// as `[[rigs]]` so keys nested inside them can be suggested too.
fn reference_value() -> toml::Value {
toml::Value::try_from(Self::default())
.unwrap_or_else(|_| toml::Value::Table(toml::Table::new()))
}
/// Load the section from a specific file path.
///
/// Accepts both a sectioned file (`[<section_key>]` at the root) and a bare
/// file whose root is the section itself. Returns an error if the file
/// cannot be read, is not valid TOML, or is sectioned for some other
/// component only.
fn load_from_file(path: &Path) -> Result<ConfigLoad<Self>, ConfigError> {
let (config, unknown_keys, present_keys) =
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
ConfigError::ParseError(
path.to_path_buf(),
format!("missing [{}] section", Self::section_key()),
)
})?;
Ok(ConfigLoad {
config,
path: Some(path.to_path_buf()),
unknown_keys,
present_keys,
})
}
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
/// the first file that contains the expected section.
///
/// Falls back to `Self::default()` with no path when nothing is found.
fn load_from_default_paths() -> Result<ConfigLoad<Self>, ConfigError> {
for path in config_search_paths() {
if path.exists() {
if let Some((config, unknown_keys, present_keys)) =
load_section_from_file::<Self>(&path, Self::section_key())?
{
return Ok(ConfigLoad {
config,
path: Some(path),
unknown_keys,
present_keys,
});
}
}
}
Ok(ConfigLoad {
config: Self::default(),
path: None,
unknown_keys: Vec::new(),
present_keys: BTreeSet::new(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn table(s: &str) -> toml::Table {
toml::from_str(s).unwrap()
}
#[test]
fn test_select_section_prefers_named_section() {
let t = table("[trx-server]\n[trx-server.general]\ncallsign = \"W1AW\"\n");
let section = select_section(&t, "trx-server").unwrap();
assert!(section.get("general").is_some());
}
#[test]
fn test_select_section_falls_back_to_root_for_bare_file() {
let t = table("[general]\ncallsign = \"W1AW\"\n");
let section = select_section(&t, "trx-server").unwrap();
assert!(section.get("general").is_some());
}
#[test]
fn test_select_section_absent_when_other_section_present() {
let t = table("[trx-client]\n[trx-client.general]\ncallsign = \"W1AW\"\n");
assert!(select_section(&t, "trx-server").is_none());
}
#[test]
fn test_select_section_empty_file_is_bare() {
let t = table("");
assert!(select_section(&t, "trx-server").is_some());
}
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Configuration types shared by `trx-server`, `trx-client` and
//! `trx-configurator`.
//!
//! Keeping the structs, the loader and the validators in one crate means the
//! setup wizard checks a config with exactly the same code the binaries load
//! it with, so the two can never drift apart.
pub mod client;
pub mod example;
pub mod file;
pub mod secrets;
pub mod server;
pub mod shared;
pub mod unknown;
pub mod url;
pub use client::ClientConfig;
pub use file::{ConfigError, ConfigFile, ConfigLoad};
pub use server::ServerConfig;
pub use shared::{validate_log_level, validate_tokens};
pub use unknown::UnknownKey;
pub use url::{parse_audio_url, parse_remote_url, RemoteEndpoint};
+297
View File
@@ -0,0 +1,297 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Keeping credentials out of the config file.
//!
//! Tokens and passphrases used to have exactly one representation: written in
//! plain text in `trx-rs.toml`, which is awkward when the config is deployed by
//! a config-management tool, committed to a private repo, or shared between
//! machines. Two alternatives are offered:
//!
//! - `${VAR}` anywhere in a config string, expanded from the environment.
//! - A `*_file` sibling of any secret key, read from disk at startup.
//!
//! Plus a nudge: a config that holds secrets and is readable by group or others
//! gets a warning.
use std::path::Path;
/// Expand `${VAR}` references in every string in a TOML value.
///
/// An unset variable is an error rather than an empty string — a silently blank
/// passphrase is the kind of thing that disables authentication by accident.
pub fn expand_env_vars(value: &mut toml::Value) -> Result<(), String> {
match value {
toml::Value::String(s) => {
if let Some(expanded) = expand_str(s)? {
*s = expanded;
}
}
toml::Value::Table(table) => {
for (_, child) in table.iter_mut() {
expand_env_vars(child)?;
}
}
toml::Value::Array(items) => {
for item in items.iter_mut() {
expand_env_vars(item)?;
}
}
_ => {}
}
Ok(())
}
/// Expand `${VAR}` in one string; `None` when there was nothing to expand.
fn expand_str(input: &str) -> Result<Option<String>, String> {
if !input.contains("${") {
return Ok(None);
}
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
// Unterminated: leave the rest exactly as written.
out.push_str(&rest[start..]);
return Ok(Some(out));
};
let name = &after[..end];
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
// Not a variable reference; pass it through untouched.
out.push_str(&rest[start..start + 2 + end + 1]);
} else {
let value = std::env::var(name)
.map_err(|_| format!("config references unset environment variable ${{{name}}}"))?;
out.push_str(&value);
}
rest = &after[end + 1..];
}
out.push_str(rest);
Ok(Some(out))
}
/// Read a single secret from a file: the whole file, trimmed.
pub fn read_secret_file(path: &str, what: &str) -> Result<String, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?;
let secret = content.trim().to_string();
if secret.is_empty() {
return Err(format!("{what}: secret file {path} is empty"));
}
warn_if_group_readable(Path::new(path), what);
Ok(secret)
}
/// Read a list of secrets, one per line. Blank lines and `#` comments are
/// skipped.
pub fn read_secret_list_file(path: &str, what: &str) -> Result<Vec<String>, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?;
let secrets: Vec<String> = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_string)
.collect();
if secrets.is_empty() {
return Err(format!("{what}: secret file {path} contains no entries"));
}
warn_if_group_readable(Path::new(path), what);
Ok(secrets)
}
/// Fill `inline` from `file` when the config used the `*_file` form.
///
/// Setting both is an error: which one wins would be a guess.
pub fn resolve_secret(
inline: &mut Option<String>,
file: &Option<String>,
what: &str,
) -> Result<(), String> {
let Some(path) = file else {
return Ok(());
};
if inline.is_some() {
return Err(format!(
"{what}: set either the value or its _file form, not both"
));
}
*inline = Some(read_secret_file(path, what)?);
Ok(())
}
/// Fill a token list from `file` when the config used the `*_file` form.
pub fn resolve_secret_list(
inline: &mut Vec<String>,
file: &Option<String>,
what: &str,
) -> Result<(), String> {
let Some(path) = file else {
return Ok(());
};
if !inline.is_empty() {
return Err(format!(
"{what}: set either the value or its _file form, not both"
));
}
*inline = read_secret_list_file(path, what)?;
Ok(())
}
/// Warn when a file holding secrets is readable beyond its owner.
///
/// Advisory only: plenty of valid setups (a dedicated service user, an
/// immutable image) are fine, so this never fails the load.
pub fn warn_if_group_readable(path: &Path, what: &str) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(path) else {
return;
};
let mode = meta.permissions().mode() & 0o077;
if mode != 0 {
tracing::warn!(
"{} is readable by group or others (mode {:o}); it holds secrets ({}). \
Consider: chmod 600 {}",
path.display(),
meta.permissions().mode() & 0o777,
what,
path.display()
);
}
}
#[cfg(not(unix))]
{
let _ = (path, what);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_expand_leaves_plain_strings_alone() {
assert_eq!(expand_str("plain").unwrap(), None);
assert_eq!(expand_str("TRXRS-%YYYY%.log").unwrap(), None);
}
#[test]
fn test_expand_substitutes_variable() {
std::env::set_var("TRX_TEST_TOKEN", "s3cret");
assert_eq!(
expand_str("Bearer ${TRX_TEST_TOKEN}!").unwrap().as_deref(),
Some("Bearer s3cret!")
);
}
#[test]
fn test_expand_errors_on_unset_variable() {
let err = expand_str("${TRX_DEFINITELY_NOT_SET_12345}").unwrap_err();
assert!(err.contains("unset environment variable"), "{err}");
}
#[test]
fn test_expand_passes_through_non_variables() {
assert_eq!(
expand_str("${not a var}").unwrap().as_deref(),
Some("${not a var}")
);
assert_eq!(
expand_str("unterminated ${VAR").unwrap().as_deref(),
Some("unterminated ${VAR")
);
}
#[test]
fn test_expand_walks_nested_tables_and_arrays() {
std::env::set_var("TRX_TEST_HOST", "radio.example.com");
let mut value: toml::Value = toml::from_str(
r#"
[remote]
url = "${TRX_TEST_HOST}:4530"
hosts = ["${TRX_TEST_HOST}"]
"#,
)
.unwrap();
expand_env_vars(&mut value).unwrap();
assert_eq!(
value["remote"]["url"].as_str(),
Some("radio.example.com:4530")
);
assert_eq!(
value["remote"]["hosts"][0].as_str(),
Some("radio.example.com")
);
}
fn temp_file(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn test_read_secret_file_trims() {
let f = temp_file(" hunter2\n");
assert_eq!(read_secret_file(path_of(&f), "test").unwrap(), "hunter2");
}
#[test]
fn test_read_secret_file_rejects_empty() {
let f = temp_file(" \n");
assert!(read_secret_file(path_of(&f), "test").is_err());
}
#[test]
fn test_read_secret_list_skips_blanks_and_comments() {
let f = temp_file("# tokens\nalpha\n\n beta \n");
assert_eq!(
read_secret_list_file(path_of(&f), "test").unwrap(),
vec!["alpha".to_string(), "beta".to_string()]
);
}
#[test]
fn test_resolve_secret_fills_from_file() {
let f = temp_file("from-file");
let mut inline = None;
resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap();
assert_eq!(inline.as_deref(), Some("from-file"));
}
#[test]
fn test_resolve_secret_rejects_both_forms() {
let f = temp_file("from-file");
let mut inline = Some("inline".to_string());
let err = resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err();
assert!(err.contains("not both"), "{err}");
}
#[test]
fn test_resolve_secret_is_a_no_op_without_file() {
let mut inline = Some("inline".to_string());
resolve_secret(&mut inline, &None, "test").unwrap();
assert_eq!(inline.as_deref(), Some("inline"));
}
#[test]
fn test_resolve_secret_list_rejects_both_forms() {
let f = temp_file("alpha");
let mut inline = vec!["inline".to_string()];
let err =
resolve_secret_list(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err();
assert!(err.contains("not both"), "{err}");
}
fn path_of(f: &tempfile::NamedTempFile) -> &str {
f.path().to_str().unwrap()
}
}
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,52 @@
//! would either bloat both binaries with unused fields or require a trait
//! abstraction that adds complexity without clear benefit.
use std::net::IpAddr;
/// A socket a component intends to bind, and what it is for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoundSocket {
pub addr: IpAddr,
pub port: u16,
/// Human-readable owner, e.g. `[listen]` or `[frontends.http]`.
pub label: String,
}
impl BoundSocket {
pub fn new(addr: IpAddr, port: u16, label: impl Into<String>) -> Self {
Self {
addr,
port,
label: label.into(),
}
}
}
/// Reject two components trying to bind the same socket.
///
/// A wildcard address (`0.0.0.0` / `::`) conflicts with any other address on
/// the same port, since binding it claims every interface. Port 0 means "pick
/// an ephemeral port" and never conflicts.
pub fn check_socket_conflicts(sockets: &[BoundSocket]) -> Result<(), String> {
for (i, a) in sockets.iter().enumerate() {
if a.port == 0 {
continue;
}
for b in &sockets[i + 1..] {
if b.port != a.port {
continue;
}
if a.addr == b.addr || a.addr.is_unspecified() || b.addr.is_unspecified() {
return Err(format!(
"{} and {} would both bind {}:{}",
a.label, b.label, a.addr, a.port
));
}
}
}
Ok(())
}
/// Validate that a log level string is one of the accepted values.
///
/// Returns `Ok(())` when `level` is `None` (defaulting is handled elsewhere)
@@ -53,6 +99,47 @@ pub fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> {
mod tests {
use super::*;
fn sock(addr: &str, port: u16, label: &str) -> BoundSocket {
BoundSocket::new(addr.parse().unwrap(), port, label)
}
#[test]
fn test_socket_conflicts_detects_exact_duplicate() {
let err = check_socket_conflicts(&[
sock("127.0.0.1", 8080, "[frontends.http]"),
sock("127.0.0.1", 8080, "[frontends.http_json]"),
])
.unwrap_err();
assert!(err.contains("127.0.0.1:8080"), "unexpected error: {err}");
}
#[test]
fn test_socket_conflicts_detects_wildcard_overlap() {
assert!(check_socket_conflicts(&[
sock("0.0.0.0", 4530, "[listen]"),
sock("127.0.0.1", 4530, "[audio]"),
])
.is_err());
}
#[test]
fn test_socket_conflicts_allows_distinct_addresses() {
assert!(check_socket_conflicts(&[
sock("127.0.0.1", 4530, "[listen]"),
sock("192.168.1.5", 4530, "[audio]"),
])
.is_ok());
}
#[test]
fn test_socket_conflicts_ignores_ephemeral_ports() {
assert!(check_socket_conflicts(&[
sock("127.0.0.1", 0, "[frontends.http_json]"),
sock("127.0.0.1", 0, "[frontends.http]"),
])
.is_ok());
}
#[test]
fn test_validate_log_level_none() {
assert!(validate_log_level(None).is_ok());
+263
View File
@@ -0,0 +1,263 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Reporting for config keys the deserializer ignored.
//!
//! Every config struct is `#[serde(default)]`, so a misspelled key used to be
//! dropped without a word and the setting silently kept its default. The
//! loader now collects the ignored key paths and pairs each with the closest
//! known key at the same level, so `prot = 9999` reads as a typo instead of
//! looking like it worked.
use std::collections::BTreeSet;
use std::fmt;
/// A config key that the deserializer did not recognise.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownKey {
/// Dotted path of the key, e.g. `listen.prot` or `rigs.0.audio.prot`.
pub path: String,
/// Closest known key at the same level, when one is near enough to suggest.
pub suggestion: Option<String>,
}
impl fmt::Display for UnknownKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.suggestion {
Some(s) => write!(
f,
"unknown config key '{}' (did you mean '{}'?)",
self.path, s
),
None => write!(f, "unknown config key '{}'", self.path),
}
}
}
/// Flatten a TOML value into the set of dotted key paths it contains.
///
/// Array indices are normalised to `0` so a path inside `[[rigs]]` matches
/// whichever entry it came from.
pub fn flatten_paths(value: &toml::Value) -> BTreeSet<String> {
let mut paths = BTreeSet::new();
walk(value, "", &mut paths);
paths
}
fn walk(value: &toml::Value, prefix: &str, paths: &mut BTreeSet<String>) {
let join = |seg: &str| {
if prefix.is_empty() {
seg.to_string()
} else {
format!("{prefix}.{seg}")
}
};
match value {
toml::Value::Table(table) => {
for (key, child) in table {
let path = join(key);
paths.insert(path.clone());
walk(child, &path, paths);
}
}
toml::Value::Array(items) => {
// Every entry of an array of tables has the same shape, so collapse
// them onto index 0 and let one entry stand for all.
for item in items {
let path = join("0");
walk(item, &path, paths);
}
}
_ => {}
}
}
/// Replace numeric path segments with `0` so array entries compare equal.
fn normalize(path: &str) -> String {
path.split('.')
.map(|seg| {
if !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit()) {
"0"
} else {
seg
}
})
.collect::<Vec<_>>()
.join(".")
}
/// Suggest the closest known key that sits at the same level as `path`.
///
/// Returns `None` when nothing is close enough to be worth printing.
pub fn suggest(path: &str, known: &BTreeSet<String>) -> Option<String> {
let normalized = normalize(path);
let (parent, leaf) = match normalized.rsplit_once('.') {
Some((parent, leaf)) => (parent, leaf),
None => ("", normalized.as_str()),
};
// Anything longer than this is a different word, not a typo.
let limit = (leaf.chars().count() / 3).clamp(1, 3);
let mut best: Option<(usize, &str)> = None;
for candidate in known {
let (cand_parent, cand_leaf) = match candidate.rsplit_once('.') {
Some((p, l)) => (p, l),
None => ("", candidate.as_str()),
};
if cand_parent != parent || cand_leaf == leaf {
continue;
}
let distance = edit_distance(leaf, cand_leaf);
if distance <= limit && best.is_none_or(|(best_d, _)| distance < best_d) {
best = Some((distance, cand_leaf));
}
}
best.map(|(_, leaf)| {
if parent.is_empty() {
leaf.to_string()
} else {
format!("{parent}.{leaf}")
}
})
}
/// Pair each ignored path with a suggestion drawn from `reference`.
pub fn describe(paths: &[String], reference: &toml::Value) -> Vec<UnknownKey> {
let known = flatten_paths(reference);
paths
.iter()
.map(|path| UnknownKey {
path: path.clone(),
suggestion: suggest(path, &known),
})
.collect()
}
/// Optimal string alignment distance: Levenshtein plus transpositions, so the
/// common `port` → `prot` slip counts as one mistake rather than two.
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut rows = vec![vec![0usize; b.len() + 1]; a.len() + 1];
for (i, row) in rows.iter_mut().enumerate() {
row[0] = i;
}
for (j, cell) in rows[0].iter_mut().enumerate() {
*cell = j;
}
for i in 1..=a.len() {
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
let mut best = (rows[i - 1][j] + 1)
.min(rows[i][j - 1] + 1)
.min(rows[i - 1][j - 1] + cost);
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
best = best.min(rows[i - 2][j - 2] + 1);
}
rows[i][j] = best;
}
}
rows[a.len()][b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
fn reference() -> toml::Value {
toml::from_str(
r#"
[general]
callsign = "N0CALL"
log_level = "info"
[listen]
enabled = true
port = 4530
[[rigs]]
id = "hf"
[rigs.audio]
port = 4531
sample_rate = 48000
"#,
)
.unwrap()
}
#[test]
fn test_flatten_collects_nested_paths() {
let paths = flatten_paths(&reference());
assert!(paths.contains("general.callsign"));
assert!(paths.contains("listen.port"));
assert!(paths.contains("rigs.0.audio.sample_rate"));
}
#[test]
fn test_suggest_finds_close_sibling() {
let known = flatten_paths(&reference());
assert_eq!(
suggest("listen.prot", &known).as_deref(),
Some("listen.port")
);
}
#[test]
fn test_suggest_inside_array_entry() {
let known = flatten_paths(&reference());
assert_eq!(
suggest("rigs.1.audio.prot", &known).as_deref(),
Some("rigs.0.audio.port")
);
}
#[test]
fn test_suggest_ignores_distant_names() {
let known = flatten_paths(&reference());
assert_eq!(suggest("listen.bananas", &known), None);
}
#[test]
fn test_suggest_does_not_cross_levels() {
let known = flatten_paths(&reference());
// `port` exists under [listen], but not under [general].
assert_eq!(suggest("general.port", &known), None);
}
#[test]
fn test_describe_formats_message() {
let described = describe(&["listen.prot".to_string()], &reference());
assert_eq!(
described[0].to_string(),
"unknown config key 'listen.prot' (did you mean 'listen.port'?)"
);
}
#[test]
fn test_describe_without_suggestion() {
let described = describe(&["listen.bananas".to_string()], &reference());
assert_eq!(
described[0].to_string(),
"unknown config key 'listen.bananas'"
);
}
#[test]
fn test_edit_distance_counts_transposition_once() {
assert_eq!(edit_distance("port", "prot"), 1);
assert_eq!(edit_distance("port", "port"), 0);
assert_eq!(edit_distance("", "port"), 4);
assert_eq!(edit_distance("sample_rat", "sample_rate"), 1);
}
}
+173
View File
@@ -0,0 +1,173 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Parsing for the `host:port` endpoint URLs used by the client's `[[remotes]]`
//! and `[frontends.audio]` settings.
//!
//! These live next to the config structs because validation needs them; the
//! client re-exports them for its connection code.
/// Default port for the server's JSON control listener.
pub const DEFAULT_REMOTE_PORT: u16 = 4530;
/// Default port for the server's Opus audio listener.
pub const DEFAULT_AUDIO_PORT: u16 = 4531;
/// A resolved `host:port` pair.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteEndpoint {
pub host: String,
pub port: u16,
}
impl RemoteEndpoint {
/// Format as a connect string, bracketing bare IPv6 hosts.
pub fn connect_addr(&self) -> String {
if self.host.contains(':') && !self.host.starts_with('[') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
}
/// Parse a remote control URL, defaulting to port 4530.
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
}
/// Parse an audio stream URL, defaulting to port 4531.
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
}
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(format!("{kind} url is empty"));
}
let addr = trimmed
.strip_prefix("tcp://")
.or_else(|| trimmed.strip_prefix("http-json://"))
.or_else(|| trimmed.strip_prefix("audio://"))
.unwrap_or(trimmed);
parse_host_port(addr, default_port, kind)
}
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
if let Some(rest) = input.strip_prefix('[') {
let closing = rest
.find(']')
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
let host = &rest[..closing];
let remainder = &rest[closing + 1..];
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
let port = if remainder.is_empty() {
default_port
} else if let Some(port_str) = remainder.strip_prefix(':') {
parse_port(port_str, kind)?
} else {
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
};
return Ok(RemoteEndpoint {
host: host.to_string(),
port,
});
}
if input.contains(':') {
if input.matches(':').count() > 1 {
return Err(format!(
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
));
}
let (host, port_str) = input
.rsplit_once(':')
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
return Ok(RemoteEndpoint {
host: host.to_string(),
port: parse_port(port_str, kind)?,
});
}
Ok(RemoteEndpoint {
host: input.to_string(),
port: default_port,
})
}
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
let port: u16 = port_str
.parse()
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
if port == 0 {
return Err(format!("invalid {kind} port: 0"));
}
Ok(port)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_remote_url_defaults_port() {
let ep = parse_remote_url("example.com").unwrap();
assert_eq!(ep.host, "example.com");
assert_eq!(ep.port, DEFAULT_REMOTE_PORT);
}
#[test]
fn test_parse_audio_url_defaults_port() {
let ep = parse_audio_url("example.com").unwrap();
assert_eq!(ep.port, DEFAULT_AUDIO_PORT);
}
#[test]
fn test_parse_strips_schemes() {
for url in &[
"tcp://host:9000",
"http-json://host:9000",
"audio://host:9000",
] {
let ep = parse_remote_url(url).unwrap();
assert_eq!(ep.host, "host");
assert_eq!(ep.port, 9000);
}
}
#[test]
fn test_parse_bracketed_ipv6() {
let ep = parse_remote_url("[::1]:4532").unwrap();
assert_eq!(ep.host, "::1");
assert_eq!(ep.port, 4532);
assert_eq!(ep.connect_addr(), "[::1]:4532");
}
#[test]
fn test_parse_rejects_unbracketed_ipv6() {
assert!(parse_remote_url("::1:4532").is_err());
}
#[test]
fn test_parse_rejects_empty_host() {
assert!(parse_remote_url(":4530").is_err());
}
#[test]
fn test_parse_rejects_zero_port() {
assert!(parse_remote_url("host:0").is_err());
}
#[test]
fn test_parse_rejects_empty_url() {
assert!(parse_remote_url(" ").is_err());
}
}
+2
View File
@@ -15,7 +15,9 @@ path = "src/main.rs"
clap = { workspace = true, features = ["derive"] }
dialoguer = "0.11"
tokio-serial = { workspace = true }
toml = "0.8"
toml_edit = "0.22"
trx-config = { path = "../trx-config" }
[dev-dependencies]
tempfile = "3"
+136 -319
View File
@@ -2,80 +2,37 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! `trx-configurator --check`: run a config file through the same loader and
//! validators the binaries use.
//!
//! This used to be a second, hand-maintained implementation — lists of known
//! keys and a handful of re-implemented range checks — which drifted out of
//! date as soon as a field was added. It now defers entirely to `trx-config`,
//! so a config that checks clean here is one the binaries will accept.
use std::fmt::Write as _;
use std::path::Path;
use toml_edit::DocumentMut;
use trx_config::{ClientConfig, ServerConfig};
/// Known top-level keys for a standalone server config.
const SERVER_KEYS: &[&str] = &[
"general",
/// Top-level keys that only appear in a server config. Used solely to guess
/// what a section-less file is meant to be; the real key checking is done by
/// the loader.
const SERVER_MARKERS: &[&str] = &[
"rig",
"rigs",
"behavior",
"listen",
"audio",
"behavior",
"sdr",
"pskreporter",
"aprsfi",
"decode_logs",
"timeouts",
"audio",
];
/// Known top-level keys for a standalone client config.
const CLIENT_KEYS: &[&str] = &["general", "remote", "remotes", "frontends"];
/// Known top-level keys for a combined trx-rs.toml.
const COMBINED_KEYS: &[&str] = &["trx-server", "trx-client"];
/// Known sub-keys within [general] (server).
const SERVER_GENERAL_KEYS: &[&str] = &["callsign", "log_level", "latitude", "longitude"];
/// Known sub-keys within [general] (client).
const CLIENT_GENERAL_KEYS: &[&str] = &[
"callsign",
"log_level",
"website_url",
"website_name",
"ais_vessel_url_base",
];
/// Known sub-keys within [rig].
const RIG_KEYS: &[&str] = &["model", "initial_freq_hz", "initial_mode", "access"];
/// Known sub-keys within [rig.access].
const ACCESS_KEYS: &[&str] = &["type", "port", "baud", "host", "tcp_port", "args"];
/// Known sub-keys within [listen].
const LISTEN_KEYS: &[&str] = &["enabled", "listen", "port", "auth"];
/// Known sub-keys within [audio] (server).
const AUDIO_KEYS: &[&str] = &[
"enabled",
"listen",
"port",
"rx_enabled",
"tx_enabled",
"device",
"sample_rate",
"channels",
"frame_duration_ms",
"bitrate_bps",
];
/// Known sub-keys within [behavior].
const BEHAVIOR_KEYS: &[&str] = &[
"poll_interval_ms",
"poll_interval_tx_ms",
"max_retries",
"retry_base_delay_ms",
"vfo_prime",
];
/// Known sub-keys within [remote].
const REMOTE_KEYS: &[&str] = &["url", "rig_id", "auth", "poll_interval_ms"];
/// Known sub-keys within [frontends].
const FRONTENDS_KEYS: &[&str] = &["http", "rigctl", "http_json", "audio"];
/// Top-level keys that only appear in a client config.
const CLIENT_MARKERS: &[&str] = &["remote", "remotes", "frontends"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DetectedType {
@@ -100,49 +57,38 @@ pub fn check_file(path: &Path) -> Result<String, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
// Step 1: TOML syntax check
let doc: DocumentMut = content
.parse()
let table: toml::Table = toml::from_str(&content)
.map_err(|e| format!("{}: TOML syntax error: {}", path.display(), e))?;
let detected = detect_type(&table);
let mut report = String::new();
let mut warnings: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
let table = doc.as_table();
// Step 2: Detect config type
let detected = detect_type(table);
writeln!(report, "{}: valid TOML", path.display()).unwrap();
writeln!(report, " Detected type: {}", detected).unwrap();
// Step 3: Structural validation
match detected {
DetectedType::Server => {
check_unknown_keys(table, SERVER_KEYS, "", &mut warnings);
check_server_sections(table, "", &mut warnings, &mut errors);
}
DetectedType::Client => {
check_unknown_keys(table, CLIENT_KEYS, "", &mut warnings);
check_client_sections(table, "", &mut warnings, &mut errors);
}
DetectedType::Server => check_server(path, &mut warnings, &mut errors),
DetectedType::Client => check_client(path, &mut warnings, &mut errors),
DetectedType::Combined => {
check_unknown_keys(table, COMBINED_KEYS, "", &mut warnings);
if let Some(server) = table.get("trx-server").and_then(|v| v.as_table()) {
check_unknown_keys(server, SERVER_KEYS, "[trx-server].", &mut warnings);
check_server_sections(server, "[trx-server].", &mut warnings, &mut errors);
if table.contains_key("trx-server") {
check_server(path, &mut warnings, &mut errors);
}
if let Some(client) = table.get("trx-client").and_then(|v| v.as_table()) {
check_unknown_keys(client, CLIENT_KEYS, "[trx-client].", &mut warnings);
check_client_sections(client, "[trx-client].", &mut warnings, &mut errors);
if table.contains_key("trx-client") {
check_client(path, &mut warnings, &mut errors);
}
}
DetectedType::Unknown => {
warnings.push("Could not detect config type. Expected server, client, or combined (trx-rs.toml) layout.".to_string());
warnings.push(
"Could not detect config type. Expected server, client, or combined \
(trx-rs.toml) layout."
.to_string(),
);
}
}
// Step 4: Format report
for w in &warnings {
writeln!(report, " warning: {}", w).unwrap();
}
@@ -169,23 +115,54 @@ pub fn check_file(path: &Path) -> Result<String, String> {
}
}
fn detect_type(table: &toml_edit::Table) -> DetectedType {
fn check_server(path: &Path, warnings: &mut Vec<String>, errors: &mut Vec<String>) {
match ServerConfig::load_from_file(path) {
Ok(loaded) => {
warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string()));
if let Err(e) = loaded.config.validate() {
errors.push(format!("[trx-server] {}", e));
}
errors.extend(
loaded
.config
.validate_sdr()
.into_iter()
.map(|e| format!("[trx-server] {}", e)),
);
}
Err(e) => errors.push(e.to_string()),
}
}
fn check_client(path: &Path, warnings: &mut Vec<String>, errors: &mut Vec<String>) {
match ClientConfig::load_from_file(path) {
Ok(loaded) => {
warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string()));
if let Err(e) = loaded.config.validate() {
errors.push(format!("[trx-client] {}", e));
}
}
Err(e) => errors.push(e.to_string()),
}
}
fn detect_type(table: &toml::Table) -> DetectedType {
if table.contains_key("trx-server") || table.contains_key("trx-client") {
return DetectedType::Combined;
}
let keys: Vec<&str> = table.iter().map(|(k, _)| k).collect();
let keys: Vec<&str> = table.keys().map(|k| k.as_str()).collect();
let server_score = keys.iter().filter(|k| SERVER_KEYS.contains(k)).count();
let client_score = keys.iter().filter(|k| CLIENT_KEYS.contains(k)).count();
// Use distinguishing keys to break ties
if keys.contains(&"rig") || keys.contains(&"rigs") || keys.contains(&"listen") {
// Distinguishing keys first, then a simple majority.
if keys.iter().any(|k| ["rig", "rigs", "listen"].contains(k)) {
return DetectedType::Server;
}
if keys.contains(&"remote") || keys.contains(&"remotes") || keys.contains(&"frontends") {
if keys.iter().any(|k| CLIENT_MARKERS.contains(k)) {
return DetectedType::Client;
}
let server_score = keys.iter().filter(|k| SERVER_MARKERS.contains(k)).count();
let client_score = keys.iter().filter(|k| CLIENT_MARKERS.contains(k)).count();
if server_score > client_score {
DetectedType::Server
} else if client_score > server_score {
@@ -197,194 +174,6 @@ fn detect_type(table: &toml_edit::Table) -> DetectedType {
}
}
fn check_unknown_keys(
table: &toml_edit::Table,
known: &[&str],
prefix: &str,
warnings: &mut Vec<String>,
) {
for (key, _) in table.iter() {
if !known.contains(&key) {
warnings.push(format!("{}unknown key '{}'", prefix, key));
}
}
}
fn check_server_sections(
table: &toml_edit::Table,
prefix: &str,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) {
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
check_unknown_keys(
general,
SERVER_GENERAL_KEYS,
&format!("{}[general].", prefix),
warnings,
);
validate_log_level(general, &format!("{}[general]", prefix), errors);
validate_coordinates(general, &format!("{}[general]", prefix), errors);
}
if let Some(rig) = table.get("rig").and_then(|v| v.as_table()) {
check_unknown_keys(rig, RIG_KEYS, &format!("{}[rig].", prefix), warnings);
if let Some(access) = rig.get("access").and_then(|v| v.as_table()) {
check_unknown_keys(
access,
ACCESS_KEYS,
&format!("{}[rig.access].", prefix),
warnings,
);
validate_access(access, &format!("{}[rig.access]", prefix), errors);
}
}
if let Some(listen) = table.get("listen").and_then(|v| v.as_table()) {
check_unknown_keys(
listen,
LISTEN_KEYS,
&format!("{}[listen].", prefix),
warnings,
);
validate_port(listen, "port", &format!("{}[listen]", prefix), errors);
}
if let Some(audio) = table.get("audio").and_then(|v| v.as_table()) {
check_unknown_keys(audio, AUDIO_KEYS, &format!("{}[audio].", prefix), warnings);
validate_port(audio, "port", &format!("{}[audio]", prefix), errors);
}
if let Some(behavior) = table.get("behavior").and_then(|v| v.as_table()) {
check_unknown_keys(
behavior,
BEHAVIOR_KEYS,
&format!("{}[behavior].", prefix),
warnings,
);
}
}
fn check_client_sections(
table: &toml_edit::Table,
prefix: &str,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) {
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
check_unknown_keys(
general,
CLIENT_GENERAL_KEYS,
&format!("{}[general].", prefix),
warnings,
);
validate_log_level(general, &format!("{}[general]", prefix), errors);
}
if let Some(remote) = table.get("remote").and_then(|v| v.as_table()) {
check_unknown_keys(
remote,
REMOTE_KEYS,
&format!("{}[remote].", prefix),
warnings,
);
}
if let Some(frontends) = table.get("frontends").and_then(|v| v.as_table()) {
check_unknown_keys(
frontends,
FRONTENDS_KEYS,
&format!("{}[frontends].", prefix),
warnings,
);
if let Some(http) = frontends.get("http").and_then(|v| v.as_table()) {
validate_port(http, "port", &format!("{}[frontends.http]", prefix), errors);
}
if let Some(rigctl) = frontends.get("rigctl").and_then(|v| v.as_table()) {
validate_port(
rigctl,
"port",
&format!("{}[frontends.rigctl]", prefix),
errors,
);
}
}
}
// ── Value validators ────────────────────────────────────────────────────
fn validate_log_level(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(level) = table.get("log_level").and_then(|v| v.as_str()) {
if !["trace", "debug", "info", "warn", "error"].contains(&level) {
errors.push(format!(
"{}.log_level '{}' is invalid (expected: trace, debug, info, warn, error)",
context, level
));
}
}
}
fn validate_coordinates(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(lat) = table
.get("latitude")
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
{
if !(-90.0..=90.0).contains(&lat) {
errors.push(format!(
"{}.latitude {} is out of range (-90..90)",
context, lat
));
}
}
if let Some(lon) = table
.get("longitude")
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
{
if !(-180.0..=180.0).contains(&lon) {
errors.push(format!(
"{}.longitude {} is out of range (-180..180)",
context, lon
));
}
}
let has_lat = table.contains_key("latitude");
let has_lon = table.contains_key("longitude");
if has_lat != has_lon {
errors.push(format!(
"{}: latitude and longitude must be set together or both omitted",
context
));
}
}
fn validate_port(table: &toml_edit::Table, key: &str, context: &str, errors: &mut Vec<String>) {
if let Some(port) = table.get(key).and_then(|v| v.as_integer()) {
if let Some(enabled) = table.get("enabled").and_then(|v| v.as_bool()) {
if enabled && port <= 0 {
errors.push(format!("{}.{} must be > 0 when enabled", context, key));
}
}
if !(0..=65535).contains(&port) {
errors.push(format!(
"{}.{} {} is out of range (0..65535)",
context, key, port
));
}
}
}
fn validate_access(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(access_type) = table.get("type").and_then(|v| v.as_str()) {
if !["serial", "tcp", "sdr"].contains(&access_type) {
errors.push(format!(
"{}.type '{}' is invalid (expected: serial, tcp, sdr)",
context, access_type
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -418,8 +207,7 @@ enabled = true
port = 4530
"#,
);
assert!(result.is_ok());
let report = result.unwrap();
let report = result.expect("expected a clean report");
assert!(report.contains("Detected type: server"));
assert!(report.contains("No issues found"));
}
@@ -432,41 +220,40 @@ port = 4530
callsign = "W1AW"
[remote]
url = "localhost:4530"
url = "192.168.1.10:4530"
[frontends.http]
enabled = true
port = 8080
"#,
);
assert!(result.is_ok());
let report = result.unwrap();
let report = result.expect("expected a clean report");
assert!(report.contains("Detected type: client"));
assert!(report.contains("No issues found"));
}
#[test]
fn test_valid_combined_config() {
let result = check_toml(
r#"
[trx-server.general]
callsign = "W1AW"
[trx-client.general]
callsign = "W1AW"
[trx-server.rig]
model = "ft817"
[trx-server.rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[trx-client.remote]
url = "localhost:4530"
url = "127.0.0.1:4530"
"#,
);
assert!(result.is_ok());
let report = result.unwrap();
let report = result.expect("expected a clean report");
assert!(report.contains("Detected type: combined"));
}
#[test]
fn test_invalid_toml_syntax() {
let result = check_toml("this is not [valid toml");
assert!(result.is_err());
let result = check_toml("[general\ncallsign = \"W1AW\"\n");
assert!(result.unwrap_err().contains("TOML syntax error"));
}
@@ -474,19 +261,22 @@ url = "localhost:4530"
fn test_unknown_key_warning() {
let result = check_toml(
r#"
[general]
callsign = "W1AW"
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[bogus_section]
foo = "bar"
[listen]
prot = 4530
"#,
);
assert!(result.is_ok());
let report = result.unwrap();
assert!(report.contains("unknown key 'bogus_section'"));
let report = result.expect("unknown keys are warnings, not errors");
assert!(
report.contains("unknown config key 'listen.prot' (did you mean 'listen.port'?)"),
"unexpected report: {report}"
);
}
#[test]
@@ -498,11 +288,13 @@ log_level = "verbose"
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#,
);
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("log_level 'verbose' is invalid"));
assert!(result.unwrap_err().contains("log_level"));
}
#[test]
@@ -510,15 +302,17 @@ model = "ft817"
let result = check_toml(
r#"
[general]
latitude = 45.0
latitude = 52.0
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#,
);
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("latitude and longitude must be set together"));
assert!(result.unwrap_err().contains("longitude"));
}
#[test]
@@ -526,16 +320,18 @@ model = "ft817"
let result = check_toml(
r#"
[general]
latitude = 95.0
latitude = 120.0
longitude = 10.0
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#,
);
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("latitude 95 is out of range"));
assert!(result.unwrap_err().contains("latitude"));
}
#[test]
@@ -547,10 +343,31 @@ model = "ft817"
[rig.access]
type = "usb"
port = "/dev/ttyUSB0"
baud = 9600
"#,
);
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("type 'usb' is invalid"));
assert!(result.unwrap_err().contains("access"));
}
/// The old checker only knew a fixed list of top-level keys and a few range
/// rules, so it passed configs the server rejects at startup.
#[test]
fn test_catches_errors_the_key_list_checker_missed() {
let result = check_toml(
r#"
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[audio]
enabled = true
frame_duration_ms = 7
"#,
);
assert!(result.unwrap_err().contains("frame_duration_ms"));
}
}
+68 -8
View File
@@ -211,10 +211,13 @@ pub fn build_server(general: ServerGeneral, rig: RigSetup, listen: ListenSetup)
let mut doc = DocumentMut::new();
doc.decor_mut()
.set_prefix("# trx-server configuration\n# Generated by trx-configurator\n");
let tables = build_server_tables(general, rig, listen);
for (key, item) in tables.iter() {
doc.insert(key, item.clone());
}
// Emit the sectioned shape (`[trx-server]`) that trx-server writes with
// --print-config, so a generated file can be dropped into a combined
// trx-rs.toml unchanged.
doc.insert(
"trx-server",
Item::Table(build_server_tables(general, rig, listen)),
);
doc
}
@@ -350,10 +353,10 @@ pub fn build_client(
let mut doc = DocumentMut::new();
doc.decor_mut()
.set_prefix("# trx-client configuration\n# Generated by trx-configurator\n");
let tables = build_client_tables(general, remote, frontends);
for (key, item) in tables.iter() {
doc.insert(key, item.clone());
}
doc.insert(
"trx-client",
Item::Table(build_client_tables(general, remote, frontends)),
);
doc
}
@@ -453,3 +456,60 @@ pub fn write_file(doc: &DocumentMut, path: &Path) -> Result<(), String> {
println!("Wrote {}", path.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use trx_config::{ClientConfig, ServerConfig};
fn write_temp(doc: &DocumentMut) -> tempfile::NamedTempFile {
let file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
std::fs::write(file.path(), doc.to_string()).unwrap();
file
}
/// The wizard used to emit root-level `[general]` / `[rig]` tables while the
/// loader demanded a `[trx-server]` section, so every generated standalone
/// config was rejected by the binary it was generated for.
#[test]
fn test_generated_server_config_loads_and_validates() {
let file = write_temp(&build_default(ConfigType::Server));
let cfg = ServerConfig::load_from_file(file.path())
.expect("generated config must load")
.config;
cfg.validate().expect("generated config must validate");
assert_eq!(cfg.rig.model.as_deref(), Some("ft817"));
assert_eq!(cfg.listen.port, 4530);
}
#[test]
fn test_generated_client_config_loads_and_validates() {
let file = write_temp(&build_default(ConfigType::Client));
let cfg = ClientConfig::load_from_file(file.path())
.expect("generated config must load")
.config;
cfg.validate().expect("generated config must validate");
assert_eq!(cfg.remote.url.as_deref(), Some("localhost:4530"));
}
#[test]
fn test_generated_combined_config_loads_both_sections() {
let file = write_temp(&build_default(ConfigType::Combined));
let server = ServerConfig::load_from_file(file.path())
.expect("server section must load")
.config;
server.validate().expect("server section must validate");
let client = ClientConfig::load_from_file(file.path())
.expect("client section must load")
.config;
client.validate().expect("client section must validate");
}
#[test]
fn test_generated_docs_are_sectioned() {
let doc = build_default(ConfigType::Server);
assert!(doc.as_table().contains_key("trx-server"));
let doc = build_default(ConfigType::Client);
assert!(doc.as_table().contains_key("trx-client"));
}
}
+1
View File
@@ -17,3 +17,4 @@ uuid = { workspace = true }
ts-rs = { version = "12.0.1", features = ["uuid-impl"] }
sgp4 = "2"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
base64 = "0.22"
+1
View File
@@ -19,6 +19,7 @@ pub mod command;
pub mod controller;
pub mod request;
pub mod response;
pub mod spectrum_wire;
pub mod state;
/// How this backend communicates with the rig.
+124
View File
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Compact wire encoding for spectrum bins.
//!
//! Bins are dBFS magnitudes, and the web UI has always drawn them from `i8`
//! values — the SSE hop to the browser quantizes and base64-encodes them. The
//! server→client hop, which is the one that crosses the operator's network,
//! used to send the same information as a JSON array of `f32`: around ten bytes
//! per bin instead of one, or roughly 10 KB per 1024-bin frame.
//!
//! Bins therefore travel as base64-encoded `i8` dBFS, about an eighth of the
//! size, at a resolution the display already rounds to. Decoding still accepts
//! the old array form, so a new client can read an older server.
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use serde::de::{SeqAccess, Visitor};
use serde::{Deserializer, Serializer};
use std::fmt;
/// Quantize to whole dBFS and encode as base64.
pub fn serialize<S: Serializer>(bins: &[f32], serializer: S) -> Result<S::Ok, S::Error> {
let quantized: Vec<u8> = bins
.iter()
.map(|&db| {
let clamped = if db.is_finite() { db } else { -128.0 };
clamped.round().clamp(-128.0, 127.0) as i8 as u8
})
.collect();
serializer.serialize_str(&BASE64.encode(quantized))
}
/// Decode base64 bins, or a plain array of numbers from an older server.
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<f32>, D::Error> {
deserializer.deserialize_any(BinsVisitor)
}
struct BinsVisitor;
impl<'de> Visitor<'de> for BinsVisitor {
type Value = Vec<f32>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("base64-encoded i8 dBFS bins, or an array of numbers")
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
let bytes = BASE64
.decode(value)
.map_err(|e| E::custom(format!("invalid base64 spectrum bins: {e}")))?;
Ok(bytes.into_iter().map(|byte| byte as i8 as f32).collect())
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut bins = Vec::with_capacity(seq.size_hint().unwrap_or(1024));
while let Some(value) = seq.next_element::<f32>()? {
bins.push(value);
}
Ok(bins)
}
}
#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Frame {
#[serde(with = "super")]
bins: Vec<f32>,
}
#[test]
fn test_round_trip_quantizes_to_whole_db() {
let frame = Frame {
bins: vec![-73.4, -20.6, 0.0, -120.2],
};
let json = serde_json::to_string(&frame).unwrap();
let back: Frame = serde_json::from_str(&json).unwrap();
assert_eq!(back.bins, vec![-73.0, -21.0, 0.0, -120.0]);
}
#[test]
fn test_serializes_as_a_base64_string() {
let json = serde_json::to_string(&Frame {
bins: vec![-1.0, 0.0],
})
.unwrap();
assert!(json.contains('"'), "bins should be a string: {json}");
assert!(!json.contains('['), "bins should not be an array: {json}");
}
#[test]
fn test_clamps_out_of_range_and_non_finite() {
let frame = Frame {
bins: vec![-400.0, 400.0, f32::NAN, f32::NEG_INFINITY],
};
let json = serde_json::to_string(&frame).unwrap();
let back: Frame = serde_json::from_str(&json).unwrap();
assert_eq!(back.bins, vec![-128.0, 127.0, -128.0, -128.0]);
}
#[test]
fn test_reads_the_old_array_form() {
let back: Frame = serde_json::from_str(r#"{"bins":[-73.25,-20.5]}"#).unwrap();
assert_eq!(back.bins, vec![-73.25, -20.5]);
}
/// The point of the change: an ordinary frame gets much smaller.
#[test]
fn test_frame_is_far_smaller_than_the_array_form() {
let bins: Vec<f32> = (0..1024).map(|i| -60.0 - (i % 40) as f32 * 0.37).collect();
let compact = serde_json::to_string(&Frame { bins: bins.clone() }).unwrap();
let verbose = serde_json::to_string(&bins).unwrap();
assert!(
compact.len() * 5 < verbose.len(),
"compact {} bytes vs array {} bytes",
compact.len(),
verbose.len()
);
}
}
+6
View File
@@ -394,6 +394,12 @@ fn default_wfm_denoise_level() -> WfmDenoiseLevel {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
pub struct 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.
#[serde(with = "crate::rig::spectrum_wire")]
#[ts(type = "Array<number>")]
pub bins: Vec<f32>,
/// Centre frequency of the SDR capture in Hz.
#[ts(type = "number")]
+3 -1
View File
@@ -18,4 +18,6 @@ pub use auth::{NoAuthValidator, SimpleTokenValidator, TokenValidator};
pub use codec::{mode_to_string, parse_envelope, parse_mode};
pub use decoders::{DecoderActivation, DecoderDescriptor, DECODER_REGISTRY};
pub use mapping::{client_command_to_rig, rig_command_to_client};
pub use types::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry};
pub use types::{
ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry, SpectrumFrame,
};
+1 -1
View File
@@ -102,7 +102,7 @@ macro_rules! define_command_mapping {
define_command_mapping! {
// ── Client-only variants (no RigCommand counterpart) ─────────────
client_only: GetRigs, GetSatPasses, SubscribeMeter;
client_only: GetRigs, GetSatPasses, SubscribeMeter, SubscribeSpectrum;
// ── Unit variants (no payload) ───────────────────────────────────
unit:
+22
View File
@@ -130,6 +130,28 @@ pub enum ClientCommand {
/// newline-delimited `MeterUpdate` JSON frames and no further commands or
/// regular responses are sent. Intended for a dedicated TCP connection.
SubscribeMeter,
/// Subscribe to a per-rig spectrum stream on this connection. Like
/// `SubscribeMeter`, the connection becomes a one-way flow of
/// newline-delimited `SpectrumFrame` JSON and no further commands or
/// regular responses are sent.
///
/// Polling `GetSpectrum` costs a round trip per frame, which caps the frame
/// rate at 1/RTT no matter how often the client asks; the server pushes at
/// its own cadence instead. Clients fall back to polling when the server
/// rejects this command.
SubscribeSpectrum,
}
/// One spectrum frame pushed by the server on a dedicated spectrum stream.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SpectrumFrame {
/// Rig identifier this frame belongs to.
pub rig_id: String,
/// The frame itself; bins travel base64-encoded (see `spectrum_wire`).
pub spectrum: trx_core::rig::state::SpectrumData,
/// Virtual-channel RDS state, mirroring what `GetSpectrum` returned.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vchan_rds: Option<Vec<trx_core::rig::state::VchanRdsEntry>>,
}
/// Fast meter sample pushed by the server on a dedicated meter stream.
+1
View File
@@ -32,6 +32,7 @@ cpal = "0.15"
num-complex = "0.4"
opus = "0.3"
trx-app = { path = "../trx-app" }
trx-config = { path = "../trx-config" }
trx-backend = { path = "trx-backend", features = ["soapysdr"] }
trx-ais = { path = "../decoders/trx-ais" }
trx-vdes = { path = "../decoders/trx-vdes" }
+1 -4
View File
@@ -2291,15 +2291,12 @@ pub async fn run_wefax_decoder(
mut state_rx: watch::Receiver<RigState>,
decode_tx: broadcast::Sender<DecodedMessage>,
histories: Arc<DecoderHistories>,
wefax_output_dir: std::path::PathBuf,
) {
use trx_wefax::{WefaxConfig, WefaxDecoder, WefaxEvent};
info!("WEFAX decoder started ({}Hz, {} ch)", sample_rate, channels);
let wefax_output_dir = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("wefax");
let config = WefaxConfig {
output_dir: Some(wefax_output_dir.to_string_lossy().into_owned()),
..WefaxConfig::default()
File diff suppressed because it is too large Load Diff
+117
View File
@@ -494,6 +494,58 @@ where
}
};
// SubscribeSpectrum: turns this connection into a one-way spectrum
// stream. Polling GetSpectrum costs a round trip per frame, so a
// client on a slow link could never reach the frame rate it asked for;
// pushing decouples the rate from the latency.
if matches!(envelope.cmd, ClientCommand::SubscribeSpectrum) {
let mut spectrum_rx = handle.spectrum_tx.subscribe();
let io_timeout = timeouts.io_timeout;
info!(
"Client {} subscribed to spectrum stream for rig '{}'",
addr, target_rig_id
);
loop {
tokio::select! {
frame = spectrum_rx.recv() => {
match frame {
Ok(frame) => {
let Ok(mut line) = serde_json::to_string(&frame) else { continue };
line.push('\n');
let write = time::timeout(
io_timeout,
writer.write_all(line.as_bytes()),
).await;
match write {
Ok(Ok(())) => {}
Ok(Err(e)) => {
info!("Client {} spectrum write failed: {}", addr, e);
break;
}
Err(_) => {
info!("Client {} spectrum write timed out", addr);
break;
}
}
}
// A client that cannot keep up skips to the newest
// frame; stale spectrum is not worth drawing.
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
changed = shutdown_rx.changed() => {
match changed {
Ok(()) if *shutdown_rx.borrow() => break,
Ok(()) => {}
Err(_) => break,
}
}
}
}
break;
}
// SubscribeMeter: turns this connection into a one-way meter stream.
// No regular responses are produced; the connection lives until the
// client disconnects or shutdown fires.
@@ -732,6 +784,7 @@ mod tests {
let (state_tx, state_rx) = watch::channel(state);
let _state_tx = state_tx;
let (meter_tx, _) = tokio::sync::broadcast::channel(8);
let (spectrum_tx, _) = tokio::sync::broadcast::channel(4);
let handle = RigHandle {
rig_id: "default".to_string(),
display_name: "Default Rig".to_string(),
@@ -739,6 +792,7 @@ mod tests {
state_rx,
audio_port: 4531,
meter_tx,
spectrum_tx,
};
let mut map = HashMap::new();
map.insert("default".to_string(), handle);
@@ -933,6 +987,7 @@ mod tests {
state_rx: state_rx_a,
audio_port: 4531,
meter_tx: meter_tx_a,
spectrum_tx: tokio::sync::broadcast::channel(4).0,
};
let (tx_b, rx_b) = mpsc::channel::<RigRequest>(8);
@@ -945,6 +1000,7 @@ mod tests {
state_rx: state_rx_b,
audio_port: 4532,
meter_tx: meter_tx_b,
spectrum_tx: tokio::sync::broadcast::channel(4).0,
};
let mut map = HashMap::new();
@@ -953,6 +1009,67 @@ mod tests {
(Arc::new(map), "rig_hf".to_string(), rx_a, rx_b)
}
/// Polling spectrum costs a round trip per frame, so the rate a client can
/// reach is capped by latency rather than by what it asked for. Subscribed
/// clients get frames pushed instead; this is that path end to end.
#[tokio::test]
async fn subscribe_spectrum_pushes_frames() {
use trx_core::rig::state::SpectrumData;
let (rigs, default_id) = make_rigs(sample_state());
let spectrum_tx = rigs.get("default").expect("rig").spectrum_tx.clone();
let ctx = make_ctx(rigs, default_id, HashSet::new());
let (mut reader, mut writer, handle, shutdown_tx) = spawn_client_io(ctx);
writer
.write_all(br#"{"cmd":"subscribe_spectrum"}"#)
.await
.expect("write");
writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush");
// The subscription is registered asynchronously; publish until it takes.
let frame = trx_protocol::SpectrumFrame {
rig_id: "default".to_string(),
spectrum: SpectrumData {
bins: vec![-73.4, -20.6, 0.0],
center_hz: 14_200_000,
sample_rate: 1_920_000,
rds: None,
},
vchan_rds: None,
};
let mut line = String::new();
for _ in 0..50 {
let _ = spectrum_tx.send(frame.clone());
tokio::select! {
read = reader.read_line(&mut line) => {
if read.expect("read") > 0 && !line.trim().is_empty() {
break;
}
line.clear();
}
_ = tokio::time::sleep(Duration::from_millis(20)) => {}
}
}
let received: trx_protocol::SpectrumFrame =
serde_json::from_str(line.trim()).unwrap_or_else(|e| panic!("frame {line:?}: {e}"));
assert_eq!(received.rig_id, "default");
assert_eq!(received.spectrum.center_hz, 14_200_000);
// Bins survive the trip quantized to whole dBFS, which is the
// resolution the display draws at anyway.
assert_eq!(received.spectrum.bins, vec![-73.0, -21.0, 0.0]);
// And they travel as base64 rather than a JSON array of floats.
assert!(
!line.contains("-73"),
"bins should not be spelled out on the wire: {line}"
);
let _ = shutdown_tx.send(true);
let _ = handle.await;
}
#[tokio::test]
async fn multi_rig_state_isolation() {
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
+267 -142
View File
@@ -30,6 +30,8 @@ use trx_core::audio::AudioStreamInfo;
use trx_app::{init_logging, normalize_name};
use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess};
use trx_config::shared::BoundSocket;
use trx_config::ConfigFile;
use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff};
use trx_core::rig::request::RigRequest;
use trx_core::rig::state::RigState;
@@ -56,6 +58,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,
/// Rig backend to use (e.g. ft817, ft450d)
#[arg(short = 'r', long = "rig")]
rig: Option<String>,
@@ -352,6 +360,7 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
})?;
let pcm_rx = sdr_rig.subscribe_pcm();
@@ -452,6 +461,7 @@ fn build_rig_task_config(
prebuilt_rig: None,
command_exec_timeout: Duration::from_millis(timeouts.command_exec_timeout_ms),
poll_refresh_timeout: Duration::from_millis(timeouts.poll_refresh_timeout_ms),
spectrum_interval_ms: rig_cfg.sdr.spectrum_interval_ms,
}
}
@@ -653,38 +663,44 @@ fn spawn_rig_audio_stack(
}
// Spawn APRS decoder task
let aprs_pcm_rx = pcm_tx.subscribe();
let aprs_state_rx = state_rx.clone();
let aprs_decode_tx = decode_tx.clone();
let aprs_sr = rig_cfg.audio.sample_rate;
let aprs_ch = rig_cfg.audio.channels;
let aprs_shutdown_rx = shutdown_rx.clone();
let aprs_logs = decoder_logs.clone();
let aprs_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs, aprs_histories) => {}
_ = wait_for_shutdown(aprs_shutdown_rx) => {}
}
}));
if rig_cfg.decoders.is_enabled("aprs") {
let aprs_pcm_rx = pcm_tx.subscribe();
let aprs_state_rx = state_rx.clone();
let aprs_decode_tx = decode_tx.clone();
let aprs_sr = rig_cfg.audio.sample_rate;
let aprs_ch = rig_cfg.audio.channels;
let aprs_shutdown_rx = shutdown_rx.clone();
let aprs_logs = decoder_logs.clone();
let aprs_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs, aprs_histories) => {}
_ = wait_for_shutdown(aprs_shutdown_rx) => {}
}
}));
}
// Spawn HF APRS decoder task
let hf_aprs_pcm_rx = pcm_tx.subscribe();
let hf_aprs_state_rx = state_rx.clone();
let hf_aprs_decode_tx = decode_tx.clone();
let hf_aprs_sr = rig_cfg.audio.sample_rate;
let hf_aprs_ch = rig_cfg.audio.channels;
let hf_aprs_shutdown_rx = shutdown_rx.clone();
let hf_aprs_logs = decoder_logs.clone();
let hf_aprs_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_hf_aprs_decoder(hf_aprs_sr, hf_aprs_ch as u16, hf_aprs_pcm_rx, hf_aprs_state_rx, hf_aprs_decode_tx, hf_aprs_logs, hf_aprs_histories) => {}
_ = wait_for_shutdown(hf_aprs_shutdown_rx) => {}
}
}));
if rig_cfg.decoders.is_enabled("aprs_hf") {
let hf_aprs_pcm_rx = pcm_tx.subscribe();
let hf_aprs_state_rx = state_rx.clone();
let hf_aprs_decode_tx = decode_tx.clone();
let hf_aprs_sr = rig_cfg.audio.sample_rate;
let hf_aprs_ch = rig_cfg.audio.channels;
let hf_aprs_shutdown_rx = shutdown_rx.clone();
let hf_aprs_logs = decoder_logs.clone();
let hf_aprs_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_hf_aprs_decoder(hf_aprs_sr, hf_aprs_ch as u16, hf_aprs_pcm_rx, hf_aprs_state_rx, hf_aprs_decode_tx, hf_aprs_logs, hf_aprs_histories) => {}
_ = wait_for_shutdown(hf_aprs_shutdown_rx) => {}
}
}));
}
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = sdr_ais_pcm_rx {
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) =
sdr_ais_pcm_rx.filter(|_| rig_cfg.decoders.is_enabled("ais"))
{
let ais_state_rx = state_rx.clone();
let ais_decode_tx = decode_tx.clone();
let ais_shutdown_rx = shutdown_rx.clone();
@@ -699,7 +715,7 @@ fn spawn_rig_audio_stack(
}));
}
if let Some(vdes_iq_rx) = sdr_vdes_iq_rx {
if let Some(vdes_iq_rx) = sdr_vdes_iq_rx.filter(|_| rig_cfg.decoders.is_enabled("vdes")) {
let vdes_state_rx = state_rx.clone();
let vdes_decode_tx = decode_tx.clone();
let vdes_shutdown_rx = shutdown_rx.clone();
@@ -721,55 +737,61 @@ fn spawn_rig_audio_stack(
}
// Spawn CW decoder task
let cw_pcm_rx = pcm_tx.subscribe();
let cw_state_rx = state_rx.clone();
let cw_decode_tx = decode_tx.clone();
let cw_sr = rig_cfg.audio.sample_rate;
let cw_ch = rig_cfg.audio.channels;
let cw_shutdown_rx = shutdown_rx.clone();
let cw_logs = decoder_logs.clone();
let cw_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs, cw_histories) => {}
_ = wait_for_shutdown(cw_shutdown_rx) => {}
}
}));
if rig_cfg.decoders.is_enabled("cw") {
let cw_pcm_rx = pcm_tx.subscribe();
let cw_state_rx = state_rx.clone();
let cw_decode_tx = decode_tx.clone();
let cw_sr = rig_cfg.audio.sample_rate;
let cw_ch = rig_cfg.audio.channels;
let cw_shutdown_rx = shutdown_rx.clone();
let cw_logs = decoder_logs.clone();
let cw_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs, cw_histories) => {}
_ = wait_for_shutdown(cw_shutdown_rx) => {}
}
}));
}
// Spawn FT8 decoder task
let ft8_pcm_rx = pcm_tx.subscribe();
let ft8_state_rx = state_rx.clone();
let ft8_decode_tx = decode_tx.clone();
let ft8_sr = rig_cfg.audio.sample_rate;
let ft8_ch = rig_cfg.audio.channels;
let ft8_shutdown_rx = shutdown_rx.clone();
let ft8_logs = decoder_logs.clone();
let ft8_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs, ft8_histories) => {}
_ = wait_for_shutdown(ft8_shutdown_rx) => {}
}
}));
if rig_cfg.decoders.is_enabled("ft8") {
let ft8_pcm_rx = pcm_tx.subscribe();
let ft8_state_rx = state_rx.clone();
let ft8_decode_tx = decode_tx.clone();
let ft8_sr = rig_cfg.audio.sample_rate;
let ft8_ch = rig_cfg.audio.channels;
let ft8_shutdown_rx = shutdown_rx.clone();
let ft8_logs = decoder_logs.clone();
let ft8_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs, ft8_histories) => {}
_ = wait_for_shutdown(ft8_shutdown_rx) => {}
}
}));
}
// Spawn FT4 decoder task
let ft4_pcm_rx = pcm_tx.subscribe();
let ft4_state_rx = state_rx.clone();
let ft4_decode_tx = decode_tx.clone();
let ft4_sr = rig_cfg.audio.sample_rate;
let ft4_ch = rig_cfg.audio.channels;
let ft4_shutdown_rx = shutdown_rx.clone();
let ft4_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {}
_ = wait_for_shutdown(ft4_shutdown_rx) => {}
}
}));
if rig_cfg.decoders.is_enabled("ft4") {
let ft4_pcm_rx = pcm_tx.subscribe();
let ft4_state_rx = state_rx.clone();
let ft4_decode_tx = decode_tx.clone();
let ft4_sr = rig_cfg.audio.sample_rate;
let ft4_ch = rig_cfg.audio.channels;
let ft4_shutdown_rx = shutdown_rx.clone();
let ft4_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {}
_ = wait_for_shutdown(ft4_shutdown_rx) => {}
}
}));
}
// Spawn FT2 decoder task
#[cfg(feature = "ft2")]
{
if rig_cfg.decoders.is_enabled("ft2") {
let ft2_pcm_rx = pcm_tx.subscribe();
let ft2_state_rx = state_rx.clone();
let ft2_decode_tx = decode_tx.clone();
@@ -786,73 +808,76 @@ fn spawn_rig_audio_stack(
}
// Spawn WSPR decoder task
let wspr_pcm_rx = pcm_tx.subscribe();
let wspr_state_rx = state_rx.clone();
let wspr_decode_tx = decode_tx.clone();
let wspr_sr = rig_cfg.audio.sample_rate;
let wspr_ch = rig_cfg.audio.channels;
let wspr_shutdown_rx = shutdown_rx.clone();
let wspr_logs = decoder_logs.clone();
let wspr_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs, wspr_histories) => {}
_ = wait_for_shutdown(wspr_shutdown_rx) => {}
}
}));
if rig_cfg.decoders.is_enabled("wspr") {
let wspr_pcm_rx = pcm_tx.subscribe();
let wspr_state_rx = state_rx.clone();
let wspr_decode_tx = decode_tx.clone();
let wspr_sr = rig_cfg.audio.sample_rate;
let wspr_ch = rig_cfg.audio.channels;
let wspr_shutdown_rx = shutdown_rx.clone();
let wspr_logs = decoder_logs.clone();
let wspr_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs, wspr_histories) => {}
_ = wait_for_shutdown(wspr_shutdown_rx) => {}
}
}));
}
// Spawn Meteor-M LRPT decoder task
let lrpt_pcm_rx = pcm_tx.subscribe();
let lrpt_state_rx = state_rx.clone();
let lrpt_decode_tx = decode_tx.clone();
let lrpt_sr = rig_cfg.audio.sample_rate;
let lrpt_ch = rig_cfg.audio.channels;
let lrpt_shutdown_rx = shutdown_rx.clone();
let lrpt_histories = histories.clone();
let lrpt_output_dir = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("lrpt");
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {}
_ = wait_for_shutdown(lrpt_shutdown_rx) => {}
}
}));
if rig_cfg.decoders.is_enabled("lrpt") {
let lrpt_pcm_rx = pcm_tx.subscribe();
let lrpt_state_rx = state_rx.clone();
let lrpt_decode_tx = decode_tx.clone();
let lrpt_sr = rig_cfg.audio.sample_rate;
let lrpt_ch = rig_cfg.audio.channels;
let lrpt_shutdown_rx = shutdown_rx.clone();
let lrpt_histories = histories.clone();
let lrpt_output_dir = rig_cfg.decoders.output_dir_for("lrpt");
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {}
_ = wait_for_shutdown(lrpt_shutdown_rx) => {}
}
}));
}
// Spawn WEFAX decoder task
let wefax_pcm_rx = pcm_tx.subscribe();
let wefax_state_rx = state_rx.clone();
let wefax_decode_tx = decode_tx.clone();
let wefax_sr = rig_cfg.audio.sample_rate;
let wefax_ch = rig_cfg.audio.channels;
let wefax_shutdown_rx = shutdown_rx.clone();
let wefax_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories) => {}
_ = wait_for_shutdown(wefax_shutdown_rx) => {}
}
}));
if rig_cfg.decoders.is_enabled("wefax") {
let wefax_pcm_rx = pcm_tx.subscribe();
let wefax_state_rx = state_rx.clone();
let wefax_decode_tx = decode_tx.clone();
let wefax_sr = rig_cfg.audio.sample_rate;
let wefax_ch = rig_cfg.audio.channels;
let wefax_shutdown_rx = shutdown_rx.clone();
let wefax_histories = histories.clone();
let wefax_output_dir = rig_cfg.decoders.output_dir_for("wefax");
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories, wefax_output_dir) => {}
_ = wait_for_shutdown(wefax_shutdown_rx) => {}
}
}));
}
// Spawn SSTV decoder task
let sstv_pcm_rx = pcm_tx.subscribe();
let sstv_state_rx = state_rx.clone();
let sstv_decode_tx = decode_tx.clone();
let sstv_sr = rig_cfg.audio.sample_rate;
let sstv_ch = rig_cfg.audio.channels;
let sstv_shutdown_rx = shutdown_rx.clone();
let sstv_histories = histories.clone();
let sstv_output_dir = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("sstv");
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {}
_ = wait_for_shutdown(sstv_shutdown_rx) => {}
}
}));
if rig_cfg.decoders.is_enabled("sstv") {
let sstv_pcm_rx = pcm_tx.subscribe();
let sstv_state_rx = state_rx.clone();
let sstv_decode_tx = decode_tx.clone();
let sstv_sr = rig_cfg.audio.sample_rate;
let sstv_ch = rig_cfg.audio.channels;
let sstv_shutdown_rx = shutdown_rx.clone();
let sstv_histories = histories.clone();
let sstv_output_dir = rig_cfg.decoders.output_dir_for("sstv");
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {}
_ = wait_for_shutdown(sstv_shutdown_rx) => {}
}
}));
}
}
if rig_cfg.audio.tx_enabled {
@@ -882,6 +907,82 @@ fn spawn_rig_audio_stack(
handles
}
/// Sockets this process will bind, given the config and the CLI overrides.
///
/// `--listen` overrides the bind address of both the control listener and every
/// rig's audio listener, so the two callers of this must agree on the rules.
fn bound_sockets(cli: &Cli, cfg: &ServerConfig, rigs: &[RigInstanceConfig]) -> Vec<BoundSocket> {
let mut sockets = Vec::new();
if cfg.listen.enabled {
sockets.push(BoundSocket::new(
cli.listen.unwrap_or(cfg.listen.listen),
cli.port.unwrap_or(cfg.listen.port),
"[listen]",
));
}
let audio_ip = cli.listen.unwrap_or(cfg.audio.listen);
for rig in rigs {
if rig.audio.enabled {
sockets.push(BoundSocket::new(
audio_ip,
rig.audio.port,
format!("rig \"{}\" [audio]", rig.id),
));
}
}
sockets
}
/// `--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.
fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad<ServerConfig>) -> 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(ServerConfig::deprecations(&loaded.present_keys));
for warning in &warnings {
println!(" warning: {}", warning);
}
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 rigs = cfg.resolved_rigs();
errors.extend(cfg.validate_all());
errors.extend(cfg.validate_sdr());
errors.extend(cfg.validate_resolved_all(&rigs, &bound_sockets(cli, cfg, &rigs)));
for e in &errors {
println!(" error: {}", e);
}
if errors.is_empty() {
println!(
" OK: {} rig(s) configured: {}",
rigs.len(),
rigs.iter()
.map(|r| r.id.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())
}
}
#[tokio::main]
async fn main() -> DynResult<()> {
let mut bootstrap_ctx = RegistrationContext::new();
@@ -894,12 +995,30 @@ async fn main() -> DynResult<()> {
return Ok(());
}
let (cfg, config_path) = if let Some(ref path) = cli.config {
let cfg = ServerConfig::load_from_file(path)?;
(cfg, Some(path.clone()))
let loaded = if let Some(ref path) = cli.config {
ServerConfig::load_from_file(path)?
} else {
ServerConfig::load_from_default_paths()?
};
let config_path = loaded.path.clone();
if cli.check_config {
return check_config(&cli, &loaded);
}
// 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();
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 server configuration: {}", e))?;
@@ -912,12 +1031,6 @@ async fn main() -> DynResult<()> {
std::process::exit(1);
}
init_logging(cfg.general.log_level.as_deref());
if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display());
}
let registry = Arc::new(bootstrap_ctx);
// --- Resolve the effective rig list ---
@@ -979,6 +1092,12 @@ async fn main() -> DynResult<()> {
(callsign, cfg.general.latitude, cfg.general.longitude)
};
// Second validation phase: now that CLI overrides have been folded in, check
// the things that need the final rig list — chiefly that no two listeners
// claim the same socket.
cfg.validate_resolved(&resolved_rigs, &bound_sockets(&cli, &cfg, &resolved_rigs))
.map_err(|e| format!("Invalid server configuration: {}", e))?;
info!(
"Starting trx-server with {} rig(s): {}",
resolved_rigs.len(),
@@ -1098,6 +1217,9 @@ async fn main() -> DynResult<()> {
let (state_tx, state_rx) = watch::channel(initial_state);
let (meter_tx, _) =
broadcast::channel::<trx_protocol::MeterUpdate>(rig_handle::METER_BROADCAST_CAPACITY);
let (spectrum_tx, _) = broadcast::channel::<trx_protocol::SpectrumFrame>(
rig_handle::SPECTRUM_BROADCAST_CAPACITY,
);
let mut task_config = build_rig_task_config(
rig_cfg,
@@ -1125,12 +1247,14 @@ async fn main() -> DynResult<()> {
let rig_shutdown_rx = shutdown_rx.clone();
let rig_id_supervisor = rig_cfg.id.clone();
let meter_tx_task = meter_tx.clone();
let spectrum_tx_task = spectrum_tx.clone();
task_handles.push(tokio::spawn(async move {
let result = rig_task::run_rig_task(
task_config,
rig_rx,
state_tx.clone(),
meter_tx_task,
spectrum_tx_task,
rig_shutdown_rx,
)
.await;
@@ -1177,6 +1301,7 @@ async fn main() -> DynResult<()> {
rig_handles.insert(
rig_cfg.id.clone(),
RigHandle {
spectrum_tx: spectrum_tx.clone(),
rig_id: rig_cfg.id.clone(),
display_name: rig_cfg.display_name().to_string(),
rig_tx,
+10 -1
View File
@@ -8,13 +8,18 @@ use tokio::sync::{broadcast, mpsc, watch};
use trx_core::rig::request::RigRequest;
use trx_core::rig::state::RigState;
use trx_protocol::MeterUpdate;
use trx_protocol::{MeterUpdate, SpectrumFrame};
/// Bounded broadcast capacity for the meter stream. Keeps ~0.5 s of buffered
/// samples at 30 Hz — more than enough slack to tolerate a scheduling blip
/// without forcing the producer to block or drop silently.
pub const METER_BROADCAST_CAPACITY: usize = 16;
/// Bounded broadcast capacity for the spectrum stream. Frames are large and
/// only the newest one is worth drawing, so the buffer stays shallow: a slow
/// client lags and skips rather than making the server hold stale frames.
pub const SPECTRUM_BROADCAST_CAPACITY: usize = 4;
/// A handle to a single running rig backend.
///
/// One `RigHandle` is created per rig in `main.rs` and stored in the shared
@@ -34,4 +39,8 @@ pub struct RigHandle {
/// ~67 Hz (CAT). Consumed by `SubscribeMeter` clients; independent of
/// the slower `state_rx` snapshot path.
pub meter_tx: broadcast::Sender<MeterUpdate>,
/// Per-rig spectrum frames published by `rig_task` while at least one
/// client is subscribed. Consumed by `SubscribeSpectrum` clients; the
/// producer skips the work entirely when nobody is listening.
pub spectrum_tx: broadcast::Sender<SpectrumFrame>,
}
+27 -1
View File
@@ -23,7 +23,7 @@ use trx_core::rig::request::RigRequest;
use trx_core::rig::state::{RigMode, RigSnapshot, RigState};
use trx_core::rig::{RigCat, RigRxStatus, RigTxStatus};
use trx_core::{DynResult, RigError, RigResult};
use trx_protocol::MeterUpdate;
use trx_protocol::{MeterUpdate, SpectrumFrame};
use crate::audio::DecoderHistories;
use crate::error::is_invalid_bcd_error;
@@ -64,6 +64,8 @@ pub struct RigTaskConfig {
pub command_exec_timeout: Duration,
/// Maximum time for a CAT poll refresh cycle.
pub poll_refresh_timeout: Duration,
/// How often to push a spectrum frame to subscribed clients, in ms.
pub spectrum_interval_ms: u64,
}
impl Default for RigTaskConfig {
@@ -94,6 +96,7 @@ impl Default for RigTaskConfig {
prebuilt_rig: None,
command_exec_timeout: DEFAULT_COMMAND_EXEC_TIMEOUT,
poll_refresh_timeout: DEFAULT_POLL_REFRESH_TIMEOUT,
spectrum_interval_ms: 50,
}
}
}
@@ -115,6 +118,7 @@ pub async fn run_rig_task(
mut rx: mpsc::Receiver<RigRequest>,
state_tx: watch::Sender<RigState>,
meter_tx: broadcast::Sender<MeterUpdate>,
spectrum_tx: broadcast::Sender<SpectrumFrame>,
mut shutdown_rx: watch::Receiver<bool>,
) -> DynResult<()> {
let histories = config.histories.clone();
@@ -273,6 +277,13 @@ pub async fn run_rig_task(
} else {
Duration::from_millis(150)
};
// Spectrum frames get their own tick so a subscribed client never pays a
// round trip per frame. The FFT is computed by the SDR thread either way;
// this only reads the latest result, and only while somebody is subscribed.
let spectrum_tick_duration = Duration::from_millis(config.spectrum_interval_ms.max(1));
let mut spectrum_tick: std::pin::Pin<Box<tokio::time::Sleep>> =
Box::pin(tokio::time::sleep(spectrum_tick_duration));
let meter_task_start = Instant::now();
let meter_state_delta_db: f64 = 0.25;
let rig_id = config.rig_id.clone();
@@ -302,6 +313,21 @@ pub async fn run_rig_task(
Err(_) => break,
}
}
// Push the latest spectrum frame to subscribed clients.
_ = &mut spectrum_tick => {
spectrum_tick = Box::pin(tokio::time::sleep(spectrum_tick_duration));
// `send` fails only when nobody is listening, but building the
// frame clones a few KB of bins, so check before doing the work.
if spectrum_tx.receiver_count() > 0 {
if let Some(spectrum) = rig.as_sdr_ref().and_then(|s| s.get_spectrum()) {
let _ = spectrum_tx.send(SpectrumFrame {
rig_id: rig_id.clone(),
spectrum,
vchan_rds: rig.as_sdr_ref().and_then(|s| s.get_vchan_rds()),
});
}
}
}
// Fast meter-only refresh between full polls.
_ = &mut meter_tick => {
meter_tick = Box::pin(tokio::time::sleep(meter_tick_duration));
@@ -154,6 +154,7 @@ impl SdrPipeline {
squelch_cfg: VirtualSquelchConfig,
nb_cfg: NoiseBlankerConfig,
channels: &[(f64, RigMode, u32)],
spectrum_fft_size: usize,
) -> Self {
const IQ_BROADCAST_CAPACITY: usize = 64;
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
@@ -219,6 +220,7 @@ impl SdrPipeline {
.name("sdr-iq-read".to_string())
.spawn(move || {
iq_read_loop(
spectrum_fft_size,
source,
sdr_sample_rate,
thread_dsps,
@@ -317,6 +319,7 @@ pub const IQ_BLOCK_SIZE: usize = 4096;
#[allow(clippy::too_many_arguments)]
fn iq_read_loop(
spectrum_fft_size: usize,
mut source: Box<dyn IqSource>,
sdr_sample_rate: u32,
channel_dsps: Arc<RwLock<Vec<Arc<Mutex<ChannelDsp>>>>>,
@@ -335,7 +338,7 @@ fn iq_read_loop(
};
let throttle = !source.is_blocking();
let mut spectrum = SpectrumSnapshotter::new();
let mut spectrum = SpectrumSnapshotter::new(spectrum_fft_size);
let mut read_error_streak: u32 = 0;
let mut zero_read_streak: u32 = 0;
let mut overflow_log_window_start: Option<Instant> = None;
@@ -572,6 +575,7 @@ mod tests {
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
&[(200_000.0, RigMode::USB, 3000)],
1024,
);
assert_eq!(pipeline.pcm_senders.len(), 1);
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1);
@@ -590,6 +594,7 @@ mod tests {
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
&[],
1024,
);
assert_eq!(pipeline.pcm_senders.len(), 0);
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 0);
@@ -9,28 +9,36 @@ use num_complex::Complex;
use rustfft::num_complex::Complex as FftComplex;
use rustfft::FftPlanner;
/// Number of FFT bins for the spectrum display.
pub(super) const SPECTRUM_FFT_SIZE: usize = 1024;
/// Default number of FFT bins for the spectrum display, used when the config
/// does not say otherwise.
pub(super) const DEFAULT_SPECTRUM_FFT_SIZE: usize = 1024;
/// Update the spectrum buffer every this many IQ blocks (~10 Hz at 1.92 MHz / 4096 block).
pub(super) const SPECTRUM_UPDATE_BLOCKS: usize = 4;
pub(super) struct SpectrumSnapshotter {
fft_size: usize,
hann_window: Vec<f32>,
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
counter: usize,
}
impl SpectrumSnapshotter {
pub(super) fn new() -> Self {
let hann_window: Vec<f32> = (0..SPECTRUM_FFT_SIZE)
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (SPECTRUM_FFT_SIZE - 1) as f32).cos()))
pub(super) fn new(fft_size: usize) -> Self {
let fft_size = if fft_size >= 2 {
fft_size
} else {
DEFAULT_SPECTRUM_FFT_SIZE
};
let hann_window: Vec<f32> = (0..fft_size)
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (fft_size - 1) as f32).cos()))
.collect();
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(SPECTRUM_FFT_SIZE);
let fft = planner.plan_fft_forward(fft_size);
Self {
fft_size,
hann_window,
fft,
counter: 0,
@@ -48,7 +56,7 @@ impl SpectrumSnapshotter {
}
self.counter = 0;
let take = samples.len().min(SPECTRUM_FFT_SIZE);
let take = samples.len().min(self.fft_size);
let mut buf: Vec<FftComplex<f32>> = samples[..take]
.iter()
.enumerate()
@@ -59,16 +67,15 @@ impl SpectrumSnapshotter {
)
})
.collect();
buf.resize(SPECTRUM_FFT_SIZE, FftComplex::new(0.0, 0.0));
buf.resize(self.fft_size, FftComplex::new(0.0, 0.0));
self.fft.process(&mut buf);
let half = SPECTRUM_FFT_SIZE / 2;
let half = self.fft_size / 2;
let bins: Vec<f32> = buf[half..]
.iter()
.chain(buf[..half].iter())
.map(|value| {
let mag =
(value.re * value.re + value.im * value.im).sqrt() / SPECTRUM_FFT_SIZE as f32;
let mag = (value.re * value.re + value.im * value.im).sqrt() / self.fft_size as f32;
20.0 * mag.max(1e-10_f32).log10()
})
.collect();
@@ -73,6 +73,11 @@ pub struct SoapySdrConfig {
pub nb_enabled: bool,
/// Noise blanker impulse threshold multiplier.
pub nb_threshold: f64,
/// FFT bin count for the spectrum display; a power of two.
///
/// Fewer bins cost less DSP and put fewer bytes on the network per frame,
/// which is what a slow server↔client link cares about.
pub spectrum_fft_size: usize,
}
impl Default for SoapySdrConfig {
@@ -99,6 +104,7 @@ impl Default for SoapySdrConfig {
max_virtual_channels: 4,
nb_enabled: false,
nb_threshold: 10.0,
spectrum_fft_size: 1024,
}
}
}
@@ -194,6 +200,7 @@ impl SoapySdrRig {
let max_virtual_channels = config.max_virtual_channels;
let nb_enabled = config.nb_enabled;
let nb_threshold = config.nb_threshold;
let spectrum_fft_size = config.spectrum_fft_size;
tracing::info!(
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
args,
@@ -293,6 +300,7 @@ impl SoapySdrRig {
threshold: nb_threshold as f32,
},
&all_channels,
spectrum_fft_size,
));
let info = RigInfo {
@@ -418,6 +426,7 @@ impl SoapySdrRig {
nb_threshold: f64,
) -> DynResult<Self> {
Self::new_from_config(SoapySdrConfig {
spectrum_fft_size: 1024,
args: args.to_string(),
channels: channels.to_vec(),
gain_mode: gain_mode.to_string(),
@@ -439,6 +439,7 @@ mod tests {
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
&[(0.0, RigMode::USB, 3_000)],
1024,
))
}
+111 -4
View File
@@ -1,36 +1,60 @@
# trx-rs example configuration
#
# Generated from the config structs; regenerate with:
# cargo run -p trx-config --example generate_example
#
# Both sections are optional: trx-server reads [trx-server], trx-client reads
# [trx-client], and either may live in its own file with the section header
# omitted. Any string may use ${ENV_VAR}, and credentials may be moved out of
# this file with the matching *_file keys.
#
# Check a config without starting anything:
# trx-server --check-config --config trx-rs.toml
# trx-client --check-config --config trx-rs.toml
# Server: drives the radio hardware.
[trx-server]
rigs = []
# Station identity. Coordinates feed PSKReporter and the map.
[trx-server.general]
callsign = "N0CALL"
log_level = "info"
latitude = 52.2297
longitude = 21.0122
# Single-rig layout. For several radios, delete this and use [[rigs]].
[trx-server.rig]
model = "ft817"
initial_freq_hz = 144300000
initial_mode = "USB"
# How to reach the radio: serial, tcp, or sdr.
[trx-server.rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
# CAT polling and retry behaviour.
[trx-server.behavior]
poll_interval_ms = 500
poll_interval_tx_ms = 100
max_retries = 3
retry_base_delay_ms = 100
vfo_prime = true
# JSON control listener that trx-client connects to.
[trx-server.listen]
enabled = true
listen = "127.0.0.1"
port = 4530
# Tokens clients must present. Empty means no authentication.
# Use tokens_file = "/etc/trx-rs/tokens" to keep them out of this file.
[trx-server.listen.auth]
tokens = []
# Opus audio stream for trx-client.
[trx-server.audio]
enabled = true
listen = "127.0.0.1"
@@ -42,25 +66,53 @@ channels = 2
frame_duration_ms = 20
bitrate_bps = 256000
# Report FT8/FT4/WSPR spots to pskreporter.info.
[trx-server.pskreporter]
enabled = false
host = "report.pskreporter.info"
port = 4739
# Forward received APRS frames to APRS-IS.
[trx-server.aprsfi]
enabled = false
host = "rotate.aprs.net"
port = 14580
passcode = -1
beacon = false
beacon_interval_secs = 1200
beacon_symbol_table = "/"
beacon_symbol_code = "-"
# Write decodes to JSON Lines files.
[trx-server.decode_logs]
enabled = false
dir = "/path/to/log/dir"
dir = "/var/lib/trx-rs/decoders"
aprs_file = "TRXRS-APRS-%YYYY%-%MM%-%DD%.log"
cw_file = "TRXRS-CW-%YYYY%-%MM%-%DD%.log"
ft8_file = "TRXRS-FT8-%YYYY%-%MM%-%DD%.log"
wspr_file = "TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"
wefax_file = "TRXRS-WEFAX-%YYYY%-%MM%-%DD%.log"
# Which decoders run. Trimming this list saves real CPU on small boxes.
# Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr.
# output_dir sets where sstv/wefax/lrpt write images (default: user cache dir).
[trx-server.decoders]
enabled = [
"aprs",
"aprs_hf",
"ais",
"cw",
"ft2",
"ft4",
"ft8",
"lrpt",
"sstv",
"vdes",
"wefax",
"wspr",
]
# SoapySDR pipeline; used when [rig.access] type = "sdr".
[trx-server.sdr]
sample_rate = 1920000
bandwidth = 1500000
@@ -68,17 +120,38 @@ wfm_deemphasis_us = 50
center_offset_hz = 100000
channels = []
max_virtual_channels = 4
spectrum_fft_size = 1024
spectrum_interval_ms = 50
# "auto" for hardware AGC, or "manual".
[trx-server.sdr.gain]
mode = "auto"
value = 30.0
# Software squelch on demodulated audio.
[trx-server.sdr.squelch]
enabled = false
threshold_db = -65.0
hysteresis_db = 3.0
tail_ms = 180
# Impulse-noise suppression on the IQ stream.
[trx-server.sdr.noise_blanker]
enabled = false
threshold = 10.0
# Timeout and buffer tuning. The defaults suit most setups.
[trx-server.timeouts]
command_exec_timeout_ms = 10000
poll_refresh_timeout_ms = 8000
io_timeout_ms = 10000
request_timeout_ms = 12000
rig_task_channel_buffer = 32
# Client: exposes the radio to users.
[trx-client]
# Labels shown in the web UI.
[trx-client.general]
callsign = "N0CALL"
website_url = "https://haxx.space"
@@ -86,24 +159,52 @@ website_name = "haxx.space"
ais_vessel_url_base = "https://www.vesselfinder.com/?mmsi="
log_level = "info"
# Legacy single-remote form; prefer [[remotes]] below.
[trx-client.remote]
url = "192.168.1.100:9000"
rig_id = "hf"
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remote.auth]
[[trx-client.remotes]]
name = "home-hf"
url = "192.168.1.100:4530"
rig_id = "hf"
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remotes.auth]
token = "my-token"
[[trx-client.remotes]]
name = "home-vhf"
url = "192.168.1.100:4530"
rig_id = "vhf"
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remotes.auth]
token = "my-token"
# Web UI. default_rig_name and the per-rig maps are keyed by the
# [[remotes]] name, not the server-side rig id.
[trx-client.frontends.http]
enabled = true
listen = "127.0.0.1"
port = 8080
default_rig_id = "hf"
default_rig_name = "home-hf"
initial_map_zoom = 10
spectrum_coverage_margin_hz = 50000
spectrum_usable_span_ratio = 0.9200000166893005
show_sdr_gain_control = true
bandplan_enabled = true
bandplan_region = "iaru_r1"
decode_history_retention_min = 1440
[trx-client.frontends.http.decode_history_retention_min_by_rig]
# Passphrase login for the web UI. rx_passphrase_file and
# control_passphrase_file keep the secrets out of this file.
[trx-client.frontends.http.auth]
enabled = false
rx_passphrase = "rx-passphrase-example"
@@ -113,6 +214,7 @@ session_ttl_min = 480
cookie_secure = false
cookie_same_site = "Lax"
# Hamlib-compatible TCP interface, one listener per rig.
[trx-client.frontends.rigctl]
enabled = false
listen = "127.0.0.1"
@@ -120,6 +222,7 @@ port = 4532
[trx-client.frontends.rigctl.rig_ports]
# JSON-over-TCP control interface.
[trx-client.frontends.http_json]
enabled = true
listen = "127.0.0.1"
@@ -128,12 +231,16 @@ port = 0
[trx-client.frontends.http_json.auth]
tokens = []
# Where to fetch the audio stream from.
[trx-client.frontends.audio]
enabled = true
server_port = 4531
[trx-client.frontends.audio.rig_urls]
[trx-client.frontends.audio.rig_ports]
# Play RX audio on a local sound device and capture TX from one.
[trx-client.frontends.audio.bridge]
enabled = false
bitrate_bps = 192000