Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17300170cc | ||
|
|
e41c13917d | ||
|
|
ae7df31d91 | ||
|
|
bf3bcc8a84 | ||
|
|
86dd36312e | ||
|
|
15ff686542 | ||
|
|
b78c4a4dd4 | ||
|
|
f396e8f235 | ||
|
|
46c9827e8a | ||
|
|
0fc977f19f | ||
|
|
084f629b5b | ||
|
|
bc63ded583 | ||
|
|
cfaeb6ee15 | ||
|
|
76bcce8c54 | ||
|
|
88ed3da6cc | ||
|
|
7c69e0de08 | ||
|
|
fbc4f6e398 | ||
|
|
d42ca4f030 | ||
|
|
335922fecc | ||
|
|
bede2e34fe | ||
|
|
da58a004fe | ||
|
|
77b283cb78 | ||
|
|
6284747339 | ||
|
|
14ad6e241b | ||
|
|
06971ff65c | ||
|
|
18107ce07e |
@@ -24,6 +24,12 @@ cargo test -p trx-core
|
||||
./target/release/trx-server --print-config > trx-server.toml
|
||||
./target/release/trx-client --print-config > trx-client.toml
|
||||
|
||||
# Validate a config without starting anything (reports every problem)
|
||||
./target/release/trx-server --check-config --config trx-rs.toml
|
||||
|
||||
# Regenerate trx-rs.toml.example after changing a config struct
|
||||
cargo run -p trx-config --example generate_example
|
||||
|
||||
# Run server
|
||||
./target/release/trx-server --config trx-server.toml
|
||||
# or via CLI args:
|
||||
@@ -41,7 +47,8 @@ This is a Cargo workspace. All crates live under `src/`:
|
||||
src/
|
||||
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
|
||||
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
|
||||
trx-app/ # Shared application helpers (config paths, logging init)
|
||||
trx-app/ # Shared application helpers (logging init, name normalization)
|
||||
trx-config/ # Client + server config structs, loader, validators (~2,500 LOC)
|
||||
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
|
||||
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
|
||||
trx-backend/ # Backend abstraction trait + factory + dummy
|
||||
|
||||
Generated
+33
-4
@@ -2412,6 +2412,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_ignored"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
@@ -3031,10 +3041,6 @@ dependencies = [
|
||||
name = "trx-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@@ -3115,6 +3121,7 @@ dependencies = [
|
||||
"toml",
|
||||
"tracing",
|
||||
"trx-app",
|
||||
"trx-config",
|
||||
"trx-core",
|
||||
"trx-frontend",
|
||||
"trx-frontend-http",
|
||||
@@ -3124,6 +3131,23 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-config"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"toml_edit 0.22.27",
|
||||
"tracing",
|
||||
"trx-core",
|
||||
"trx-decode-log",
|
||||
"trx-reporting",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-configurator"
|
||||
version = "0.1.0"
|
||||
@@ -3132,13 +3156,16 @@ dependencies = [
|
||||
"dialoguer",
|
||||
"tempfile",
|
||||
"tokio-serial",
|
||||
"toml",
|
||||
"toml_edit 0.22.27",
|
||||
"trx-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -3294,12 +3321,14 @@ dependencies = [
|
||||
"trx-app",
|
||||
"trx-aprs",
|
||||
"trx-backend",
|
||||
"trx-config",
|
||||
"trx-core",
|
||||
"trx-cw",
|
||||
"trx-decode-log",
|
||||
"trx-ftx",
|
||||
"trx-protocol",
|
||||
"trx-reporting",
|
||||
"trx-sstv",
|
||||
"trx-vdes",
|
||||
"trx-wefax",
|
||||
"trx-wspr",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Spectrum Controls — Visual Rework
|
||||
|
||||
The strip between the spectrum and the radio controls (`#spectrum-controls`)
|
||||
holds eight controls in two groups. This proposes how it should *look*.
|
||||
|
||||
Every control stays, in its current order, with its current name and its
|
||||
current behaviour. Nothing here changes what a button does, what commits when,
|
||||
or what is stored. It is a styling and layout change.
|
||||
|
||||
*Status: implemented. Kept as the record of what was changed and why.*
|
||||
|
||||
---
|
||||
|
||||
## What it looks like now
|
||||
|
||||
```
|
||||
Bandwidth [ 12 ] kHz [Set] [Auto BW] [Sweet-spot] Peak Hold [2 s] Floor [-115] dB Range [90] dB [Auto] Contrast [——●——] 1.0
|
||||
```
|
||||
|
||||
Four problems, all of them visual:
|
||||
|
||||
**1. Four different control heights on one line.** The bare number inputs, the
|
||||
buttons, the `select` and the range slider are each sized by their own rule, so
|
||||
nothing shares a baseline and the row reads as a pile rather than a strip.
|
||||
|
||||
**2. Units are loose text.** `kHz`, `dB`, `dB` and the contrast value `1.0` are
|
||||
text nodes sitting outside the control they belong to, separated from it by a
|
||||
gap the same size as the gap between unrelated controls. The eye has to work
|
||||
out which number owns which unit.
|
||||
|
||||
**3. A quarter of the strip is a hole.** `justify-content: space-between` puts
|
||||
about 250 px of nothing in the middle at 1600 px, and the two groups read as
|
||||
two unrelated things because the only thing between them is emptiness.
|
||||
|
||||
**4. The groups stagger between 1100 and 1400 px.** The bandwidth group wraps
|
||||
to two lines while the level group stays on one, so the level group floats at a
|
||||
height of its own, aligned with neither line of the group beside it. This is
|
||||
the worst of it, and it happens at a common window width.
|
||||
|
||||
Two smaller things: 24 px controls are below any touch-target guideline, and
|
||||
the contrast readout has no fixed width, so the row twitches as the value
|
||||
changes between `1.0` and `0.9`.
|
||||
|
||||
## Proposed
|
||||
|
||||
**One control height, units inside their field, and a rule where the clusters
|
||||
meet.**
|
||||
|
||||
- **A field is one box.** Label, value and unit share a single bordered box —
|
||||
`Bandwidth │ 12.0 │ kHz` — so a number and its unit can never be read apart.
|
||||
Fields, buttons, the peak-hold select and the contrast slider are all 1.7 rem
|
||||
tall, on 44 px targets under a coarse pointer.
|
||||
- **A rule, not a hole.** The two clusters are separated by a thin vertical
|
||||
rule with normal spacing either side. The slack goes to a flexible spacer, so
|
||||
the strip is left-aligned rather than pushed apart.
|
||||
- **A cluster never splits.** Each cluster is `nowrap`; the container wraps. If
|
||||
a cluster does not fit on the line it drops whole to the next one,
|
||||
left-aligned with the one above. No staggering, at any width.
|
||||
- **Rules fall away at line starts.** A cluster that begins a line has no rule
|
||||
hanging off its left edge.
|
||||
- **The contrast readout gets a fixed, tabular slot**, so the row is still.
|
||||
|
||||
Below the existing mobile breakpoint the strip already stacks; the same field
|
||||
component applies there, which is most of what makes it look deliberate.
|
||||
|
||||
## What this does not change
|
||||
|
||||
`Set` stays. `Auto BW` and `Auto` keep their names, even though they mean
|
||||
different things — that is a naming question, not a styling one. Sweet-spot
|
||||
stays where it is and keeps its behaviour. Nothing gains or loses persistence.
|
||||
Nothing moves into a popover, and no control is hidden behind a click.
|
||||
|
||||
Those are all worth arguing about separately; a note of them is at the end of
|
||||
this file so the arguments are not lost.
|
||||
|
||||
## Implementation
|
||||
|
||||
One pass, no behaviour touched:
|
||||
|
||||
1. `.spectrum-field` and `.spectrum-btn` in `style.css`, replacing the six
|
||||
per-id rules (`#spectrum-bw-input`, `#spectrum-floor-input`,
|
||||
`#spectrum-range-input`, `#spectrum-bw-label`, `#spectrum-floor-label`,
|
||||
`#spectrum-range-label`) that currently repeat the same declarations.
|
||||
2. Markup in `index.html`: the loose `kHz` / `dB` text nodes move inside their
|
||||
label, which keeps every id and every event handler exactly where it is.
|
||||
3. `#spectrum-controls` becomes a wrapping flex row with a spacer;
|
||||
`#spectrum-bw-row` and `#spectrum-level-row` become `nowrap` clusters with a
|
||||
left rule.
|
||||
4. The mobile block in the media query drops the rules it no longer needs.
|
||||
|
||||
`app.ts` is not touched. Every id survives, so the existing handlers, the
|
||||
`spectrum-layout.mjs` geometry test and the broadcast-layout highlight all keep
|
||||
working.
|
||||
|
||||
### Tests
|
||||
|
||||
Extend `spectrum-layout.mjs`, which already measures this area:
|
||||
|
||||
- Every control in the strip shares one height, at 1600, 1200 and 900 px.
|
||||
- No two clusters sit at different vertical offsets on the same line — the
|
||||
staggering bug, asserted directly.
|
||||
- The strip never overflows its container and never overlaps the hint line.
|
||||
|
||||
---
|
||||
|
||||
## Noted for later, not proposed here
|
||||
|
||||
Behavioural observations from reading the code, kept so they are not lost:
|
||||
|
||||
- `Auto BW` (filter) and `Auto` (display scaling) are both called Auto, 600 px
|
||||
apart.
|
||||
- `Floor`, `Range` and `Contrast` are not persisted; `Peak Hold` is.
|
||||
- `Auto` and `Auto BW` are one-shot: no state, nothing to turn off.
|
||||
- `Sweet-spot` retunes the SDR and waits up to 1.4 s per candidate centre, with
|
||||
no busy indication.
|
||||
- Contrast resets on a double-click that nothing advertises.
|
||||
+199
-22
@@ -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, 128–8192 |
|
||||
| `spectrum_interval_ms` | u64 | `50` | How often a spectrum frame is pushed to subscribed clients |
|
||||
|
||||
Spectrum is the largest thing on the client connection. On a slow or
|
||||
high-latency link, halving `spectrum_fft_size` halves the bytes per frame (at
|
||||
half the frequency resolution) and raising `spectrum_interval_ms` sends fewer of
|
||||
them; see [Spectrum over a slow link](#spectrum-over-a-slow-link).
|
||||
|
||||
#### `[sdr.gain]`
|
||||
|
||||
@@ -197,6 +236,29 @@ Notes:
|
||||
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
|
||||
`%MM%`, `%DD%` (UTC).
|
||||
|
||||
#### `[decoders]`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | string[] | all decoders | Decoders to run for this rig |
|
||||
| `output_dir` | string | `"$XDG_CACHE_HOME/trx-rs"` | Base directory for decoders that write images |
|
||||
|
||||
Valid decoder names: `aprs`, `aprs_hf`, `ais`, `cw`, `ft2`, `ft4`, `ft8`,
|
||||
`lrpt`, `sstv`, `vdes`, `wefax`, `wspr` — the same names `[[sdr.channels]]`
|
||||
uses. An unrecognised name is a config error.
|
||||
|
||||
Every decoder runs by default, which costs real CPU on a small machine. On a
|
||||
station that only works digital modes, listing just what you use is worth it:
|
||||
|
||||
```toml
|
||||
[decoders]
|
||||
enabled = ["ft8", "ft4", "wspr"]
|
||||
```
|
||||
|
||||
`sstv`, `wefax` and `lrpt` write images into a subdirectory of `output_dir`
|
||||
named after the decoder. `ais` and `vdes` additionally require an SDR channel
|
||||
configured to feed them.
|
||||
|
||||
#### Multi-Rig Configuration
|
||||
|
||||
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
|
||||
@@ -240,12 +302,32 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
|-------|------|---------|-------------|
|
||||
| `url` | string | — | Server address (e.g. `localhost:4530`) |
|
||||
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
||||
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
|
||||
|
||||
#### `[remote.auth]`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `token` | string | — | Auth token (must not be empty if set) |
|
||||
| `token_file` | string | — | Read the token from this file instead |
|
||||
|
||||
#### `[[remotes]]`
|
||||
|
||||
Preferred over the single `[remote]` section: one entry per rig, each mapping a
|
||||
short name to a server and an optional server-side rig id.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `name` | string | — | Short name used everywhere in the client |
|
||||
| `url` | string | — | Server address (`host:port`) |
|
||||
| `rig_id` | string | — | Rig id on a multi-rig server |
|
||||
| `auth.token` | string | — | Auth token |
|
||||
| `auth.token_file` | string | — | Read the token from this file instead |
|
||||
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
||||
|
||||
The `name` is the key used by `default_rig_name`, `rigctl.rig_ports`,
|
||||
`audio.rig_urls`, `audio.rig_ports` and `decode_history_retention_min_by_rig`.
|
||||
A name in any of those maps that no remote answers to is a config error.
|
||||
|
||||
#### `[frontends.http]`
|
||||
|
||||
@@ -254,6 +336,31 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
| `enabled` | bool | `true` | Enable web UI |
|
||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||
| `port` | u16 | `8080` | Bind port |
|
||||
| `default_rig_name` | string | — | Remote selected on startup |
|
||||
| `initial_map_zoom` | u8 | `10` | Starting zoom for the APRS map |
|
||||
| `show_sdr_gain_control` | bool | `true` | Expose the RF gain control |
|
||||
| `bandplan_enabled` | bool | `true` | Show the bandplan strip |
|
||||
| `bandplan_region` | string | `"iaru_r1"` | `iaru_r1`, `iaru_r2`, or `iaru_r3` |
|
||||
| `decode_history_retention_min` | u64 | `1440` | Decode history retention |
|
||||
| `decode_history_retention_min_by_rig` | table | `{}` | Per-remote retention override |
|
||||
| `spectrum_coverage_margin_hz` | u32 | `50000` | Centre-retune guard margin |
|
||||
| `spectrum_usable_span_ratio` | f32 | `0.92` | Usable fraction of the sampled span |
|
||||
|
||||
#### `[frontends.http.auth]`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Require a passphrase |
|
||||
| `rx_passphrase` | string | — | Passphrase granting receive-only access |
|
||||
| `rx_passphrase_file` | string | — | Read it from this file instead |
|
||||
| `control_passphrase` | string | — | Passphrase granting full control |
|
||||
| `control_passphrase_file` | string | — | Read it from this file instead |
|
||||
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users |
|
||||
| `session_ttl_min` | u64 | `480` | Session lifetime |
|
||||
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
|
||||
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
|
||||
|
||||
With `enabled = true`, at least one passphrase must be set.
|
||||
|
||||
#### `[frontends.rigctl]`
|
||||
|
||||
@@ -261,7 +368,11 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Enable Hamlib rigctl |
|
||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||
| `port` | u16 | `4532` | Bind port |
|
||||
| `rig_ports` | table | `{}` | Remote name → local port; one listener each |
|
||||
|
||||
One listener is started per `rig_ports` entry, each routing to its rig, so
|
||||
`rig_ports` must name at least one remote when the frontend is enabled. The
|
||||
older single `port` key and `--rigctl-port` are ignored.
|
||||
|
||||
#### `[frontends.http_json]`
|
||||
|
||||
@@ -271,13 +382,17 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
|
||||
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
|
||||
| `auth.tokens_file` | string | — | Read tokens from this file, one per line |
|
||||
|
||||
#### `[frontends.audio]`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `true` | Enable audio client |
|
||||
| `server_port` | u16 | `4531` | Server audio port |
|
||||
| `server_url` | string | — | Audio endpoint for every remote |
|
||||
| `rig_urls` | table | `{}` | Remote name → audio URL (wins over `server_url`) |
|
||||
| `server_port` | u16 | `4531` | Fallback port when no URL is configured |
|
||||
| `rig_ports` | table | `{}` | Remote name → port; superseded by `rig_urls` |
|
||||
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
|
||||
| `bridge.rx_output_device` | string | — | Local playback device |
|
||||
| `bridge.tx_input_device` | string | — | Local capture device |
|
||||
@@ -287,16 +402,78 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
|
||||
loopback on Linux, BlackHole on macOS).
|
||||
|
||||
### Spectrum over a slow link
|
||||
|
||||
Spectrum dominates the server↔client connection: everything else is a few
|
||||
hundred bytes, a frame is a few kilobytes. Three things govern what it costs.
|
||||
|
||||
**Frames are pushed, not polled.** The client subscribes and the server sends
|
||||
frames at `[sdr].spectrum_interval_ms`. Polling cost a round trip per frame, so
|
||||
the rate was capped at 1/RTT — on a 200 ms link you could not exceed 5 frames a
|
||||
second however often the client asked. Clients fall back to polling
|
||||
automatically against a server too old to stream.
|
||||
|
||||
**Bins travel as whole dBFS.** They are base64-encoded `i8` on the wire, about
|
||||
an eighth of the JSON array of floats they used to be, at the resolution the
|
||||
display draws anyway.
|
||||
|
||||
**Both ends have a rate, and the slower one wins.** The server pushes no faster
|
||||
than `[sdr].spectrum_interval_ms`; the client asks for no more than
|
||||
`[[remotes]].spectrum_interval_ms`.
|
||||
|
||||
For a link that struggles, start here:
|
||||
|
||||
```toml
|
||||
[trx-server.sdr]
|
||||
spectrum_fft_size = 512 # half the bins, half the bytes
|
||||
spectrum_interval_ms = 200 # 5 frames/s instead of 20
|
||||
|
||||
[[trx-client.remotes]]
|
||||
name = "remote-site"
|
||||
url = "radio.example.com:4530"
|
||||
spectrum_interval_ms = 200
|
||||
```
|
||||
|
||||
That is roughly 0.7 KB per frame at 5 frames/s — about 3.5 KB/s, against
|
||||
roughly 200 KB/s for 1024 float bins at 20 frames/s.
|
||||
|
||||
### CLI Override Summary
|
||||
|
||||
**trx-server:**
|
||||
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
|
||||
`--port`. SDR options are file-only.
|
||||
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`,
|
||||
`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only.
|
||||
|
||||
**trx-client:**
|
||||
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
|
||||
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
|
||||
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
|
||||
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`,
|
||||
`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`,
|
||||
`--http-port`, `--rigctl-listen`, `--http-json-listen`, `--http-json-port`,
|
||||
`--callsign`.
|
||||
|
||||
`--listen` on the server overrides the bind address of both the control
|
||||
listener and every rig's audio listener.
|
||||
|
||||
---
|
||||
|
||||
## Multiple Rigs in the Web UI
|
||||
|
||||
A client connected to several rigs decodes all of them at once, whichever one
|
||||
is on screen. The rig picker in the header decides which rig the UI is about,
|
||||
and each page answers that differently:
|
||||
|
||||
| Page | Shows |
|
||||
|------|-------|
|
||||
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
|
||||
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
|
||||
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. HF APRS is a source of its own there, filtered apart from VHF APRS. |
|
||||
| Statistics | The whole station, including the per-rig comparison. |
|
||||
|
||||
Switching rigs repaints the radio and digital modes pages for the rig now
|
||||
selected. Nothing is lost by switching: the traffic other rigs heard is still
|
||||
held, and switching back brings it up again. The one exception is the CW pane,
|
||||
which is a single running stream of copied text rather than a list of frames,
|
||||
so it starts empty on the rig you switch to.
|
||||
|
||||
Each browser tab keeps its own selection, so two tabs can watch two rigs.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -79,6 +79,61 @@ pub struct SstvImage {
|
||||
pub started_ms: i64,
|
||||
}
|
||||
|
||||
impl SstvImage {
|
||||
/// The picture as a canvas, for saving or encoding.
|
||||
pub fn canvas(&self) -> ImageCanvas {
|
||||
let width = usize::from(self.width);
|
||||
let mut canvas = ImageCanvas::new(width, usize::from(self.height));
|
||||
for (y, row) in self.rgb.chunks(width * 3).enumerate() {
|
||||
canvas.put_row(y, row);
|
||||
}
|
||||
canvas
|
||||
}
|
||||
|
||||
/// The picture as PNG bytes.
|
||||
pub fn to_png(&self) -> Result<Vec<u8>, String> {
|
||||
self.canvas().to_png()
|
||||
}
|
||||
|
||||
/// The picture as a base64 PNG, for the journey to a client.
|
||||
pub fn to_png_base64(&self) -> Result<String, String> {
|
||||
self.canvas().to_png_base64()
|
||||
}
|
||||
|
||||
/// Write the picture into `dir`, named for when and where it arrived.
|
||||
pub fn save_png(
|
||||
&self,
|
||||
dir: &std::path::Path,
|
||||
freq_hz: u64,
|
||||
) -> Result<std::path::PathBuf, String> {
|
||||
self.canvas()
|
||||
.save_png(dir, freq_hz, self.mode, &stamp(self.started_ms))
|
||||
}
|
||||
}
|
||||
|
||||
/// `YYYYMMDDTHHMMSSZ` for a millisecond timestamp, for file names that sort.
|
||||
fn stamp(ms: i64) -> String {
|
||||
let secs = ms.div_euclid(1000);
|
||||
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
let (hour, minute, second) = (rest / 3600, (rest % 3600) / 60, rest % 60);
|
||||
format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}Z")
|
||||
}
|
||||
|
||||
/// Days since the Unix epoch to a calendar date (Howard Hinnant's algorithm).
|
||||
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
enum State {
|
||||
/// Listening for a header.
|
||||
Searching,
|
||||
@@ -587,3 +642,18 @@ fn finish(reception: &Reception) -> SstvImage {
|
||||
started_ms: reception.started_ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_stamps_are_utc_and_sort_in_time_order() {
|
||||
// Known instants, checked against `date -u -r <secs>`.
|
||||
assert_eq!(stamp(0), "19700101T000000Z");
|
||||
assert_eq!(stamp(1_000_000_000_000), "20010909T014640Z");
|
||||
assert_eq!(stamp(1_770_000_000_000), "20260202T024000Z");
|
||||
// Sorting the names sorts the pictures.
|
||||
assert!(stamp(1_770_000_000_000) < stamp(1_770_000_001_000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>(§ion_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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -27,11 +27,11 @@ use trx_core::audio::{
|
||||
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
||||
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
||||
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO,
|
||||
AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED,
|
||||
AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB,
|
||||
AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS,
|
||||
AUDIO_MSG_WSPR_DECODE,
|
||||
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_SSTV_DECODE,
|
||||
AUDIO_MSG_SSTV_PROGRESS, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
|
||||
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
|
||||
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
|
||||
AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
||||
};
|
||||
use trx_core::decode::DecodedMessage;
|
||||
use trx_frontend::VChanAudioCmd;
|
||||
@@ -533,7 +533,9 @@ async fn handle_single_rig_connection(
|
||||
| AUDIO_MSG_LRPT_IMAGE
|
||||
| AUDIO_MSG_LRPT_PROGRESS
|
||||
| AUDIO_MSG_WEFAX_DECODE
|
||||
| AUDIO_MSG_WEFAX_PROGRESS,
|
||||
| AUDIO_MSG_WEFAX_PROGRESS
|
||||
| AUDIO_MSG_SSTV_DECODE
|
||||
| AUDIO_MSG_SSTV_PROGRESS,
|
||||
payload,
|
||||
)) => {
|
||||
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
||||
|
||||
+5
-1214
File diff suppressed because it is too large
Load Diff
+138
-8
@@ -20,6 +20,7 @@ use tokio::task::JoinHandle;
|
||||
use tracing::{error, info};
|
||||
|
||||
use trx_app::{init_logging, normalize_name};
|
||||
use trx_config::ConfigFile;
|
||||
use trx_core::audio::AudioStreamInfo;
|
||||
|
||||
use trx_core::decode::DecodedMessage;
|
||||
@@ -50,6 +51,12 @@ struct Cli {
|
||||
/// Print example configuration and exit
|
||||
#[arg(long = "print-config")]
|
||||
print_config: bool,
|
||||
/// Treat unknown configuration keys as a fatal error
|
||||
#[arg(long = "strict-config")]
|
||||
strict_config: bool,
|
||||
/// Validate the configuration and exit without starting anything
|
||||
#[arg(long = "check-config")]
|
||||
check_config: bool,
|
||||
/// Remote server URL (host:port)
|
||||
#[arg(short = 'u', long = "url")]
|
||||
url: Option<String>,
|
||||
@@ -74,7 +81,7 @@ struct Cli {
|
||||
/// rigctl frontend listen address
|
||||
#[arg(long = "rigctl-listen")]
|
||||
rigctl_listen: Option<IpAddr>,
|
||||
/// rigctl frontend listen port
|
||||
/// Deprecated: ignored, use [frontends.rigctl].rig_ports
|
||||
#[arg(long = "rigctl-port")]
|
||||
rigctl_port: Option<u16>,
|
||||
/// JSON TCP frontend listen address
|
||||
@@ -109,6 +116,65 @@ async fn main() -> DynResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--check-config`: report everything wrong with the configuration and exit.
|
||||
///
|
||||
/// Unlike startup, this reports every problem it finds rather than stopping at
|
||||
/// the first, so a config can be fixed in one pass. The file is checked as
|
||||
/// written, without CLI overrides.
|
||||
fn check_config(loaded: &trx_config::ConfigLoad<ClientConfig>) -> DynResult<()> {
|
||||
match &loaded.path {
|
||||
Some(path) => println!("{}", path.display()),
|
||||
None => println!("(no config file found; checking built-in defaults)"),
|
||||
}
|
||||
|
||||
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
|
||||
warnings.extend(ClientConfig::deprecations(&loaded.present_keys));
|
||||
|
||||
let mut cfg = loaded.config.clone();
|
||||
let mut errors = Vec::new();
|
||||
if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) {
|
||||
errors.push(e);
|
||||
}
|
||||
let cfg = &cfg;
|
||||
let remotes = cfg.resolved_remotes();
|
||||
if remotes.is_empty() {
|
||||
warnings.push(
|
||||
"no remotes configured; --url will be required at startup (add [[remotes]] entries)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
errors.extend(cfg.validate_all());
|
||||
if !remotes.is_empty() {
|
||||
errors.extend(cfg.validate_resolved_all(&remotes));
|
||||
}
|
||||
|
||||
for w in &warnings {
|
||||
println!(" warning: {}", w);
|
||||
}
|
||||
for e in &errors {
|
||||
println!(" error: {}", e);
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
println!(
|
||||
" OK: {} remote(s) configured: {}",
|
||||
remotes.len(),
|
||||
remotes
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
if !warnings.is_empty() {
|
||||
println!(" {} warning(s)", warnings.len());
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the state needed after async initialization completes.
|
||||
struct AppState {
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
@@ -135,20 +201,44 @@ async fn async_init() -> DynResult<AppState> {
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
let (cfg, config_path) = if let Some(ref path) = cli.config {
|
||||
let cfg = ClientConfig::load_from_file(path)?;
|
||||
(cfg, Some(path.clone()))
|
||||
let loaded = if let Some(ref path) = cli.config {
|
||||
ClientConfig::load_from_file(path)?
|
||||
} else {
|
||||
ClientConfig::load_from_default_paths()?
|
||||
};
|
||||
cfg.validate()
|
||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||
let config_path = loaded.path.clone();
|
||||
|
||||
init_logging(cfg.general.log_level.as_deref());
|
||||
if cli.check_config {
|
||||
match check_config(&loaded) {
|
||||
Ok(()) => std::process::exit(0),
|
||||
Err(e) => {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logging comes up before any config complaint so the warnings are visible.
|
||||
init_logging(loaded.config.general.log_level.as_deref());
|
||||
|
||||
if let Some(ref path) = config_path {
|
||||
info!("Loaded configuration from {}", path.display());
|
||||
}
|
||||
loaded.report_unknown_keys(cli.strict_config)?;
|
||||
loaded.report_deprecations();
|
||||
if cli.rigctl_port.is_some() {
|
||||
tracing::warn!(
|
||||
"--rigctl-port is ignored; give each rig its own listener via \
|
||||
[frontends.rigctl].rig_ports"
|
||||
);
|
||||
}
|
||||
|
||||
let mut cfg = loaded.config;
|
||||
// Secrets configured as *_file are read before validation, so everything
|
||||
// downstream sees resolved values.
|
||||
cfg.resolve_secrets(config_path.as_deref())?;
|
||||
cfg.validate()
|
||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||
|
||||
frontend_runtime.http_auth.tokens = cfg
|
||||
.frontends
|
||||
@@ -198,10 +288,14 @@ async fn async_init() -> DynResult<AppState> {
|
||||
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
|
||||
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
|
||||
vec![RemoteEntry {
|
||||
spectrum_interval_ms: cfg.remote.spectrum_interval_ms,
|
||||
name,
|
||||
url: url.clone(),
|
||||
rig_id,
|
||||
auth: config::RemoteAuthConfig { token },
|
||||
auth: config::RemoteAuthConfig {
|
||||
token,
|
||||
token_file: None,
|
||||
},
|
||||
poll_interval_ms,
|
||||
}]
|
||||
} else {
|
||||
@@ -262,6 +356,30 @@ async fn async_init() -> DynResult<AppState> {
|
||||
.http_json_listen
|
||||
.unwrap_or(cfg.frontends.http_json.listen);
|
||||
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
|
||||
|
||||
// Fold the CLI overrides back into the config so validation and the
|
||||
// frontends agree on what is about to be bound.
|
||||
cfg.frontends.http.enabled = frontends.iter().any(|f| f == "http");
|
||||
cfg.frontends.rigctl.enabled = frontends.iter().any(|f| f == "rigctl");
|
||||
cfg.frontends.http_json.enabled = frontends.iter().any(|f| f == "httpjson");
|
||||
cfg.frontends.http.listen = http_listen;
|
||||
cfg.frontends.http.port = http_port;
|
||||
cfg.frontends.rigctl.listen = rigctl_listen;
|
||||
cfg.frontends.http_json.listen = http_json_listen;
|
||||
cfg.frontends.http_json.port = http_json_port;
|
||||
|
||||
// Second validation phase: the per-rig maps are keyed by remote short name,
|
||||
// so they can only be checked once the remote list is final.
|
||||
if cli.url.is_none() {
|
||||
cfg.validate_resolved(&resolved_remotes)
|
||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||
} else {
|
||||
// --url replaces the configured remotes outright, so only the socket
|
||||
// checks still apply.
|
||||
trx_config::shared::check_socket_conflicts(&cfg.bound_sockets())
|
||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||
}
|
||||
|
||||
let callsign = cli
|
||||
.callsign
|
||||
.clone()
|
||||
@@ -366,6 +484,13 @@ async fn async_init() -> DynResult<AppState> {
|
||||
.map(|e| e.poll_interval_ms)
|
||||
.min()
|
||||
.unwrap_or(750);
|
||||
// Entries sharing a server share its connections, so the most frequent
|
||||
// request wins: whoever wants spectrum fastest sets the rate.
|
||||
let spectrum_interval = entries
|
||||
.iter()
|
||||
.map(|e| e.spectrum_interval_ms)
|
||||
.min()
|
||||
.unwrap_or_else(|| remote_client::DEFAULT_SPECTRUM_INTERVAL.as_millis() as u64);
|
||||
|
||||
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
||||
for entry in entries {
|
||||
@@ -379,6 +504,8 @@ async fn async_init() -> DynResult<AppState> {
|
||||
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
|
||||
rig_states: frontend_runtime.routing.rig_states.clone(),
|
||||
poll_interval: Duration::from_millis(poll_interval),
|
||||
spectrum_interval: Duration::from_millis(spectrum_interval),
|
||||
spectrum_stream_unsupported: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
spectrum: frontend_runtime.spectrum.sender.clone(),
|
||||
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
|
||||
server_connected: frontend_runtime.routing.server_connected.clone(),
|
||||
@@ -527,6 +654,9 @@ async fn async_init() -> DynResult<AppState> {
|
||||
DecodedMessage::LrptProgress(_) => {}
|
||||
DecodedMessage::Wefax(_) => {}
|
||||
DecodedMessage::WefaxProgress(_) => {}
|
||||
// Pictures replay from their own history, not this one.
|
||||
DecodedMessage::Sstv(_) => {}
|
||||
DecodedMessage::SstvProgress(_) => {}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+275
-103
@@ -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())),
|
||||
|
||||
@@ -17,8 +17,8 @@ use uuid::Uuid;
|
||||
|
||||
use trx_core::audio::AudioStreamInfo;
|
||||
use trx_core::decode::{
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
||||
WsprMessage,
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
|
||||
WefaxMessage, WsprMessage,
|
||||
};
|
||||
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
||||
use trx_core::{DynResult, RigRequest, RigState};
|
||||
@@ -233,6 +233,7 @@ pub struct DecodeHistoryContext {
|
||||
pub ft2: DecodeHistory<Ft8Message>,
|
||||
pub wspr: DecodeHistory<WsprMessage>,
|
||||
pub wefax: DecodeHistory<WefaxMessage>,
|
||||
pub sstv: DecodeHistory<SstvMessage>,
|
||||
}
|
||||
|
||||
impl Default for DecodeHistoryContext {
|
||||
@@ -248,6 +249,7 @@ impl Default for DecodeHistoryContext {
|
||||
ft2: Arc::new(Mutex::new(VecDeque::new())),
|
||||
wspr: Arc::new(Mutex::new(VecDeque::new())),
|
||||
wefax: Arc::new(Mutex::new(VecDeque::new())),
|
||||
sstv: Arc::new(Mutex::new(VecDeque::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
@@ -140,18 +144,22 @@ function aisLatestByVessel(messages) {
|
||||
}
|
||||
return Array.from(byMmsi.values());
|
||||
}
|
||||
function aisRigMessages() {
|
||||
return forActiveRig(aisMessageHistory);
|
||||
}
|
||||
function updateAisSummary() {
|
||||
const plan = currentAisChannelPlan();
|
||||
if (aisChannelSummaryEl) {
|
||||
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||
}
|
||||
const vessels = aisLatestByVessel(aisMessageHistory);
|
||||
const rigMessages = aisRigMessages();
|
||||
const vessels = aisLatestByVessel(rigMessages);
|
||||
if (aisVesselCountEl) {
|
||||
const count = vessels.length;
|
||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (aisLatestSeenEl) {
|
||||
const latest = aisMessageHistory[0];
|
||||
const latest = rigMessages[0];
|
||||
if (!latest) {
|
||||
aisLatestSeenEl.textContent = "No traffic yet";
|
||||
} else {
|
||||
@@ -222,7 +230,9 @@ function updateAisBar() {
|
||||
updateAisSummary();
|
||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||
const recent = aisMessageHistory.filter(
|
||||
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)
|
||||
);
|
||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||
if (!isAis || messages.length === 0) {
|
||||
aisBarOverlay.style.display = "none";
|
||||
@@ -267,7 +277,7 @@ function renderAisHistory() {
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const message of aisMessageHistory) {
|
||||
for (const message of aisRigMessages()) {
|
||||
fragment.appendChild(renderAisRow(message));
|
||||
}
|
||||
aisMessagesEl.replaceChildren(fragment);
|
||||
@@ -299,10 +309,10 @@ function normalizeServerAisMessage(msg) {
|
||||
}
|
||||
function onServerAisBatch(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerAisMessage(msg);
|
||||
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
@@ -342,8 +352,9 @@ if (aisFilterInput) {
|
||||
});
|
||||
}
|
||||
function onServerAis(msg) {
|
||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||
addAisMessage(normalizeServerAisMessage(msg));
|
||||
const message = normalizeServerAisMessage(msg);
|
||||
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
||||
addAisMessage(message);
|
||||
}
|
||||
updateAisSummary();
|
||||
window.trxPluginRuntime.registerDecoder({
|
||||
@@ -353,6 +364,10 @@ window.trxPluginRuntime.registerDecoder({
|
||||
restore: onServerAisBatch,
|
||||
reset: resetAisHistoryView,
|
||||
prune: pruneAisHistoryView,
|
||||
rerender: () => {
|
||||
updateAisBar();
|
||||
renderAisHistory();
|
||||
},
|
||||
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||
syncMap: () => {
|
||||
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
|
||||
|
||||
@@ -885,7 +885,7 @@ function elementById(id) {
|
||||
if (restoreFocus) more.focus();
|
||||
};
|
||||
api.closeMobileOverlays = closeMore;
|
||||
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||
if (!source) return;
|
||||
const item = document.createElement("button");
|
||||
@@ -932,7 +932,7 @@ function elementById(id) {
|
||||
["Overview", ["overview"]],
|
||||
["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
||||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]],
|
||||
["Broadcast & images", ["rds", "sat", "wefax"]]
|
||||
["Broadcast & images", ["rds", "sat", "wefax", "sstv"]]
|
||||
];
|
||||
groups.forEach(([label, ids]) => {
|
||||
const group = document.createElement("optgroup");
|
||||
@@ -1065,6 +1065,9 @@ var runtime = {
|
||||
plugin.prune();
|
||||
return true;
|
||||
},
|
||||
rerenderAll() {
|
||||
for (const plugin of decoders.values()) plugin.rerender?.();
|
||||
},
|
||||
syncMapAll() {
|
||||
for (const plugin of decoders.values()) plugin.syncMap?.();
|
||||
},
|
||||
@@ -1529,6 +1532,7 @@ var TAB_ORDER = [
|
||||
"bookmarks",
|
||||
"digital-modes",
|
||||
"map",
|
||||
"satellites",
|
||||
"statistics",
|
||||
"recorder",
|
||||
"settings",
|
||||
@@ -1539,6 +1543,7 @@ var TAB_PATHS = {
|
||||
bookmarks: "/bookmarks",
|
||||
"digital-modes": "/digital-modes",
|
||||
map: "/map",
|
||||
satellites: "/satellites",
|
||||
statistics: "/statistics",
|
||||
recorder: "/recorder",
|
||||
settings: "/settings",
|
||||
@@ -1748,6 +1753,7 @@ var pluginGroups = {
|
||||
"/background-decode.js",
|
||||
"/sat.js",
|
||||
"/wefax.js",
|
||||
"/sstv.js",
|
||||
"/ais.js",
|
||||
"/vdes.js",
|
||||
"/aprs.js",
|
||||
@@ -1755,6 +1761,7 @@ var pluginGroups = {
|
||||
],
|
||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||
satellites: ["/satellite-predictions.js"],
|
||||
statistics: ["/map-core.js"],
|
||||
bookmarks: ["/bookmarks.js"],
|
||||
recorder: [],
|
||||
@@ -2240,7 +2247,7 @@ function currentDecodeHistoryRetentionMs() {
|
||||
}
|
||||
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
||||
window.applyDecodeHistoryRetention = function() {
|
||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) {
|
||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"]) {
|
||||
window.trxPluginRuntime.prune(decoder);
|
||||
}
|
||||
};
|
||||
@@ -3551,6 +3558,13 @@ function positionRdsPsOverlay() {
|
||||
function resetRdsDisplay() {
|
||||
updateRdsPsOverlay(primaryRds);
|
||||
}
|
||||
function refreshDecodeBars() {
|
||||
window.updateAisBar?.();
|
||||
window.updateVdesBar?.();
|
||||
window.updateAprsBar?.();
|
||||
window.updateFt8Bar?.();
|
||||
window.updateCwBar?.();
|
||||
}
|
||||
function resetDecoderStateOnRigSwitch() {
|
||||
primaryRds = null;
|
||||
vchanRdsById = /* @__PURE__ */ new Map();
|
||||
@@ -3565,6 +3579,8 @@ function resetDecoderStateOnRigSwitch() {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = "--";
|
||||
});
|
||||
refreshDecodeBars();
|
||||
window.trxPluginRuntime.rerenderAll();
|
||||
}
|
||||
function resetWfmStereoIndicator() {
|
||||
if (!wfmStFlagEl) return;
|
||||
@@ -4784,10 +4800,7 @@ function render(update) {
|
||||
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
||||
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
||||
}
|
||||
if (window.updateAisBar) window.updateAisBar();
|
||||
if (window.updateVdesBar) window.updateVdesBar();
|
||||
if (window.updateAprsBar) window.updateAprsBar();
|
||||
if (window.updateFt8Bar) window.updateFt8Bar();
|
||||
refreshDecodeBars();
|
||||
for (const d of decoderRegistry) {
|
||||
if (d.activation !== "toggle") continue;
|
||||
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||||
@@ -4820,6 +4833,9 @@ function render(update) {
|
||||
for (const [key, entry] of Object.entries(_decoderToggles)) {
|
||||
syncDecoderToggle(entry, !!update[key], entry.label);
|
||||
}
|
||||
if (typeof update.sstv_decode_enabled === "boolean" && window.syncSstvToggle) {
|
||||
window.syncSstvToggle(update.sstv_decode_enabled);
|
||||
}
|
||||
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
|
||||
window.syncWefaxToggle(update.wefax_decode_enabled);
|
||||
}
|
||||
@@ -5840,6 +5856,7 @@ function _initMapWhenReady() {
|
||||
}
|
||||
function navigateToTab(name, options = {}) {
|
||||
window.trxUi?.closeMobileOverlays?.();
|
||||
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
||||
const { updateHistory = true, replaceHistory = false } = options;
|
||||
if (authEnabled && !authRole && name !== "main") {
|
||||
showAuthGate(false);
|
||||
@@ -5869,7 +5886,11 @@ function navigateToTab(name, options = {}) {
|
||||
updateTabHistory(name, replaceHistory);
|
||||
}
|
||||
scheduleSpectrumLayout();
|
||||
void loadPluginsForTab(name).catch((error) => {
|
||||
if (leavingSatellites) window.clearSatPredictionDom?.();
|
||||
void loadPluginsForTab(name).then(() => {
|
||||
if (name === "satellites") window.refreshSatPredictions?.();
|
||||
flushPendingDecodeStats();
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
if (name === "map") {
|
||||
@@ -6316,9 +6337,6 @@ function _wireSubTabBar(bar) {
|
||||
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
||||
});
|
||||
}
|
||||
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
|
||||
window.clearSatPredictionDom();
|
||||
}
|
||||
});
|
||||
}
|
||||
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
||||
@@ -7594,11 +7612,43 @@ function updateDecodeStatus(text) {
|
||||
if (el && el.textContent !== "Receiving") el.textContent = text;
|
||||
}
|
||||
}
|
||||
var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
|
||||
"lrpt_image",
|
||||
"lrpt_progress",
|
||||
"wefax",
|
||||
"wefax_progress",
|
||||
"sstv",
|
||||
"sstv_progress"
|
||||
]);
|
||||
var pendingDecodeStats = [];
|
||||
var PENDING_DECODE_STATS_MAX = 5e4;
|
||||
function recordDecodeStat(kind, rig, tsMs) {
|
||||
const stats = window.trx.modules.map;
|
||||
if (stats) {
|
||||
stats.statsRecordDecode(kind, rig, tsMs);
|
||||
return;
|
||||
}
|
||||
pendingDecodeStats.push({ kind, rig, tsMs });
|
||||
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
|
||||
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
|
||||
}
|
||||
}
|
||||
function flushPendingDecodeStats() {
|
||||
const stats = window.trx.modules.map;
|
||||
if (!stats || pendingDecodeStats.length === 0) return;
|
||||
for (const entry of pendingDecodeStats.splice(0)) {
|
||||
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
|
||||
}
|
||||
stats.scheduleStatsRender();
|
||||
}
|
||||
function scheduleStatsRenderIfLoaded() {
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
function dispatchDecodeMessage(msg, skipStats = false) {
|
||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
|
||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
|
||||
scheduleStatsRenderIfLoaded();
|
||||
}
|
||||
}
|
||||
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
|
||||
@@ -7642,11 +7692,11 @@ function loadDecodeHistoryOnMainThread(onReady, onError) {
|
||||
}
|
||||
function restoreDecodeHistoryGroup(kind, messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
|
||||
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||
for (const msg of messages) {
|
||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
||||
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
||||
}
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
scheduleStatsRenderIfLoaded();
|
||||
}
|
||||
window.trxPluginRuntime.restore(kind, messages);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
normalizeAprsPacket,
|
||||
renderAprsPacketRow
|
||||
} from "./chunk-OPEIVJGD.js";
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
@@ -82,20 +86,25 @@ function aprsFilterMatch(pkt) {
|
||||
].filter(Boolean).join(" ").toUpperCase();
|
||||
return haystack.includes(aprsFilterText);
|
||||
}
|
||||
function aprsRigPackets() {
|
||||
return forActiveRig(aprsPacketHistory);
|
||||
}
|
||||
function aprsVisiblePackets() {
|
||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
||||
const rigPackets = aprsRigPackets();
|
||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
|
||||
return packets.filter(aprsFilterMatch);
|
||||
}
|
||||
function updateAprsSummary() {
|
||||
const rigPackets = aprsRigPackets();
|
||||
const visible = aprsVisiblePackets();
|
||||
if (aprsTotalCountEl) {
|
||||
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
||||
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||
}
|
||||
if (aprsVisibleCountEl) {
|
||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||
}
|
||||
if (aprsLatestSeenEl) {
|
||||
const latest = aprsPacketHistory[0];
|
||||
const latest = rigPackets[0];
|
||||
if (!latest) {
|
||||
aprsLatestSeenEl.textContent = "No packets yet";
|
||||
} else {
|
||||
@@ -153,7 +162,9 @@ function updateAprsBar() {
|
||||
if (!aprsBarOverlay) return;
|
||||
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
||||
const okFrames = aprsPacketHistory.filter(
|
||||
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id)
|
||||
);
|
||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||
@@ -215,11 +226,11 @@ function normalizeServerAprsPacket(pkt) {
|
||||
}
|
||||
function onServerAprsBatch(packets) {
|
||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
let hasCrcOk = false;
|
||||
for (const pkt of packets) {
|
||||
const next = normalizeServerAprsPacket(pkt);
|
||||
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
@@ -277,8 +288,9 @@ if (aprsFilterInput) {
|
||||
});
|
||||
}
|
||||
function onServerAprs(pkt) {
|
||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||
const packet = normalizeServerAprsPacket(pkt);
|
||||
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
|
||||
addAprsPacket(packet);
|
||||
}
|
||||
renderAprsHistory();
|
||||
window.trxPluginRuntime.registerDecoder({
|
||||
@@ -288,6 +300,10 @@ window.trxPluginRuntime.registerDecoder({
|
||||
restore: onServerAprsBatch,
|
||||
reset: resetAprsHistoryView,
|
||||
prune: pruneAprsHistoryView,
|
||||
rerender: () => {
|
||||
updateAprsBar();
|
||||
renderAprsHistory();
|
||||
},
|
||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||
syncMap: () => {
|
||||
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
|
||||
|
||||
+33
-12
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
@@ -153,9 +157,10 @@ function initializeFtxDecoder(config) {
|
||||
const render = () => {
|
||||
prune();
|
||||
if (!messagesElement) return;
|
||||
const rigMessages = forActiveRig(history);
|
||||
const fragment = document.createDocumentFragment();
|
||||
let count = 0;
|
||||
for (const message of history) {
|
||||
for (const message of rigMessages) {
|
||||
if (count >= 200) break;
|
||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||
fragment.appendChild(renderRow(message));
|
||||
@@ -163,20 +168,26 @@ function initializeFtxDecoder(config) {
|
||||
}
|
||||
messagesElement.replaceChildren(fragment);
|
||||
};
|
||||
const normalize = (message) => {
|
||||
const plotLocator = (message) => {
|
||||
const raw = message.message ?? "";
|
||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
||||
if (grids.length === 0) return;
|
||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||
const frequency = displayFrequency(message.freq_hz);
|
||||
if (grids.length > 0) {
|
||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||
...message,
|
||||
freq_hz: frequency ?? message.freq_hz,
|
||||
locator_details: locatorDetails
|
||||
});
|
||||
}
|
||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||
...message,
|
||||
freq_hz: frequency ?? message.freq_hz,
|
||||
locator_details: locatorDetails
|
||||
});
|
||||
};
|
||||
const normalize = (message) => {
|
||||
const frequency = displayFrequency(message.freq_hz);
|
||||
plotLocator(message);
|
||||
return {
|
||||
// The rig that heard it, kept so the mini view can tell a decode of the
|
||||
// rig on screen from one a background rig made on another band.
|
||||
rig_id: message.rig_id ?? null,
|
||||
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||
ts_ms: message.ts_ms,
|
||||
snr_db: message.snr_db,
|
||||
@@ -188,7 +199,9 @@ function initializeFtxDecoder(config) {
|
||||
};
|
||||
const receiveBatch = (messages) => {
|
||||
if (messages.length === 0) return;
|
||||
if (status) status.textContent = "Receiving";
|
||||
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
|
||||
status.textContent = "Receiving";
|
||||
}
|
||||
history = messages.map(normalize).reverse().concat(history);
|
||||
prune();
|
||||
bridge.setFt8FamilyBarDecoder?.(id);
|
||||
@@ -202,7 +215,7 @@ function initializeFtxDecoder(config) {
|
||||
bridge.clearMapMarkersByType?.(id);
|
||||
};
|
||||
const barFrames = () => {
|
||||
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
|
||||
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5 && isActiveRigDecode(message.rig_id)).slice(0, 8);
|
||||
let html = "";
|
||||
for (const message of recent) {
|
||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||
@@ -226,7 +239,15 @@ function initializeFtxDecoder(config) {
|
||||
prune();
|
||||
render();
|
||||
},
|
||||
reset
|
||||
reset,
|
||||
rerender: () => {
|
||||
bridge.updateFt8Bar?.();
|
||||
render();
|
||||
},
|
||||
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||
syncMap: () => {
|
||||
for (const message of [...history].reverse()) plotLocator(message);
|
||||
}
|
||||
});
|
||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||
const updatePeriod = () => {
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
hostState
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/active-rig.ts
|
||||
function isActiveRigDecode(rigId) {
|
||||
const activeRigId = hostState.lastActiveRigId;
|
||||
if (!activeRigId || !rigId) return true;
|
||||
return rigId === activeRigId;
|
||||
}
|
||||
function forActiveRig(items) {
|
||||
return items.filter((item) => isActiveRigDecode(item.rig_id));
|
||||
}
|
||||
|
||||
export {
|
||||
isActiveRigDecode,
|
||||
forActiveRig
|
||||
};
|
||||
@@ -1,3 +1,6 @@
|
||||
import {
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
@@ -25,7 +28,7 @@ var CW_BAR_LINE_GAP_MS = 5e3;
|
||||
var cwLastAppendTime = 0;
|
||||
var cwTonePickerRaf = null;
|
||||
var cwBarHistory = [];
|
||||
var cwBarCurrentLine = null;
|
||||
var cwBarCurrentLines = /* @__PURE__ */ new Map();
|
||||
var cwBarDismissedAtMs = 0;
|
||||
var cwAutoLocalOverride = null;
|
||||
function escapeCwHtml(input) {
|
||||
@@ -50,20 +53,22 @@ cwWindow.applyCwAutoUiFromServer = function(enabled) {
|
||||
if (cwAutoLocalOverride !== null) return;
|
||||
applyCwAutoUi(enabled);
|
||||
};
|
||||
function cwBarFlushCurrentLine() {
|
||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||
cwBarHistory.unshift(cwBarCurrentLine);
|
||||
function cwBarFlushCurrentLine(key) {
|
||||
const line = cwBarCurrentLines.get(key);
|
||||
cwBarCurrentLines.delete(key);
|
||||
if (line?.text.trim()) {
|
||||
cwBarHistory.unshift(line);
|
||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||
}
|
||||
cwBarCurrentLine = null;
|
||||
}
|
||||
function updateCwBar() {
|
||||
if (!cwBarOverlay) return;
|
||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||
const isCw = mode === "CW" || mode === "CWR";
|
||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
|
||||
const inProgress = [...cwBarCurrentLines.values()].filter((l) => l.text && isActiveRigDecode(l.rigId)).sort((a, b) => b.tsMs - a.tsMs);
|
||||
const liveLines = [...inProgress, ...recent];
|
||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||
cwBarOverlay.style.display = "none";
|
||||
@@ -308,7 +313,7 @@ function resetCwHistoryView() {
|
||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||
cwLastAppendTime = 0;
|
||||
cwBarHistory = [];
|
||||
cwBarCurrentLine = null;
|
||||
cwBarCurrentLines.clear();
|
||||
updateCwBar();
|
||||
drawCwTonePicker();
|
||||
}
|
||||
@@ -324,8 +329,9 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
||||
})();
|
||||
});
|
||||
function onServerCw(evt) {
|
||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||
if (evt.text && cwOutputEl) {
|
||||
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
|
||||
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
|
||||
if (evt.text && cwOutputEl && forSelectedRig) {
|
||||
const now = Date.now();
|
||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
||||
const line = document.createElement("div");
|
||||
@@ -346,25 +352,29 @@ function onServerCw(evt) {
|
||||
}
|
||||
if (evt.text) {
|
||||
const now = Date.now();
|
||||
const rigId = evt.rig_id ?? null;
|
||||
const key = rigId ?? "";
|
||||
if (evt.text === "\n") {
|
||||
cwBarFlushCurrentLine();
|
||||
cwBarFlushCurrentLine(key);
|
||||
} else {
|
||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||
cwBarFlushCurrentLine();
|
||||
let line = cwBarCurrentLines.get(key);
|
||||
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||
cwBarFlushCurrentLine(key);
|
||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||
cwBarCurrentLines.set(key, line);
|
||||
}
|
||||
cwBarCurrentLine.text += evt.text;
|
||||
cwBarCurrentLine.lastMs = now;
|
||||
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
||||
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
||||
line.text += evt.text;
|
||||
line.lastMs = now;
|
||||
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
|
||||
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
|
||||
}
|
||||
updateCwBar();
|
||||
}
|
||||
if (cwSignalIndicator) {
|
||||
if (cwSignalIndicator && forSelectedRig) {
|
||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||
}
|
||||
if (!cwAutoInput || cwAutoInput.checked) {
|
||||
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
|
||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||
}
|
||||
@@ -389,7 +399,14 @@ cwWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "cw",
|
||||
onMessage: onServerCw,
|
||||
restore: restoreCwHistory,
|
||||
reset: resetCwHistoryView
|
||||
reset: resetCwHistoryView,
|
||||
// The copied text of a rig that is no longer on screen cannot be unpicked
|
||||
// from the pane, so the switch starts the new rig's stream from empty.
|
||||
rerender: () => {
|
||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||
cwLastAppendTime = 0;
|
||||
updateCwBar();
|
||||
}
|
||||
});
|
||||
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||
ensureCwToneCanvasResolution();
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
(() => {
|
||||
// src/decode-history-worker.ts
|
||||
var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"];
|
||||
var workerScope = self;
|
||||
function decodeCborUint(view, bytes, state, additional) {
|
||||
const offset = state.offset;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
initializeFtxDecoder
|
||||
} from "./chunk-O2Y7YEVQ.js";
|
||||
} from "./chunk-PJ5Q7CWJ.js";
|
||||
import "./chunk-S57W63QN.js";
|
||||
import "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/ft2.ts
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
initializeFtxDecoder
|
||||
} from "./chunk-O2Y7YEVQ.js";
|
||||
} from "./chunk-PJ5Q7CWJ.js";
|
||||
import "./chunk-S57W63QN.js";
|
||||
import "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/ft4.ts
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
initializeFt8FamilyBar,
|
||||
initializeFtxDecoder,
|
||||
installFtxCompatibilityHelpers
|
||||
} from "./chunk-O2Y7YEVQ.js";
|
||||
} from "./chunk-PJ5Q7CWJ.js";
|
||||
import "./chunk-S57W63QN.js";
|
||||
import "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/ft8.ts
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
normalizeAprsPacket,
|
||||
renderAprsPacketRow
|
||||
} from "./chunk-OPEIVJGD.js";
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
@@ -67,21 +71,26 @@ function hfAprsFilterMatch(pkt) {
|
||||
].filter(Boolean).join(" ").toUpperCase();
|
||||
return haystack.includes(hfAprsFilterText);
|
||||
}
|
||||
function hfAprsRigPackets() {
|
||||
return forActiveRig(hfAprsPacketHistory);
|
||||
}
|
||||
function hfAprsVisiblePackets() {
|
||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
||||
const rigPackets = hfAprsRigPackets();
|
||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
|
||||
return packets.filter(hfAprsFilterMatch);
|
||||
}
|
||||
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||
function updateHfAprsSummary() {
|
||||
const rigPackets = hfAprsRigPackets();
|
||||
const visible = hfAprsVisiblePackets();
|
||||
if (hfAprsTotalCountEl) {
|
||||
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
||||
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||
}
|
||||
if (hfAprsVisibleCountEl) {
|
||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||
}
|
||||
if (hfAprsLatestSeenEl) {
|
||||
const latest = hfAprsPacketHistory[0];
|
||||
const latest = rigPackets[0];
|
||||
if (!latest) {
|
||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||
} else {
|
||||
@@ -140,17 +149,32 @@ function resetHfAprsHistoryView() {
|
||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||
hfAprsPacketHistory = [];
|
||||
renderHfAprsHistory();
|
||||
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
|
||||
}
|
||||
function pruneHfAprsHistoryView() {
|
||||
pruneHfAprsPacketHistory();
|
||||
renderHfAprsHistory();
|
||||
}
|
||||
function plotHfAprsPacket(pkt) {
|
||||
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
|
||||
hfAprsWindow.aprsMapAddStation(
|
||||
pkt.srcCall ?? "",
|
||||
pkt.lat,
|
||||
pkt.lon,
|
||||
pkt.info ?? "",
|
||||
pkt.symbolTable,
|
||||
pkt.symbolCode,
|
||||
pkt,
|
||||
"hf_aprs"
|
||||
);
|
||||
}
|
||||
function addHfAprsPacket(pkt) {
|
||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||
pkt._tsMs = tsMs;
|
||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
hfAprsPacketHistory.unshift(pkt);
|
||||
pruneHfAprsPacketHistory();
|
||||
plotHfAprsPacket(pkt);
|
||||
scheduleHfAprsHistoryRender();
|
||||
}
|
||||
function normalizeServerHfAprsPacket(pkt) {
|
||||
@@ -158,13 +182,14 @@ function normalizeServerHfAprsPacket(pkt) {
|
||||
}
|
||||
function onServerHfAprsBatch(packets) {
|
||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const pkt of packets) {
|
||||
const next = normalizeServerHfAprsPacket(pkt);
|
||||
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
plotHfAprsPacket(next);
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
@@ -227,8 +252,9 @@ if (hfAprsFilterInput) {
|
||||
});
|
||||
}
|
||||
function onServerHfAprs(pkt) {
|
||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
||||
const packet = normalizeServerHfAprsPacket(pkt);
|
||||
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||
addHfAprsPacket(packet);
|
||||
}
|
||||
renderHfAprsHistory();
|
||||
window.trxPluginRuntime.registerDecoder({
|
||||
@@ -237,5 +263,10 @@ window.trxPluginRuntime.registerDecoder({
|
||||
onBatch: onServerHfAprsBatch,
|
||||
restore: onServerHfAprsBatch,
|
||||
reset: resetHfAprsHistoryView,
|
||||
prune: pruneHfAprsHistoryView
|
||||
prune: pruneHfAprsHistoryView,
|
||||
rerender: renderHfAprsHistory,
|
||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||
syncMap: () => {
|
||||
for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ var mapWindow = window;
|
||||
const decodeContactPaths = /* @__PURE__ */ new Map();
|
||||
let selectedMapQsoKey = null;
|
||||
const mapMarkers = /* @__PURE__ */ new Set();
|
||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||
const MAP_FILTER_ALL_KEY = "__all";
|
||||
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
|
||||
@@ -128,6 +128,12 @@ var mapWindow = window;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
function aprsEntrySource(entry) {
|
||||
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||
}
|
||||
function aprsStationKey(call, source) {
|
||||
return source === "aprs" ? call : `${source}:${call}`;
|
||||
}
|
||||
function refreshAprsTrack(call, entry) {
|
||||
if (!entry) return;
|
||||
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
||||
@@ -149,7 +155,7 @@ var mapWindow = window;
|
||||
lineJoin: "round",
|
||||
interactive: false
|
||||
});
|
||||
track.__trxType = "aprs";
|
||||
track.__trxType = aprsEntrySource(entry);
|
||||
track._aprsCall = call;
|
||||
entry.track = track;
|
||||
}
|
||||
@@ -381,6 +387,7 @@ var mapWindow = window;
|
||||
}
|
||||
function mapSourceLabel(type) {
|
||||
if (type === "bookmark") return "Bookmarks";
|
||||
if (type === "hf_aprs") return "HF APRS";
|
||||
return String(type || "").toUpperCase();
|
||||
}
|
||||
function locatorFilterColor(type) {
|
||||
@@ -396,6 +403,7 @@ var mapWindow = window;
|
||||
if (type === "vdes") return "#a78bfa";
|
||||
if (type === "sat") return "#f59e0b";
|
||||
if (type === "aprs") return "#00d17f";
|
||||
if (type === "hf_aprs") return "#fb7185";
|
||||
return locatorFilterColor(type);
|
||||
}
|
||||
function bandForHz(hz) {
|
||||
@@ -931,10 +939,8 @@ var mapWindow = window;
|
||||
}
|
||||
}
|
||||
for (const entry of stationMarkers.values()) {
|
||||
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
|
||||
availableSources.add("aprs");
|
||||
break;
|
||||
}
|
||||
if (!entry?.visibleInHistoryWindow) continue;
|
||||
availableSources.add(aprsEntrySource(entry));
|
||||
}
|
||||
const bandMap = /* @__PURE__ */ new Map();
|
||||
for (const entry of locatorMarkers.values()) {
|
||||
@@ -966,7 +972,7 @@ var mapWindow = window;
|
||||
for (const key of Array.from(mapLocatorFilter.bands)) {
|
||||
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
||||
}
|
||||
const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
|
||||
const sourceItems = ["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
|
||||
key,
|
||||
label: mapSourceLabel(key),
|
||||
color: mapSourceColor(key),
|
||||
@@ -1028,9 +1034,10 @@ var mapWindow = window;
|
||||
}
|
||||
return parts.join(" ").toLowerCase();
|
||||
}
|
||||
if (type === "aprs") {
|
||||
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||
const entry = stationMarkers.get(call);
|
||||
if (type === "aprs" || type === "hf_aprs") {
|
||||
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||
const entry = stationMarkers.get(key);
|
||||
const call = entry?.call ?? key;
|
||||
const info = entry?.info ? String(entry.info) : "";
|
||||
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
||||
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
||||
@@ -1200,9 +1207,10 @@ var mapWindow = window;
|
||||
}
|
||||
};
|
||||
mapWindow.clearMapMarkersByType = function(type) {
|
||||
if (type === "aprs") {
|
||||
if (type === "aprs" || type === "hf_aprs") {
|
||||
selectedAprsTrackCall = null;
|
||||
stationMarkers.forEach((entry) => {
|
||||
stationMarkers.forEach((entry, key) => {
|
||||
if (aprsEntrySource(entry) !== type) return;
|
||||
if (entry && entry.marker) {
|
||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||
mapMarkers.delete(entry.marker);
|
||||
@@ -1211,8 +1219,8 @@ var mapWindow = window;
|
||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||
mapMarkers.delete(entry.track);
|
||||
}
|
||||
stationMarkers.delete(key);
|
||||
});
|
||||
stationMarkers.clear();
|
||||
return;
|
||||
}
|
||||
if (type === "ais") {
|
||||
@@ -1448,13 +1456,15 @@ var mapWindow = window;
|
||||
if (!ll) return;
|
||||
const entry = stationMarkers.get(marker._aprsCall);
|
||||
if (!entry) return;
|
||||
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||
const source = aprsEntrySource(entry);
|
||||
const call = entry.call ?? String(marker._aprsCall);
|
||||
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||
refreshAprsTrack(String(marker._aprsCall), entry);
|
||||
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
|
||||
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
|
||||
entry.track.addTo(aprsMap);
|
||||
}
|
||||
selectedAprsTrackCall = String(marker._aprsCall);
|
||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
|
||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
|
||||
return;
|
||||
}
|
||||
if (marker._aisMmsi) {
|
||||
@@ -1848,28 +1858,33 @@ var mapWindow = window;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function _aprsAddMarkerToMap(call, entry) {
|
||||
function _aprsAddMarkerToMap(key, entry) {
|
||||
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
||||
refreshAprsTrack(call, entry);
|
||||
refreshAprsTrack(key, entry);
|
||||
const source = aprsEntrySource(entry);
|
||||
const call = entry.call ?? key;
|
||||
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
||||
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
||||
const color = mapSourceColor(source);
|
||||
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
|
||||
radius: 6,
|
||||
color: "#00d17f",
|
||||
fillColor: "#00d17f",
|
||||
color,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.8
|
||||
}).addTo(aprsMap).bindPopup(popupContent);
|
||||
marker.__trxType = "aprs";
|
||||
marker.__trxType = source;
|
||||
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
|
||||
marker._aprsCall = call;
|
||||
marker._aprsCall = key;
|
||||
entry.marker = marker;
|
||||
mapMarkers.add(marker);
|
||||
}
|
||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
|
||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
|
||||
const nextPoint = [lat, lon];
|
||||
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
||||
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
||||
const existing = stationMarkers.get(call);
|
||||
const stationSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||
const key = aprsStationKey(call, stationSource);
|
||||
const existing = stationMarkers.get(key);
|
||||
if (existing) {
|
||||
existing.pkt = pkt;
|
||||
existing.lat = lat;
|
||||
@@ -1888,7 +1903,8 @@ var mapWindow = window;
|
||||
} else if (prevPoint) {
|
||||
prevPoint.tsMs = tsMs;
|
||||
}
|
||||
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
|
||||
existing.call = call;
|
||||
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
|
||||
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
||||
existing.marker.setLatLng([lat, lon]);
|
||||
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
||||
@@ -1899,7 +1915,8 @@ var mapWindow = window;
|
||||
track: null,
|
||||
trackHistory: [{ lat, lon, tsMs }],
|
||||
trackPoints: [nextPoint],
|
||||
type: "aprs",
|
||||
type: stationSource,
|
||||
call,
|
||||
pkt,
|
||||
lat,
|
||||
lon,
|
||||
@@ -1908,9 +1925,9 @@ var mapWindow = window;
|
||||
symbolCode,
|
||||
rigIds: new Set(msgRigId ? [msgRigId] : [])
|
||||
};
|
||||
stationMarkers.set(call, entry);
|
||||
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
|
||||
stationMarkers.set(key, entry);
|
||||
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
|
||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
|
||||
if (aprsMap) scheduleDecodeMapMaintenance();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ var satDom = {
|
||||
status: document.getElementById("sat-status"),
|
||||
liveView: document.getElementById("sat-live-view"),
|
||||
historyView: document.getElementById("sat-history-view"),
|
||||
predictionsView: document.getElementById("sat-predictions-view"),
|
||||
liveLatest: document.getElementById("sat-live-latest"),
|
||||
historyList: document.getElementById("sat-history-list"),
|
||||
historyCount: document.getElementById("sat-history-count"),
|
||||
@@ -17,29 +16,12 @@ var satDom = {
|
||||
typeFilter: document.getElementById("sat-type-filter"),
|
||||
lrptState: document.getElementById("sat-lrpt-state"),
|
||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||
viewPredBtn: document.getElementById("sat-view-predictions"),
|
||||
predFilter: document.getElementById("sat-pred-filter"),
|
||||
predMinEl: document.getElementById("sat-pred-min-el"),
|
||||
predCategory: document.getElementById("sat-pred-category"),
|
||||
predCurrentList: document.getElementById("sat-pred-current-list"),
|
||||
predUpcomingList: document.getElementById("sat-pred-list"),
|
||||
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
||||
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
||||
predStatus: document.getElementById("sat-pred-status")
|
||||
viewHistoryBtn: document.getElementById("sat-view-history")
|
||||
};
|
||||
var satImageHistory = [];
|
||||
var SAT_MAX_IMAGES = 100;
|
||||
var SAT_PRED_PAGE_SIZE = 50;
|
||||
var satPredShowAll = false;
|
||||
var satFilterText = "";
|
||||
var satActiveView = "live";
|
||||
var satPredData = [];
|
||||
var satPredFilterText = "";
|
||||
var satPredMinEl = 0;
|
||||
var satPredCategory = "all";
|
||||
var satPredSatCount = 0;
|
||||
var satPredCountdownTimer = null;
|
||||
function scheduleSatUi(key, job) {
|
||||
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
||||
satWindow.trxScheduleUiFrameJob(key, job);
|
||||
@@ -48,37 +30,19 @@ function scheduleSatUi(key, job) {
|
||||
job();
|
||||
}
|
||||
function switchSatView(view) {
|
||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||
satActiveView = view;
|
||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
||||
if (leavingPredictions) clearPredictionDom();
|
||||
if (view === "history") {
|
||||
renderSatHistoryTable();
|
||||
} else if (view === "predictions") {
|
||||
satPredShowAll = false;
|
||||
void loadSatPredictions();
|
||||
}
|
||||
if (view === "history") renderSatHistoryTable();
|
||||
}
|
||||
function clearPredictionDom() {
|
||||
stopCountdownTimer();
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
}
|
||||
satWindow.clearSatPredictionDom = clearPredictionDom;
|
||||
satDom.viewLiveBtn?.addEventListener("click", () => {
|
||||
switchSatView("live");
|
||||
});
|
||||
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
||||
switchSatView("history");
|
||||
});
|
||||
satDom.viewPredBtn?.addEventListener("click", () => {
|
||||
switchSatView("predictions");
|
||||
});
|
||||
var lastSatLrptOn = null;
|
||||
satWindow.updateSatLiveState = function(update) {
|
||||
if (!satDom.lrptState) return;
|
||||
@@ -275,200 +239,6 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click",
|
||||
}
|
||||
})();
|
||||
});
|
||||
function azToCardinal(deg) {
|
||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
||||
}
|
||||
function formatPredTime(ms) {
|
||||
const d = new Date(ms);
|
||||
const now = /* @__PURE__ */ new Date();
|
||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
return `${day}${hh}:${mm}`;
|
||||
}
|
||||
function formatPredDuration(s) {
|
||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||
return `${s}s`;
|
||||
}
|
||||
function formatCountdown(ms) {
|
||||
const totalSec = Math.max(0, Math.floor(ms / 1e3));
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
function elevationClass(deg) {
|
||||
if (deg >= 45) return "sat-pred-el-high";
|
||||
if (deg >= 10) return "sat-pred-el-mid";
|
||||
return "sat-pred-el-low";
|
||||
}
|
||||
function stopCountdownTimer() {
|
||||
if (satPredCountdownTimer) {
|
||||
clearInterval(satPredCountdownTimer);
|
||||
satPredCountdownTimer = null;
|
||||
}
|
||||
}
|
||||
function startCountdownTimer(container) {
|
||||
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
|
||||
if (countdownEls.length === 0) return;
|
||||
satPredCountdownTimer = setInterval(() => {
|
||||
if (satActiveView !== "predictions") {
|
||||
stopCountdownTimer();
|
||||
return;
|
||||
}
|
||||
const n = Date.now();
|
||||
let anyActive = false;
|
||||
for (const el of countdownEls) {
|
||||
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
||||
const rem = los - n;
|
||||
if (rem > 0) {
|
||||
el.textContent = formatCountdown(rem);
|
||||
anyActive = true;
|
||||
} else {
|
||||
el.textContent = "0:00";
|
||||
}
|
||||
}
|
||||
if (!anyActive) {
|
||||
stopCountdownTimer();
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
}
|
||||
}, 1e3);
|
||||
}
|
||||
function buildCurrentPassRow(pass, now) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row-current";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
const remaining = Math.max(0, pass.los_ms - now);
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
function buildUpcomingPassRow(pass) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
function getFilteredPredictions() {
|
||||
let items = satPredData;
|
||||
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
||||
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
||||
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
|
||||
return items;
|
||||
}
|
||||
function applyPredFilters() {
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
}
|
||||
var satPredictionFilter = satDom.predFilter;
|
||||
satPredictionFilter?.addEventListener("input", () => {
|
||||
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
||||
applyPredFilters();
|
||||
});
|
||||
var satPredictionMinElevation = satDom.predMinEl;
|
||||
satPredictionMinElevation?.addEventListener("change", () => {
|
||||
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
||||
applyPredFilters();
|
||||
});
|
||||
var satPredictionCategory = satDom.predCategory;
|
||||
satPredictionCategory?.addEventListener("change", () => {
|
||||
satPredCategory = satPredictionCategory.value;
|
||||
applyPredFilters();
|
||||
});
|
||||
function renderSatPredictions(passes, error) {
|
||||
stopCountdownTimer();
|
||||
if (error) {
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(passes) || passes.length === 0) {
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
||||
if (satDom.predCurrentList) {
|
||||
if (current.length === 0) {
|
||||
satDom.predCurrentList.innerHTML = "";
|
||||
} else {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||
satDom.predCurrentList.replaceChildren(frag);
|
||||
}
|
||||
}
|
||||
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
||||
if (satDom.predUpcomingList) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||
if (hiddenCount > 0) {
|
||||
const moreRow = document.createElement("div");
|
||||
moreRow.className = "sat-pred-row";
|
||||
moreRow.style.cursor = "pointer";
|
||||
moreRow.style.textAlign = "center";
|
||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
|
||||
moreRow.addEventListener("click", () => {
|
||||
satPredShowAll = true;
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
});
|
||||
frag.appendChild(moreRow);
|
||||
}
|
||||
satDom.predUpcomingList.replaceChildren(frag);
|
||||
}
|
||||
if (satDom.predStatus) {
|
||||
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
||||
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
|
||||
satDom.predStatus.textContent = text;
|
||||
}
|
||||
if (current.length > 0 && satActiveView === "predictions") {
|
||||
startCountdownTimer(satDom.predCurrentList);
|
||||
}
|
||||
}
|
||||
async function loadSatPredictions() {
|
||||
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…";
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
try {
|
||||
const resp = await fetch("/sat_passes");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
satPredSatCount = data.satellite_count || 0;
|
||||
if (data.error) {
|
||||
satPredData = [];
|
||||
renderSatPredictions([], data.error);
|
||||
} else {
|
||||
satPredData = data.passes || [];
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
}
|
||||
} catch (error) {
|
||||
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
satWindow.satShowOnMap = function(south, west, north, east) {
|
||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||
satWindow.enableMapSourceFilter("sat");
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
// src/plugins/satellite-predictions.ts
|
||||
var predWindow = window;
|
||||
var dom = {
|
||||
page: document.getElementById("tab-satellites"),
|
||||
filter: document.getElementById("sat-pred-filter"),
|
||||
minElevation: document.getElementById("sat-pred-min-el"),
|
||||
category: document.getElementById("sat-pred-category"),
|
||||
currentList: document.getElementById("sat-pred-current-list"),
|
||||
upcomingList: document.getElementById("sat-pred-list"),
|
||||
currentSection: document.getElementById("sat-pred-current-section"),
|
||||
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
|
||||
status: document.getElementById("sat-pred-status")
|
||||
};
|
||||
var PAGE_SIZE = 50;
|
||||
var predShowAll = false;
|
||||
var predData = [];
|
||||
var predFilterText = "";
|
||||
var predMinEl = 0;
|
||||
var predCategory = "all";
|
||||
var predSatCount = 0;
|
||||
var predCountdownTimer = null;
|
||||
function isPageVisible() {
|
||||
return !!dom.page && dom.page.style.display !== "none";
|
||||
}
|
||||
function azToCardinal(deg) {
|
||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
||||
}
|
||||
function formatPredTime(ms) {
|
||||
const d = new Date(ms);
|
||||
const now = /* @__PURE__ */ new Date();
|
||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
return `${day}${hh}:${mm}`;
|
||||
}
|
||||
function formatPredDuration(s) {
|
||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||
return `${s}s`;
|
||||
}
|
||||
function formatCountdown(ms) {
|
||||
const totalSec = Math.max(0, Math.floor(ms / 1e3));
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
function elevationClass(deg) {
|
||||
if (deg >= 45) return "sat-pred-el-high";
|
||||
if (deg >= 10) return "sat-pred-el-mid";
|
||||
return "sat-pred-el-low";
|
||||
}
|
||||
function stopCountdownTimer() {
|
||||
if (predCountdownTimer) {
|
||||
clearInterval(predCountdownTimer);
|
||||
predCountdownTimer = null;
|
||||
}
|
||||
}
|
||||
function startCountdownTimer(container) {
|
||||
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
|
||||
if (countdownEls.length === 0) return;
|
||||
predCountdownTimer = setInterval(() => {
|
||||
if (!isPageVisible()) {
|
||||
stopCountdownTimer();
|
||||
return;
|
||||
}
|
||||
const n = Date.now();
|
||||
let anyActive = false;
|
||||
for (const el of countdownEls) {
|
||||
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
||||
const rem = los - n;
|
||||
if (rem > 0) {
|
||||
el.textContent = formatCountdown(rem);
|
||||
anyActive = true;
|
||||
} else {
|
||||
el.textContent = "0:00";
|
||||
}
|
||||
}
|
||||
if (!anyActive) {
|
||||
stopCountdownTimer();
|
||||
render(filtered());
|
||||
}
|
||||
}, 1e3);
|
||||
}
|
||||
function buildCurrentPassRow(pass, now) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row-current";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
const remaining = Math.max(0, pass.los_ms - now);
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
function buildUpcomingPassRow(pass) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
function filtered() {
|
||||
let items = predData;
|
||||
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
|
||||
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
|
||||
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
|
||||
return items;
|
||||
}
|
||||
function applyFilters() {
|
||||
render(filtered());
|
||||
}
|
||||
var filterInput = dom.filter;
|
||||
filterInput?.addEventListener("input", () => {
|
||||
predFilterText = filterInput.value.trim().toUpperCase();
|
||||
applyFilters();
|
||||
});
|
||||
var minElevationSelect = dom.minElevation;
|
||||
minElevationSelect?.addEventListener("change", () => {
|
||||
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
|
||||
applyFilters();
|
||||
});
|
||||
var categorySelect = dom.category;
|
||||
categorySelect?.addEventListener("change", () => {
|
||||
predCategory = categorySelect.value;
|
||||
applyFilters();
|
||||
});
|
||||
function render(passes, error) {
|
||||
stopCountdownTimer();
|
||||
if (error) {
|
||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||
if (dom.currentSection) dom.currentSection.style.display = "none";
|
||||
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
||||
if (dom.status) dom.status.textContent = error;
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(passes) || passes.length === 0) {
|
||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||
if (dom.currentSection) dom.currentSection.style.display = "none";
|
||||
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
||||
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
|
||||
if (dom.currentList) {
|
||||
if (current.length === 0) {
|
||||
dom.currentList.innerHTML = "";
|
||||
} else {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||
dom.currentList.replaceChildren(frag);
|
||||
}
|
||||
}
|
||||
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
|
||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
|
||||
if (dom.upcomingList) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||
if (hiddenCount > 0) {
|
||||
const moreRow = document.createElement("div");
|
||||
moreRow.className = "sat-pred-row";
|
||||
moreRow.style.cursor = "pointer";
|
||||
moreRow.style.textAlign = "center";
|
||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
|
||||
moreRow.addEventListener("click", () => {
|
||||
predShowAll = true;
|
||||
render(filtered());
|
||||
});
|
||||
frag.appendChild(moreRow);
|
||||
}
|
||||
dom.upcomingList.replaceChildren(frag);
|
||||
}
|
||||
if (dom.status) {
|
||||
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
||||
if (predSatCount > 0) text += ` · ${predSatCount} satellites tracked`;
|
||||
dom.status.textContent = text;
|
||||
}
|
||||
if (current.length > 0 && isPageVisible()) {
|
||||
startCountdownTimer(dom.currentList);
|
||||
}
|
||||
}
|
||||
async function load() {
|
||||
if (dom.status) dom.status.textContent = "Loading predictions…";
|
||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||
try {
|
||||
const resp = await fetch("/sat_passes");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
predSatCount = data.satellite_count || 0;
|
||||
if (data.error) {
|
||||
predData = [];
|
||||
render([], data.error);
|
||||
} else {
|
||||
predData = data.passes || [];
|
||||
render(filtered());
|
||||
}
|
||||
} catch (error) {
|
||||
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
function clearPredictionDom() {
|
||||
stopCountdownTimer();
|
||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||
}
|
||||
predWindow.clearSatPredictionDom = clearPredictionDom;
|
||||
predWindow.refreshSatPredictions = function() {
|
||||
predShowAll = false;
|
||||
void load();
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/sstv.ts
|
||||
var sstvWindow = window;
|
||||
var sstvDom = {
|
||||
status: document.getElementById("sstv-status"),
|
||||
liveView: document.getElementById("sstv-live-view"),
|
||||
historyView: document.getElementById("sstv-history-view"),
|
||||
liveContainer: document.getElementById("sstv-live-container"),
|
||||
liveInfo: document.getElementById("sstv-live-info"),
|
||||
liveCanvas: document.getElementById("sstv-live-canvas"),
|
||||
liveLatest: document.getElementById("sstv-live-latest"),
|
||||
historyList: document.getElementById("sstv-history-list"),
|
||||
historyCount: document.getElementById("sstv-history-count"),
|
||||
filterInput: document.getElementById("sstv-filter"),
|
||||
sortSelect: document.getElementById("sstv-sort"),
|
||||
toggleBtn: document.getElementById("sstv-decode-toggle-btn"),
|
||||
clearBtn: document.getElementById("sstv-clear-btn"),
|
||||
viewLiveBtn: document.getElementById("sstv-view-live"),
|
||||
viewHistoryBtn: document.getElementById("sstv-view-history")
|
||||
};
|
||||
var SSTV_MAX_IMAGES = 100;
|
||||
var sstvHistory = [];
|
||||
var liveCtx = null;
|
||||
var liveMode = "";
|
||||
var liveHeight = 0;
|
||||
var liveRows = 0;
|
||||
var activeView = "live";
|
||||
var filterText = "";
|
||||
function retentionMs() {
|
||||
return sstvWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3;
|
||||
}
|
||||
function pruneHistory() {
|
||||
const cutoff = Date.now() - retentionMs();
|
||||
sstvHistory = sstvHistory.filter((image) => (image._tsMs || 0) > cutoff);
|
||||
}
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
function scheduleUi(key, job) {
|
||||
if (typeof sstvWindow.trxScheduleUiFrameJob === "function") {
|
||||
sstvWindow.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
function imageUrl(image) {
|
||||
if (!image.path) return null;
|
||||
const filename = image.path.split(/[\\/]/).pop();
|
||||
return filename ? `/sstv-images/${encodeURIComponent(filename)}` : null;
|
||||
}
|
||||
function decodeBase64(data) {
|
||||
const binary = atob(data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
function switchView(view) {
|
||||
activeView = view;
|
||||
if (sstvDom.liveView) sstvDom.liveView.style.display = view === "live" ? "" : "none";
|
||||
if (sstvDom.historyView) sstvDom.historyView.style.display = view === "history" ? "" : "none";
|
||||
for (const button of [sstvDom.viewLiveBtn, sstvDom.viewHistoryBtn]) {
|
||||
button?.classList.remove("sat-view-active");
|
||||
}
|
||||
if (view === "live") sstvDom.viewLiveBtn?.classList.add("sat-view-active");
|
||||
else sstvDom.viewHistoryBtn?.classList.add("sat-view-active");
|
||||
if (view === "history") renderHistoryTable();
|
||||
}
|
||||
sstvDom.viewLiveBtn?.addEventListener("click", () => {
|
||||
switchView("live");
|
||||
});
|
||||
sstvDom.viewHistoryBtn?.addEventListener("click", () => {
|
||||
switchView("history");
|
||||
});
|
||||
function beginPicture(mode, width, height) {
|
||||
const canvas = sstvDom.liveCanvas;
|
||||
if (!canvas || width <= 0 || height <= 0) return;
|
||||
liveMode = mode;
|
||||
liveHeight = height;
|
||||
liveRows = 0;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
liveCtx = canvas.getContext("2d");
|
||||
if (!liveCtx) return;
|
||||
liveCtx.fillStyle = "#606060";
|
||||
liveCtx.fillRect(0, 0, width, height);
|
||||
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "";
|
||||
updateLiveInfo();
|
||||
}
|
||||
function updateLiveInfo() {
|
||||
if (!sstvDom.liveInfo) return;
|
||||
const canvas = sstvDom.liveCanvas;
|
||||
const size = canvas ? `${canvas.width}×${canvas.height}` : "";
|
||||
sstvDom.liveInfo.textContent = liveMode ? `${liveMode} · ${size} · line ${liveRows}${liveHeight ? ` of ${liveHeight}` : ""}` : "";
|
||||
}
|
||||
function paintRow(line, rgb) {
|
||||
const canvas = sstvDom.liveCanvas;
|
||||
if (!liveCtx || !canvas) return;
|
||||
const width = canvas.width;
|
||||
if (line < 0 || line >= canvas.height || rgb.length < width * 3) return;
|
||||
const row = liveCtx.createImageData(width, 1);
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
row.data[x * 4] = rgb[x * 3] ?? 0;
|
||||
row.data[x * 4 + 1] = rgb[x * 3 + 1] ?? 0;
|
||||
row.data[x * 4 + 2] = rgb[x * 3 + 2] ?? 0;
|
||||
row.data[x * 4 + 3] = 255;
|
||||
}
|
||||
liveCtx.putImageData(row, 0, line);
|
||||
liveRows = Math.max(liveRows, line + 1);
|
||||
}
|
||||
function onProgress(msg) {
|
||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||
if (msg.state) {
|
||||
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
||||
return;
|
||||
}
|
||||
if (typeof msg.line !== "number" || !msg.line_data) return;
|
||||
const line = msg.line;
|
||||
const rgb = decodeBase64(msg.line_data);
|
||||
scheduleUi(`sstv-row-${line}`, () => {
|
||||
paintRow(line, rgb);
|
||||
updateLiveInfo();
|
||||
});
|
||||
}
|
||||
function onImage(msg) {
|
||||
const image = { ...msg };
|
||||
image._tsMs = typeof msg.ts_ms === "number" ? msg.ts_ms : Date.now();
|
||||
image._ts = new Date(image._tsMs).toLocaleTimeString();
|
||||
sstvHistory.push(image);
|
||||
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||
pruneHistory();
|
||||
if (!isActiveRigDecode(image.rig_id)) return;
|
||||
if (sstvDom.status) {
|
||||
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
||||
}
|
||||
scheduleUi("sstv-latest", renderLatestCard);
|
||||
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
||||
}
|
||||
function sstvRigImages() {
|
||||
return forActiveRig(sstvHistory);
|
||||
}
|
||||
function renderLatestCard() {
|
||||
if (!sstvDom.liveLatest) return;
|
||||
const rigImages = sstvRigImages();
|
||||
const latest = rigImages[rigImages.length - 1];
|
||||
if (!latest) {
|
||||
sstvDom.liveLatest.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const url = imageUrl(latest);
|
||||
const lines = `${latest.lines ?? 0}${latest.height ? ` of ${latest.height}` : ""} lines`;
|
||||
const state = latest.complete ? "complete" : "partial";
|
||||
sstvDom.liveLatest.innerHTML = `
|
||||
<div class="sat-latest-card">
|
||||
<div style="display:flex; align-items:baseline; gap:0.5rem; flex-wrap:wrap;">
|
||||
<strong>${escapeHtml(latest.mode ?? "SSTV")}</strong>
|
||||
<small style="color:var(--text-muted);">${escapeHtml(latest._ts ?? "")} · ${lines} · ${state}</small>
|
||||
</div>
|
||||
${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">
|
||||
<img src="${escapeHtml(url)}" alt="Received ${escapeHtml(latest.mode ?? "SSTV")} picture"
|
||||
style="margin-top:0.4rem; width:100%; max-width:640px; image-rendering:pixelated;" />
|
||||
</a>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
function filteredHistory() {
|
||||
const rigImages = sstvRigImages();
|
||||
const text = filterText.trim().toLowerCase();
|
||||
const matching = text ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : rigImages;
|
||||
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||
return matching;
|
||||
}
|
||||
function renderHistoryTable() {
|
||||
if (!sstvDom.historyList) return;
|
||||
pruneHistory();
|
||||
const rows = filteredHistory();
|
||||
sstvDom.historyList.innerHTML = rows.map((image) => {
|
||||
const url = imageUrl(image);
|
||||
const size = image.width && image.height ? `${image.width}×${image.height}` : "--";
|
||||
const lines = image.complete ? String(image.lines ?? 0) : `${image.lines ?? 0} (partial)`;
|
||||
return `<div class="sat-history-row">
|
||||
<span class="sat-col-time">${escapeHtml(image._ts ?? "")}</span>
|
||||
<span class="sat-col-type">${escapeHtml(image.mode ?? "--")}</span>
|
||||
<span class="sat-col-sat">${escapeHtml(size)}</span>
|
||||
<span class="sat-col-lines">${escapeHtml(lines)}</span>
|
||||
<span class="sat-col-link">${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">View</a>` : "--"}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
if (sstvDom.historyCount) {
|
||||
sstvDom.historyCount.textContent = rows.length ? `${rows.length} picture${rows.length === 1 ? "" : "s"}` : "No pictures yet";
|
||||
}
|
||||
}
|
||||
sstvDom.filterInput?.addEventListener("input", () => {
|
||||
filterText = sstvDom.filterInput?.value ?? "";
|
||||
renderHistoryTable();
|
||||
});
|
||||
sstvDom.sortSelect?.addEventListener("change", () => {
|
||||
renderHistoryTable();
|
||||
});
|
||||
function restoreHistory(entries) {
|
||||
if (!Array.isArray(entries)) return;
|
||||
for (const entry of entries) onImage(entry);
|
||||
}
|
||||
function resetHistoryView() {
|
||||
sstvHistory = [];
|
||||
liveRows = 0;
|
||||
liveMode = "";
|
||||
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "none";
|
||||
if (sstvDom.status) sstvDom.status.textContent = "Idle";
|
||||
renderLatestCard();
|
||||
renderHistoryTable();
|
||||
}
|
||||
sstvWindow.syncSstvToggle = function syncSstvToggle(enabled) {
|
||||
const button = sstvDom.toggleBtn;
|
||||
if (!button) return;
|
||||
button.textContent = enabled ? "Disable SSTV" : "Enable SSTV";
|
||||
button.setAttribute("aria-pressed", String(enabled));
|
||||
button.classList.toggle("is-active", enabled);
|
||||
};
|
||||
sstvDom.toggleBtn?.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (sstvDom.toggleBtn) {
|
||||
await sstvWindow.takeSchedulerControlForDecoderDisable?.(sstvDom.toggleBtn);
|
||||
}
|
||||
await hostCore.postPath("/toggle_sstv_decode");
|
||||
} catch (e) {
|
||||
console.error("SSTV toggle failed", e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
sstvDom.clearBtn?.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await hostCore.postPath("/clear_sstv_decode");
|
||||
resetHistoryView();
|
||||
} catch (e) {
|
||||
console.error("SSTV clear failed", e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
renderLatestCard();
|
||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "sstv",
|
||||
onMessage: onImage,
|
||||
restore: restoreHistory,
|
||||
prune: renderHistoryTable,
|
||||
reset: resetHistoryView,
|
||||
rerender: () => {
|
||||
renderLatestCard();
|
||||
renderHistoryTable();
|
||||
}
|
||||
});
|
||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "sstv_progress",
|
||||
onMessage: onProgress
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
@@ -60,17 +64,21 @@ function vdesHexPreview(rawBytes) {
|
||||
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
||||
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
||||
}
|
||||
function vdesRigMessages() {
|
||||
return forActiveRig(vdesMessageHistory);
|
||||
}
|
||||
function updateVdesSummary() {
|
||||
pruneVdesMessageHistory();
|
||||
if (vdesChannelSummaryEl) {
|
||||
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||
}
|
||||
const rigMessages = vdesRigMessages();
|
||||
if (vdesFrameCountEl) {
|
||||
const count = vdesMessageHistory.length;
|
||||
const count = rigMessages.length;
|
||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (vdesLatestSeenEl) {
|
||||
const latest = vdesMessageHistory[0];
|
||||
const latest = rigMessages[0];
|
||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||
}
|
||||
}
|
||||
@@ -137,7 +145,7 @@ function updateVdesBar() {
|
||||
updateVdesSummary();
|
||||
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
||||
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
||||
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)).slice(0, 6);
|
||||
if (!isVdes || messages.length === 0) {
|
||||
vdesBarOverlay.style.display = "none";
|
||||
vdesBarOverlay.innerHTML = "";
|
||||
@@ -182,7 +190,7 @@ function renderVdesHistory() {
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const message of vdesMessageHistory) {
|
||||
for (const message of vdesRigMessages()) {
|
||||
fragment.appendChild(renderVdesRow(message));
|
||||
}
|
||||
vdesMessagesEl.replaceChildren(fragment);
|
||||
@@ -209,10 +217,10 @@ function normalizeServerVdesMessage(msg) {
|
||||
}
|
||||
function onServerVdesBatch(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerVdesMessage(msg);
|
||||
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
@@ -251,8 +259,8 @@ function plotVdesMessage(msg) {
|
||||
vdesWindow.vdesMapAddPoint(msg);
|
||||
}
|
||||
function onServerVdes(msg) {
|
||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||
const next = normalizeServerVdesMessage(msg);
|
||||
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||
addVdesMessage(next);
|
||||
plotVdesMessage(next);
|
||||
}
|
||||
@@ -269,6 +277,10 @@ window.trxPluginRuntime.registerDecoder({
|
||||
restore: onServerVdesBatch,
|
||||
reset: resetVdesHistoryView,
|
||||
prune: pruneVdesHistoryView,
|
||||
rerender: () => {
|
||||
updateVdesBar();
|
||||
renderVdesHistory();
|
||||
},
|
||||
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||
syncMap: () => {
|
||||
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
@@ -102,13 +106,17 @@ function paintLine(lineBytes) {
|
||||
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||
wefaxLiveLineCount++;
|
||||
}
|
||||
function wefaxRigImages() {
|
||||
return forActiveRig(wefaxImageHistory);
|
||||
}
|
||||
function renderWefaxLatestCard() {
|
||||
if (!wefaxDom.liveLatest) return;
|
||||
if (wefaxImageHistory.length === 0) {
|
||||
const rigImages = wefaxRigImages();
|
||||
if (rigImages.length === 0) {
|
||||
wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
||||
return;
|
||||
}
|
||||
const img = wefaxImageHistory[0];
|
||||
const img = rigImages[0];
|
||||
if (!img) return;
|
||||
const ts = img._ts || "--";
|
||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||
@@ -129,7 +137,7 @@ function renderWefaxLatestCard() {
|
||||
wefaxDom.liveLatest.innerHTML = html;
|
||||
}
|
||||
function getWefaxFilteredHistory() {
|
||||
let items = wefaxImageHistory;
|
||||
let items = wefaxRigImages();
|
||||
if (wefaxFilterText) {
|
||||
items = items.filter(function(i) {
|
||||
const haystack = [
|
||||
@@ -173,7 +181,7 @@ function renderWefaxHistoryTable() {
|
||||
}
|
||||
wefaxDom.historyList.replaceChildren(fragment);
|
||||
if (wefaxDom.historyCount) {
|
||||
const total = wefaxImageHistory.length;
|
||||
const total = wefaxRigImages().length;
|
||||
const shown = items.length;
|
||||
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
|
||||
}
|
||||
@@ -208,6 +216,7 @@ function addWefaxImage(msg) {
|
||||
}
|
||||
}
|
||||
function onServerWefaxProgress(msg) {
|
||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||
if (msg.state && !msg.line_data) {
|
||||
if (wefaxDom.status) {
|
||||
wefaxDom.status.textContent = msg.state;
|
||||
@@ -234,6 +243,7 @@ function onServerWefaxProgress(msg) {
|
||||
}
|
||||
function onServerWefax(msg) {
|
||||
addWefaxImage(msg);
|
||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
||||
if (wefaxDom.status) {
|
||||
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
|
||||
@@ -328,7 +338,11 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||
onMessage: onServerWefax,
|
||||
restore: restoreWefaxHistory,
|
||||
prune: pruneWefaxHistoryView,
|
||||
reset: resetWefaxHistoryView
|
||||
reset: resetWefaxHistoryView,
|
||||
rerender: () => {
|
||||
renderWefaxLatestCard();
|
||||
renderWefaxHistoryTable();
|
||||
}
|
||||
});
|
||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "wefax_progress",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
@@ -64,10 +68,10 @@ function renderWsprRow(msg) {
|
||||
function renderWsprHistory() {
|
||||
pruneWsprMessageHistory();
|
||||
if (!wsprMessagesEl) return;
|
||||
const rigMessages = forActiveRig(wsprMessageHistory);
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
||||
const message = wsprMessageHistory[i];
|
||||
if (message) fragment.appendChild(renderWsprRow(message));
|
||||
for (const message of rigMessages) {
|
||||
fragment.appendChild(renderWsprRow(message));
|
||||
}
|
||||
wsprMessagesEl.replaceChildren(fragment);
|
||||
}
|
||||
@@ -90,6 +94,8 @@ function normalizeServerWsprMessage(msg) {
|
||||
station,
|
||||
rfHz,
|
||||
history: {
|
||||
rig_id: msg.rig_id ?? null,
|
||||
_rfHz: rfHz,
|
||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms,
|
||||
snr_db: msg.snr_db,
|
||||
@@ -101,16 +107,11 @@ function normalizeServerWsprMessage(msg) {
|
||||
}
|
||||
function onServerWsprBatch(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
||||
});
|
||||
}
|
||||
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||
plotWsprLocator(msg);
|
||||
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||
normalized.push(next.history);
|
||||
}
|
||||
@@ -250,15 +251,19 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
||||
}
|
||||
})();
|
||||
});
|
||||
function onServerWspr(msg) {
|
||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||
function plotWsprLocator(msg) {
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
||||
});
|
||||
}
|
||||
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
|
||||
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
...rfHz === null ? {} : { freq_hz: rfHz }
|
||||
});
|
||||
}
|
||||
function onServerWspr(msg) {
|
||||
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
plotWsprLocator(msg);
|
||||
addWsprMessage(next.history);
|
||||
}
|
||||
wsprWindow.trxPluginRuntime.registerDecoder({
|
||||
@@ -267,5 +272,10 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
||||
onBatch: onServerWsprBatch,
|
||||
restore: onServerWsprBatch,
|
||||
prune: pruneWsprHistoryView,
|
||||
reset: resetWsprHistoryView
|
||||
reset: resetWsprHistoryView,
|
||||
rerender: renderWsprHistory,
|
||||
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||
syncMap: () => {
|
||||
for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
|
||||
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
|
||||
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
|
||||
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
|
||||
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
|
||||
@@ -52,14 +53,21 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
||||
<span class="tab-label">Bookmarks</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="digital-modes">
|
||||
<button class="tab" data-tab="digital-modes" aria-label="Digital modes">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
|
||||
<span class="tab-label">Digital modes</span>
|
||||
<!-- A fifth of a phone screen does not hold "Digital modes"; the
|
||||
button keeps the full name for assistive tech. -->
|
||||
<span class="tab-label-short" aria-hidden="true">Digital</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="map">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
||||
<span class="tab-label">Map</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="satellites">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-satellite"/></svg>
|
||||
<span class="tab-label">Satellites</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="statistics">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
||||
<span class="tab-label">Statistics</span>
|
||||
@@ -163,13 +171,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div id="spectrum-size-grip" title="Drag to resize spectrum height" aria-label="Resize spectrum height"></div>
|
||||
<div id="spectrum-controls">
|
||||
<div id="spectrum-bw-row">
|
||||
<label id="spectrum-bw-label">Bandwidth <input type="number" id="spectrum-bw-input" value="" step="0.1" min="0.1" /> kHz</label>
|
||||
<button id="spectrum-bw-set-btn" type="button">Set</button>
|
||||
<button id="spectrum-bw-auto-btn" type="button">Auto BW</button>
|
||||
<button id="spectrum-bw-sweet-btn" type="button">Sweet-spot</button>
|
||||
<label id="spectrum-bw-label" class="spectrum-field"><span class="spectrum-field-name">Bandwidth</span><input type="number" id="spectrum-bw-input" value="" step="0.1" min="0.1" /><span class="spectrum-field-unit">kHz</span></label>
|
||||
<button id="spectrum-bw-set-btn" class="spectrum-btn" type="button">Set</button>
|
||||
<button id="spectrum-bw-auto-btn" class="spectrum-btn" type="button">Auto BW</button>
|
||||
<button id="spectrum-bw-sweet-btn" class="spectrum-btn" type="button">Sweet-spot</button>
|
||||
</div>
|
||||
<div class="spectrum-controls-spacer" aria-hidden="true"></div>
|
||||
<div id="spectrum-level-row">
|
||||
<label class="overview-control" id="spectrum-peak-hold-label">Peak Hold
|
||||
<label class="overview-control spectrum-field" id="spectrum-peak-hold-label"><span class="spectrum-field-name">Peak Hold</span>
|
||||
<select id="overview-peak-hold" class="status-input">
|
||||
<option value="0">Off</option>
|
||||
<option value="500">0.5 s</option>
|
||||
@@ -182,10 +191,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<option value="60000">60 s</option>
|
||||
</select>
|
||||
</label>
|
||||
<label id="spectrum-floor-label">Floor <input type="number" id="spectrum-floor-input" value="-115" step="5" /> dB</label>
|
||||
<label id="spectrum-range-label">Range <input type="number" id="spectrum-range-input" value="90" step="10" min="10" /> dB</label>
|
||||
<button id="spectrum-auto-btn" type="button">Auto</button>
|
||||
<label id="spectrum-gamma-label">Contrast <input type="range" id="spectrum-gamma-input" min="0.2" max="3.0" step="0.1" value="1.0" /><span id="spectrum-gamma-value">1.0</span></label>
|
||||
<label id="spectrum-floor-label" class="spectrum-field"><span class="spectrum-field-name">Floor</span><input type="number" id="spectrum-floor-input" value="-115" step="5" /><span class="spectrum-field-unit">dB</span></label>
|
||||
<label id="spectrum-range-label" class="spectrum-field"><span class="spectrum-field-name">Range</span><input type="number" id="spectrum-range-input" value="90" step="10" min="10" /><span class="spectrum-field-unit">dB</span></label>
|
||||
<button id="spectrum-auto-btn" class="spectrum-btn" type="button">Auto</button>
|
||||
<label id="spectrum-gamma-label" class="spectrum-field"><span class="spectrum-field-name">Contrast</span><input type="range" id="spectrum-gamma-input" min="0.2" max="3.0" step="0.1" value="1.0" /><span id="spectrum-gamma-value" class="spectrum-field-value">1.0</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spectrum-hint" class="spectrum-hint-mouse">Scroll to zoom · Ctrl+Scroll to tune · Drag to pan · Drag BW edges to resize · +/- zoom · Arrows pan · 0 reset</div>
|
||||
@@ -584,6 +593,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<button class="sub-tab" data-subtab="rds">RDS</button>
|
||||
<button class="sub-tab" data-subtab="sat">SAT</button>
|
||||
<button class="sub-tab" data-subtab="wefax">WEFAX</button>
|
||||
<button class="sub-tab" data-subtab="sstv">SSTV</button>
|
||||
</div>
|
||||
<div id="subtab-overview" class="sub-tab-panel">
|
||||
<div class="plugin-item" data-decoder="ais">
|
||||
@@ -646,6 +656,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-item" data-decoder="sstv">
|
||||
<strong>SSTV Decoder</strong>
|
||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||
Slow-Scan Television — pictures in Martin, Scottie, Robot, PD and Wraase modes
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-item" data-decoder="wefax">
|
||||
<strong>WEFAX Decoder</strong>
|
||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||
@@ -653,6 +669,56 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subtab-sstv" class="sub-tab-panel" style="display:none;">
|
||||
<div class="ft8-controls">
|
||||
<button id="sstv-decode-toggle-btn" type="button">Enable SSTV</button>
|
||||
<small id="sstv-status" style="color:var(--text-muted);">Idle</small>
|
||||
</div>
|
||||
<!-- View selector -->
|
||||
<div class="sat-view-bar">
|
||||
<button id="sstv-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
||||
<button id="sstv-view-history" class="sat-view-btn" type="button">History</button>
|
||||
</div>
|
||||
<!-- Live view -->
|
||||
<div id="sstv-live-view">
|
||||
<div style="margin:0 0 0.5rem;">
|
||||
<div style="color:var(--text-muted); font-size:0.82rem; line-height:1.5;">
|
||||
<strong>Slow-Scan Television</strong> — pictures sent as tones, in Martin,
|
||||
Scottie, Robot, PD and Wraase modes. Tune a sideband or FM signal and enable the
|
||||
decoder; the mode is read from the header the sender opens with.
|
||||
</div>
|
||||
</div>
|
||||
<div id="sstv-live-container" style="display:none; margin:0.5rem 0;">
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.3rem;">
|
||||
<strong>Receiving</strong>
|
||||
<small id="sstv-live-info" style="color:var(--text-muted);"></small>
|
||||
</div>
|
||||
<canvas id="sstv-live-canvas" width="320" height="256"
|
||||
style="width:100%; max-width:640px; image-rendering:pixelated; background:#111;"></canvas>
|
||||
</div>
|
||||
<div id="sstv-live-latest" style="margin-top:0.5rem;"></div>
|
||||
</div>
|
||||
<!-- History view -->
|
||||
<div id="sstv-history-view" style="display:none;">
|
||||
<div class="sat-history-controls">
|
||||
<input id="sstv-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. Martin, PD120)" />
|
||||
<select id="sstv-sort" class="sat-sort-select">
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
</select>
|
||||
<button id="sstv-clear-btn" type="button" style="font-size:0.8rem;">Clear All</button>
|
||||
</div>
|
||||
<div class="sat-history-header">
|
||||
<span class="sat-col-time">Time</span>
|
||||
<span class="sat-col-type">Mode</span>
|
||||
<span class="sat-col-sat">Size</span>
|
||||
<span class="sat-col-lines">Lines</span>
|
||||
<span class="sat-col-link">Image</span>
|
||||
</div>
|
||||
<div id="sstv-history-list"></div>
|
||||
<small id="sstv-history-count" style="color:var(--text-muted);font-size:0.75rem;">No pictures yet</small>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subtab-rds" class="sub-tab-panel" style="display:none;">
|
||||
<div class="rds-grid">
|
||||
<div class="rds-field"><span class="rds-label">Status</span><span id="rds-status" class="rds-value rds-no-signal">No signal</span></div>
|
||||
@@ -857,7 +923,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="sat-view-bar">
|
||||
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
||||
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
|
||||
<button id="sat-view-predictions" class="sat-view-btn" type="button">Predictions</button>
|
||||
</div>
|
||||
<!-- Live view -->
|
||||
<div id="sat-live-view">
|
||||
@@ -899,50 +964,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div id="sat-history-list"></div>
|
||||
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
||||
</div>
|
||||
<!-- Predictions view -->
|
||||
<div id="sat-predictions-view" style="display:none;">
|
||||
<div class="ft8-controls">
|
||||
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
||||
<select id="sat-pred-category" class="sat-sort-select">
|
||||
<option value="all">All</option>
|
||||
<option value="weather">Weather</option>
|
||||
<option value="amateur">Ham Radio</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<select id="sat-pred-min-el" class="sat-sort-select">
|
||||
<option value="0">All passes</option>
|
||||
<option value="10">Min 10°</option>
|
||||
<option value="20">Min 20°</option>
|
||||
<option value="45">Min 45°</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Current passes -->
|
||||
<div id="sat-pred-current-section">
|
||||
<div class="sat-pred-section-title">Currently receivable</div>
|
||||
<div class="sat-pred-header sat-pred-header-current">
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
<span class="sat-pred-col-el">Max El</span>
|
||||
<span class="sat-pred-col-time">AOS Start</span>
|
||||
<span class="sat-pred-col-time">AOS End</span>
|
||||
<span class="sat-pred-col-countdown">Time left</span>
|
||||
<span class="sat-pred-col-dir">Direction</span>
|
||||
</div>
|
||||
<div id="sat-pred-current-list"></div>
|
||||
</div>
|
||||
<!-- Upcoming passes -->
|
||||
<div id="sat-pred-upcoming-section">
|
||||
<div class="sat-pred-section-title">Upcoming passes</div>
|
||||
<div class="sat-pred-header">
|
||||
<span class="sat-pred-col-time">AOS (UTC)</span>
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
<span class="sat-pred-col-el">Max El</span>
|
||||
<span class="sat-pred-col-dur">Duration</span>
|
||||
<span class="sat-pred-col-dir">Direction</span>
|
||||
</div>
|
||||
<div id="sat-pred-list"></div>
|
||||
</div>
|
||||
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
|
||||
<div class="ft8-controls">
|
||||
@@ -1056,6 +1077,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div id="tab-satellites" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">Satellites</h2>
|
||||
<div class="ft8-controls">
|
||||
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
||||
<select id="sat-pred-category" class="sat-sort-select">
|
||||
<option value="all">All</option>
|
||||
<option value="weather">Weather</option>
|
||||
<option value="amateur">Ham Radio</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<select id="sat-pred-min-el" class="sat-sort-select">
|
||||
<option value="0">All passes</option>
|
||||
<option value="10">Min 10°</option>
|
||||
<option value="20">Min 20°</option>
|
||||
<option value="45">Min 45°</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Current passes -->
|
||||
<div id="sat-pred-current-section">
|
||||
<div class="sat-pred-section-title">Currently receivable</div>
|
||||
<div class="sat-pred-header sat-pred-header-current">
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
<span class="sat-pred-col-el">Max El</span>
|
||||
<span class="sat-pred-col-time">AOS Start</span>
|
||||
<span class="sat-pred-col-time">AOS End</span>
|
||||
<span class="sat-pred-col-countdown">Time left</span>
|
||||
<span class="sat-pred-col-dir">Direction</span>
|
||||
</div>
|
||||
<div id="sat-pred-current-list"></div>
|
||||
</div>
|
||||
<!-- Upcoming passes -->
|
||||
<div id="sat-pred-upcoming-section">
|
||||
<div class="sat-pred-section-title">Upcoming passes</div>
|
||||
<div class="sat-pred-header">
|
||||
<span class="sat-pred-col-time">AOS (UTC)</span>
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
<span class="sat-pred-col-el">Max El</span>
|
||||
<span class="sat-pred-col-dur">Duration</span>
|
||||
<span class="sat-pred-col-dir">Direction</span>
|
||||
</div>
|
||||
<div id="sat-pred-list"></div>
|
||||
</div>
|
||||
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
||||
</div>
|
||||
|
||||
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">Statistics</h2>
|
||||
<template id="tmpl-statistics">
|
||||
|
||||
@@ -1717,6 +1717,8 @@ small { color: var(--text-muted); }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab-label { display: block; }
|
||||
/* Only the bottom nav is short of room. */
|
||||
.tab-label-short { display: none; }
|
||||
.tab-more-icon { display: none; }
|
||||
.about-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
|
||||
.about-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; padding: 0; overflow: hidden; }
|
||||
@@ -2592,7 +2594,6 @@ button.map-qso-card:focus-visible {
|
||||
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.24);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
will-change: backdrop-filter;
|
||||
overflow: auto;
|
||||
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
|
||||
}
|
||||
@@ -2610,9 +2611,15 @@ button.map-qso-card:focus-visible {
|
||||
.map-overlay-filters.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
/* With the filters collapsed the bar has no reason to span the map. */
|
||||
/* With the filters collapsed the bar has no reason to span the map.
|
||||
It keeps both anchors and shrinks inside them, rather than dropping `left`
|
||||
and being sized by shrink-to-fit: the anchored box is a size the browser
|
||||
already knows, and an absolutely positioned, backdrop-filtered, composited
|
||||
box being asked to size itself from its content is the shape of thing that
|
||||
renders as nothing on engines other than the one it was written against. */
|
||||
.map-overlay-panel.filters-hidden {
|
||||
left: auto;
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
}
|
||||
.map-overlay-actions {
|
||||
display: flex;
|
||||
@@ -2739,7 +2746,6 @@ button.map-qso-card:focus-visible {
|
||||
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
will-change: backdrop-filter;
|
||||
overflow: auto;
|
||||
}
|
||||
.map-band-legend.is-empty {
|
||||
@@ -3810,11 +3816,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
@media (max-width: 760px) {
|
||||
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
|
||||
}
|
||||
/* One navigation model at every width. Statistics, Recorder, Settings and
|
||||
* About are occasional destinations: they live behind More rather than
|
||||
* competing with the operating tabs for the row and then scrolling out of
|
||||
/* One navigation model at every width. Satellites, Statistics, Recorder,
|
||||
* Settings and About are occasional destinations: they live behind More rather
|
||||
* than competing with the operating tabs for the row and then scrolling out of
|
||||
* reach. The mobile layout already grouped them this way; the desktop strip
|
||||
* now matches it, which is why the tab strip no longer needs to scroll. */
|
||||
.tab-bar-nav .tab[data-tab="satellites"],
|
||||
.tab-bar-nav .tab[data-tab="statistics"],
|
||||
.tab-bar-nav .tab[data-tab="recorder"],
|
||||
.tab-bar-nav .tab[data-tab="settings"],
|
||||
@@ -3892,7 +3899,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
input.status-input, select.status-input { font-size: 1.1rem; }
|
||||
:root { --header-waterfall-overlap: 0rem; }
|
||||
.controls-tray-scroll { overflow-x: auto; }
|
||||
.controls-tray { width: 100%; min-width: 0; padding-left: 0.85rem; padding-right: 0.85rem; }
|
||||
/* A grid column sizes to its content by default, so one row wider than the
|
||||
phone — the mode picker, six buttons across — dragged the whole tray out
|
||||
with it and left the rest to be found by scrolling sideways. `minmax(0,
|
||||
1fr)` lets the column be as narrow as the screen and the rows wrap inside
|
||||
it. */
|
||||
.controls-tray {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding-left: 0.85rem;
|
||||
padding-right: 0.85rem;
|
||||
}
|
||||
.freq-inline { gap: 0.5rem; flex-wrap: wrap; }
|
||||
.header-text { width: auto; min-width: 0; flex: 0 1 auto; }
|
||||
.header-main {
|
||||
@@ -4013,12 +4031,9 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
|
||||
}
|
||||
.tab-icon, .tab-more-icon { display: block; }
|
||||
/* Shorten long tab labels to keep bottom nav compact */
|
||||
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
|
||||
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
|
||||
.tab[data-tab="statistics"] .tab-label { font-size: 0.6rem; }
|
||||
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
|
||||
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
|
||||
.tab[data-tab="satellites"], .tab[data-tab="statistics"],
|
||||
.tab[data-tab="recorder"], .tab[data-tab="settings"],
|
||||
.tab[data-tab="about"] { display: none; }
|
||||
.mobile-more-btn { display: flex; }
|
||||
.mobile-more-menu {
|
||||
position: fixed;
|
||||
@@ -4030,7 +4045,48 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
color: var(--accent-text);
|
||||
background: color-mix(in srgb, var(--accent-green) 10%, transparent);
|
||||
}
|
||||
.tab .tab-label, .tab[data-tab] .tab-label { font-size: 0.75rem; }
|
||||
/* The bottom nav divides the bar between its five destinations, so a tab is
|
||||
about a fifth of the screen and the label has to live inside that. Sharing
|
||||
the width evenly beats sizing each tab to its own label, which gave
|
||||
"Digital modes" the same room as "Map" and clipped it mid-word. */
|
||||
.tab-bar-nav .tab {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding-left: 0.15rem;
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
.tab .tab-label, .tab[data-tab] .tab-label {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
/* The long ones, sized against the screen rather than at a fixed size.
|
||||
A tab is a fifth of the viewport, so what fits in it is a function of the
|
||||
viewport — and only roughly of the font size, since how wide a platform
|
||||
renders a word varies by ten per cent or more between machines. A fixed
|
||||
0.6rem fit here with ten pixels to spare and clipped in CI.
|
||||
|
||||
(These rules also used to sit above the one that sets the size for all
|
||||
labels — identical specificity, later in the file, so nothing was ever
|
||||
shortened.) */
|
||||
.tab[data-tab="bookmarks"] .tab-label,
|
||||
.tab[data-tab="statistics"] .tab-label {
|
||||
font-size: clamp(0.46rem, 2.2vw, 0.62rem);
|
||||
}
|
||||
/* "Digital modes" does not fit at any size worth reading, so the nav shows
|
||||
the short form and the button carries the full one as its label. */
|
||||
.tab[data-tab="digital-modes"] .tab-label { display: none; }
|
||||
.tab[data-tab="digital-modes"] .tab-label-short {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: clamp(0.46rem, 2.2vw, 0.62rem);
|
||||
}
|
||||
.decoder-tab-select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -4068,10 +4124,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
/* The rig's name is the widest thing in the bar and it does not need to be:
|
||||
139 px of a 338 px bar went on it, and everything else was pushed into the
|
||||
overflow menu to make room. Enough for a short name, ellipsised past that,
|
||||
with the full name still in the menu it opens. */
|
||||
.header-rig-switch select,
|
||||
.header-style-pick select {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
max-width: 6.5rem;
|
||||
padding-right: 0.2rem;
|
||||
font-size: 0.8rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.header-bar-btn {
|
||||
flex: 0 0 auto;
|
||||
@@ -4102,12 +4166,20 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
.mult-col {
|
||||
min-width: 0;
|
||||
}
|
||||
/* These are given the width of their column below; with padding on a
|
||||
content box that is the column's width *plus* the padding, which is what
|
||||
pushed the page itself sideways by a dozen pixels. */
|
||||
.wavelength-display,
|
||||
.sig-strength-display,
|
||||
.jog-step,
|
||||
.jog-mult {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.sig-strength-display {
|
||||
min-width: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.jog-step button,
|
||||
.jog-mult button {
|
||||
flex: 1 1 0;
|
||||
@@ -4131,47 +4203,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
.signal-measure #sig-result {
|
||||
width: 100%;
|
||||
}
|
||||
#spectrum-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.45rem;
|
||||
padding-top: 0.45rem;
|
||||
}
|
||||
#spectrum-bw-row,
|
||||
#spectrum-level-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.4rem;
|
||||
}
|
||||
#spectrum-bw-label,
|
||||
#spectrum-floor-label,
|
||||
#spectrum-range-label,
|
||||
#spectrum-peak-hold-label {
|
||||
flex: 1 1 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
#spectrum-gamma-label {
|
||||
flex: 1 1 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
#spectrum-gamma-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 3rem;
|
||||
}
|
||||
#spectrum-bw-input,
|
||||
#spectrum-floor-input,
|
||||
#spectrum-range-input,
|
||||
#overview-peak-hold {
|
||||
flex: 1 1 auto;
|
||||
min-width: 3rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#spectrum-bw-set-btn,
|
||||
#spectrum-bw-auto-btn,
|
||||
#spectrum-bw-sweet-btn,
|
||||
#spectrum-auto-btn {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.spectrum-edge-shift {
|
||||
width: 0.95rem;
|
||||
}
|
||||
@@ -4821,111 +4852,166 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
}
|
||||
/* ── Spectrum control strip ────────────────────────────────────────────
|
||||
Two clusters of controls under the plot: the receiver's bandwidth on the
|
||||
left, the display's levels on the right. Everything in here is one height
|
||||
and one shape — a row whose controls each size themselves reads as a pile
|
||||
rather than a strip.
|
||||
|
||||
The container wraps; a cluster does not. A cluster that cannot fit drops
|
||||
whole to the next line and starts it left-aligned, rather than splitting and
|
||||
leaving the cluster beside it floating at a height of its own, which is what
|
||||
the two rows did to each other between 1100 and 1400 px. */
|
||||
#spectrum-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 3px 4px 0;
|
||||
gap: 0.6rem;
|
||||
gap: 0.35rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
#spectrum-bw-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
#spectrum-bw-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
min-width: 0;
|
||||
}
|
||||
#spectrum-bw-input {
|
||||
width: 4.5rem;
|
||||
padding: 1px 4px;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
text-align: right;
|
||||
height: 1.5rem;
|
||||
}
|
||||
#spectrum-bw-set-btn,
|
||||
#spectrum-bw-auto-btn,
|
||||
#spectrum-bw-sweet-btn {
|
||||
height: 1.5rem;
|
||||
min-height: 0;
|
||||
padding: 0 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
#spectrum-bw-row,
|
||||
#spectrum-level-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
#spectrum-floor-label {
|
||||
display: flex;
|
||||
/* Takes the slack, so the clusters reach the ends of a wide strip without
|
||||
`space-between` opening a quarter-width hole in the middle of it. */
|
||||
.spectrum-controls-spacer {
|
||||
flex: 1 1 1.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* A field is one box: name, value and unit share a border, so a number can
|
||||
never be read apart from the unit it is in. */
|
||||
.spectrum-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
/* Border-box on both field and button, or the button's own border and
|
||||
padding add to the height and the two end up a couple of pixels apart —
|
||||
which is the pile-of-controls look this is meant to end. */
|
||||
box-sizing: border-box;
|
||||
height: 1.7rem;
|
||||
padding: 0 0.4rem 0 0.5rem;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
background: var(--input-bg);
|
||||
white-space: nowrap;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
#spectrum-floor-input {
|
||||
width: 3.4rem;
|
||||
padding: 1px 4px;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
text-align: right;
|
||||
height: 1.5rem;
|
||||
.spectrum-field:focus-within {
|
||||
border-color: color-mix(in srgb, var(--accent-green) 45%, var(--border-light));
|
||||
}
|
||||
#spectrum-range-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
.spectrum-field-name {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
#spectrum-range-input {
|
||||
width: 3.4rem;
|
||||
padding: 1px 4px;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--input-bg);
|
||||
/* Unit and readout hold a fixed, tabular slot: the row must not twitch as a
|
||||
value goes from 1.0 to 0.9. */
|
||||
.spectrum-field-unit,
|
||||
.spectrum-field-value {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 1.7rem;
|
||||
}
|
||||
.spectrum-field-value {
|
||||
color: var(--text);
|
||||
text-align: right;
|
||||
height: 1.5rem;
|
||||
}
|
||||
#spectrum-auto-btn {
|
||||
height: 1.5rem;
|
||||
.spectrum-field input[type="number"] {
|
||||
width: 3.2rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 0.78rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.spectrum-field input[type="number"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
#spectrum-bw-input {
|
||||
width: 3.6rem;
|
||||
}
|
||||
/* The select carries `status-input` for the sake of other layouts, which gives
|
||||
it a border and a background of its own; inside a field that reads as a box
|
||||
drawn inside a box. */
|
||||
#spectrum-controls .spectrum-field select {
|
||||
width: auto;
|
||||
height: 1.35rem;
|
||||
min-height: 0;
|
||||
padding: 0 8px;
|
||||
font-size: 0.75rem;
|
||||
padding: 0 0.1rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
#spectrum-gamma-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
#spectrum-gamma-input {
|
||||
width: 5rem;
|
||||
height: 1.5rem;
|
||||
.spectrum-field input[type="range"] {
|
||||
width: 5.5rem;
|
||||
height: 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
#spectrum-gamma-value {
|
||||
font-size: 0.75rem;
|
||||
min-width: 1.6rem;
|
||||
text-align: right;
|
||||
.spectrum-btn {
|
||||
box-sizing: border-box;
|
||||
height: 1.7rem;
|
||||
min-height: 0;
|
||||
padding: 0 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.73rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Fingers need more than a 27 px target; a mouse does not, and the strip has
|
||||
no width to spare on a desktop. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.spectrum-field,
|
||||
.spectrum-btn {
|
||||
height: 2.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Narrow screens: the same fields, stacked. This sits after the rules it
|
||||
overrides — `#spectrum-bw-row` in both places has identical specificity, so
|
||||
whichever comes last wins, and from inside the earlier media query the
|
||||
narrow rules silently lost. */
|
||||
@media (max-width: 640px) {
|
||||
/* Narrow: the clusters stack, and a field takes the width so its name and
|
||||
its value sit at opposite ends of a line the thumb can hit. */
|
||||
#spectrum-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.45rem;
|
||||
padding-top: 0.45rem;
|
||||
}
|
||||
#spectrum-bw-row,
|
||||
#spectrum-level-row {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.4rem;
|
||||
}
|
||||
.spectrum-controls-spacer {
|
||||
display: none;
|
||||
}
|
||||
#spectrum-controls .spectrum-field {
|
||||
flex: 1 1 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
#spectrum-controls .spectrum-field input[type="number"],
|
||||
#spectrum-controls .spectrum-field input[type="range"],
|
||||
#spectrum-controls .spectrum-field select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 3rem;
|
||||
}
|
||||
#spectrum-controls .spectrum-btn {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
.spectrum-hint-mouse,
|
||||
.spectrum-hint-touch {
|
||||
@@ -4944,9 +5030,15 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
|
||||
/* ── Phone layout (≤ 520px) ───────────────────────────────────────────── */
|
||||
@media (max-width: 520px) {
|
||||
/* Single-column controls: jog first, then mode, then power */
|
||||
/* Single-column controls: jog first, then mode, then power.
|
||||
`nowrap` is the whole point of it. The base rule wraps, and a *column*
|
||||
that wraps starts a second column when its items are taller than it is —
|
||||
which put the transmit controls at x=400 on a 390 px screen, off the side
|
||||
of a tray 354 px wide, reachable only by a horizontal scroll with nothing
|
||||
to say it was there. */
|
||||
.controls-row {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
.controls-col-center {
|
||||
@@ -6135,8 +6227,16 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
.controls-tray { container-type: inline-size; container-name: controls; }
|
||||
.decode-history-table-wrap { container-type: inline-size; container-name: decode-table; }
|
||||
|
||||
@container controls (max-width: 600px) {
|
||||
.controls-tray .controls-row { flex-wrap: wrap; }
|
||||
/* Wrapping a narrow tray's columns onto more lines is right while the row runs
|
||||
left to right. Below the phone breakpoint the row is a column, and wrap there
|
||||
means "start another column" — which is how the transmit controls ended up
|
||||
beside the tray instead of under it, off the side of the screen. This rule
|
||||
outranks the phone one (two classes to its one) and sits later in the file,
|
||||
so it has to exclude itself rather than be overridden. */
|
||||
@media (min-width: 521px) {
|
||||
@container controls (max-width: 600px) {
|
||||
.controls-tray .controls-row { flex-wrap: wrap; }
|
||||
}
|
||||
}
|
||||
@container decode-table (max-width: 500px) {
|
||||
.decode-history-table th:nth-child(n+4),
|
||||
|
||||
@@ -23,12 +23,14 @@ await build({
|
||||
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
|
||||
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
||||
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
||||
sstv: path.join(sourceDir, "plugins", "sstv.ts"),
|
||||
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
||||
ais: path.join(sourceDir, "plugins", "ais.ts"),
|
||||
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
|
||||
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
||||
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
||||
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
|
||||
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
|
||||
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
||||
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
||||
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs",
|
||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs",
|
||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -51,7 +51,7 @@ export type RigRxStatus = { sig: number | null, };
|
||||
|
||||
export type RigStatus = { freq: Freq, mode: RigMode, tx_en: boolean, vfo: RigVfo | null, tx: RigTxStatus | null, rx: RigRxStatus | null, lock: boolean | null, };
|
||||
|
||||
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, sstv_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||
|
||||
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
||||
|
||||
@@ -78,6 +78,10 @@ export type RdsData = { pi?: number | null, program_service?: string | null, rad
|
||||
export type SpectrumData = {
|
||||
/**
|
||||
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
|
||||
*
|
||||
* On the wire these are base64-encoded `i8` whole dBFS (see
|
||||
* `spectrum_wire`), which is what the display draws anyway; the TypeScript
|
||||
* type describes the decoded array the browser receives over SSE.
|
||||
*/
|
||||
bins: Array<number>,
|
||||
/**
|
||||
@@ -111,7 +115,7 @@ export type RigSnapshot = { info: RigInfo, status: RigStatus, band: string | nul
|
||||
/**
|
||||
* Per-virtual-channel RDS snapshots, when available.
|
||||
*/
|
||||
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, sstv_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||
|
||||
export type RigListItem = { remote: string, display_name: string | null, manufacturer: string, model: string, supported_modes: Array<RigMode>, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, };
|
||||
|
||||
|
||||
@@ -385,11 +385,14 @@ declare global {
|
||||
refreshCwTonePicker?(): void;
|
||||
updateFt8RfDisplay?(): void;
|
||||
clearSatPredictionDom?(): void;
|
||||
refreshSatPredictions?(): void;
|
||||
syncWefaxToggle?(enabled: boolean): void;
|
||||
syncSstvToggle?(enabled: boolean): void;
|
||||
updateAisBar?(value?: number): void;
|
||||
updateVdesBar?(value?: number): void;
|
||||
updateAprsBar?(value?: number): void;
|
||||
updateFt8Bar?(value?: number): void;
|
||||
updateCwBar?(value?: number): void;
|
||||
updateSatLiveState?(value: unknown): void;
|
||||
applyCwAutoUi?(enabled: boolean): void;
|
||||
applyCwAutoUiFromServer?(enabled: boolean): void;
|
||||
@@ -858,7 +861,7 @@ function currentDecodeHistoryRetentionMs() {
|
||||
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
||||
|
||||
window.applyDecodeHistoryRetention = function() {
|
||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) {
|
||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"]) {
|
||||
window.trxPluginRuntime.prune(decoder);
|
||||
}
|
||||
};
|
||||
@@ -2205,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;
|
||||
@@ -2224,6 +2238,12 @@ function resetDecoderStateOnRigSwitch() {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = "--";
|
||||
});
|
||||
|
||||
// The decode stream is not rig-scoped, so the histories behind the mini views
|
||||
// and the decoder panels survive the switch: repaint both for the rig now
|
||||
// selected instead of leaving the outgoing rig's traffic on screen.
|
||||
refreshDecodeBars();
|
||||
window.trxPluginRuntime.rerenderAll();
|
||||
}
|
||||
|
||||
function resetWfmStereoIndicator() {
|
||||
@@ -3674,10 +3694,7 @@ function render(update: AppUpdate) {
|
||||
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
||||
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
||||
}
|
||||
if (window.updateAisBar) window.updateAisBar();
|
||||
if (window.updateVdesBar) window.updateVdesBar();
|
||||
if (window.updateAprsBar) window.updateAprsBar();
|
||||
if (window.updateFt8Bar) window.updateFt8Bar();
|
||||
refreshDecodeBars();
|
||||
// Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong.
|
||||
for (const d of decoderRegistry) {
|
||||
if (d.activation !== "toggle") continue;
|
||||
@@ -3712,7 +3729,11 @@ function render(update: AppUpdate) {
|
||||
for (const [key, entry] of Object.entries(_decoderToggles)) {
|
||||
syncDecoderToggle(entry, !!update[key], entry.label);
|
||||
}
|
||||
// WEFAX toggle sync (plugin-owned, belt-and-suspenders alongside _decoderToggles).
|
||||
// Image decoders own their own toggle buttons; keep them in step with the
|
||||
// rig state as well as with the click that set it.
|
||||
if (typeof update.sstv_decode_enabled === "boolean" && window.syncSstvToggle) {
|
||||
window.syncSstvToggle(update.sstv_decode_enabled);
|
||||
}
|
||||
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
|
||||
window.syncWefaxToggle(update.wefax_decode_enabled);
|
||||
}
|
||||
@@ -4844,6 +4865,7 @@ function _initMapWhenReady() {
|
||||
|
||||
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
|
||||
window.trxUi?.closeMobileOverlays?.();
|
||||
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
||||
const { updateHistory = true, replaceHistory = false } = options;
|
||||
if (authEnabled && !authRole && name !== "main") {
|
||||
showAuthGate(false);
|
||||
@@ -4884,7 +4906,15 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
|
||||
updateTabHistory(name, replaceHistory);
|
||||
}
|
||||
scheduleSpectrumLayout();
|
||||
void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); });
|
||||
// Its countdowns tick every second, so the page stops when it is not on screen.
|
||||
if (leavingSatellites) window.clearSatPredictionDom?.();
|
||||
void loadPluginsForTab(name).then(() => {
|
||||
// Passes go stale while the page is closed, so each visit reloads them.
|
||||
// The first visit is what imports the module, which renders on its own.
|
||||
if (name === "satellites") window.refreshSatPredictions?.();
|
||||
// The map module owns the decode log; this is the moment it can exist.
|
||||
flushPendingDecodeStats();
|
||||
}).catch((error: unknown) => { console.error(error); });
|
||||
if (name === "map") {
|
||||
_initMapWhenReady();
|
||||
}
|
||||
@@ -5236,10 +5266,6 @@ function _wireSubTabBar(bar: WiredElement) {
|
||||
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
||||
});
|
||||
}
|
||||
// Clear SAT prediction DOM when leaving the SAT tab to reduce node count.
|
||||
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
|
||||
window.clearSatPredictionDom();
|
||||
}
|
||||
});
|
||||
}
|
||||
document.querySelectorAll<WiredElement>(".sub-tab-bar").forEach(_wireSubTabBar);
|
||||
@@ -6567,11 +6593,55 @@ function updateDecodeStatus(text: string) {
|
||||
if (el && el.textContent !== "Receiving") el.textContent = text;
|
||||
}
|
||||
}
|
||||
// Picture decoders produce one message per image and a stream of progress
|
||||
// updates; neither is a spot, and counting them would swamp the statistics.
|
||||
const IMAGE_DECODE_KINDS = new Set([
|
||||
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
|
||||
]);
|
||||
|
||||
// The decode log the Statistics page counts lives in the map module, which is
|
||||
// lazy: it arrives when the Map or Statistics tab is first opened, long after
|
||||
// the history replayed and the live decodes started coming. Recording into a
|
||||
// module that is not there yet dropped every one of them, and nothing replayed
|
||||
// them afterwards -- so the page opened empty and only filled in from decodes
|
||||
// heard after it, which is why it took a reload (landing on the tab, so the
|
||||
// module loads at startup) to show the whole picture. Hold them here until
|
||||
// the module arrives, then hand them over.
|
||||
interface PendingDecodeStat { kind: string; rig: string | null; tsMs: number | undefined }
|
||||
const pendingDecodeStats: PendingDecodeStat[] = [];
|
||||
// The module's own log keeps 50k entries; there is no point holding more here.
|
||||
const PENDING_DECODE_STATS_MAX = 50_000;
|
||||
|
||||
function recordDecodeStat(kind: string, rig: string | null, tsMs?: number) {
|
||||
const stats = window.trx.modules.map;
|
||||
if (stats) {
|
||||
stats.statsRecordDecode(kind, rig, tsMs);
|
||||
return;
|
||||
}
|
||||
pendingDecodeStats.push({ kind, rig, tsMs });
|
||||
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
|
||||
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
function flushPendingDecodeStats() {
|
||||
const stats = window.trx.modules.map;
|
||||
if (!stats || pendingDecodeStats.length === 0) return;
|
||||
for (const entry of pendingDecodeStats.splice(0)) {
|
||||
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
|
||||
}
|
||||
stats.scheduleStatsRender();
|
||||
}
|
||||
|
||||
function scheduleStatsRenderIfLoaded() {
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
|
||||
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
|
||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
|
||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
|
||||
scheduleStatsRenderIfLoaded();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6619,11 +6689,11 @@ function loadDecodeHistoryOnMainThread(onReady: (groups: DecodeHistoryGroups) =>
|
||||
function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
// Record statistics for restored history messages.
|
||||
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
|
||||
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||
for (const msg of messages) {
|
||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
||||
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
||||
}
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
scheduleStatsRenderIfLoaded();
|
||||
}
|
||||
window.trxPluginRuntime.restore(kind, messages);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
export {};
|
||||
|
||||
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"] as const;
|
||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"] as const;
|
||||
type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number];
|
||||
type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
|
||||
interface DecodeState { offset: number }
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
export const TAB_ORDER = [
|
||||
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
|
||||
"main", "bookmarks", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
|
||||
] as const;
|
||||
|
||||
export type TabName = typeof TAB_ORDER[number];
|
||||
@@ -13,6 +13,7 @@ export const TAB_PATHS: Readonly<Record<TabName, string>> = {
|
||||
bookmarks: "/bookmarks",
|
||||
"digital-modes": "/digital-modes",
|
||||
map: "/map",
|
||||
satellites: "/satellites",
|
||||
statistics: "/statistics",
|
||||
recorder: "/recorder",
|
||||
settings: "/settings",
|
||||
|
||||
@@ -31,7 +31,7 @@ function mapEl(id: string): MapElement {
|
||||
return element as MapElement;
|
||||
}
|
||||
|
||||
type DecoderSource = "ais" | "vdes" | "aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
|
||||
type DecoderSource = "ais" | "vdes" | "aprs" | "hf_aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
|
||||
interface TrackPoint { lat: number; lon: number; tsMs: number }
|
||||
interface DecodeDetail {
|
||||
station?: string | null; source?: string | null; target?: string | null;
|
||||
@@ -83,6 +83,9 @@ interface MapEntry {
|
||||
bookmarks?: Array<Record<string, unknown>>;
|
||||
bounds?: Leaflet.LatLngBoundsExpression;
|
||||
symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
|
||||
/** Callsign as heard. The map key qualifies it with the source, so that a
|
||||
* station worked on both 144 MHz and 30 m keeps one marker per band. */
|
||||
call?: string;
|
||||
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
|
||||
pathKey?: string; sourceGrid?: string; targetGrid?: string;
|
||||
from?: LatLon; to?: LatLon;
|
||||
@@ -171,7 +174,7 @@ interface MapWindow {
|
||||
clearMapMarkersByType?(type: DecoderSource): void;
|
||||
navigateToAprsMap?(lat: number, lon: number): void;
|
||||
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
|
||||
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage): void;
|
||||
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage, source?: "aprs" | "hf_aprs"): void;
|
||||
aisMapAddVessel?(message: MapMessage): void;
|
||||
vdesMapAddPoint?(message: MapMessage): void;
|
||||
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
|
||||
@@ -228,7 +231,7 @@ const mapWindow = window as unknown as MapWindow;
|
||||
const decodeContactPaths = new Map<string, MapEntry>();
|
||||
let selectedMapQsoKey: string | null = null;
|
||||
const mapMarkers = new Set<TrxLayer>();
|
||||
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||
/** Chip key that clears a selection rather than naming a band or a source. */
|
||||
const MAP_FILTER_ALL_KEY = "__all";
|
||||
@@ -302,6 +305,18 @@ const mapWindow = window as unknown as MapWindow;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** The source a station marker belongs to, defaulting to VHF APRS for
|
||||
* entries stored before HF APRS had a source of its own. */
|
||||
function aprsEntrySource(entry: MapEntry | null | undefined): DecoderSource {
|
||||
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||
}
|
||||
|
||||
/** Map key for a station: the callsign alone on VHF, source-qualified on HF,
|
||||
* so one station heard on both bands keeps a marker for each. */
|
||||
function aprsStationKey(call: string, source: DecoderSource): string {
|
||||
return source === "aprs" ? call : `${source}:${call}`;
|
||||
}
|
||||
|
||||
function refreshAprsTrack(call: string, entry: MapEntry): void {
|
||||
if (!entry) return;
|
||||
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
||||
@@ -323,7 +338,7 @@ const mapWindow = window as unknown as MapWindow;
|
||||
lineJoin: "round",
|
||||
interactive: false,
|
||||
}) as unknown as TrxLayer;
|
||||
track.__trxType = "aprs";
|
||||
track.__trxType = aprsEntrySource(entry);
|
||||
track._aprsCall = call;
|
||||
entry.track = track;
|
||||
}
|
||||
@@ -575,6 +590,7 @@ const mapWindow = window as unknown as MapWindow;
|
||||
|
||||
function mapSourceLabel(type: DecoderSource): string {
|
||||
if (type === "bookmark") return "Bookmarks";
|
||||
if (type === "hf_aprs") return "HF APRS";
|
||||
return String(type || "").toUpperCase();
|
||||
}
|
||||
|
||||
@@ -594,6 +610,8 @@ const mapWindow = window as unknown as MapWindow;
|
||||
if (type === "vdes") return "#a78bfa";
|
||||
if (type === "sat") return "#f59e0b";
|
||||
if (type === "aprs") return "#00d17f";
|
||||
// Far enough from the VHF green to tell the two apart at a glance.
|
||||
if (type === "hf_aprs") return "#fb7185";
|
||||
return locatorFilterColor(type);
|
||||
}
|
||||
|
||||
@@ -1188,10 +1206,8 @@ const mapWindow = window as unknown as MapWindow;
|
||||
}
|
||||
}
|
||||
for (const entry of stationMarkers.values()) {
|
||||
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
|
||||
availableSources.add("aprs");
|
||||
break;
|
||||
}
|
||||
if (!entry?.visibleInHistoryWindow) continue;
|
||||
availableSources.add(aprsEntrySource(entry));
|
||||
}
|
||||
const bandMap = new Map<string, FilterChip>();
|
||||
for (const entry of locatorMarkers.values()) {
|
||||
@@ -1225,7 +1241,7 @@ const mapWindow = window as unknown as MapWindow;
|
||||
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
||||
}
|
||||
|
||||
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
|
||||
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
|
||||
.filter((key) => availableSources.has(key))
|
||||
.map((key) => ({
|
||||
key,
|
||||
@@ -1296,9 +1312,10 @@ const mapWindow = window as unknown as MapWindow;
|
||||
}
|
||||
return parts.join(" ").toLowerCase();
|
||||
}
|
||||
if (type === "aprs") {
|
||||
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||
const entry = stationMarkers.get(call);
|
||||
if (type === "aprs" || type === "hf_aprs") {
|
||||
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||
const entry = stationMarkers.get(key);
|
||||
const call = entry?.call ?? key;
|
||||
const info = entry?.info ? String(entry.info) : "";
|
||||
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
||||
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
||||
@@ -1496,9 +1513,10 @@ const mapWindow = window as unknown as MapWindow;
|
||||
};
|
||||
|
||||
mapWindow.clearMapMarkersByType = function(type) {
|
||||
if (type === "aprs") {
|
||||
if (type === "aprs" || type === "hf_aprs") {
|
||||
selectedAprsTrackCall = null;
|
||||
stationMarkers.forEach((entry) => {
|
||||
stationMarkers.forEach((entry, key) => {
|
||||
if (aprsEntrySource(entry) !== type) return;
|
||||
if (entry && entry.marker) {
|
||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||
mapMarkers.delete(entry.marker);
|
||||
@@ -1507,8 +1525,8 @@ const mapWindow = window as unknown as MapWindow;
|
||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||
mapMarkers.delete(entry.track);
|
||||
}
|
||||
stationMarkers.delete(key);
|
||||
});
|
||||
stationMarkers.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1781,13 +1799,15 @@ const mapWindow = window as unknown as MapWindow;
|
||||
if (!ll) return;
|
||||
const entry = stationMarkers.get(marker._aprsCall);
|
||||
if (!entry) return;
|
||||
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||
const source = aprsEntrySource(entry);
|
||||
const call = entry.call ?? String(marker._aprsCall);
|
||||
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||
refreshAprsTrack(String(marker._aprsCall), entry);
|
||||
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
|
||||
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
|
||||
entry.track.addTo(aprsMap);
|
||||
}
|
||||
selectedAprsTrackCall = String(marker._aprsCall);
|
||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
|
||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2270,28 +2290,33 @@ const mapWindow = window as unknown as MapWindow;
|
||||
return null;
|
||||
}
|
||||
|
||||
function _aprsAddMarkerToMap(call: string, entry: MapEntry): void {
|
||||
function _aprsAddMarkerToMap(key: string, entry: MapEntry): void {
|
||||
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
||||
refreshAprsTrack(call, entry);
|
||||
refreshAprsTrack(key, entry);
|
||||
const source = aprsEntrySource(entry);
|
||||
const call = entry.call ?? key;
|
||||
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
||||
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
||||
const color = mapSourceColor(source);
|
||||
const marker = (icon
|
||||
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
|
||||
: L.circleMarker([entry.lat, entry.lon], {
|
||||
radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8
|
||||
radius: 6, color, fillColor: color, fillOpacity: 0.8
|
||||
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
|
||||
marker.__trxType = "aprs";
|
||||
marker.__trxType = source;
|
||||
marker.__trxRigIds = entry.rigIds || new Set();
|
||||
marker._aprsCall = call;
|
||||
marker._aprsCall = key;
|
||||
entry.marker = marker;
|
||||
mapMarkers.add(marker);
|
||||
}
|
||||
|
||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
|
||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
|
||||
const nextPoint: Leaflet.LatLngTuple = [lat, lon];
|
||||
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
||||
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
||||
const existing = stationMarkers.get(call);
|
||||
const stationSource: DecoderSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||
const key = aprsStationKey(call, stationSource);
|
||||
const existing = stationMarkers.get(key);
|
||||
if (existing) {
|
||||
existing.pkt = pkt;
|
||||
existing.lat = lat;
|
||||
@@ -2310,7 +2335,8 @@ const mapWindow = window as unknown as MapWindow;
|
||||
} else if (prevPoint) {
|
||||
prevPoint.tsMs = tsMs;
|
||||
}
|
||||
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
|
||||
existing.call = call;
|
||||
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
|
||||
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
||||
existing.marker.setLatLng([lat, lon]);
|
||||
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
||||
@@ -2321,7 +2347,8 @@ const mapWindow = window as unknown as MapWindow;
|
||||
track: null,
|
||||
trackHistory: [{ lat, lon, tsMs }],
|
||||
trackPoints: [nextPoint],
|
||||
type: "aprs",
|
||||
type: stationSource,
|
||||
call,
|
||||
pkt,
|
||||
lat,
|
||||
lon,
|
||||
@@ -2330,9 +2357,9 @@ const mapWindow = window as unknown as MapWindow;
|
||||
symbolCode,
|
||||
rigIds: new Set(msgRigId ? [msgRigId] : []),
|
||||
};
|
||||
stationMarkers.set(call, entry);
|
||||
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
|
||||
stationMarkers.set(key, entry);
|
||||
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
|
||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
|
||||
if (aprsMap) scheduleDecodeMapMaintenance();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
|
||||
type PluginGroup =
|
||||
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
|
||||
| "bookmarks" | "recorder" | "settings";
|
||||
|
||||
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
||||
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
||||
@@ -11,10 +13,11 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
||||
// the Map tab. Their map calls are optional, so map-core stays lazy.
|
||||
"digital-modes": [
|
||||
"/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js",
|
||||
"/sat.js", "/wefax.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js",
|
||||
"/sat.js", "/wefax.js", "/sstv.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js",
|
||||
],
|
||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||
satellites: ["/satellite-predictions.js"],
|
||||
statistics: ["/map-core.js"],
|
||||
bookmarks: ["/bookmarks.js"],
|
||||
recorder: [],
|
||||
|
||||
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
|
||||
plugin.prune();
|
||||
return true;
|
||||
},
|
||||
rerenderAll() { for (const plugin of decoders.values()) plugin.rerender?.(); },
|
||||
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
|
||||
clearQueued() { queued.clear(); },
|
||||
hasDecoder: (id) => decoders.has(id),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostState } from "./host.js";
|
||||
|
||||
// The decode stream is not rig-scoped: every rig a client is connected to sends
|
||||
// its decodes to every browser, each frame naming the rig that heard it (the
|
||||
// client stamps `rig_id` as it leaves the audio connection). The map wants all
|
||||
// of them — it has its own filter, and plotting only one rig would empty it —
|
||||
// but the radio page and the decoder panels describe one rig at a time: the one
|
||||
// the operator selected, whose spectrum is on screen and whose audio is
|
||||
// playing. A frame a background rig copied on another band belongs to neither
|
||||
// picture, so both filter on this.
|
||||
export function isActiveRigDecode(rigId: string | null | undefined): boolean {
|
||||
const activeRigId = hostState.lastActiveRigId;
|
||||
// Before the rig list has arrived, and for a decode that reached the browser
|
||||
// without a rig of its own, there is nothing to disagree with.
|
||||
if (!activeRigId || !rigId) return true;
|
||||
return rigId === activeRigId;
|
||||
}
|
||||
|
||||
/** The decodes of `items` that the selected rig heard, in their original order. */
|
||||
export function forActiveRig<T extends { rig_id?: string | null | undefined }>(items: T[]): T[] {
|
||||
return items.filter((item) => isActiveRigDecode(item.rig_id));
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { hostCore, hostState } from "./host.js";
|
||||
|
||||
export {};
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||
|
||||
interface AisMessage {
|
||||
@@ -190,20 +191,27 @@ function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
|
||||
return Array.from(byMmsi.values());
|
||||
}
|
||||
|
||||
/** What the selected rig heard. The history holds every rig's traffic, for
|
||||
* the map; the panel is about the rig the operator is working. */
|
||||
function aisRigMessages(): AisMessage[] {
|
||||
return forActiveRig(aisMessageHistory);
|
||||
}
|
||||
|
||||
function updateAisSummary() {
|
||||
const plan = currentAisChannelPlan();
|
||||
if (aisChannelSummaryEl) {
|
||||
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||
}
|
||||
|
||||
const vessels = aisLatestByVessel(aisMessageHistory);
|
||||
const rigMessages = aisRigMessages();
|
||||
const vessels = aisLatestByVessel(rigMessages);
|
||||
if (aisVesselCountEl) {
|
||||
const count = vessels.length;
|
||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
if (aisLatestSeenEl) {
|
||||
const latest = aisMessageHistory[0];
|
||||
const latest = rigMessages[0];
|
||||
if (!latest) {
|
||||
aisLatestSeenEl.textContent = "No traffic yet";
|
||||
} else {
|
||||
@@ -314,7 +322,9 @@ function updateAisBar() {
|
||||
|
||||
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
|
||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||
const recent = aisMessageHistory.filter(
|
||||
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id),
|
||||
);
|
||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||
if (!isAis || messages.length === 0) {
|
||||
aisBarOverlay.style.display = "none";
|
||||
@@ -368,7 +378,7 @@ function renderAisHistory() {
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const message of aisMessageHistory) {
|
||||
for (const message of aisRigMessages()) {
|
||||
fragment.appendChild(renderAisRow(message));
|
||||
}
|
||||
aisMessagesEl.replaceChildren(fragment);
|
||||
@@ -407,10 +417,11 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage {
|
||||
|
||||
function onServerAisBatch(messages: AisMessage[]): void {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||
const normalized: AisMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerAisMessage(msg);
|
||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
@@ -452,8 +463,9 @@ if (aisFilterInput) {
|
||||
}
|
||||
|
||||
function onServerAis(msg: AisMessage): void {
|
||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||
addAisMessage(normalizeServerAisMessage(msg));
|
||||
const message = normalizeServerAisMessage(msg);
|
||||
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
||||
addAisMessage(message);
|
||||
}
|
||||
|
||||
updateAisSummary();
|
||||
@@ -464,6 +476,7 @@ updateAisSummary();
|
||||
restore: onServerAisBatch,
|
||||
reset: resetAisHistoryView,
|
||||
prune: pruneAisHistoryView,
|
||||
rerender: () => { updateAisBar(); renderAisHistory(); },
|
||||
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { hostCore, hostState } from "./host.js";
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import {
|
||||
aprsAgeText,
|
||||
aprsPacketCategory,
|
||||
@@ -106,21 +107,29 @@ function aprsFilterMatch(pkt: AprsPacket): boolean {
|
||||
return haystack.includes(aprsFilterText);
|
||||
}
|
||||
|
||||
/** What the selected rig heard. The history holds every rig's traffic, for
|
||||
* the map; the panel is about the rig the operator is working. */
|
||||
function aprsRigPackets(): AprsPacket[] {
|
||||
return forActiveRig(aprsPacketHistory);
|
||||
}
|
||||
|
||||
function aprsVisiblePackets(): AprsPacket[] {
|
||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
||||
const rigPackets = aprsRigPackets();
|
||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
|
||||
return packets.filter(aprsFilterMatch);
|
||||
}
|
||||
|
||||
function updateAprsSummary() {
|
||||
const rigPackets = aprsRigPackets();
|
||||
const visible = aprsVisiblePackets();
|
||||
if (aprsTotalCountEl) {
|
||||
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
||||
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||
}
|
||||
if (aprsVisibleCountEl) {
|
||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||
}
|
||||
if (aprsLatestSeenEl) {
|
||||
const latest = aprsPacketHistory[0];
|
||||
const latest = rigPackets[0];
|
||||
if (!latest) {
|
||||
aprsLatestSeenEl.textContent = "No packets yet";
|
||||
} else {
|
||||
@@ -179,7 +188,9 @@ function updateAprsBar() {
|
||||
if (!aprsBarOverlay) return;
|
||||
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
|
||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
||||
const okFrames = aprsPacketHistory.filter(
|
||||
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id),
|
||||
);
|
||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||
@@ -256,11 +267,12 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
|
||||
|
||||
function onServerAprsBatch(packets: AprsPacket[]): void {
|
||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||
const normalized: AprsPacket[] = [];
|
||||
let hasCrcOk = false;
|
||||
for (const pkt of packets) {
|
||||
const next = normalizeServerAprsPacket(pkt);
|
||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
@@ -324,8 +336,9 @@ if (aprsFilterInput) {
|
||||
|
||||
// --- Server-side APRS decode handler ---
|
||||
function onServerAprs(pkt: AprsPacket): void {
|
||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||
const packet = normalizeServerAprsPacket(pkt);
|
||||
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
|
||||
addAprsPacket(packet);
|
||||
}
|
||||
|
||||
renderAprsHistory();
|
||||
@@ -336,6 +349,7 @@ renderAprsHistory();
|
||||
restore: onServerAprsBatch,
|
||||
reset: resetAprsHistoryView,
|
||||
prune: pruneAprsHistoryView,
|
||||
rerender: () => { updateAprsBar(); renderAprsHistory(); },
|
||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { hostCore } from "./host.js";
|
||||
|
||||
import { isActiveRigDecode } from "./active-rig.js";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||
|
||||
export {};
|
||||
@@ -20,8 +21,11 @@ interface CwRenderer {
|
||||
drawPoints(points: number[], size: number, color: Rgba): void;
|
||||
}
|
||||
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
|
||||
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
|
||||
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number }
|
||||
interface CwEvent { rig_id?: string | null; text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
|
||||
interface CwLine {
|
||||
rigId: string | null; tsMs: number; ts: string; text: string;
|
||||
wpm: number | null; tone_hz: number | null; lastMs: number;
|
||||
}
|
||||
interface CwToneRange {
|
||||
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
|
||||
toneSpanHz: number; lowerSideband: boolean; mode: string;
|
||||
@@ -66,7 +70,10 @@ const CW_BAR_LINE_GAP_MS = 5000;
|
||||
let cwLastAppendTime = 0;
|
||||
let cwTonePickerRaf: number | null = null;
|
||||
let cwBarHistory: CwLine[] = [];
|
||||
let cwBarCurrentLine: CwLine | null = null;
|
||||
// One line in progress per rig, keyed by rig id ("" for a decode that names no
|
||||
// rig). Two rigs copying at the same time each fill their own line instead of
|
||||
// braiding their characters into one.
|
||||
const cwBarCurrentLines = new Map<string, CwLine>();
|
||||
let cwBarDismissedAtMs = 0;
|
||||
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
||||
// acknowledged). While set, server-state updates must not override the
|
||||
@@ -101,12 +108,13 @@ cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
|
||||
applyCwAutoUi(enabled);
|
||||
};
|
||||
|
||||
function cwBarFlushCurrentLine(): void {
|
||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||
cwBarHistory.unshift(cwBarCurrentLine);
|
||||
function cwBarFlushCurrentLine(key: string): void {
|
||||
const line = cwBarCurrentLines.get(key);
|
||||
cwBarCurrentLines.delete(key);
|
||||
if (line?.text.trim()) {
|
||||
cwBarHistory.unshift(line);
|
||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||
}
|
||||
cwBarCurrentLine = null;
|
||||
}
|
||||
|
||||
function updateCwBar(): void {
|
||||
@@ -114,9 +122,12 @@ function updateCwBar(): void {
|
||||
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
||||
const isCw = mode === "CW" || mode === "CWR";
|
||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||
// Prepend the in-progress line so characters appear immediately
|
||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
|
||||
// Prepend the in-progress lines so characters appear immediately
|
||||
const inProgress = [...cwBarCurrentLines.values()]
|
||||
.filter((l) => l.text && isActiveRigDecode(l.rigId))
|
||||
.sort((a, b) => b.tsMs - a.tsMs);
|
||||
const liveLines = [...inProgress, ...recent];
|
||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||
cwBarOverlay.style.display = "none";
|
||||
@@ -402,7 +413,7 @@ function resetCwHistoryView(): void {
|
||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||
cwLastAppendTime = 0;
|
||||
cwBarHistory = [];
|
||||
cwBarCurrentLine = null;
|
||||
cwBarCurrentLines.clear();
|
||||
updateCwBar();
|
||||
drawCwTonePicker();
|
||||
}
|
||||
@@ -421,8 +432,13 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
||||
|
||||
// --- Server-side CW decode handler ---
|
||||
function onServerCw(evt: CwEvent): void {
|
||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||
if (evt.text && cwOutputEl) {
|
||||
// The panel is a single stream of copied text: characters from a background
|
||||
// rig do not belong in it at all, and interleaving them would make both
|
||||
// rigs unreadable. The bar histories below keep every rig, each on its own
|
||||
// line, and pick out the selected rig when they paint.
|
||||
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
|
||||
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
|
||||
if (evt.text && cwOutputEl && forSelectedRig) {
|
||||
// Append decoded text to output
|
||||
const now = Date.now();
|
||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
||||
@@ -445,25 +461,29 @@ function onServerCw(evt: CwEvent): void {
|
||||
// Bar history accumulation (regardless of pause state)
|
||||
if (evt.text) {
|
||||
const now = Date.now();
|
||||
const rigId = evt.rig_id ?? null;
|
||||
const key = rigId ?? "";
|
||||
if (evt.text === "\n") {
|
||||
cwBarFlushCurrentLine();
|
||||
cwBarFlushCurrentLine(key);
|
||||
} else {
|
||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||
cwBarFlushCurrentLine();
|
||||
let line = cwBarCurrentLines.get(key);
|
||||
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||
cwBarFlushCurrentLine(key);
|
||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||
cwBarCurrentLines.set(key, line);
|
||||
}
|
||||
cwBarCurrentLine.text += evt.text;
|
||||
cwBarCurrentLine.lastMs = now;
|
||||
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
||||
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
||||
line.text += evt.text;
|
||||
line.lastMs = now;
|
||||
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
|
||||
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
|
||||
}
|
||||
updateCwBar();
|
||||
}
|
||||
if (cwSignalIndicator) {
|
||||
if (cwSignalIndicator && forSelectedRig) {
|
||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||
}
|
||||
if (!cwAutoInput || cwAutoInput.checked) {
|
||||
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
|
||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||
}
|
||||
@@ -491,6 +511,13 @@ cwWindow.trxPluginRuntime.registerDecoder({
|
||||
onMessage: onServerCw,
|
||||
restore: restoreCwHistory,
|
||||
reset: resetCwHistoryView,
|
||||
// The copied text of a rig that is no longer on screen cannot be unpicked
|
||||
// from the pane, so the switch starts the new rig's stream from empty.
|
||||
rerender: () => {
|
||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||
cwLastAppendTime = 0;
|
||||
updateCwBar();
|
||||
},
|
||||
});
|
||||
|
||||
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { hostCore } from "./host.js";
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||
|
||||
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
|
||||
@@ -15,6 +16,7 @@ export interface FtxMessage {
|
||||
snr_db?: number | undefined;
|
||||
dt_s?: number | undefined;
|
||||
freq_hz?: number | undefined;
|
||||
rig_id?: string | null | undefined;
|
||||
receiver?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -227,9 +229,12 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
||||
const render = (): void => {
|
||||
prune();
|
||||
if (!messagesElement) return;
|
||||
// The history holds every rig's decodes, for the map; the panel is about
|
||||
// the rig the operator is working.
|
||||
const rigMessages = forActiveRig(history);
|
||||
const fragment = document.createDocumentFragment();
|
||||
let count = 0;
|
||||
for (const message of history) {
|
||||
for (const message of rigMessages) {
|
||||
if (count >= 200) break;
|
||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||
fragment.appendChild(renderRow(message));
|
||||
@@ -237,20 +242,32 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
||||
}
|
||||
messagesElement.replaceChildren(fragment);
|
||||
};
|
||||
const normalize = (message: FtxMessage): FtxMessage => {
|
||||
/** Hands a decode's grid squares to the map, if the map module is loaded yet.
|
||||
* Split out of normalize so a decode can be replayed onto a map that
|
||||
* arrived later: the module is lazy, and everything decoded before it
|
||||
* loaded had nowhere to go. */
|
||||
const plotLocator = (message: FtxMessage): void => {
|
||||
const raw = message.message ?? "";
|
||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||
const grids = locatorDetails.length > 0
|
||||
? locatorDetails.map(({ grid }) => grid)
|
||||
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
||||
if (grids.length === 0) return;
|
||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||
// Already an RF frequency on a replay, an audio offset on arrival; the
|
||||
// conversion only fires below 100 kHz, so it is right either way.
|
||||
const frequency = displayFrequency(message.freq_hz);
|
||||
if (grids.length > 0) {
|
||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
|
||||
});
|
||||
}
|
||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
|
||||
});
|
||||
};
|
||||
const normalize = (message: FtxMessage): FtxMessage => {
|
||||
const frequency = displayFrequency(message.freq_hz);
|
||||
plotLocator(message);
|
||||
return {
|
||||
// The rig that heard it, kept so the mini view can tell a decode of the
|
||||
// rig on screen from one a background rig made on another band.
|
||||
rig_id: message.rig_id ?? null,
|
||||
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||
ts_ms: message.ts_ms,
|
||||
snr_db: message.snr_db,
|
||||
@@ -262,7 +279,10 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
||||
};
|
||||
const receiveBatch = (messages: FtxMessage[]): void => {
|
||||
if (messages.length === 0) return;
|
||||
if (status) status.textContent = "Receiving";
|
||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
|
||||
status.textContent = "Receiving";
|
||||
}
|
||||
history = messages.map(normalize).reverse().concat(history);
|
||||
prune();
|
||||
bridge.setFt8FamilyBarDecoder?.(id);
|
||||
@@ -277,7 +297,8 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
||||
};
|
||||
const barFrames = (): BarFrames => {
|
||||
const recent = history
|
||||
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000)
|
||||
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000
|
||||
&& isActiveRigDecode(message.rig_id))
|
||||
.slice(0, 8);
|
||||
let html = "";
|
||||
for (const message of recent) {
|
||||
@@ -299,6 +320,9 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
||||
restore: receiveBatch,
|
||||
prune: () => { prune(); render(); },
|
||||
reset,
|
||||
rerender: () => { bridge.updateFt8Bar?.(); render(); },
|
||||
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||
syncMap: () => { for (const message of [...history].reverse()) plotLocator(message); },
|
||||
});
|
||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { hostCore, hostState } from "./host.js";
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import {
|
||||
aprsAgeText,
|
||||
aprsPacketCategory,
|
||||
@@ -19,6 +20,12 @@ interface HfAprsBridge {
|
||||
getDecodeHistoryRetentionMs?: () => number;
|
||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||
navigateToAprsMap?: (lat: number, lon: number) => void;
|
||||
aprsMapAddStation?: (
|
||||
call: string, lat: number, lon: number, info: string,
|
||||
symbolTable: string | null | undefined, symbolCode: string | null | undefined,
|
||||
packet: AprsPacket, source: "aprs" | "hf_aprs",
|
||||
) => void;
|
||||
clearMapMarkersByType?: (type: string) => void;
|
||||
getDecodeRigMeta?: () => unknown;
|
||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||
@@ -90,23 +97,31 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
|
||||
return haystack.includes(hfAprsFilterText);
|
||||
}
|
||||
|
||||
/** What the selected rig heard. The history holds every rig's traffic, for
|
||||
* the map; the panel is about the rig the operator is working. */
|
||||
function hfAprsRigPackets(): AprsPacket[] {
|
||||
return forActiveRig(hfAprsPacketHistory);
|
||||
}
|
||||
|
||||
function hfAprsVisiblePackets(): AprsPacket[] {
|
||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
||||
const rigPackets = hfAprsRigPackets();
|
||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
|
||||
return packets.filter(hfAprsFilterMatch);
|
||||
}
|
||||
|
||||
const collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||
|
||||
function updateHfAprsSummary() {
|
||||
const rigPackets = hfAprsRigPackets();
|
||||
const visible = hfAprsVisiblePackets();
|
||||
if (hfAprsTotalCountEl) {
|
||||
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
||||
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||
}
|
||||
if (hfAprsVisibleCountEl) {
|
||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||
}
|
||||
if (hfAprsLatestSeenEl) {
|
||||
const latest = hfAprsPacketHistory[0];
|
||||
const latest = rigPackets[0];
|
||||
if (!latest) {
|
||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||
} else {
|
||||
@@ -168,6 +183,7 @@ function resetHfAprsHistoryView(): void {
|
||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||
hfAprsPacketHistory = [];
|
||||
renderHfAprsHistory();
|
||||
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
|
||||
}
|
||||
|
||||
function pruneHfAprsHistoryView(): void {
|
||||
@@ -175,6 +191,17 @@ function pruneHfAprsHistoryView(): void {
|
||||
renderHfAprsHistory();
|
||||
}
|
||||
|
||||
/** Hands a positioned packet to the map, if the map module is loaded yet.
|
||||
* HF traffic goes on as its own source: it is a different band and a
|
||||
* different path, and the map filter offers it separately. */
|
||||
function plotHfAprsPacket(pkt: AprsPacket): void {
|
||||
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
|
||||
hfAprsWindow.aprsMapAddStation(
|
||||
pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "",
|
||||
pkt.symbolTable, pkt.symbolCode, pkt, "hf_aprs",
|
||||
);
|
||||
}
|
||||
|
||||
function addHfAprsPacket(pkt: AprsPacket): void {
|
||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||
pkt._tsMs = tsMs;
|
||||
@@ -183,6 +210,8 @@ function addHfAprsPacket(pkt: AprsPacket): void {
|
||||
hfAprsPacketHistory.unshift(pkt);
|
||||
pruneHfAprsPacketHistory();
|
||||
|
||||
plotHfAprsPacket(pkt);
|
||||
|
||||
scheduleHfAprsHistoryRender();
|
||||
}
|
||||
|
||||
@@ -192,13 +221,15 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
|
||||
|
||||
function onServerHfAprsBatch(packets: AprsPacket[]): void {
|
||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||
const normalized: AprsPacket[] = [];
|
||||
for (const pkt of packets) {
|
||||
const next = normalizeServerHfAprsPacket(pkt);
|
||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
plotHfAprsPacket(next);
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
@@ -266,8 +297,9 @@ if (hfAprsFilterInput) {
|
||||
|
||||
// --- Server-side HF APRS decode handler ---
|
||||
function onServerHfAprs(pkt: AprsPacket): void {
|
||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
||||
const packet = normalizeServerHfAprsPacket(pkt);
|
||||
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||
addHfAprsPacket(packet);
|
||||
}
|
||||
|
||||
renderHfAprsHistory();
|
||||
@@ -278,4 +310,7 @@ renderHfAprsHistory();
|
||||
restore: onServerHfAprsBatch,
|
||||
reset: resetHfAprsHistoryView,
|
||||
prune: pruneHfAprsHistoryView,
|
||||
rerender: renderHfAprsHistory,
|
||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||
syncMap: () => { for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry); },
|
||||
});
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface DecoderPlugin<TMessage = unknown> {
|
||||
restore?(messages: TMessage[]): void;
|
||||
reset?(): void;
|
||||
prune?(): void;
|
||||
/** Repaint the plugin's panel from what it already holds. Called when the
|
||||
* selected rig changes: the panels show one rig's traffic, and the decodes
|
||||
* behind them arrived long before the switch. */
|
||||
rerender?(): void;
|
||||
/** Replay everything the plugin is holding onto the map. Called when the map
|
||||
* module attaches, which can happen long after the decodes arrived. */
|
||||
syncMap?(): void;
|
||||
@@ -22,6 +26,7 @@ export interface TrxPluginRuntime {
|
||||
reset(id: string): boolean;
|
||||
resetAll(): void;
|
||||
prune(id: string): boolean;
|
||||
rerenderAll(): void;
|
||||
syncMapAll(): void;
|
||||
clearQueued(): void;
|
||||
hasDecoder(id: string): boolean;
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
|
||||
import { hostCore } from "./host.js";
|
||||
|
||||
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
|
||||
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate } from "./satellite-types";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||
|
||||
type SatelliteView = "live" | "history" | "predictions";
|
||||
type SatelliteView = "live" | "history";
|
||||
interface SatelliteBridge {
|
||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||
clearSatPredictionDom?: () => void;
|
||||
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
||||
addSatMapOverlay?: (image: SatelliteImage) => void;
|
||||
clearSatMapOverlays?: () => void;
|
||||
@@ -25,14 +24,14 @@ const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow;
|
||||
// --- SAT Plugin ---
|
||||
// Live view: decoder state, latest image card
|
||||
// History view: filterable table of all decoded images
|
||||
// Predictions view: next 24 h passes for ham satellites
|
||||
//
|
||||
// Pass predictions live on their own page; see satellite-predictions.ts.
|
||||
|
||||
// ── DOM references (cached once) ───────────────────────────────────
|
||||
const satDom = {
|
||||
status: document.getElementById("sat-status"),
|
||||
liveView: document.getElementById("sat-live-view"),
|
||||
historyView: document.getElementById("sat-history-view"),
|
||||
predictionsView: document.getElementById("sat-predictions-view"),
|
||||
liveLatest: document.getElementById("sat-live-latest"),
|
||||
historyList: document.getElementById("sat-history-list"),
|
||||
historyCount: document.getElementById("sat-history-count"),
|
||||
@@ -42,30 +41,13 @@ const satDom = {
|
||||
lrptState: document.getElementById("sat-lrpt-state"),
|
||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||
viewPredBtn: document.getElementById("sat-view-predictions"),
|
||||
predFilter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
|
||||
predMinEl: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
|
||||
predCategory: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
|
||||
predCurrentList: document.getElementById("sat-pred-current-list"),
|
||||
predUpcomingList: document.getElementById("sat-pred-list"),
|
||||
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
||||
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
||||
predStatus: document.getElementById("sat-pred-status"),
|
||||
};
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────
|
||||
let satImageHistory: SatelliteImage[] = [];
|
||||
const SAT_MAX_IMAGES = 100;
|
||||
const SAT_PRED_PAGE_SIZE = 50;
|
||||
let satPredShowAll = false;
|
||||
let satFilterText = "";
|
||||
let satActiveView: SatelliteView = "live";
|
||||
let satPredData: SatellitePass[] = [];
|
||||
let satPredFilterText = "";
|
||||
let satPredMinEl = 0;
|
||||
let satPredCategory = "all";
|
||||
let satPredSatCount = 0;
|
||||
let satPredCountdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── UI scheduler helper ─────────────────────────────────────────────
|
||||
function scheduleSatUi(key: string, job: () => void): void {
|
||||
@@ -78,33 +60,16 @@ function scheduleSatUi(key: string, job: () => void): void {
|
||||
|
||||
// ── View switching ──────────────────────────────────────────────────
|
||||
function switchSatView(view: SatelliteView): void {
|
||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||
satActiveView = view;
|
||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
||||
if (leavingPredictions) clearPredictionDom();
|
||||
if (view === "history") {
|
||||
renderSatHistoryTable();
|
||||
} else if (view === "predictions") {
|
||||
satPredShowAll = false;
|
||||
void loadSatPredictions();
|
||||
}
|
||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||
if (view === "history") renderSatHistoryTable();
|
||||
}
|
||||
|
||||
function clearPredictionDom() {
|
||||
stopCountdownTimer();
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
}
|
||||
satWindow.clearSatPredictionDom = clearPredictionDom;
|
||||
|
||||
satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
|
||||
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
|
||||
satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); });
|
||||
|
||||
// ── Live view: decoder state ────────────────────────────────────────
|
||||
let lastSatLrptOn: boolean | null = null;
|
||||
@@ -335,234 +300,6 @@ document
|
||||
}
|
||||
})(); });
|
||||
|
||||
// ── Predictions: helpers ────────────────────────────────────────────
|
||||
function azToCardinal(deg: number): string {
|
||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
||||
}
|
||||
|
||||
function formatPredTime(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const now = new Date();
|
||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
return `${day}${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function formatPredDuration(s: number): string {
|
||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function formatCountdown(ms: number): string {
|
||||
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function elevationClass(deg: number): string {
|
||||
if (deg >= 45) return "sat-pred-el-high";
|
||||
if (deg >= 10) return "sat-pred-el-mid";
|
||||
return "sat-pred-el-low";
|
||||
}
|
||||
|
||||
// ── Predictions: countdown timer management ─────────────────────────
|
||||
function stopCountdownTimer() {
|
||||
if (satPredCountdownTimer) {
|
||||
clearInterval(satPredCountdownTimer);
|
||||
satPredCountdownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdownTimer(container: HTMLElement | null): void {
|
||||
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
|
||||
if (countdownEls.length === 0) return;
|
||||
|
||||
satPredCountdownTimer = setInterval(() => {
|
||||
if (satActiveView !== "predictions") {
|
||||
stopCountdownTimer();
|
||||
return;
|
||||
}
|
||||
const n = Date.now();
|
||||
let anyActive = false;
|
||||
for (const el of countdownEls) {
|
||||
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
||||
const rem = los - n;
|
||||
if (rem > 0) {
|
||||
el.textContent = formatCountdown(rem);
|
||||
anyActive = true;
|
||||
} else {
|
||||
el.textContent = "0:00";
|
||||
}
|
||||
}
|
||||
if (!anyActive) {
|
||||
stopCountdownTimer();
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ── Predictions: row builders ───────────────────────────────────────
|
||||
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row-current";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
const remaining = Math.max(0, pass.los_ms - now);
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
|
||||
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── Predictions: filter state ───────────────────────────────────────
|
||||
function getFilteredPredictions(): SatellitePass[] {
|
||||
let items = satPredData;
|
||||
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
||||
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
||||
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
|
||||
return items;
|
||||
}
|
||||
|
||||
function applyPredFilters() {
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
}
|
||||
|
||||
const satPredictionFilter = satDom.predFilter;
|
||||
satPredictionFilter?.addEventListener("input", () => {
|
||||
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
||||
applyPredFilters();
|
||||
});
|
||||
|
||||
const satPredictionMinElevation = satDom.predMinEl;
|
||||
satPredictionMinElevation?.addEventListener("change", () => {
|
||||
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
||||
applyPredFilters();
|
||||
});
|
||||
|
||||
const satPredictionCategory = satDom.predCategory;
|
||||
satPredictionCategory?.addEventListener("change", () => {
|
||||
satPredCategory = satPredictionCategory.value;
|
||||
applyPredFilters();
|
||||
});
|
||||
|
||||
// ── Predictions: main render ────────────────────────────────────────
|
||||
function renderSatPredictions(passes: SatellitePass[], error?: string): void {
|
||||
stopCountdownTimer();
|
||||
|
||||
if (error) {
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(passes) || passes.length === 0) {
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||
|
||||
// ── Current passes ──
|
||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
||||
if (satDom.predCurrentList) {
|
||||
if (current.length === 0) {
|
||||
satDom.predCurrentList.innerHTML = "";
|
||||
} else {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||
satDom.predCurrentList.replaceChildren(frag);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upcoming passes ──
|
||||
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
||||
if (satDom.predUpcomingList) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||
if (hiddenCount > 0) {
|
||||
const moreRow = document.createElement("div");
|
||||
moreRow.className = "sat-pred-row";
|
||||
moreRow.style.cursor = "pointer";
|
||||
moreRow.style.textAlign = "center";
|
||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
|
||||
moreRow.addEventListener("click", () => {
|
||||
satPredShowAll = true;
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
});
|
||||
frag.appendChild(moreRow);
|
||||
}
|
||||
satDom.predUpcomingList.replaceChildren(frag);
|
||||
}
|
||||
|
||||
// ── Status ──
|
||||
if (satDom.predStatus) {
|
||||
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
|
||||
if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`;
|
||||
satDom.predStatus.textContent = text;
|
||||
}
|
||||
|
||||
// ── Countdown timer ──
|
||||
if (current.length > 0 && satActiveView === "predictions") {
|
||||
startCountdownTimer(satDom.predCurrentList);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Predictions: data loading ───────────────────────────────────────
|
||||
async function loadSatPredictions(): Promise<void> {
|
||||
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
try {
|
||||
const resp = await fetch("/sat_passes");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json() as SatellitePassResponse;
|
||||
satPredSatCount = data.satellite_count || 0;
|
||||
if (data.error) {
|
||||
satPredData = [];
|
||||
renderSatPredictions([], data.error);
|
||||
} else {
|
||||
satPredData = data.passes || [];
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigate to map centered on satellite image bounds ──────────────
|
||||
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
|
||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import type { SatellitePass, SatellitePassResponse } from "./satellite-types";
|
||||
|
||||
// --- Satellite pass predictions ---
|
||||
//
|
||||
// Its own page rather than a third view inside the weather-satellite decoder:
|
||||
// when a bird comes over is a planning question, and it was buried under
|
||||
// Digital modes beside decoders it has nothing to do with.
|
||||
|
||||
interface PredictionsBridge {
|
||||
clearSatPredictionDom?: () => void;
|
||||
refreshSatPredictions?: () => void;
|
||||
}
|
||||
const predWindow = window as unknown as PredictionsBridge;
|
||||
|
||||
// ── DOM references (cached once) ───────────────────────────────────
|
||||
const dom = {
|
||||
page: document.getElementById("tab-satellites"),
|
||||
filter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
|
||||
minElevation: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
|
||||
category: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
|
||||
currentList: document.getElementById("sat-pred-current-list"),
|
||||
upcomingList: document.getElementById("sat-pred-list"),
|
||||
currentSection: document.getElementById("sat-pred-current-section"),
|
||||
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
|
||||
status: document.getElementById("sat-pred-status"),
|
||||
};
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────
|
||||
const PAGE_SIZE = 50;
|
||||
let predShowAll = false;
|
||||
let predData: SatellitePass[] = [];
|
||||
let predFilterText = "";
|
||||
let predMinEl = 0;
|
||||
let predCategory = "all";
|
||||
let predSatCount = 0;
|
||||
let predCountdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** The countdowns tick once a second, so they stop when nobody is looking. */
|
||||
function isPageVisible(): boolean {
|
||||
return !!dom.page && dom.page.style.display !== "none";
|
||||
}
|
||||
|
||||
// ── Predictions: helpers ────────────────────────────────────────────
|
||||
function azToCardinal(deg: number): string {
|
||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
||||
}
|
||||
|
||||
function formatPredTime(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const now = new Date();
|
||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
return `${day}${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function formatPredDuration(s: number): string {
|
||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function formatCountdown(ms: number): string {
|
||||
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function elevationClass(deg: number): string {
|
||||
if (deg >= 45) return "sat-pred-el-high";
|
||||
if (deg >= 10) return "sat-pred-el-mid";
|
||||
return "sat-pred-el-low";
|
||||
}
|
||||
|
||||
// ── Predictions: countdown timer management ─────────────────────────
|
||||
function stopCountdownTimer() {
|
||||
if (predCountdownTimer) {
|
||||
clearInterval(predCountdownTimer);
|
||||
predCountdownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdownTimer(container: HTMLElement | null): void {
|
||||
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
|
||||
if (countdownEls.length === 0) return;
|
||||
|
||||
predCountdownTimer = setInterval(() => {
|
||||
if (!isPageVisible()) {
|
||||
stopCountdownTimer();
|
||||
return;
|
||||
}
|
||||
const n = Date.now();
|
||||
let anyActive = false;
|
||||
for (const el of countdownEls) {
|
||||
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
||||
const rem = los - n;
|
||||
if (rem > 0) {
|
||||
el.textContent = formatCountdown(rem);
|
||||
anyActive = true;
|
||||
} else {
|
||||
el.textContent = "0:00";
|
||||
}
|
||||
}
|
||||
if (!anyActive) {
|
||||
stopCountdownTimer();
|
||||
render(filtered());
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ── Predictions: row builders ───────────────────────────────────────
|
||||
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row-current";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
const remaining = Math.max(0, pass.los_ms - now);
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
|
||||
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── Predictions: filter state ───────────────────────────────────────
|
||||
function filtered(): SatellitePass[] {
|
||||
let items = predData;
|
||||
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
|
||||
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
|
||||
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
|
||||
return items;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
render(filtered());
|
||||
}
|
||||
|
||||
const filterInput = dom.filter;
|
||||
filterInput?.addEventListener("input", () => {
|
||||
predFilterText = filterInput.value.trim().toUpperCase();
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
const minElevationSelect = dom.minElevation;
|
||||
minElevationSelect?.addEventListener("change", () => {
|
||||
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
const categorySelect = dom.category;
|
||||
categorySelect?.addEventListener("change", () => {
|
||||
predCategory = categorySelect.value;
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// ── Predictions: main render ────────────────────────────────────────
|
||||
function render(passes: SatellitePass[], error?: string): void {
|
||||
stopCountdownTimer();
|
||||
|
||||
if (error) {
|
||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||
if (dom.currentSection) dom.currentSection.style.display = "none";
|
||||
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
||||
if (dom.status) dom.status.textContent = error;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(passes) || passes.length === 0) {
|
||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||
if (dom.currentSection) dom.currentSection.style.display = "none";
|
||||
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
||||
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||
|
||||
// ── Current passes ──
|
||||
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
|
||||
if (dom.currentList) {
|
||||
if (current.length === 0) {
|
||||
dom.currentList.innerHTML = "";
|
||||
} else {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||
dom.currentList.replaceChildren(frag);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upcoming passes ──
|
||||
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
|
||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
|
||||
if (dom.upcomingList) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||
if (hiddenCount > 0) {
|
||||
const moreRow = document.createElement("div");
|
||||
moreRow.className = "sat-pred-row";
|
||||
moreRow.style.cursor = "pointer";
|
||||
moreRow.style.textAlign = "center";
|
||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
|
||||
moreRow.addEventListener("click", () => {
|
||||
predShowAll = true;
|
||||
render(filtered());
|
||||
});
|
||||
frag.appendChild(moreRow);
|
||||
}
|
||||
dom.upcomingList.replaceChildren(frag);
|
||||
}
|
||||
|
||||
// ── Status ──
|
||||
if (dom.status) {
|
||||
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
|
||||
if (predSatCount > 0) text += ` \u00B7 ${predSatCount} satellites tracked`;
|
||||
dom.status.textContent = text;
|
||||
}
|
||||
|
||||
// ── Countdown timer ──
|
||||
if (current.length > 0 && isPageVisible()) {
|
||||
startCountdownTimer(dom.currentList);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Predictions: data loading ───────────────────────────────────────
|
||||
async function load(): Promise<void> {
|
||||
if (dom.status) dom.status.textContent = "Loading predictions\u2026";
|
||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||
try {
|
||||
const resp = await fetch("/sat_passes");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json() as SatellitePassResponse;
|
||||
predSatCount = data.satellite_count || 0;
|
||||
if (data.error) {
|
||||
predData = [];
|
||||
render([], data.error);
|
||||
} else {
|
||||
predData = data.passes || [];
|
||||
render(filtered());
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Page lifecycle ──────────────────────────────────────────────────
|
||||
function clearPredictionDom(): void {
|
||||
stopCountdownTimer();
|
||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||
}
|
||||
predWindow.clearSatPredictionDom = clearPredictionDom;
|
||||
|
||||
/**
|
||||
* Called by the router each time the Satellites page is opened, including the
|
||||
* visit that imports this module — so loading is driven from one place only.
|
||||
*/
|
||||
predWindow.refreshSatPredictions = function () {
|
||||
predShowAll = false;
|
||||
void load();
|
||||
};
|
||||
@@ -0,0 +1,373 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// sstv.ts — SSTV decoder panel.
|
||||
//
|
||||
// Live view: decoder state, a canvas the picture fills row by row as it
|
||||
// arrives, and a card for the last one received. History view: a filterable
|
||||
// table of received pictures with thumbnails.
|
||||
//
|
||||
// Watching a picture build up is most of the appeal of the mode, so rows are
|
||||
// painted as they arrive rather than waiting for the frame to finish — a
|
||||
// transmission takes between 36 seconds and two minutes.
|
||||
|
||||
import { hostCore } from "./host.js";
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||
|
||||
export {};
|
||||
|
||||
interface SstvImage {
|
||||
rig_id?: string | null;
|
||||
ts_ms?: number;
|
||||
vis?: number;
|
||||
mode?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
lines?: number;
|
||||
complete?: boolean;
|
||||
path?: string;
|
||||
_tsMs?: number;
|
||||
_ts?: string;
|
||||
}
|
||||
|
||||
interface SstvProgress {
|
||||
rig_id?: string | null;
|
||||
state?: string;
|
||||
mode?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
line?: number;
|
||||
line_data?: string;
|
||||
}
|
||||
|
||||
interface SstvBridge {
|
||||
getDecodeHistoryRetentionMs?: () => number;
|
||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
||||
syncSstvToggle?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const sstvWindow = window as unknown as SstvBridge & PluginRuntimeWindow;
|
||||
|
||||
const sstvDom = {
|
||||
status: document.getElementById("sstv-status"),
|
||||
liveView: document.getElementById("sstv-live-view"),
|
||||
historyView: document.getElementById("sstv-history-view"),
|
||||
liveContainer: document.getElementById("sstv-live-container"),
|
||||
liveInfo: document.getElementById("sstv-live-info"),
|
||||
liveCanvas: document.getElementById("sstv-live-canvas") as HTMLCanvasElement | null,
|
||||
liveLatest: document.getElementById("sstv-live-latest"),
|
||||
historyList: document.getElementById("sstv-history-list"),
|
||||
historyCount: document.getElementById("sstv-history-count"),
|
||||
filterInput: document.getElementById("sstv-filter") as HTMLInputElement | null,
|
||||
sortSelect: document.getElementById("sstv-sort") as HTMLSelectElement | null,
|
||||
toggleBtn: document.getElementById("sstv-decode-toggle-btn"),
|
||||
clearBtn: document.getElementById("sstv-clear-btn"),
|
||||
viewLiveBtn: document.getElementById("sstv-view-live"),
|
||||
viewHistoryBtn: document.getElementById("sstv-view-history"),
|
||||
};
|
||||
|
||||
const SSTV_MAX_IMAGES = 100;
|
||||
|
||||
let sstvHistory: SstvImage[] = [];
|
||||
let liveCtx: CanvasRenderingContext2D | null = null;
|
||||
let liveMode = "";
|
||||
let liveHeight = 0;
|
||||
let liveRows = 0;
|
||||
let activeView: "live" | "history" = "live";
|
||||
let filterText = "";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function retentionMs(): number {
|
||||
return sstvWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneHistory() {
|
||||
const cutoff = Date.now() - retentionMs();
|
||||
sstvHistory = sstvHistory.filter((image) => (image._tsMs || 0) > cutoff);
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function scheduleUi(key: string, job: () => void): void {
|
||||
if (typeof sstvWindow.trxScheduleUiFrameJob === "function") {
|
||||
sstvWindow.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
/** The URL the server serves a saved picture from, given its stored path. */
|
||||
function imageUrl(image: SstvImage): string | null {
|
||||
if (!image.path) return null;
|
||||
const filename = image.path.split(/[\\/]/).pop();
|
||||
return filename ? `/sstv-images/${encodeURIComponent(filename)}` : null;
|
||||
}
|
||||
|
||||
function decodeBase64(data: string): Uint8Array {
|
||||
const binary = atob(data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// ── View switching ──────────────────────────────────────────────────
|
||||
|
||||
function switchView(view: "live" | "history"): void {
|
||||
activeView = view;
|
||||
if (sstvDom.liveView) sstvDom.liveView.style.display = view === "live" ? "" : "none";
|
||||
if (sstvDom.historyView) sstvDom.historyView.style.display = view === "history" ? "" : "none";
|
||||
for (const button of [sstvDom.viewLiveBtn, sstvDom.viewHistoryBtn]) {
|
||||
button?.classList.remove("sat-view-active");
|
||||
}
|
||||
if (view === "live") sstvDom.viewLiveBtn?.classList.add("sat-view-active");
|
||||
else sstvDom.viewHistoryBtn?.classList.add("sat-view-active");
|
||||
if (view === "history") renderHistoryTable();
|
||||
}
|
||||
|
||||
sstvDom.viewLiveBtn?.addEventListener("click", () => { switchView("live"); });
|
||||
sstvDom.viewHistoryBtn?.addEventListener("click", () => { switchView("history"); });
|
||||
|
||||
// ── Live canvas ─────────────────────────────────────────────────────
|
||||
|
||||
/** Start a new picture: size the canvas to the mode and clear it. */
|
||||
function beginPicture(mode: string, width: number, height: number): void {
|
||||
const canvas = sstvDom.liveCanvas;
|
||||
if (!canvas || width <= 0 || height <= 0) return;
|
||||
liveMode = mode;
|
||||
liveHeight = height;
|
||||
liveRows = 0;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
liveCtx = canvas.getContext("2d");
|
||||
if (!liveCtx) return;
|
||||
// Mid-grey, not black: the rows below the one arriving have not been
|
||||
// received, which is a different thing from having been received as black.
|
||||
liveCtx.fillStyle = "#606060";
|
||||
liveCtx.fillRect(0, 0, width, height);
|
||||
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "";
|
||||
updateLiveInfo();
|
||||
}
|
||||
|
||||
function updateLiveInfo(): void {
|
||||
if (!sstvDom.liveInfo) return;
|
||||
const canvas = sstvDom.liveCanvas;
|
||||
const size = canvas ? `${canvas.width}×${canvas.height}` : "";
|
||||
sstvDom.liveInfo.textContent = liveMode
|
||||
? `${liveMode} · ${size} · line ${liveRows}${liveHeight ? ` of ${liveHeight}` : ""}`
|
||||
: "";
|
||||
}
|
||||
|
||||
/** Paint one row of RGB triples at its own line number. */
|
||||
function paintRow(line: number, rgb: Uint8Array): void {
|
||||
const canvas = sstvDom.liveCanvas;
|
||||
if (!liveCtx || !canvas) return;
|
||||
const width = canvas.width;
|
||||
if (line < 0 || line >= canvas.height || rgb.length < width * 3) return;
|
||||
const row = liveCtx.createImageData(width, 1);
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
row.data[x * 4] = rgb[x * 3] ?? 0;
|
||||
row.data[x * 4 + 1] = rgb[x * 3 + 1] ?? 0;
|
||||
row.data[x * 4 + 2] = rgb[x * 3 + 2] ?? 0;
|
||||
row.data[x * 4 + 3] = 255;
|
||||
}
|
||||
liveCtx.putImageData(row, 0, line);
|
||||
liveRows = Math.max(liveRows, line + 1);
|
||||
}
|
||||
|
||||
// ── Server messages ─────────────────────────────────────────────────
|
||||
|
||||
function onProgress(msg: SstvProgress): void {
|
||||
// The live canvas draws one picture at a time: a background rig's rows would
|
||||
// paint straight over the one being watched.
|
||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||
if (msg.state) {
|
||||
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||
// A state update carries the geometry; a row update carries only the row.
|
||||
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
||||
return;
|
||||
}
|
||||
if (typeof msg.line !== "number" || !msg.line_data) return;
|
||||
const line = msg.line;
|
||||
const rgb = decodeBase64(msg.line_data);
|
||||
scheduleUi(`sstv-row-${line}`, () => {
|
||||
paintRow(line, rgb);
|
||||
updateLiveInfo();
|
||||
});
|
||||
}
|
||||
|
||||
function onImage(msg: SstvImage): void {
|
||||
const image: SstvImage = { ...msg };
|
||||
image._tsMs = typeof msg.ts_ms === "number" ? msg.ts_ms : Date.now();
|
||||
image._ts = new Date(image._tsMs).toLocaleTimeString();
|
||||
sstvHistory.push(image);
|
||||
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||
pruneHistory();
|
||||
|
||||
// Kept in the history either way, so switching to that rig finds it there.
|
||||
if (!isActiveRigDecode(image.rig_id)) return;
|
||||
|
||||
if (sstvDom.status) {
|
||||
sstvDom.status.textContent = image.complete
|
||||
? `Received ${image.mode ?? "picture"}`
|
||||
: `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
||||
}
|
||||
scheduleUi("sstv-latest", renderLatestCard);
|
||||
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
/** The pictures the selected rig decoded, oldest first, as the history is kept. */
|
||||
function sstvRigImages(): SstvImage[] {
|
||||
return forActiveRig(sstvHistory);
|
||||
}
|
||||
|
||||
function renderLatestCard(): void {
|
||||
if (!sstvDom.liveLatest) return;
|
||||
const rigImages = sstvRigImages();
|
||||
const latest = rigImages[rigImages.length - 1];
|
||||
if (!latest) {
|
||||
sstvDom.liveLatest.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const url = imageUrl(latest);
|
||||
const lines = `${latest.lines ?? 0}${latest.height ? ` of ${latest.height}` : ""} lines`;
|
||||
const state = latest.complete ? "complete" : "partial";
|
||||
sstvDom.liveLatest.innerHTML = `
|
||||
<div class="sat-latest-card">
|
||||
<div style="display:flex; align-items:baseline; gap:0.5rem; flex-wrap:wrap;">
|
||||
<strong>${escapeHtml(latest.mode ?? "SSTV")}</strong>
|
||||
<small style="color:var(--text-muted);">${escapeHtml(latest._ts ?? "")} · ${lines} · ${state}</small>
|
||||
</div>
|
||||
${url
|
||||
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">
|
||||
<img src="${escapeHtml(url)}" alt="Received ${escapeHtml(latest.mode ?? "SSTV")} picture"
|
||||
style="margin-top:0.4rem; width:100%; max-width:640px; image-rendering:pixelated;" />
|
||||
</a>`
|
||||
: ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function filteredHistory(): SstvImage[] {
|
||||
// Every rig's pictures are kept; the panel shows the selected rig's.
|
||||
const rigImages = sstvRigImages();
|
||||
const text = filterText.trim().toLowerCase();
|
||||
const matching = text
|
||||
? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
|
||||
: rigImages;
|
||||
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||
return matching;
|
||||
}
|
||||
|
||||
function renderHistoryTable(): void {
|
||||
if (!sstvDom.historyList) return;
|
||||
pruneHistory();
|
||||
const rows = filteredHistory();
|
||||
sstvDom.historyList.innerHTML = rows
|
||||
.map((image) => {
|
||||
const url = imageUrl(image);
|
||||
const size = image.width && image.height ? `${image.width}×${image.height}` : "--";
|
||||
const lines = image.complete
|
||||
? String(image.lines ?? 0)
|
||||
: `${image.lines ?? 0} (partial)`;
|
||||
return `<div class="sat-history-row">
|
||||
<span class="sat-col-time">${escapeHtml(image._ts ?? "")}</span>
|
||||
<span class="sat-col-type">${escapeHtml(image.mode ?? "--")}</span>
|
||||
<span class="sat-col-sat">${escapeHtml(size)}</span>
|
||||
<span class="sat-col-lines">${escapeHtml(lines)}</span>
|
||||
<span class="sat-col-link">${url
|
||||
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">View</a>`
|
||||
: "--"}</span>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
if (sstvDom.historyCount) {
|
||||
sstvDom.historyCount.textContent = rows.length
|
||||
? `${rows.length} picture${rows.length === 1 ? "" : "s"}`
|
||||
: "No pictures yet";
|
||||
}
|
||||
}
|
||||
|
||||
sstvDom.filterInput?.addEventListener("input", () => {
|
||||
filterText = sstvDom.filterInput?.value ?? "";
|
||||
renderHistoryTable();
|
||||
});
|
||||
sstvDom.sortSelect?.addEventListener("change", () => { renderHistoryTable(); });
|
||||
|
||||
// ── Decoder history plumbing ────────────────────────────────────────
|
||||
|
||||
function restoreHistory(entries: unknown[]): void {
|
||||
if (!Array.isArray(entries)) return;
|
||||
for (const entry of entries) onImage(entry as SstvImage);
|
||||
}
|
||||
|
||||
function resetHistoryView(): void {
|
||||
sstvHistory = [];
|
||||
liveRows = 0;
|
||||
liveMode = "";
|
||||
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "none";
|
||||
if (sstvDom.status) sstvDom.status.textContent = "Idle";
|
||||
renderLatestCard();
|
||||
renderHistoryTable();
|
||||
}
|
||||
|
||||
// ── Controls ────────────────────────────────────────────────────────
|
||||
|
||||
sstvWindow.syncSstvToggle = function syncSstvToggle(enabled: boolean) {
|
||||
const button = sstvDom.toggleBtn as HTMLButtonElement | null;
|
||||
if (!button) return;
|
||||
button.textContent = enabled ? "Disable SSTV" : "Enable SSTV";
|
||||
button.setAttribute("aria-pressed", String(enabled));
|
||||
button.classList.toggle("is-active", enabled);
|
||||
};
|
||||
|
||||
sstvDom.toggleBtn?.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (sstvDom.toggleBtn) {
|
||||
await sstvWindow.takeSchedulerControlForDecoderDisable?.(sstvDom.toggleBtn);
|
||||
}
|
||||
await hostCore.postPath("/toggle_sstv_decode");
|
||||
} catch (e) {
|
||||
console.error("SSTV toggle failed", e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
sstvDom.clearBtn?.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await hostCore.postPath("/clear_sstv_decode");
|
||||
resetHistoryView();
|
||||
} catch (e) {
|
||||
console.error("SSTV clear failed", e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
renderLatestCard();
|
||||
|
||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "sstv",
|
||||
onMessage: onImage as (msg: unknown) => void,
|
||||
restore: restoreHistory,
|
||||
prune: renderHistoryTable,
|
||||
reset: resetHistoryView,
|
||||
rerender: () => { renderLatestCard(); renderHistoryTable(); },
|
||||
});
|
||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "sstv_progress",
|
||||
onMessage: onProgress as (msg: unknown) => void,
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { hostCore } from "./host.js";
|
||||
|
||||
export {};
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||
|
||||
interface VdesMessage {
|
||||
@@ -116,17 +117,24 @@ function vdesHexPreview(rawBytes: number[] | undefined): string {
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/** What the selected rig heard. The history holds every rig's traffic, for
|
||||
* the map; the panel is about the rig the operator is working. */
|
||||
function vdesRigMessages(): VdesMessage[] {
|
||||
return forActiveRig(vdesMessageHistory);
|
||||
}
|
||||
|
||||
function updateVdesSummary() {
|
||||
pruneVdesMessageHistory();
|
||||
if (vdesChannelSummaryEl) {
|
||||
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||
}
|
||||
const rigMessages = vdesRigMessages();
|
||||
if (vdesFrameCountEl) {
|
||||
const count = vdesMessageHistory.length;
|
||||
const count = rigMessages.length;
|
||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (vdesLatestSeenEl) {
|
||||
const latest = vdesMessageHistory[0];
|
||||
const latest = rigMessages[0];
|
||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||
}
|
||||
}
|
||||
@@ -228,7 +236,9 @@ function updateVdesBar() {
|
||||
updateVdesSummary();
|
||||
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
|
||||
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
||||
const messages = vdesMessageHistory
|
||||
.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id))
|
||||
.slice(0, 6);
|
||||
if (!isVdes || messages.length === 0) {
|
||||
vdesBarOverlay.style.display = "none";
|
||||
vdesBarOverlay.innerHTML = "";
|
||||
@@ -278,7 +288,7 @@ function renderVdesHistory() {
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const message of vdesMessageHistory) {
|
||||
for (const message of vdesRigMessages()) {
|
||||
fragment.appendChild(renderVdesRow(message));
|
||||
}
|
||||
vdesMessagesEl.replaceChildren(fragment);
|
||||
@@ -309,10 +319,11 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
|
||||
|
||||
function onServerVdesBatch(messages: VdesMessage[]): void {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||
const normalized: VdesMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerVdesMessage(msg);
|
||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
@@ -354,8 +365,8 @@ function plotVdesMessage(msg: VdesMessage): void {
|
||||
}
|
||||
|
||||
function onServerVdes(msg: VdesMessage): void {
|
||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||
const next = normalizeServerVdesMessage(msg);
|
||||
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||
addVdesMessage(next);
|
||||
plotVdesMessage(next);
|
||||
}
|
||||
@@ -374,6 +385,7 @@ updateVdesSummary();
|
||||
restore: onServerVdesBatch,
|
||||
reset: resetVdesHistoryView,
|
||||
prune: pruneVdesHistoryView,
|
||||
rerender: () => { updateVdesBar(); renderVdesHistory(); },
|
||||
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
|
||||
});
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
|
||||
import { hostCore } from "./host.js";
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||
|
||||
export {};
|
||||
|
||||
interface WefaxImage {
|
||||
rig_id?: string | null;
|
||||
ts_ms?: number;
|
||||
ioc?: number;
|
||||
lpm?: number;
|
||||
@@ -20,6 +22,7 @@ interface WefaxImage {
|
||||
}
|
||||
|
||||
interface WefaxProgress {
|
||||
rig_id?: string | null;
|
||||
state?: string;
|
||||
line_data?: string;
|
||||
line_count?: number;
|
||||
@@ -156,15 +159,21 @@ function paintLine(lineBytes: Uint8Array): void {
|
||||
}
|
||||
|
||||
// ── Live view: latest image card ────────────────────────────────────
|
||||
/** The pictures the selected rig decoded, newest first. */
|
||||
function wefaxRigImages(): WefaxImage[] {
|
||||
return forActiveRig(wefaxImageHistory);
|
||||
}
|
||||
|
||||
function renderWefaxLatestCard() {
|
||||
if (!wefaxDom.liveLatest) return;
|
||||
if (wefaxImageHistory.length === 0) {
|
||||
const rigImages = wefaxRigImages();
|
||||
if (rigImages.length === 0) {
|
||||
wefaxDom.liveLatest.innerHTML =
|
||||
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const img = wefaxImageHistory[0];
|
||||
const img = rigImages[0];
|
||||
if (!img) return;
|
||||
const ts = img._ts || '--';
|
||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
||||
@@ -193,7 +202,8 @@ function renderWefaxLatestCard() {
|
||||
|
||||
// ── History view: table ─────────────────────────────────────────────
|
||||
function getWefaxFilteredHistory() {
|
||||
let items = wefaxImageHistory;
|
||||
// Every rig's pictures are kept; the panel shows the selected rig's.
|
||||
let items = wefaxRigImages();
|
||||
|
||||
if (wefaxFilterText) {
|
||||
items = items.filter(function (i) {
|
||||
@@ -253,7 +263,7 @@ function renderWefaxHistoryTable() {
|
||||
wefaxDom.historyList.replaceChildren(fragment);
|
||||
|
||||
if (wefaxDom.historyCount) {
|
||||
const total = wefaxImageHistory.length;
|
||||
const total = wefaxRigImages().length;
|
||||
const shown = items.length;
|
||||
wefaxDom.historyCount.textContent =
|
||||
total === 0
|
||||
@@ -298,6 +308,9 @@ function addWefaxImage(msg: WefaxImage): void {
|
||||
|
||||
// ── SSE event handlers (public API) ─────────────────────────────────
|
||||
function onServerWefaxProgress(msg: WefaxProgress): void {
|
||||
// The live canvas draws one picture at a time: a background rig's lines
|
||||
// would paint straight over the one being watched.
|
||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||
// State-only update (no image data): show decoder state in status.
|
||||
if (msg.state && !msg.line_data) {
|
||||
if (wefaxDom.status) {
|
||||
@@ -331,6 +344,8 @@ function onServerWefaxProgress(msg: WefaxProgress): void {
|
||||
|
||||
function onServerWefax(msg: WefaxImage): void {
|
||||
addWefaxImage(msg);
|
||||
// Kept in the history either way, so switching to that rig finds it there.
|
||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||
|
||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
||||
if (wefaxDom.status) {
|
||||
@@ -438,6 +453,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||
restore: restoreWefaxHistory,
|
||||
prune: pruneWefaxHistoryView,
|
||||
reset: resetWefaxHistoryView,
|
||||
rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); },
|
||||
});
|
||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "wefax_progress",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { hostCore } from "./host.js";
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||
|
||||
export {};
|
||||
@@ -15,6 +16,10 @@ interface WsprMessage {
|
||||
snr_db?: number | undefined;
|
||||
dt_s?: number | undefined;
|
||||
freq_hz?: number | undefined;
|
||||
rig_id?: string | null | undefined;
|
||||
/** RF frequency the spot was heard on, kept so a map replay does not
|
||||
* recompute it against wherever the dial has moved to since. */
|
||||
_rfHz?: number | null | undefined;
|
||||
receiver?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -102,10 +107,12 @@ function renderWsprRow(msg: WsprMessage): HTMLDivElement {
|
||||
function renderWsprHistory(): void {
|
||||
pruneWsprMessageHistory();
|
||||
if (!wsprMessagesEl) return;
|
||||
// The history holds every rig's decodes, for the map; the panel is about the
|
||||
// rig the operator is working.
|
||||
const rigMessages = forActiveRig(wsprMessageHistory);
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
||||
const message = wsprMessageHistory[i];
|
||||
if (message) fragment.appendChild(renderWsprRow(message));
|
||||
for (const message of rigMessages) {
|
||||
fragment.appendChild(renderWsprRow(message));
|
||||
}
|
||||
wsprMessagesEl.replaceChildren(fragment);
|
||||
}
|
||||
@@ -130,6 +137,8 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
|
||||
station,
|
||||
rfHz,
|
||||
history: {
|
||||
rig_id: msg.rig_id ?? null,
|
||||
_rfHz: rfHz,
|
||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms,
|
||||
snr_db: msg.snr_db,
|
||||
@@ -142,16 +151,12 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
|
||||
|
||||
function onServerWsprBatch(messages: WsprMessage[]): void {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||
const normalized: WsprMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
|
||||
});
|
||||
}
|
||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||
plotWsprLocator(msg);
|
||||
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||
normalized.push(next.history);
|
||||
}
|
||||
@@ -311,15 +316,24 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
||||
})();
|
||||
});
|
||||
|
||||
function onServerWspr(msg: WsprMessage): void {
|
||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||
/** Hands a spot's grid squares to the map, if the map module is loaded yet.
|
||||
* The module is lazy, so a spot heard before it arrived has to be replayable. */
|
||||
function plotWsprLocator(msg: WsprMessage): void {
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
|
||||
});
|
||||
}
|
||||
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
|
||||
// A replayed spot carries the frequency it was heard on; a fresh one has it
|
||||
// worked out from the dial it just arrived against.
|
||||
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
...(rfHz === null ? {} : { freq_hz: rfHz }),
|
||||
});
|
||||
}
|
||||
|
||||
function onServerWspr(msg: WsprMessage): void {
|
||||
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
plotWsprLocator(msg);
|
||||
addWsprMessage(next.history);
|
||||
}
|
||||
|
||||
@@ -330,4 +344,7 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
||||
restore: onServerWsprBatch,
|
||||
prune: pruneWsprHistoryView,
|
||||
reset: resetWsprHistoryView,
|
||||
rerender: renderWsprHistory,
|
||||
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||
syncMap: () => { for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message); },
|
||||
});
|
||||
|
||||
@@ -499,7 +499,7 @@ function elementById<T extends HTMLElement>(id: string): T {
|
||||
if (restoreFocus) more.focus();
|
||||
};
|
||||
api.closeMobileOverlays = closeMore;
|
||||
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||
if (!source) return;
|
||||
const item = document.createElement("button");
|
||||
@@ -541,7 +541,7 @@ function elementById<T extends HTMLElement>(id: string): T {
|
||||
select.setAttribute("aria-label", "Decoder view");
|
||||
const groups: Array<[string, string[]]> = [
|
||||
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
||||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
|
||||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax", "sstv"]],
|
||||
];
|
||||
groups.forEach(([label, ids]) => {
|
||||
const group = document.createElement("optgroup");
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// The radio page and the digital modes page both describe one rig: the one the
|
||||
// operator selected. Every rig a client is connected to feeds the same decode
|
||||
// stream, so both the mini views over the waterfall and the panels behind them
|
||||
// filter on the rig that heard each decode. The map is the exception and keeps
|
||||
// all of them — it has its own rig filter — which is why the histories these
|
||||
// views read from are never pruned by rig.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import { bundleEntry } from "./bundle-entry.mjs";
|
||||
import { createHost } from "./host-fixture.mjs";
|
||||
|
||||
class ElementFixture {
|
||||
constructor(value = "") {
|
||||
this.children = [];
|
||||
this.innerHTML = "";
|
||||
this.textContent = "";
|
||||
this.value = value;
|
||||
this.style = {};
|
||||
this.dataset = {};
|
||||
this.classList = { add() {}, remove() {}, toggle() {} };
|
||||
}
|
||||
appendChild(child) { this.children.push(child); return child; }
|
||||
removeChild(child) { this.children.splice(this.children.indexOf(child), 1); }
|
||||
replaceChildren(...nodes) { this.children = nodes.flatMap((node) => node.children ?? [node]); }
|
||||
addEventListener() {}
|
||||
setAttribute() {}
|
||||
querySelector() { return null; }
|
||||
querySelectorAll() { return []; }
|
||||
get firstChild() { return this.children[0] ?? null; }
|
||||
get lastElementChild() { return this.children.at(-1) ?? null; }
|
||||
get scrollHeight() { return this.children.length; }
|
||||
}
|
||||
|
||||
/** A document whose named elements exist and whose unknown ones do not. */
|
||||
function createDocument(elements) {
|
||||
return {
|
||||
documentElement: {},
|
||||
getElementById: (id) => elements.get(id) ?? null,
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
createElement: () => new ElementFixture(),
|
||||
createDocumentFragment: () => new ElementFixture(),
|
||||
};
|
||||
}
|
||||
|
||||
async function runPlugin(entry, { window, document: doc, extras = {} }) {
|
||||
const context = vm.createContext({
|
||||
window,
|
||||
document: doc,
|
||||
navigator: {},
|
||||
requestAnimationFrame(callback) { callback(); return 1; },
|
||||
getComputedStyle: () => ({ getPropertyValue: () => "" }),
|
||||
Date, Number, String, Math, Array, Map, Set, Reflect, console,
|
||||
...extras,
|
||||
});
|
||||
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
|
||||
const source = await bundleEntry(new URL(`../src/plugins/${entry}.ts`, import.meta.url));
|
||||
new vm.Script(runtime).runInContext(context);
|
||||
new vm.Script(source).runInContext(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
test("the APRS mini view and panel both keep to the active rig", async () => {
|
||||
const overlay = new ElementFixture();
|
||||
const packets = new ElementFixture();
|
||||
const totals = new ElementFixture();
|
||||
const elements = new Map([
|
||||
["aprs-bar-overlay", overlay],
|
||||
["aprs-packets", packets],
|
||||
["aprs-total-count", totals],
|
||||
["mode", new ElementFixture("PKT")],
|
||||
]);
|
||||
const window = {
|
||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||
trxUi: { confirm: async () => true },
|
||||
aprsMapAddStation: () => {},
|
||||
};
|
||||
await runPlugin("aprs", { window, document: createDocument(elements) });
|
||||
|
||||
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
|
||||
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
|
||||
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
|
||||
|
||||
assert.match(overlay.innerHTML, /SP1AAA/, "the active rig's frame is missing from the mini view");
|
||||
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's frame reached the mini view");
|
||||
assert.equal(packets.children.length, 1, "the APRS panel listed a background rig's frame");
|
||||
assert.equal(totals.textContent, "1 total", `the APRS panel counted "${totals.textContent}"`);
|
||||
});
|
||||
|
||||
test("the APRS mini view shows every frame until a rig is known", async () => {
|
||||
const overlay = new ElementFixture();
|
||||
const elements = new Map([
|
||||
["aprs-bar-overlay", overlay],
|
||||
["mode", new ElementFixture("PKT")],
|
||||
]);
|
||||
const window = {
|
||||
...createHost(),
|
||||
trxUi: { confirm: async () => true },
|
||||
aprsMapAddStation: () => {},
|
||||
};
|
||||
await runPlugin("aprs", { window, document: createDocument(elements) });
|
||||
|
||||
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
|
||||
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
|
||||
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB" });
|
||||
|
||||
assert.match(overlay.innerHTML, /SP1AAA/);
|
||||
assert.match(overlay.innerHTML, /SP2BBB/);
|
||||
});
|
||||
|
||||
test("the AIS mini view keeps to the active rig", async () => {
|
||||
const overlay = new ElementFixture();
|
||||
const elements = new Map([
|
||||
["ais-bar-overlay", overlay],
|
||||
["mode", new ElementFixture("AIS")],
|
||||
]);
|
||||
const window = {
|
||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||
trxUi: { confirm: async () => true },
|
||||
aisMapAddVessel: () => {},
|
||||
};
|
||||
await runPlugin("ais", { window, document: createDocument(elements) });
|
||||
|
||||
const now = Date.now();
|
||||
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
|
||||
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
|
||||
|
||||
assert.match(overlay.innerHTML, /NEARBY/);
|
||||
assert.doesNotMatch(overlay.innerHTML, /ELSEWHERE/, "a background rig's vessel reached the mini view");
|
||||
});
|
||||
|
||||
test("the VDES mini view keeps to the active rig", async () => {
|
||||
const overlay = new ElementFixture();
|
||||
const elements = new Map([
|
||||
["vdes-bar-overlay", overlay],
|
||||
["mode", new ElementFixture("VDES")],
|
||||
]);
|
||||
const window = {
|
||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||
trxUi: { confirm: async () => true },
|
||||
vdesMapAddPoint: () => {},
|
||||
};
|
||||
await runPlugin("vdes", { window, document: createDocument(elements) });
|
||||
|
||||
const now = Date.now();
|
||||
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
|
||||
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
|
||||
|
||||
assert.match(overlay.innerHTML, /SP1AAA/);
|
||||
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's burst reached the mini view");
|
||||
});
|
||||
|
||||
test("the CW mini view keeps to the active rig and does not braid two rigs into a line", async () => {
|
||||
const overlay = new ElementFixture();
|
||||
const elements = new Map([
|
||||
["cw-bar-overlay", overlay],
|
||||
["cw-output", new ElementFixture()],
|
||||
["mode", new ElementFixture("CW")],
|
||||
]);
|
||||
const window = {
|
||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||
trxUi: { confirm: async () => true },
|
||||
addEventListener() {},
|
||||
};
|
||||
await runPlugin("cw", { window, document: createDocument(elements) });
|
||||
|
||||
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
||||
window.trxPluginRuntime.dispatch("cw", { text: "DX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
|
||||
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
||||
|
||||
assert.match(overlay.innerHTML, /CQ SP1AAA/, "the active rig's line was broken up or lost");
|
||||
assert.doesNotMatch(overlay.innerHTML, /DX/, "a background rig's characters reached the mini view");
|
||||
});
|
||||
|
||||
test("the FT8 mini view keeps to the active rig", async () => {
|
||||
const overlay = new ElementFixture();
|
||||
const elements = new Map([
|
||||
["ft8-bar-overlay", overlay],
|
||||
["mode", new ElementFixture("DIG")],
|
||||
]);
|
||||
const window = {
|
||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||
ft8BaseHz: 7_074_000,
|
||||
trxUi: { confirm: async () => true },
|
||||
mapAddLocator: () => {},
|
||||
setInterval() { return 1; },
|
||||
};
|
||||
await runPlugin("ft8", { window, document: createDocument(elements) });
|
||||
|
||||
const now = Date.now();
|
||||
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
|
||||
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
|
||||
|
||||
assert.match(overlay.innerHTML, /SP1AAA/);
|
||||
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view");
|
||||
});
|
||||
|
||||
// ── The digital modes panels ───────────────────────────────────────────────
|
||||
// The same rule one page over: a panel lists the selected rig's decodes only.
|
||||
|
||||
test("the AIS panel lists the active rig's vessels only", async () => {
|
||||
const messages = new ElementFixture();
|
||||
const count = new ElementFixture();
|
||||
const elements = new Map([
|
||||
["ais-messages", messages],
|
||||
["ais-vessel-count", count],
|
||||
["mode", new ElementFixture("AIS")],
|
||||
]);
|
||||
const window = {
|
||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||
trxUi: { confirm: async () => true },
|
||||
aisMapAddVessel: () => {},
|
||||
};
|
||||
await runPlugin("ais", { window, document: createDocument(elements) });
|
||||
|
||||
const now = Date.now();
|
||||
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
|
||||
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
|
||||
|
||||
assert.equal(messages.children.length, 1, "the AIS panel listed a background rig's vessel");
|
||||
assert.equal(count.textContent, "1 vessel", `the panel counted "${count.textContent}"`);
|
||||
});
|
||||
|
||||
test("the VDES panel lists the active rig's bursts only", async () => {
|
||||
const messages = new ElementFixture();
|
||||
const count = new ElementFixture();
|
||||
const elements = new Map([
|
||||
["vdes-messages", messages],
|
||||
["vdes-frame-count", count],
|
||||
["mode", new ElementFixture("VDES")],
|
||||
]);
|
||||
const window = {
|
||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||
trxUi: { confirm: async () => true },
|
||||
vdesMapAddPoint: () => {},
|
||||
};
|
||||
await runPlugin("vdes", { window, document: createDocument(elements) });
|
||||
|
||||
const now = Date.now();
|
||||
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
|
||||
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
|
||||
|
||||
assert.equal(messages.children.length, 1, "the VDES panel listed a background rig's burst");
|
||||
assert.equal(count.textContent, "1 burst", `the panel counted "${count.textContent}"`);
|
||||
});
|
||||
|
||||
test("the FT8 panel lists the active rig's decodes only", async () => {
|
||||
const messages = new ElementFixture();
|
||||
const elements = new Map([
|
||||
["ft8-messages", messages],
|
||||
["mode", new ElementFixture("DIG")],
|
||||
]);
|
||||
const window = {
|
||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||
ft8BaseHz: 7_074_000,
|
||||
trxUi: { confirm: async () => true },
|
||||
mapAddLocator: () => {},
|
||||
setInterval() { return 1; },
|
||||
};
|
||||
await runPlugin("ft8", { window, document: createDocument(elements) });
|
||||
|
||||
const now = Date.now();
|
||||
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
|
||||
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
|
||||
|
||||
assert.equal(messages.children.length, 1, "the FT8 panel listed a background rig's decode");
|
||||
});
|
||||
|
||||
test("the WSPR panel lists the active rig's spots only", async () => {
|
||||
const messages = new ElementFixture();
|
||||
const elements = new Map([
|
||||
["wspr-messages", messages],
|
||||
["mode", new ElementFixture("DIG")],
|
||||
]);
|
||||
const window = {
|
||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||
ft8BaseHz: 14_095_600,
|
||||
trxUi: { confirm: async () => true },
|
||||
mapAddLocator: () => {},
|
||||
};
|
||||
await runPlugin("wspr", {
|
||||
window,
|
||||
document: createDocument(elements),
|
||||
extras: { setInterval: () => 1 },
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
window.trxPluginRuntime.dispatch("wspr", { message: "SP1AAA JO91 30", freq_hz: 1_500, ts_ms: now, rig_id: "rig-a" });
|
||||
window.trxPluginRuntime.dispatch("wspr", { message: "SP2BBB JO94 27", freq_hz: 1_520, ts_ms: now, rig_id: "rig-b" });
|
||||
|
||||
assert.equal(messages.children.length, 1, "the WSPR panel listed a background rig's spot");
|
||||
});
|
||||
|
||||
test("the CW pane copies the active rig only", async () => {
|
||||
const output = new ElementFixture();
|
||||
const elements = new Map([
|
||||
["cw-output", output],
|
||||
["cw-bar-overlay", new ElementFixture()],
|
||||
["mode", new ElementFixture("CW")],
|
||||
]);
|
||||
const window = {
|
||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||
trxUi: { confirm: async () => true },
|
||||
addEventListener() {},
|
||||
};
|
||||
await runPlugin("cw", { window, document: createDocument(elements) });
|
||||
|
||||
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
||||
window.trxPluginRuntime.dispatch("cw", { text: "XX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
|
||||
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
||||
|
||||
const copied = output.children.map((line) => line.textContent).join("");
|
||||
assert.equal(copied, "CQ SP1AAA", `the pane copied "${copied}"`);
|
||||
});
|
||||
|
||||
test("switching rigs repaints the panels through the runtime", async () => {
|
||||
const packets = new ElementFixture();
|
||||
const elements = new Map([
|
||||
["aprs-packets", packets],
|
||||
["aprs-bar-overlay", new ElementFixture()],
|
||||
["mode", new ElementFixture("PKT")],
|
||||
]);
|
||||
// A mutable host: the rig picker writes lastActiveRigId, and the panels read
|
||||
// it as they paint.
|
||||
const host = createHost({ state: { lastActiveRigId: "rig-a" } });
|
||||
let activeRigId = "rig-a";
|
||||
Object.defineProperty(host.trx.state, "lastActiveRigId", { get: () => activeRigId });
|
||||
const window = { ...host, trxUi: { confirm: async () => true }, aprsMapAddStation: () => {} };
|
||||
await runPlugin("aprs", { window, document: createDocument(elements) });
|
||||
|
||||
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
|
||||
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
|
||||
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
|
||||
assert.equal(packets.children.length, 1);
|
||||
|
||||
activeRigId = "rig-b";
|
||||
window.trxPluginRuntime.rerenderAll();
|
||||
assert.equal(packets.children.length, 1, "the panel did not follow the switch");
|
||||
const shown = packets.children.map((row) => row.innerHTML).join("");
|
||||
assert.match(shown, /SP2BBB/, "the panel still shows the rig that was switched away from");
|
||||
assert.doesNotMatch(shown, /SP1AAA/);
|
||||
});
|
||||
@@ -22,9 +22,25 @@ const BEACON = {
|
||||
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
|
||||
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
||||
};
|
||||
// A second rig listening in the background, on its own band. Both pages
|
||||
// describe the rig on screen, so its traffic belongs in neither the panel nor
|
||||
// the mini view — only on the map, which shows the whole station.
|
||||
// HF APRS travels a different band and a different path from the VHF list, so
|
||||
// it goes on the map under a source of its own that the filter can hide.
|
||||
const HF_BEACON = {
|
||||
type: "hf_aprs", src_call: "SP5HF-7", dest_call: "APRS", path: "WIDE2-2", info: "HF beacon",
|
||||
packet_type: "position", crc_ok: true, lat: 50.06, lon: 19.94,
|
||||
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
||||
};
|
||||
const OTHER_RIG_VESSEL = {
|
||||
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
|
||||
lat: 51.92, lon: 4.48, rig_id: "rig-b",
|
||||
};
|
||||
|
||||
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
|
||||
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" });
|
||||
const fixture = await startWebFixture({
|
||||
spectrum: true, decodes: [VESSEL, BEACON, HF_BEACON, OTHER_RIG_VESSEL], mode: "AIS",
|
||||
});
|
||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||
|
||||
try {
|
||||
@@ -41,11 +57,14 @@ try {
|
||||
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
|
||||
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
|
||||
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
|
||||
otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false,
|
||||
aisRows: document.getElementById("ais-messages")?.children.length ?? 0,
|
||||
mapLoaded: !!window.trx.modules.map,
|
||||
}));
|
||||
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
|
||||
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
|
||||
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
|
||||
assert.equal(panels.otherRig, false, "the AIS panel listed a background rig's vessel");
|
||||
|
||||
// The mini view rides over the waterfall on the radio page.
|
||||
await page.locator('.tab[data-tab="main"]').click();
|
||||
@@ -56,11 +75,13 @@ try {
|
||||
shown: getComputedStyle(bar).display !== "none",
|
||||
pins: bar.querySelectorAll(".aprs-bar-pin").length,
|
||||
names: bar.textContent.includes("NEDERLAND"),
|
||||
otherRig: bar.textContent.includes("ELDERBERRY"),
|
||||
};
|
||||
});
|
||||
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
|
||||
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
|
||||
assert.equal(miniView.names, true, "the mini view does not name the vessel");
|
||||
assert.equal(miniView.otherRig, false, "the mini view shows a background rig's vessel");
|
||||
|
||||
// Following the pin: the map opens, on the vessel. This is the path that was
|
||||
// broken for every decoder — the module that owned the navigation had not
|
||||
@@ -89,8 +110,41 @@ try {
|
||||
: Object.keys(collection ?? {}).length);
|
||||
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
|
||||
});
|
||||
assert.ok(markers.ais > 0, "the vessel never reached the map");
|
||||
assert.ok(markers.stations > 0, "the APRS station never reached the map");
|
||||
// Both APRS stations are on it, each under its own source: the HF one used
|
||||
// to be dropped entirely, since the HF list never plotted anything.
|
||||
const aprsSources = await page.evaluate(() => {
|
||||
const entries = [...(window.trx.modules.map?.stationMarkers ?? new Map()).entries()];
|
||||
return entries.map(([key, entry]) => `${entry?.type ?? "?"}:${entry?.call ?? key}`).sort();
|
||||
});
|
||||
assert.deepEqual(aprsSources, ["aprs:SP2SJG-9", "hf_aprs:SP5HF-7"],
|
||||
`the map holds ${JSON.stringify(aprsSources)}`);
|
||||
// And the filter offers HF APRS on a chip of its own, next to the VHF one.
|
||||
await page.locator('#map-locator-phase .map-locator-phase-btn[data-phase="type"]').click();
|
||||
await page.waitForTimeout(400);
|
||||
const chips = await page.evaluate(() => [...document.querySelectorAll("#map-locator-choice-filter .map-locator-chip")]
|
||||
.map((chip) => chip.dataset.filterKey));
|
||||
assert.ok(chips.includes("aprs") && chips.includes("hf_aprs"),
|
||||
`the Show chips are ${JSON.stringify(chips)}`);
|
||||
|
||||
// Turning that chip off takes the HF station off the map and leaves the VHF
|
||||
// one on: the two are filtered apart, which is the whole point of the split.
|
||||
await page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="hf_aprs"]').click();
|
||||
await page.waitForTimeout(500);
|
||||
const shownAfterFilter = await page.evaluate(() => {
|
||||
const map = window.trx.modules.map;
|
||||
const onMap = [];
|
||||
(map?.stationMarkers ?? new Map()).forEach((entry, key) => {
|
||||
if (entry?.marker && map.aprsMap?.hasLayer(entry.marker)) onMap.push(entry?.call ?? key);
|
||||
});
|
||||
return onMap.sort();
|
||||
});
|
||||
assert.deepEqual(shownAfterFilter, ["SP2SJG-9"],
|
||||
`hiding HF APRS left ${JSON.stringify(shownAfterFilter)} on the map`);
|
||||
// The map is the whole station's view — the one place a background rig's
|
||||
// traffic belongs — so both vessels are on it even though the panel and the
|
||||
// mini view show only the selected rig's.
|
||||
assert.equal(markers.ais, 2, `${markers.ais} of 2 vessels reached the map`);
|
||||
|
||||
assert.deepEqual(runtimeErrors, []);
|
||||
} finally {
|
||||
@@ -103,6 +157,8 @@ try {
|
||||
// left the client on its retry path and the history path untested.
|
||||
const HISTORY_AIS = 900;
|
||||
const HISTORY_APRS = 300;
|
||||
const HISTORY_FT8 = 12;
|
||||
const HISTORY_WSPR = 8;
|
||||
const historyFixture = await startWebFixture({
|
||||
spectrum: true,
|
||||
mode: "AIS",
|
||||
@@ -112,6 +168,14 @@ const historyFixture = await startWebFixture({
|
||||
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
|
||||
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||
})),
|
||||
ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({
|
||||
message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2,
|
||||
freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||
})),
|
||||
wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({
|
||||
message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5,
|
||||
freq_hz: 1500 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||
})),
|
||||
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
|
||||
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
|
||||
info: `history ${index}`, packet_type: "position", crc_ok: true,
|
||||
@@ -199,6 +263,27 @@ try {
|
||||
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
|
||||
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
|
||||
|
||||
// The Statistics page counts the same history. Its decode log lives in the
|
||||
// map module, which is lazy, so every decode that arrived before the module
|
||||
// did used to be recorded into nothing at all — the page opened empty and
|
||||
// only filled in from decodes heard afterwards, and it took a reload landing
|
||||
// on the tab (module loaded at startup, before the history) to show the lot.
|
||||
await replay.page.evaluate(() => window.navigateToTab("statistics"));
|
||||
await replay.page.waitForTimeout(1500);
|
||||
// The counters are written with toLocaleString(), so a four-figure count
|
||||
// arrives as "1,220" — whichever separator the runner's locale picks. Read
|
||||
// the digits rather than the formatting.
|
||||
const counted = await replay.page.evaluate(() => {
|
||||
const count = (id) => Number((document.getElementById(id)?.textContent ?? "").replace(/\D/g, ""));
|
||||
return { decodes: count("stats-total-decodes"), grids: count("stats-unique-grids") };
|
||||
});
|
||||
assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR,
|
||||
`the statistics counted ${counted.decodes} decodes`);
|
||||
// Grid squares come from the FT8 and WSPR spots, which had no map replay of
|
||||
// their own: the locators of everything heard before the map loaded were lost.
|
||||
assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR,
|
||||
`the statistics counted ${counted.grids} grid squares`);
|
||||
|
||||
assert.deepEqual(replay.runtimeErrors, []);
|
||||
} finally {
|
||||
await replay.browser.close();
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// What a phone gets. A page that scrolls sideways is a page with something on
|
||||
// it nobody will find: the transmit controls spent this whole period laid out
|
||||
// at x=400 on a 390 px screen, off the side of a tray 354 px wide, reachable
|
||||
// only by a horizontal scroll with nothing to say it was there.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||
|
||||
/* global document, getComputedStyle */
|
||||
|
||||
const PHONES = [430, 390, 360];
|
||||
|
||||
const fixture = await startWebFixture({ spectrum: true, tx: true });
|
||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||
|
||||
try {
|
||||
for (const width of PHONES) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#content").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(1800);
|
||||
|
||||
const layout = await page.evaluate((viewport) => {
|
||||
const box = (element) => element.getBoundingClientRect();
|
||||
// Anything laid out past the right edge of the screen.
|
||||
const past = [];
|
||||
const walk = (element) => {
|
||||
const rect = box(element);
|
||||
if (rect.width > 0 && rect.right > viewport + 1) {
|
||||
past.push(`${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ""}`);
|
||||
}
|
||||
for (const child of element.children) walk(child);
|
||||
};
|
||||
walk(document.getElementById("content"));
|
||||
const tx = document.getElementById("tx-power-col");
|
||||
return {
|
||||
documentScroll: document.documentElement.scrollWidth,
|
||||
past: past.slice(0, 6),
|
||||
txRight: tx ? Math.round(box(tx).right) : null,
|
||||
txVisible: tx ? getComputedStyle(tx).display !== "none" : false,
|
||||
rigSelect: Math.round(box(document.getElementById("header-rig-switch-select")).width),
|
||||
};
|
||||
}, width);
|
||||
|
||||
assert.equal(layout.documentScroll, width,
|
||||
`the page scrolls sideways at ${width}px (${layout.documentScroll}px wide)`);
|
||||
assert.deepEqual(layout.past, [],
|
||||
`laid out past the screen at ${width}px: ${layout.past.join(", ")}`);
|
||||
if (layout.txVisible) {
|
||||
assert.ok(layout.txRight <= width + 1,
|
||||
`the transmit controls end at ${layout.txRight}px on a ${width}px screen`);
|
||||
}
|
||||
// The rig's name was taking 139px of a 338px bar and pushing everything
|
||||
// else into the overflow menu.
|
||||
assert.ok(layout.rigSelect <= 112,
|
||||
`the rig picker is ${layout.rigSelect}px wide at ${width}px`);
|
||||
|
||||
// The bottom nav keeps its labels — that is what makes it navigation
|
||||
// rather than five glyphs — and a label used to overflow its tab and run
|
||||
// into the next one: "Bookmarks igital mode".
|
||||
//
|
||||
// What is asserted is that a label stays inside its own tab and, if it is
|
||||
// too long for it, ends in an ellipsis. Not that it fits: how wide a
|
||||
// platform draws a word varies by more than ten per cent, so "it fits"
|
||||
// passes on the machine it was written on and fails on the next one —
|
||||
// which is what a fixed font size did here.
|
||||
const nav = await page.evaluate(() => {
|
||||
const bar = document.querySelector(".tab-bar-nav");
|
||||
return [...bar.querySelectorAll(".tab")]
|
||||
.filter((tab) => tab.getBoundingClientRect().width > 0)
|
||||
.map((tab) => {
|
||||
const label = [...tab.querySelectorAll(".tab-label, .tab-label-short")]
|
||||
.find((span) => getComputedStyle(span).display !== "none");
|
||||
const style = label ? getComputedStyle(label) : null;
|
||||
const labelBox = label?.getBoundingClientRect();
|
||||
const tabBox = tab.getBoundingClientRect();
|
||||
return {
|
||||
text: (label?.textContent ?? "").trim(),
|
||||
escapes: labelBox
|
||||
? labelBox.left < tabBox.left - 1 || labelBox.right > tabBox.right + 1
|
||||
: false,
|
||||
truncates: style?.textOverflow === "ellipsis" && style?.overflow !== "visible",
|
||||
empty: !labelBox || labelBox.width < 1,
|
||||
};
|
||||
});
|
||||
});
|
||||
assert.ok(nav.length >= 4, `the bottom nav has ${nav.length} destinations at ${width}px`);
|
||||
const escaped = nav.filter((tab) => tab.escapes).map((tab) => tab.text);
|
||||
assert.deepEqual(escaped, [],
|
||||
`labels overflowing their tab at ${width}px: ${escaped.join(", ")}`);
|
||||
const untruncatable = nav.filter((tab) => !tab.truncates).map((tab) => tab.text);
|
||||
assert.deepEqual(untruncatable, [],
|
||||
`labels that would be cut rather than ellipsised at ${width}px: ${untruncatable.join(", ")}`);
|
||||
const blank = nav.filter((tab) => tab.empty).map((tab, index) => tab.text || `#${index}`);
|
||||
assert.deepEqual(blank, [], `destinations with no label at ${width}px: ${blank.join(", ")}`);
|
||||
}
|
||||
|
||||
// A tab whose visible label is shortened for the nav still has to say what
|
||||
// it is to anything that reads the page rather than looks at it.
|
||||
const named = await page.evaluate(() => {
|
||||
const tab = document.querySelector('.tab[data-tab="digital-modes"]');
|
||||
const short = tab.querySelector(".tab-label-short");
|
||||
return {
|
||||
label: tab.getAttribute("aria-label"),
|
||||
shortHidden: short?.getAttribute("aria-hidden"),
|
||||
};
|
||||
});
|
||||
assert.equal(named.label, "Digital modes", "the shortened tab lost its full name");
|
||||
assert.equal(named.shortHidden, "true", "the short label is read out as well as shown");
|
||||
|
||||
// Hiding the map's filters leaves the bar, and the bar has to still be
|
||||
// there to bring them back — it carries the only button that does.
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(`${fixture.origin}/map`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
||||
await page.waitForTimeout(1200);
|
||||
await page.locator("#map-overlay-toggle-btn").click();
|
||||
await page.waitForTimeout(400);
|
||||
const collapsed = await page.evaluate(() => {
|
||||
const panel = document.querySelector(".map-overlay-panel");
|
||||
const style = getComputedStyle(panel);
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const button = document.getElementById("map-overlay-toggle-btn").getBoundingClientRect();
|
||||
return {
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
opacity: Number(style.opacity),
|
||||
visibility: style.visibility,
|
||||
display: style.display,
|
||||
buttonWidth: Math.round(button.width),
|
||||
};
|
||||
});
|
||||
assert.ok(collapsed.width > 0 && collapsed.height > 0,
|
||||
`the collapsed bar measures ${collapsed.width}x${collapsed.height}`);
|
||||
assert.equal(collapsed.visibility, "visible", "the collapsed bar is not visible");
|
||||
assert.notEqual(collapsed.display, "none", "the collapsed bar is display:none");
|
||||
assert.equal(collapsed.opacity, 1, `the collapsed bar is at opacity ${collapsed.opacity}`);
|
||||
assert.ok(collapsed.buttonWidth > 0, "Show Filters has no size to click");
|
||||
|
||||
assert.deepEqual(runtimeErrors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fixture.close();
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// Pass predictions used to be a third view inside the weather-satellite
|
||||
// decoder, under Digital modes — a planning tool filed with the decoders it has
|
||||
// nothing to do with. It is its own page now, so what this guards is that the
|
||||
// page exists at its own address, renders passes, and that the decoder card no
|
||||
// longer offers the view it gave up.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||
|
||||
/* global document, window, getComputedStyle */
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const now = Date.now();
|
||||
const satPasses = {
|
||||
satellite_count: 2,
|
||||
passes: [
|
||||
// In progress right now, so the page has a countdown to run.
|
||||
{
|
||||
satellite: "NOAA 19", category: "weather",
|
||||
aos_ms: now - 4 * 60_000, los_ms: now + 6 * 60_000,
|
||||
max_elevation_deg: 62.4, duration_s: 600,
|
||||
azimuth_aos_deg: 10, azimuth_los_deg: 190,
|
||||
},
|
||||
{
|
||||
satellite: "ISS", category: "amateur",
|
||||
aos_ms: now + 2 * HOUR, los_ms: now + 2 * HOUR + 480_000,
|
||||
max_elevation_deg: 18.2, duration_s: 480,
|
||||
azimuth_aos_deg: 200, azimuth_los_deg: 20,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fixture = await startWebFixture({ satPasses });
|
||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1500, height: 950 });
|
||||
|
||||
// The page is reachable at its own address, not through a decoder sub-view.
|
||||
// #content is the radio panel on the main tab, so the readiness signal for a
|
||||
// deep link is the destination panel itself.
|
||||
await page.goto(`${fixture.origin}/satellites`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#tab-satellites").waitFor({ state: "visible" });
|
||||
|
||||
await page.waitForFunction(
|
||||
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
const rendered = await page.evaluate(() => ({
|
||||
current: document.getElementById("sat-pred-current-list").textContent,
|
||||
upcoming: document.getElementById("sat-pred-list").textContent,
|
||||
status: document.getElementById("sat-pred-status").textContent,
|
||||
activeTab: document.querySelector(".tab-bar .tab.active")?.dataset.tab,
|
||||
}));
|
||||
assert.match(rendered.current, /NOAA 19/, `current passes read "${rendered.current}"`);
|
||||
assert.match(rendered.upcoming, /ISS/, `upcoming passes read "${rendered.upcoming}"`);
|
||||
assert.match(rendered.status, /1 active/, `status reads "${rendered.status}"`);
|
||||
assert.equal(rendered.activeTab, "satellites", "the Satellites tab is the active one");
|
||||
|
||||
// A pass in progress counts down, so the seconds have to move on their own.
|
||||
const firstTick = await page.evaluate(
|
||||
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
|
||||
await page.waitForTimeout(1600);
|
||||
const secondTick = await page.evaluate(
|
||||
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
|
||||
assert.notEqual(firstTick, secondTick, `the countdown sat at ${firstTick}`);
|
||||
|
||||
// Leaving stops the countdown rather than leaving a timer running behind a
|
||||
// hidden page.
|
||||
await page.evaluate(() => { window.navigateToTab("main"); });
|
||||
await page.waitForTimeout(300);
|
||||
const afterLeaving = await page.evaluate(() => ({
|
||||
visible: document.getElementById("tab-satellites").style.display,
|
||||
rows: document.getElementById("sat-pred-current-list").childElementCount,
|
||||
}));
|
||||
assert.equal(afterLeaving.visible, "none", "the page stayed on screen after navigating away");
|
||||
assert.equal(afterLeaving.rows, 0, "the countdown rows outlived the page");
|
||||
|
||||
// Coming back reloads rather than showing whatever was there before.
|
||||
await page.evaluate(() => { window.navigateToTab("satellites"); });
|
||||
await page.waitForFunction(
|
||||
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
// The page is an occasional destination, so it lives behind Tools rather than
|
||||
// taking a slot in the operating strip — the same treatment Statistics,
|
||||
// Recorder, Settings and About get.
|
||||
const nav = await page.evaluate(() => ({
|
||||
inStrip: getComputedStyle(document.querySelector('.tab-bar-nav .tab[data-tab="satellites"]')).display,
|
||||
inTools: !!document.querySelector('#mobile-more-menu [data-navigate-tab="satellites"]'),
|
||||
}));
|
||||
assert.equal(nav.inStrip, "none", "Satellites took a slot in the operating strip");
|
||||
assert.ok(nav.inTools, "Satellites is not reachable from Tools");
|
||||
|
||||
// The decoder card keeps Live and History, and no longer offers Predictions.
|
||||
await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
|
||||
const satCard = await page.evaluate(() => ({
|
||||
predictionsButton: !!document.getElementById("sat-view-predictions"),
|
||||
predictionsView: !!document.getElementById("sat-predictions-view"),
|
||||
live: !!document.getElementById("sat-view-live"),
|
||||
history: !!document.getElementById("sat-view-history"),
|
||||
}));
|
||||
assert.equal(satCard.predictionsButton, false, "the decoder card still offers Predictions");
|
||||
assert.equal(satCard.predictionsView, false, "the old predictions view is still in the page");
|
||||
assert.ok(satCard.live && satCard.history, "the decoder card lost Live/History");
|
||||
|
||||
assert.deepEqual(runtimeErrors, [], "the page threw while showing predictions");
|
||||
console.log("satellite predictions page tests passed");
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fixture.close();
|
||||
}
|
||||
@@ -196,6 +196,62 @@ try {
|
||||
await fixture.close();
|
||||
}
|
||||
|
||||
// The strip of controls under the plot. Its two clusters — the receiver's
|
||||
// bandwidth and the display's levels — used to size themselves independently:
|
||||
// four control heights on one line, and between about 1100 and 1400 px the
|
||||
// left cluster wrapped to two lines while the right one did not, leaving the
|
||||
// two at heights that matched neither each other nor anything else.
|
||||
const stripFixture = await startWebFixture({ spectrum: true });
|
||||
const strip = await startBrowser(chromium);
|
||||
try {
|
||||
for (const width of [1600, 1200, 900]) {
|
||||
await strip.page.setViewportSize({ width, height: 950 });
|
||||
await strip.page.goto(stripFixture.origin, { waitUntil: "domcontentloaded" });
|
||||
await strip.page.locator("#spectrum-panel").waitFor({ state: "visible" });
|
||||
await strip.page.waitForTimeout(1600);
|
||||
|
||||
const measured = await strip.page.evaluate(() => {
|
||||
const controls = document.getElementById("spectrum-controls");
|
||||
const box = (element) => element.getBoundingClientRect();
|
||||
const clusters = [...controls.children]
|
||||
.filter((child) => child.id)
|
||||
.map((child) => ({ id: child.id, top: Math.round(box(child).top) }));
|
||||
const parts = [...controls.querySelectorAll(".spectrum-field, .spectrum-btn")];
|
||||
return {
|
||||
heights: [...new Set(parts.map((part) => Math.round(box(part).height)))],
|
||||
count: parts.length,
|
||||
clusters,
|
||||
// Rows are lines of the strip: clusters sharing a top are on one line.
|
||||
lines: new Set(clusters.map((cluster) => cluster.top)).size,
|
||||
overflows: controls.scrollWidth > controls.clientWidth + 1,
|
||||
insidePanel: box(controls).right
|
||||
<= box(document.getElementById("spectrum-panel")).right + 1,
|
||||
};
|
||||
});
|
||||
|
||||
// Five fields and four buttons: bandwidth, Set, Auto BW, Sweet-spot, peak
|
||||
// hold, floor, range, Auto, contrast.
|
||||
assert.equal(measured.count, 9, `the strip has ${measured.count} controls at ${width}px`);
|
||||
assert.deepEqual(measured.heights.length, 1,
|
||||
`controls are ${measured.heights.join(", ")}px tall at ${width}px`);
|
||||
assert.equal(measured.overflows, false, `the strip overflows at ${width}px`);
|
||||
assert.equal(measured.insidePanel, true, `the strip runs past the plot at ${width}px`);
|
||||
|
||||
// Either both clusters share a line, or each has one to itself. What must
|
||||
// never happen is one cluster floating against the middle of the other.
|
||||
const tops = measured.clusters.map((cluster) => cluster.top);
|
||||
assert.ok(
|
||||
measured.lines === 1 || measured.lines === measured.clusters.length,
|
||||
`clusters sit at ${tops.join(", ")} at ${width}px`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.deepEqual(strip.runtimeErrors, []);
|
||||
} finally {
|
||||
await strip.browser.close();
|
||||
await stripFixture.close();
|
||||
}
|
||||
|
||||
// The band plan is fetched once at startup, which can land before the session
|
||||
// exists. It used to fail silently and never retry, so the allocations only
|
||||
// turned up if the operator reloaded the page by hand.
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// The SSTV panel: a picture arriving row by row, and what is kept once it has.
|
||||
// Watching the image build up is the point of the mode, so rows have to reach
|
||||
// the canvas as they arrive rather than at the end of a two-minute frame.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import { bundleEntry } from "./bundle-entry.mjs";
|
||||
import { createHost } from "./host-fixture.mjs";
|
||||
|
||||
/** A DOM stub with only what the plugin reaches for. */
|
||||
function makeElement(id) {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
id,
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
value: "",
|
||||
style: {},
|
||||
attributes: {},
|
||||
classList: {
|
||||
classes: new Set(),
|
||||
add(name) { this.classes.add(name); },
|
||||
remove(name) { this.classes.delete(name); },
|
||||
toggle(name, on) { if (on) this.classes.add(name); else this.classes.delete(name); },
|
||||
contains(name) { return this.classes.has(name); },
|
||||
},
|
||||
addEventListener(type, handler) { listeners.set(type, handler); },
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||||
getAttribute(name) { return this.attributes[name] ?? null; },
|
||||
click() { listeners.get("click")?.(); },
|
||||
fire(type, event) { listeners.get(type)?.(event); },
|
||||
};
|
||||
}
|
||||
|
||||
function makeCanvas(id) {
|
||||
const element = makeElement(id);
|
||||
element.width = 0;
|
||||
element.height = 0;
|
||||
const painted = [];
|
||||
const fills = [];
|
||||
element.painted = painted;
|
||||
element.fills = fills;
|
||||
element.getContext = () => ({
|
||||
fillStyle: "",
|
||||
fillRect: (...args) => { fills.push(args); },
|
||||
createImageData: (width, height) => ({
|
||||
width, height, data: new Uint8ClampedArray(width * height * 4),
|
||||
}),
|
||||
putImageData: (image, x, y) => { painted.push({ x, y, data: image.data }); },
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
async function loadPanel() {
|
||||
const elements = new Map();
|
||||
const element = (id) => {
|
||||
if (!elements.has(id)) {
|
||||
elements.set(id, id.endsWith("canvas") ? makeCanvas(id) : makeElement(id));
|
||||
}
|
||||
return elements.get(id);
|
||||
};
|
||||
// Touch every id the panel defines, so the plugin caches real stubs.
|
||||
for (const id of [
|
||||
"sstv-status", "sstv-live-view", "sstv-history-view", "sstv-live-container",
|
||||
"sstv-live-info", "sstv-live-canvas", "sstv-live-latest", "sstv-history-list",
|
||||
"sstv-history-count", "sstv-filter", "sstv-sort", "sstv-decode-toggle-btn",
|
||||
"sstv-clear-btn", "sstv-view-live", "sstv-view-history",
|
||||
]) element(id);
|
||||
|
||||
const window = { ...createHost() };
|
||||
const context = vm.createContext({
|
||||
window,
|
||||
document: { getElementById: (id) => elements.get(id) ?? null },
|
||||
atob: (data) => Buffer.from(data, "base64").toString("binary"),
|
||||
Date, Number, String, Math, Set, Uint8Array, Uint8ClampedArray, JSON, console,
|
||||
});
|
||||
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
|
||||
const source = await bundleEntry(new URL("../src/plugins/sstv.ts", import.meta.url));
|
||||
new vm.Script(runtime).runInContext(context);
|
||||
new vm.Script(source).runInContext(context);
|
||||
return { window, runtime: window.trxPluginRuntime, element };
|
||||
}
|
||||
|
||||
/** One row of RGB triples, base64 as the server sends it. */
|
||||
function rowData(width, [r, g, b]) {
|
||||
const bytes = new Uint8Array(width * 3);
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
bytes[x * 3] = r;
|
||||
bytes[x * 3 + 1] = g;
|
||||
bytes[x * 3 + 2] = b;
|
||||
}
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
}
|
||||
|
||||
test("a picture arriving paints its rows as they come", async () => {
|
||||
const { runtime, element } = await loadPanel();
|
||||
|
||||
// The header names the mode and the geometry; the canvas takes both.
|
||||
runtime.dispatch("sstv_progress", {
|
||||
state: "Receiving Martin M1", mode: "Martin M1", width: 320, height: 256, line: 0,
|
||||
});
|
||||
const canvas = element("sstv-live-canvas");
|
||||
assert.equal(canvas.width, 320, "the canvas did not take the mode's width");
|
||||
assert.equal(canvas.height, 256, "the canvas did not take the mode's height");
|
||||
assert.equal(element("sstv-live-container").style.display, "",
|
||||
"the live view stayed hidden while a picture was arriving");
|
||||
assert.match(element("sstv-status").textContent, /Martin M1/);
|
||||
|
||||
// Rows land at the line number they carry, not in arrival order: a decoder
|
||||
// that painted them in sequence would shear a picture with a dropped line.
|
||||
runtime.dispatch("sstv_progress", { line: 4, line_data: rowData(320, [255, 0, 0]) });
|
||||
runtime.dispatch("sstv_progress", { line: 2, line_data: rowData(320, [0, 0, 255]) });
|
||||
assert.deepEqual(canvas.painted.map((p) => p.y), [4, 2],
|
||||
`rows painted at ${canvas.painted.map((p) => p.y).join(",")}`);
|
||||
assert.deepEqual([...canvas.painted[0].data.slice(0, 4)], [255, 0, 0, 255], "row 4 is not red");
|
||||
assert.deepEqual([...canvas.painted[1].data.slice(0, 4)], [0, 0, 255, 255], "row 2 is not blue");
|
||||
assert.match(element("sstv-live-info").textContent, /Martin M1/);
|
||||
});
|
||||
|
||||
test("a received picture is kept, shown, and linked by file name alone", async () => {
|
||||
const { runtime, element } = await loadPanel();
|
||||
|
||||
// Recent, not a fixed date: the panel drops anything older than the history
|
||||
// retention window, so a picture stamped with the day the test was written
|
||||
// passes until that day is a day ago.
|
||||
runtime.dispatch("sstv", {
|
||||
ts_ms: Date.now() - 60_000,
|
||||
vis: 44, mode: "Martin M1", width: 320, height: 256, lines: 256, complete: true,
|
||||
path: "/home/op/.cache/trx-rs/sstv/SSTV_20260805T120000Z_14230000_Martin-M1.png",
|
||||
});
|
||||
|
||||
const latest = element("sstv-live-latest").innerHTML;
|
||||
assert.match(latest, /Martin M1/);
|
||||
assert.match(latest, /complete/);
|
||||
// The server serves pictures by file name; the path it stored is its own.
|
||||
assert.match(latest, /\/sstv-images\/SSTV_20260805T120000Z_14230000_Martin-M1\.png/);
|
||||
assert.doesNotMatch(latest, /home\/op/, "the server's filesystem path reached the page");
|
||||
|
||||
element("sstv-view-history").click();
|
||||
const history = element("sstv-history-list").innerHTML;
|
||||
assert.match(history, /Martin M1/);
|
||||
assert.match(history, /320×256/);
|
||||
assert.match(element("sstv-history-count").textContent, /1 picture/);
|
||||
});
|
||||
|
||||
test("a picture cut short is kept, and says so", async () => {
|
||||
const { runtime, element } = await loadPanel();
|
||||
|
||||
runtime.dispatch("sstv", {
|
||||
ts_ms: Date.now(), vis: 60, mode: "Scottie S1", width: 320, height: 256,
|
||||
lines: 91, complete: false, path: "/cache/SSTV_x_Scottie-S1.png",
|
||||
});
|
||||
|
||||
assert.match(element("sstv-status").textContent, /Partial/);
|
||||
element("sstv-view-history").click();
|
||||
assert.match(element("sstv-history-list").innerHTML, /91 \(partial\)/);
|
||||
});
|
||||
|
||||
test("clearing empties the panel", async () => {
|
||||
const { runtime, element } = await loadPanel();
|
||||
runtime.dispatch("sstv", { ts_ms: Date.now(), mode: "PD120", lines: 496, complete: true });
|
||||
assert.match(element("sstv-live-latest").innerHTML, /PD120/);
|
||||
|
||||
runtime.reset("sstv");
|
||||
assert.equal(element("sstv-live-latest").innerHTML, "");
|
||||
assert.equal(element("sstv-status").textContent, "Idle");
|
||||
assert.equal(element("sstv-live-container").style.display, "none");
|
||||
});
|
||||
|
||||
test("the toggle button follows the rig state", async () => {
|
||||
const { window, element } = await loadPanel();
|
||||
const button = element("sstv-decode-toggle-btn");
|
||||
|
||||
window.syncSstvToggle(true);
|
||||
assert.equal(button.textContent, "Disable SSTV");
|
||||
assert.equal(button.getAttribute("aria-pressed"), "true");
|
||||
|
||||
window.syncSstvToggle(false);
|
||||
assert.equal(button.textContent, "Enable SSTV");
|
||||
assert.equal(button.getAttribute("aria-pressed"), "false");
|
||||
});
|
||||
@@ -27,6 +27,21 @@ const dial = () => page.evaluate(() => ({
|
||||
path: window.location.pathname,
|
||||
}));
|
||||
|
||||
// Typed, not filled. The app holds back its own refreshes of the frequency
|
||||
// field from the first keystroke until Enter, so that a state update arriving
|
||||
// mid-edit does not rewrite what is being typed. `fill()` sets the value
|
||||
// without a keystroke, leaving the field unguarded: on a slow machine a state
|
||||
// update could land between the fill and the Enter and put the old frequency
|
||||
// back, and the Enter would then re-apply the frequency the radio was already
|
||||
// on. Selecting first arms the guard before a single character changes.
|
||||
async function tuneByHand(text) {
|
||||
const field = page.locator("#freq");
|
||||
await field.click();
|
||||
await field.press("ControlOrMeta+a");
|
||||
await field.pressSequentially(text);
|
||||
await field.press("Enter");
|
||||
}
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1500, height: 950 });
|
||||
|
||||
@@ -49,8 +64,7 @@ try {
|
||||
|
||||
// Tuning by hand rewrites the link. This is the part that makes the address
|
||||
// bar shareable at any moment rather than only at load.
|
||||
await page.locator("#freq").fill("7.040M");
|
||||
await page.locator("#freq").press("Enter");
|
||||
await tuneByHand("7.040M");
|
||||
await page.waitForTimeout(1500);
|
||||
const tuned = await dial();
|
||||
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
|
||||
@@ -59,8 +73,7 @@ try {
|
||||
|
||||
// Tuning is not navigation: a swept dial must not bury the back button.
|
||||
const historyLength = await page.evaluate(() => window.history.length);
|
||||
await page.locator("#freq").fill("7.100M");
|
||||
await page.locator("#freq").press("Enter");
|
||||
await tuneByHand("7.100M");
|
||||
await page.waitForTimeout(1200);
|
||||
assert.equal(await page.evaluate(() => window.history.length), historyLength,
|
||||
"tuning pushed a history entry instead of replacing one");
|
||||
|
||||
@@ -29,6 +29,7 @@ const DECODER_REGISTRY = [
|
||||
{ id: "cw", label: "CW", activation: "toggle", active_modes: ["CW", "CWR"] },
|
||||
{ id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
|
||||
{ id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] },
|
||||
{ id: "sstv", label: "SSTV", activation: "toggle", active_modes: ["USB", "FM"] },
|
||||
{ id: "ais", label: "AIS", activation: "toggle", active_modes: ["FM"] },
|
||||
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
|
||||
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
||||
@@ -104,7 +105,7 @@ function encodeCbor(value) {
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"];
|
||||
|
||||
function assetPath(urlPath) {
|
||||
// Every tab route has its own index handler on the server (see api/assets.rs),
|
||||
@@ -134,6 +135,7 @@ export async function startWebFixture({
|
||||
bandplan = {},
|
||||
bandplanEnabled = false,
|
||||
bandplanUnauthorizedFirst = false,
|
||||
satPasses = null,
|
||||
} = {}) {
|
||||
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
||||
remote,
|
||||
@@ -203,6 +205,7 @@ export async function startWebFixture({
|
||||
wspr_decode_enabled: false,
|
||||
lrpt_decode_enabled: false,
|
||||
wefax_decode_enabled: false,
|
||||
sstv_decode_enabled: false,
|
||||
recorder_enabled: false,
|
||||
clients: 1,
|
||||
rigctl_clients: 0,
|
||||
@@ -228,6 +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();
|
||||
@@ -175,21 +181,34 @@ pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path<String>) -
|
||||
static_asset_response(&req, content_type, entry)
|
||||
}
|
||||
|
||||
/// Serve a received SSTV picture out of the local cache.
|
||||
#[get("/sstv-images/{filename}")]
|
||||
pub(crate) async fn sstv_image(path: web::Path<String>) -> impl Responder {
|
||||
cached_png("sstv", &path.into_inner())
|
||||
}
|
||||
|
||||
#[get("/images/{filename}")]
|
||||
pub(crate) async fn wefax_image(path: web::Path<String>) -> impl Responder {
|
||||
let filename = path.into_inner();
|
||||
// Reject path traversal attempts.
|
||||
cached_png("wefax", &path.into_inner())
|
||||
}
|
||||
|
||||
/// Read a PNG out of one of the decoder cache directories.
|
||||
///
|
||||
/// The file name comes from a client, so it is checked rather than trusted: no
|
||||
/// separators, no parent references, and a .png suffix. Everything a decoder
|
||||
/// writes is named that way, and nothing else in the cache is servable.
|
||||
fn cached_png(decoder: &str, filename: &str) -> HttpResponse {
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return HttpResponse::BadRequest().body("invalid filename");
|
||||
}
|
||||
if !filename.ends_with(".png") {
|
||||
return HttpResponse::BadRequest().body("only .png files are accessible");
|
||||
}
|
||||
let dir = dirs::cache_dir()
|
||||
let file_path = dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||
.join("trx-rs")
|
||||
.join("wefax");
|
||||
let file_path = dir.join(&filename);
|
||||
.join(decoder)
|
||||
.join(filename);
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(data) => HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
|
||||
@@ -60,6 +60,7 @@ struct DecodeHistoryPayload {
|
||||
ft2: Vec<trx_core::decode::Ft8Message>,
|
||||
wspr: Vec<trx_core::decode::WsprMessage>,
|
||||
wefax: Vec<trx_core::decode::WefaxMessage>,
|
||||
sstv: Vec<trx_core::decode::SstvMessage>,
|
||||
}
|
||||
|
||||
impl DecodeHistoryPayload {
|
||||
@@ -74,6 +75,7 @@ impl DecodeHistoryPayload {
|
||||
+ self.ft2.len()
|
||||
+ self.wspr.len()
|
||||
+ self.wefax.len()
|
||||
+ self.sstv.len()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +95,7 @@ fn collect_decode_history(
|
||||
ft2: crate::server::audio::snapshot_ft2_history(context, rig_filter),
|
||||
wspr: crate::server::audio::snapshot_wspr_history(context, rig_filter),
|
||||
wefax: crate::server::audio::snapshot_wefax_history(context, rig_filter),
|
||||
sstv: crate::server::audio::snapshot_sstv_history(context, rig_filter),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,10 +454,40 @@ pub async fn toggle_wefax_decode(
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_sstv_decode")]
|
||||
pub async fn toggle_sstv_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetSstvDecodeEnabled(!rig_state.decoders.sstv_decode_enabled),
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decoder clear endpoints
|
||||
// ============================================================================
|
||||
|
||||
#[post("/clear_sstv_decode")]
|
||||
pub async fn clear_sstv_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetSstvDecoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_wefax_decode")]
|
||||
pub async fn clear_wefax_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
|
||||
@@ -591,6 +591,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(decoder::toggle_wspr_decode)
|
||||
.service(decoder::toggle_lrpt_decode)
|
||||
.service(decoder::toggle_wefax_decode)
|
||||
.service(decoder::toggle_sstv_decode)
|
||||
.service(decoder::clear_ais_decode)
|
||||
.service(decoder::clear_vdes_decode)
|
||||
.service(decoder::clear_aprs_decode)
|
||||
@@ -602,6 +603,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(decoder::clear_wspr_decode)
|
||||
.service(decoder::clear_lrpt_decode)
|
||||
.service(decoder::clear_wefax_decode)
|
||||
.service(decoder::clear_sstv_decode)
|
||||
// Bookmark CRUD
|
||||
.service(bookmarks::list_bookmarks)
|
||||
.service(bookmarks::create_bookmark)
|
||||
@@ -637,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)
|
||||
@@ -645,6 +648,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(assets::style_css)
|
||||
.service(assets::themes_css)
|
||||
.service(assets::wefax_image)
|
||||
.service(assets::sstv_image)
|
||||
.service(assets::bandplan_json)
|
||||
// Vendored DSEG14 Classic font
|
||||
.service(assets::dseg14_classic_woff2)
|
||||
|
||||
@@ -24,8 +24,8 @@ use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use trx_core::decode::{
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
||||
WsprMessage,
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
|
||||
WefaxMessage, WsprMessage,
|
||||
};
|
||||
use trx_frontend::FrontendRuntimeContext;
|
||||
|
||||
@@ -335,6 +335,43 @@ fn record_wefax(context: &FrontendRuntimeContext, mut msg: WefaxMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a received picture, saving the PNG the server sent into the local
|
||||
/// cache so `/images/` can serve it back.
|
||||
fn record_sstv(context: &FrontendRuntimeContext, mut msg: SstvMessage) {
|
||||
if let Some(ref data) = msg.png_data {
|
||||
if let Some(ref path) = msg.path {
|
||||
if let Some(filename) = std::path::Path::new(path).file_name() {
|
||||
let dir = dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||
.join("trx-rs")
|
||||
.join("sstv");
|
||||
if std::fs::create_dir_all(&dir).is_ok() {
|
||||
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
|
||||
let local_path = dir.join(filename);
|
||||
if let Err(e) = std::fs::write(&local_path, &bytes) {
|
||||
tracing::warn!("SSTV: failed to save local image: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The picture itself is on disk now; keeping a megabyte of base64 per
|
||||
// entry in memory is what the history does not need.
|
||||
msg.png_data = None;
|
||||
|
||||
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.sstv
|
||||
.lock()
|
||||
.expect("sstv history mutex poisoned");
|
||||
history.push_back((Instant::now(), rig_id, msg));
|
||||
while history.len() > 100 {
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the entry's rig_id matches the optional filter.
|
||||
/// `None` filter means "all rigs".
|
||||
fn matches_rig_filter(entry_rig: Option<&str>, filter: Option<&str>) -> bool {
|
||||
@@ -526,6 +563,31 @@ pub fn snapshot_wefax_history(
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn snapshot_sstv_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_filter: Option<&str>,
|
||||
) -> Vec<SstvMessage> {
|
||||
let history = context
|
||||
.decode_history
|
||||
.sstv
|
||||
.lock()
|
||||
.expect("sstv history mutex poisoned");
|
||||
history
|
||||
.iter()
|
||||
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
|
||||
.map(|(_, _, msg)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_sstv_history(context: &FrontendRuntimeContext) {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.sstv
|
||||
.lock()
|
||||
.expect("sstv history mutex poisoned");
|
||||
history.clear();
|
||||
}
|
||||
|
||||
pub fn clear_wefax_history(context: &FrontendRuntimeContext) {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
@@ -650,6 +712,9 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
|
||||
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
|
||||
DecodedMessage::Wefax(msg) => record_wefax(&context, msg),
|
||||
DecodedMessage::WefaxProgress(_) => {}
|
||||
DecodedMessage::Sstv(msg) => record_sstv(&context, msg),
|
||||
// Progress is for watching, not for keeping.
|
||||
DecodedMessage::SstvProgress(_) => {}
|
||||
DecodedMessage::LrptImage(_) => {}
|
||||
DecodedMessage::LrptProgress(_) => {}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(§ion);
|
||||
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -74,6 +74,11 @@ pub const AUDIO_MSG_LRPT_PROGRESS: u8 = 0x18;
|
||||
pub const AUDIO_MSG_WEFAX_DECODE: u8 = 0x19;
|
||||
/// Server → client: WEFAX decode progress (JSON `DecodedMessage::WefaxProgress`).
|
||||
pub const AUDIO_MSG_WEFAX_PROGRESS: u8 = 0x1A;
|
||||
/// Server → client: SSTV received picture (JSON `DecodedMessage::Sstv`).
|
||||
pub const AUDIO_MSG_SSTV_DECODE: u8 = 0x1B;
|
||||
/// Server → client: SSTV decode progress, one line at a time
|
||||
/// (JSON `DecodedMessage::SstvProgress`).
|
||||
pub const AUDIO_MSG_SSTV_PROGRESS: u8 = 0x1C;
|
||||
|
||||
/// Maximum payload size for normal messages (1 MB).
|
||||
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
|
||||
|
||||
@@ -36,6 +36,10 @@ pub enum DecodedMessage {
|
||||
Wefax(WefaxMessage),
|
||||
#[serde(rename = "wefax_progress")]
|
||||
WefaxProgress(WefaxProgress),
|
||||
#[serde(rename = "sstv")]
|
||||
Sstv(SstvMessage),
|
||||
#[serde(rename = "sstv_progress")]
|
||||
SstvProgress(SstvProgress),
|
||||
}
|
||||
|
||||
impl DecodedMessage {
|
||||
@@ -52,6 +56,8 @@ impl DecodedMessage {
|
||||
Self::LrptProgress(m) => m.rig_id = Some(id),
|
||||
Self::Wefax(m) => m.rig_id = Some(id),
|
||||
Self::WefaxProgress(m) => m.rig_id = Some(id),
|
||||
Self::Sstv(m) => m.rig_id = Some(id),
|
||||
Self::SstvProgress(m) => m.rig_id = Some(id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +74,8 @@ impl DecodedMessage {
|
||||
Self::LrptProgress(m) => m.rig_id.as_deref(),
|
||||
Self::Wefax(m) => m.rig_id.as_deref(),
|
||||
Self::WefaxProgress(m) => m.rig_id.as_deref(),
|
||||
Self::Sstv(m) => m.rig_id.as_deref(),
|
||||
Self::SstvProgress(m) => m.rig_id.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,3 +327,52 @@ pub struct WefaxProgress {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
/// A received SSTV picture, complete or cut short.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SstvMessage {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rig_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ts_ms: Option<i64>,
|
||||
/// VIS code the transmission announced itself with.
|
||||
pub vis: u8,
|
||||
/// Mode name, e.g. "Martin M1".
|
||||
pub mode: String,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
/// Image lines actually received, which is the height only if the whole
|
||||
/// frame arrived.
|
||||
pub lines: u16,
|
||||
/// Whether reception reached the bottom of the frame.
|
||||
pub complete: bool,
|
||||
/// Filesystem path to the saved PNG, when one was written.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
/// Base64-encoded PNG for the trip to a client. Populated when sending and
|
||||
/// stripped before the message is stored in history, which would otherwise
|
||||
/// hold a megabyte per picture.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub png_data: Option<String>,
|
||||
}
|
||||
|
||||
/// A picture arriving, emitted per line so it can be watched.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SstvProgress {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rig_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ts_ms: Option<i64>,
|
||||
pub vis: u8,
|
||||
pub mode: String,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
/// Index of the line this update carries.
|
||||
pub line: u16,
|
||||
/// Base64-encoded RGB triples for that one line.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub line_data: Option<String>,
|
||||
/// Decoder state for display, e.g. "Receiving Martin M1".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ pub enum RigCommand {
|
||||
SetWsprDecodeEnabled(bool),
|
||||
SetLrptDecodeEnabled(bool),
|
||||
SetWefaxDecodeEnabled(bool),
|
||||
SetSstvDecodeEnabled(bool),
|
||||
ResetAprsDecoder,
|
||||
ResetHfAprsDecoder,
|
||||
ResetCwDecoder,
|
||||
@@ -42,6 +43,7 @@ pub enum RigCommand {
|
||||
ResetWsprDecoder,
|
||||
ResetLrptDecoder,
|
||||
ResetWefaxDecoder,
|
||||
ResetSstvDecoder,
|
||||
SetBandwidth(u32),
|
||||
SetSdrGain(f64),
|
||||
SetSdrLnaGain(f64),
|
||||
|
||||
@@ -462,6 +462,8 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
|
||||
| RigCommand::ResetLrptDecoder
|
||||
| RigCommand::SetWefaxDecodeEnabled(_)
|
||||
| RigCommand::ResetWefaxDecoder
|
||||
| RigCommand::SetSstvDecodeEnabled(_)
|
||||
| RigCommand::ResetSstvDecoder
|
||||
| RigCommand::SetBandwidth(_)
|
||||
| RigCommand::SetSdrGain(_)
|
||||
| RigCommand::SetSdrLnaGain(_)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ pub struct DecoderConfig {
|
||||
#[serde(default)]
|
||||
pub wefax_decode_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub sstv_decode_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub recorder_enabled: bool,
|
||||
}
|
||||
|
||||
@@ -62,6 +64,8 @@ pub struct DecoderResetSeqs {
|
||||
pub lrpt_decode_reset_seq: u64,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub wefax_decode_reset_seq: u64,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub sstv_decode_reset_seq: u64,
|
||||
}
|
||||
|
||||
/// Simple transceiver state representation held by the rig task.
|
||||
@@ -390,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")]
|
||||
|
||||
@@ -139,6 +139,16 @@ pub const DECODER_REGISTRY: &[DecoderDescriptor] = &[
|
||||
background_decode: false,
|
||||
bookmark_selectable: true,
|
||||
},
|
||||
DecoderDescriptor {
|
||||
id: "sstv",
|
||||
label: "SSTV",
|
||||
activation: DecoderActivation::Toggle,
|
||||
// SSTV is sent on sideband on HF and on FM simplex on VHF, and the
|
||||
// decoder cares only about the audio it is handed.
|
||||
active_modes: &["USB", "LSB", "FM", "AM", "DIG"],
|
||||
background_decode: false,
|
||||
bookmark_selectable: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
@@ -122,7 +122,8 @@ define_command_mapping! {
|
||||
ResetFt2Decoder <=> ResetFt2Decoder,
|
||||
ResetWsprDecoder <=> ResetWsprDecoder,
|
||||
ResetLrptDecoder <=> ResetLrptDecoder,
|
||||
ResetWefaxDecoder <=> ResetWefaxDecoder;
|
||||
ResetWefaxDecoder <=> ResetWefaxDecoder,
|
||||
ResetSstvDecoder <=> ResetSstvDecoder;
|
||||
|
||||
// ── Single-field struct <=> tuple ────────────────────────────────
|
||||
field:
|
||||
@@ -140,6 +141,7 @@ define_command_mapping! {
|
||||
SetWsprDecodeEnabled { enabled } <=> SetWsprDecodeEnabled,
|
||||
SetLrptDecodeEnabled { enabled } <=> SetLrptDecodeEnabled,
|
||||
SetWefaxDecodeEnabled { enabled } <=> SetWefaxDecodeEnabled,
|
||||
SetSstvDecodeEnabled { enabled } <=> SetSstvDecodeEnabled,
|
||||
SetBandwidth { bandwidth_hz } <=> SetBandwidth,
|
||||
SetSdrGain { gain_db } <=> SetSdrGain,
|
||||
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
|
||||
|
||||
@@ -73,6 +73,9 @@ pub enum ClientCommand {
|
||||
SetWefaxDecodeEnabled {
|
||||
enabled: bool,
|
||||
},
|
||||
SetSstvDecodeEnabled {
|
||||
enabled: bool,
|
||||
},
|
||||
ResetAprsDecoder,
|
||||
ResetHfAprsDecoder,
|
||||
ResetCwDecoder,
|
||||
@@ -82,6 +85,7 @@ pub enum ClientCommand {
|
||||
ResetWsprDecoder,
|
||||
ResetLrptDecoder,
|
||||
ResetWefaxDecoder,
|
||||
ResetSstvDecoder,
|
||||
SetBandwidth {
|
||||
bandwidth_hz: u32,
|
||||
},
|
||||
@@ -126,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.
|
||||
|
||||
@@ -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" }
|
||||
@@ -41,6 +42,7 @@ trx-cw = { path = "../decoders/trx-cw" }
|
||||
trx-decode-log = { path = "../decoders/trx-decode-log" }
|
||||
trx-ftx = { path = "../decoders/trx-ftx" }
|
||||
trx-wefax = { path = "../decoders/trx-wefax" }
|
||||
trx-sstv = { path = "../decoders/trx-sstv" }
|
||||
trx-wspr = { path = "../decoders/trx-wspr" }
|
||||
trx-wxsat = { path = "../decoders/trx-wxsat" }
|
||||
trx-protocol = { path = "../trx-protocol" }
|
||||
|
||||
+253
-8
@@ -30,10 +30,11 @@ use trx_core::audio::{
|
||||
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
||||
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
||||
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME,
|
||||
AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ,
|
||||
AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB,
|
||||
AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
||||
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_SSTV_DECODE, AUDIO_MSG_SSTV_PROGRESS,
|
||||
AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW,
|
||||
AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE,
|
||||
AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE,
|
||||
AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use trx_core::decode::{AisMessage, AprsPacket, CwEvent};
|
||||
@@ -292,6 +293,11 @@ fn build_history_blob(histories: &DecoderHistories) -> (Vec<u8>, usize) {
|
||||
DecodedMessage::Wefax,
|
||||
AUDIO_MSG_WEFAX_DECODE
|
||||
);
|
||||
push_history!(
|
||||
histories.snapshot_sstv_history(),
|
||||
DecodedMessage::Sstv,
|
||||
AUDIO_MSG_SSTV_DECODE
|
||||
);
|
||||
|
||||
(blob, count)
|
||||
}
|
||||
@@ -2285,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()
|
||||
@@ -2444,6 +2447,244 @@ pub async fn run_wefax_decoder(
|
||||
info!("WEFAX decoder stopped");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSTV decoder task
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Turn a decoded picture into the message clients receive, saving the PNG on
|
||||
/// the way. The picture travels as base64 so a client on another machine has
|
||||
/// it without a second request; the server keeps its own copy on disk.
|
||||
fn sstv_message(
|
||||
image: &trx_sstv::SstvImage,
|
||||
output_dir: &std::path::Path,
|
||||
freq_hz: u64,
|
||||
) -> DecodedMessage {
|
||||
let path = match image.save_png(output_dir, freq_hz) {
|
||||
Ok(path) => Some(path.to_string_lossy().into_owned()),
|
||||
Err(e) => {
|
||||
warn!("SSTV: failed to save image: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
let png_data = image.to_png_base64().ok();
|
||||
DecodedMessage::Sstv(trx_core::decode::SstvMessage {
|
||||
rig_id: None,
|
||||
ts_ms: Some(image.started_ms),
|
||||
vis: image.vis,
|
||||
mode: image.mode.to_string(),
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
lines: image.lines,
|
||||
complete: image.complete,
|
||||
path,
|
||||
png_data,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the SSTV decoder task. Processes PCM when enabled and the rig mode is
|
||||
/// one SSTV is sent in.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn run_sstv_decoder(
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
||||
mut state_rx: watch::Receiver<RigState>,
|
||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||
histories: Arc<DecoderHistories>,
|
||||
output_dir: std::path::PathBuf,
|
||||
) {
|
||||
use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent};
|
||||
|
||||
info!("SSTV decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||
|
||||
let mut decoder = SstvDecoder::new(sample_rate, SstvConfig::default());
|
||||
let mut was_active = false;
|
||||
let mut last_reset_seq: u64 = 0;
|
||||
|
||||
let is_sstv_mode = |mode: &RigMode| {
|
||||
matches!(
|
||||
mode,
|
||||
RigMode::USB | RigMode::LSB | RigMode::FM | RigMode::AM | RigMode::DIG
|
||||
)
|
||||
};
|
||||
|
||||
// Reset returns any picture in progress rather than discarding it: a
|
||||
// half-received frame is still worth keeping.
|
||||
macro_rules! flush {
|
||||
($decoder:expr) => {
|
||||
for event in $decoder.reset() {
|
||||
if let SstvEvent::Complete(image) = event {
|
||||
// Read the dial now rather than caching it: the picture is
|
||||
// named for where it was received.
|
||||
let freq_hz = state_rx.borrow().status.freq.hz;
|
||||
let msg = sstv_message(&image, &output_dir, freq_hz);
|
||||
if let DecodedMessage::Sstv(ref stored) = msg {
|
||||
histories.record_sstv_message(stored.clone());
|
||||
}
|
||||
let _ = decode_tx.send(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let mut active = state_rx.borrow().decoders.sstv_decode_enabled
|
||||
&& is_sstv_mode(&state_rx.borrow().status.mode);
|
||||
|
||||
loop {
|
||||
if !active {
|
||||
match state_rx.changed().await {
|
||||
Ok(()) => {
|
||||
let state = state_rx.borrow();
|
||||
active = state.decoders.sstv_decode_enabled && is_sstv_mode(&state.status.mode);
|
||||
if active {
|
||||
pcm_rx = pcm_rx.resubscribe();
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
recv = pcm_rx.recv() => {
|
||||
match recv {
|
||||
Ok(frame) => {
|
||||
let (process_enabled, reset_seq) = {
|
||||
let state = state_rx.borrow();
|
||||
(
|
||||
state.decoders.sstv_decode_enabled
|
||||
&& is_sstv_mode(&state.status.mode),
|
||||
state.reset_seqs.sstv_decode_reset_seq,
|
||||
)
|
||||
};
|
||||
|
||||
if reset_seq != last_reset_seq {
|
||||
last_reset_seq = reset_seq;
|
||||
flush!(decoder);
|
||||
info!("SSTV decoder reset (seq={})", last_reset_seq);
|
||||
pcm_rx = pcm_rx.resubscribe();
|
||||
continue;
|
||||
}
|
||||
|
||||
if !process_enabled {
|
||||
if was_active {
|
||||
flush!(decoder);
|
||||
was_active = false;
|
||||
}
|
||||
active = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mono = if channels > 1 {
|
||||
let num_frames = frame.len() / channels as usize;
|
||||
let mut mono = Vec::with_capacity(num_frames);
|
||||
for i in 0..num_frames {
|
||||
mono.push(frame[i * channels as usize]);
|
||||
}
|
||||
mono
|
||||
} else {
|
||||
frame
|
||||
};
|
||||
|
||||
was_active = true;
|
||||
let events = tokio::task::block_in_place(|| {
|
||||
let _span = info_span!("sstv_decode").entered();
|
||||
decoder.process_samples(&mono)
|
||||
});
|
||||
|
||||
let latest_reset_seq = state_rx.borrow().reset_seqs.sstv_decode_reset_seq;
|
||||
if latest_reset_seq != reset_seq {
|
||||
last_reset_seq = latest_reset_seq;
|
||||
flush!(decoder);
|
||||
info!("SSTV decoder reset (seq={})", last_reset_seq);
|
||||
pcm_rx = pcm_rx.resubscribe();
|
||||
continue;
|
||||
}
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
SstvEvent::Started { vis, mode, width, height } => {
|
||||
info!("SSTV: receiving {} (VIS {})", mode, vis);
|
||||
let _ = decode_tx.send(DecodedMessage::SstvProgress(
|
||||
trx_core::decode::SstvProgress {
|
||||
rig_id: None,
|
||||
ts_ms: Some(now_ms()),
|
||||
vis,
|
||||
mode: mode.to_string(),
|
||||
width,
|
||||
height,
|
||||
line: 0,
|
||||
line_data: None,
|
||||
state: Some(format!("Receiving {mode}")),
|
||||
},
|
||||
));
|
||||
}
|
||||
SstvEvent::Row { line, rgb } => {
|
||||
// One row per message: the picture is meant
|
||||
// to be watched arriving, and a row of a
|
||||
// 640-wide mode is under 2 kB.
|
||||
let _ = decode_tx.send(DecodedMessage::SstvProgress(
|
||||
trx_core::decode::SstvProgress {
|
||||
rig_id: None,
|
||||
ts_ms: Some(now_ms()),
|
||||
vis: 0,
|
||||
mode: String::new(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
line,
|
||||
line_data: Some({
|
||||
use base64::Engine as _;
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.encode(&rgb)
|
||||
}),
|
||||
state: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
SstvEvent::Complete(image) => {
|
||||
info!(
|
||||
"SSTV: {} picture, {} of {} lines",
|
||||
image.mode, image.lines, image.height
|
||||
);
|
||||
let freq_hz = state_rx.borrow().status.freq.hz;
|
||||
let msg = sstv_message(&image, &output_dir, freq_hz);
|
||||
if let DecodedMessage::Sstv(ref stored) = msg {
|
||||
histories.record_sstv_message(stored.clone());
|
||||
}
|
||||
let _ = decode_tx.send(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("SSTV decoder: dropped {} PCM frames", n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
changed = state_rx.changed() => {
|
||||
match changed {
|
||||
Ok(()) => {
|
||||
let state = state_rx.borrow();
|
||||
active = state.decoders.sstv_decode_enabled
|
||||
&& is_sstv_mode(&state.status.mode);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("SSTV decoder stopped");
|
||||
}
|
||||
|
||||
/// Milliseconds since the epoch.
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Virtual-channel audio support
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3078,6 +3319,8 @@ async fn handle_audio_client(
|
||||
DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
|
||||
DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
|
||||
DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
|
||||
DecodedMessage::Sstv(_) => AUDIO_MSG_SSTV_DECODE,
|
||||
DecodedMessage::SstvProgress(_) => AUDIO_MSG_SSTV_PROGRESS,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
||||
@@ -3110,6 +3353,8 @@ async fn handle_audio_client(
|
||||
DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
|
||||
DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
|
||||
DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
|
||||
DecodedMessage::Sstv(_) => AUDIO_MSG_SSTV_DECODE,
|
||||
DecodedMessage::SstvProgress(_) => AUDIO_MSG_SSTV_PROGRESS,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
||||
|
||||
+5
-1518
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user