Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3bcc8a84 | ||
|
|
86dd36312e | ||
|
|
15ff686542 | ||
|
|
b78c4a4dd4 | ||
|
|
f396e8f235 | ||
|
|
46c9827e8a | ||
|
|
0fc977f19f | ||
|
|
084f629b5b | ||
|
|
bc63ded583 | ||
|
|
cfaeb6ee15 | ||
|
|
76bcce8c54 | ||
|
|
88ed3da6cc | ||
|
|
7c69e0de08 | ||
|
|
fbc4f6e398 | ||
|
|
d42ca4f030 | ||
|
|
335922fecc | ||
|
|
bede2e34fe | ||
|
|
da58a004fe | ||
|
|
77b283cb78 | ||
|
|
6284747339 | ||
|
|
14ad6e241b | ||
|
|
06971ff65c | ||
|
|
18107ce07e | ||
|
|
a0b0c0ed81 | ||
|
|
a2c630a92b |
@@ -24,6 +24,12 @@ cargo test -p trx-core
|
||||
./target/release/trx-server --print-config > trx-server.toml
|
||||
./target/release/trx-client --print-config > trx-client.toml
|
||||
|
||||
# Validate a config without starting anything (reports every problem)
|
||||
./target/release/trx-server --check-config --config trx-rs.toml
|
||||
|
||||
# Regenerate trx-rs.toml.example after changing a config struct
|
||||
cargo run -p trx-config --example generate_example
|
||||
|
||||
# Run server
|
||||
./target/release/trx-server --config trx-server.toml
|
||||
# or via CLI args:
|
||||
@@ -41,7 +47,8 @@ This is a Cargo workspace. All crates live under `src/`:
|
||||
src/
|
||||
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
|
||||
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
|
||||
trx-app/ # Shared application helpers (config paths, logging init)
|
||||
trx-app/ # Shared application helpers (logging init, name normalization)
|
||||
trx-config/ # Client + server config structs, loader, validators (~2,500 LOC)
|
||||
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
|
||||
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
|
||||
trx-backend/ # Backend abstraction trait + factory + dummy
|
||||
|
||||
Generated
+43
-4
@@ -2412,6 +2412,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_ignored"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
@@ -3031,10 +3041,6 @@ dependencies = [
|
||||
name = "trx-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@@ -3115,6 +3121,7 @@ dependencies = [
|
||||
"toml",
|
||||
"tracing",
|
||||
"trx-app",
|
||||
"trx-config",
|
||||
"trx-core",
|
||||
"trx-frontend",
|
||||
"trx-frontend-http",
|
||||
@@ -3124,6 +3131,23 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-config"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"toml_edit 0.22.27",
|
||||
"tracing",
|
||||
"trx-core",
|
||||
"trx-decode-log",
|
||||
"trx-reporting",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-configurator"
|
||||
version = "0.1.0"
|
||||
@@ -3132,13 +3156,16 @@ dependencies = [
|
||||
"dialoguer",
|
||||
"tempfile",
|
||||
"tokio-serial",
|
||||
"toml",
|
||||
"toml_edit 0.22.27",
|
||||
"trx-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -3294,12 +3321,14 @@ dependencies = [
|
||||
"trx-app",
|
||||
"trx-aprs",
|
||||
"trx-backend",
|
||||
"trx-config",
|
||||
"trx-core",
|
||||
"trx-cw",
|
||||
"trx-decode-log",
|
||||
"trx-ftx",
|
||||
"trx-protocol",
|
||||
"trx-reporting",
|
||||
"trx-sstv",
|
||||
"trx-vdes",
|
||||
"trx-wefax",
|
||||
"trx-wspr",
|
||||
@@ -3307,6 +3336,16 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-sstv"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"png",
|
||||
"tracing",
|
||||
"trx-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-vdes"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -12,11 +12,13 @@ members = [
|
||||
"src/decoders/trx-ftx",
|
||||
"src/decoders/trx-rds",
|
||||
"src/decoders/trx-vdes",
|
||||
"src/decoders/trx-sstv",
|
||||
"src/decoders/trx-wefax",
|
||||
"src/decoders/trx-wspr",
|
||||
"src/trx-core",
|
||||
"src/trx-protocol",
|
||||
"src/trx-app",
|
||||
"src/trx-config",
|
||||
"src/trx-reporting",
|
||||
"src/trx-server",
|
||||
"src/trx-server/trx-backend",
|
||||
|
||||
@@ -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.
|
||||
+232
-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,111 @@ 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. |
|
||||
| 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.
|
||||
|
||||
---
|
||||
|
||||
## Tune Links
|
||||
|
||||
Every page of the web UI carries what the radio is doing in its address, so the
|
||||
URL in the address bar is always a link someone else can open:
|
||||
|
||||
```
|
||||
http://receiver.example:8080/?rig=sdr&f=14074000&mode=USB&bw=3000
|
||||
```
|
||||
|
||||
| Parameter | Meaning |
|
||||
|-----------|---------|
|
||||
| `f` | Frequency. Hz by default; `7074k` and `14.074M` also work. |
|
||||
| `mode` | Demodulation mode, e.g. `USB`, `CW`, `WFM`. |
|
||||
| `bw` | Filter bandwidth in Hz. Ignored by rigs without filter control. |
|
||||
| `rig` | Rig to select first, by id, on a multi-rig client. |
|
||||
|
||||
Opening such a link selects the rig, sets the mode, tunes, and applies the
|
||||
bandwidth, in that order — a mode change carries its own default bandwidth, so
|
||||
an explicit `bw` is applied last. Anything the rig cannot do (an unknown mode,
|
||||
a frequency outside its range) is reported and the rest of the link still
|
||||
applies. All four parameters are optional.
|
||||
|
||||
The link button in the top bar copies the current link to the clipboard. The
|
||||
address bar itself is updated as you tune, using `replaceState`, so sweeping
|
||||
the dial does not fill the browser's history.
|
||||
|
||||
Applying a link changes the radio, so it needs the `control` role; an `rx`
|
||||
session opens the page and says the link was not applied. Links describe the
|
||||
rig's own dial — while a tab is listening to a virtual channel the address is
|
||||
left as it was, rather than publishing a frequency the rig is not on.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-sstv"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../trx-core" }
|
||||
base64 = "0.22"
|
||||
png = "0.17"
|
||||
tracing = "0.1"
|
||||
@@ -0,0 +1,91 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Encode a test card, decode it back, write both as PNGs to a directory.
|
||||
//!
|
||||
//! `cargo run -p trx-sstv --example round_trip_png -- /tmp/out`
|
||||
|
||||
use trx_sstv::encode::{encode, Frame};
|
||||
use trx_sstv::mode::mode_for_vis;
|
||||
use trx_sstv::{ImageCanvas, SstvConfig, SstvDecoder, SstvEvent};
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).unwrap_or_else(|| ".".into());
|
||||
for vis in [44u8, 60, 12, 8, 95] {
|
||||
let mode = mode_for_vis(vis).expect("mode in table");
|
||||
let (w, h) = (usize::from(mode.width), usize::from(mode.height));
|
||||
let mut rgb = vec![0u8; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let at = (y * w + x) * 3;
|
||||
let (r, g, b) = if y < h / 3 {
|
||||
[
|
||||
(255u8, 255u8, 255u8),
|
||||
(255, 255, 0),
|
||||
(0, 255, 255),
|
||||
(0, 255, 0),
|
||||
(255, 0, 255),
|
||||
(255, 0, 0),
|
||||
(0, 0, 255),
|
||||
(0, 0, 0),
|
||||
][x * 8 / w]
|
||||
} else if y < 2 * h / 3 {
|
||||
let t = (x * 255 / w) as u8;
|
||||
(t, 255 - t, ((y * 255) / h) as u8)
|
||||
} else {
|
||||
// Diagonal stripes: a line-timing error shows up as a kink.
|
||||
if ((x + y) / 16) % 2 == 0 {
|
||||
(240, 240, 40)
|
||||
} else {
|
||||
(20, 20, 90)
|
||||
}
|
||||
};
|
||||
rgb[at] = r;
|
||||
rgb[at + 1] = g;
|
||||
rgb[at + 2] = b;
|
||||
}
|
||||
}
|
||||
let name = mode.name.replace(' ', "-");
|
||||
let mut sent = ImageCanvas::new(w, h);
|
||||
for y in 0..h {
|
||||
sent.put_row(y, &rgb[y * w * 3..(y + 1) * w * 3]);
|
||||
}
|
||||
std::fs::write(
|
||||
format!("{dir}/{name}-sent.png"),
|
||||
sent.to_png().expect("png"),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let frame = Frame {
|
||||
width: w,
|
||||
height: h,
|
||||
rgb: &rgb,
|
||||
};
|
||||
let mut audio = encode(mode, &frame, 48_000);
|
||||
audio.extend(std::iter::repeat_n(0.0, 4800));
|
||||
let mut decoder = SstvDecoder::new(48_000, SstvConfig::default());
|
||||
let mut got = None;
|
||||
for block in audio.chunks(1024) {
|
||||
for event in decoder.process_samples(block) {
|
||||
if let SstvEvent::Complete(image) = event {
|
||||
got = Some(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
let image = got.expect("no image decoded");
|
||||
let mut canvas = ImageCanvas::new(w, h);
|
||||
for y in 0..h {
|
||||
canvas.put_row(y, &image.rgb[y * w * 3..(y + 1) * w * 3]);
|
||||
}
|
||||
std::fs::write(
|
||||
format!("{dir}/{name}-decoded.png"),
|
||||
canvas.to_png().expect("png"),
|
||||
)
|
||||
.expect("write");
|
||||
println!(
|
||||
"{}: {} lines, complete={}",
|
||||
mode.name, image.lines, image.complete
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! SSTV decoder configuration.
|
||||
|
||||
/// Settings for [`crate::decoder::SstvDecoder`].
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SstvConfig {
|
||||
/// VIS code of a mode to assume when no header is heard, so tuning in
|
||||
/// part-way through a transmission still produces a picture. `None` means
|
||||
/// wait for a header, which is the safe default: guessing wrong yields a
|
||||
/// convincing image of nothing.
|
||||
pub force_mode: Option<u8>,
|
||||
/// Directory for saved PNGs. `None` keeps images in memory only.
|
||||
pub output_dir: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! The decoder: audio in, pictures out.
|
||||
//!
|
||||
//! Reception is a small state machine. It listens for a VIS header, and once
|
||||
//! one names a mode it walks the transmission a line at a time, sampling each
|
||||
//! scan at the offsets the mode's segment list gives. Every line is looked for
|
||||
//! at the time the mode says it should arrive, then nudged into place by the
|
||||
//! sync pulse actually found near it — a transmitter's clock and a receiver's
|
||||
//! sound card never agree exactly, and over two minutes of Martin M1 an
|
||||
//! uncorrected error of a few parts per million visibly shears the picture.
|
||||
//!
|
||||
//! Rows are emitted as they are decoded so a picture can be watched arriving,
|
||||
//! which is most of the appeal of the mode.
|
||||
|
||||
use crate::config::SstvConfig;
|
||||
use crate::demod::FreqDemod;
|
||||
use crate::image::ImageCanvas;
|
||||
use crate::mode::{level_from_hz, mode_for_vis, Channel, ColorModel, SstvMode};
|
||||
use crate::vis::find_vis;
|
||||
|
||||
/// Anything below this is the sync pulse rather than picture: black is 1500 Hz
|
||||
/// and sync is 1200 Hz, so the line sits between them.
|
||||
const SYNC_THRESHOLD_HZ: f32 = 1350.0;
|
||||
|
||||
/// How much of the signal to keep while hunting for a header. A header is 940
|
||||
/// ms; two seconds leaves room for one to straddle several blocks of audio.
|
||||
const SEARCH_HISTORY_MS: f64 = 2000.0;
|
||||
|
||||
/// From the start bit to the end of the stop bit: ten 30 ms cells. A header
|
||||
/// search must never retire a stretch shorter than this, or a start bit split
|
||||
/// across two blocks of audio is dismissed on half a view of it.
|
||||
const HEADER_SPAN_MS: f64 = 330.0;
|
||||
|
||||
/// How far from its predicted position a line's sync pulse is looked for.
|
||||
const SYNC_SEARCH_MS: f64 = 12.0;
|
||||
|
||||
/// Fraction of the observed timing error applied to the next line. Damped,
|
||||
/// because a sync pulse found in noise is worth less than the prediction.
|
||||
const SYNC_CORRECTION: f64 = 0.45;
|
||||
|
||||
/// Consecutive lines with no sync pulse anywhere near the prediction before
|
||||
/// the transmission is taken to have ended.
|
||||
const MISSING_SYNC_LIMIT: u32 = 8;
|
||||
|
||||
/// What the decoder has to say.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SstvEvent {
|
||||
/// A header was decoded and reception has begun.
|
||||
Started {
|
||||
vis: u8,
|
||||
mode: &'static str,
|
||||
width: u16,
|
||||
height: u16,
|
||||
},
|
||||
/// One image row is ready, as RGB triples.
|
||||
Row { line: u16, rgb: Vec<u8> },
|
||||
/// Reception finished — at the bottom of the frame, or because the signal
|
||||
/// went away. Carries the picture either way.
|
||||
Complete(SstvImage),
|
||||
}
|
||||
|
||||
/// A received picture.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SstvImage {
|
||||
pub vis: u8,
|
||||
pub mode: &'static str,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
/// Rows actually received, which is the height only if it ran to the end.
|
||||
pub lines: u16,
|
||||
/// Whether the whole frame arrived.
|
||||
pub complete: bool,
|
||||
/// RGB triples, `width * height * 3` bytes. Rows never received are grey.
|
||||
pub rgb: Vec<u8>,
|
||||
/// When reception started, in milliseconds since the epoch.
|
||||
pub started_ms: i64,
|
||||
}
|
||||
|
||||
impl SstvImage {
|
||||
/// The picture as a canvas, for saving or encoding.
|
||||
pub fn canvas(&self) -> ImageCanvas {
|
||||
let width = usize::from(self.width);
|
||||
let mut canvas = ImageCanvas::new(width, usize::from(self.height));
|
||||
for (y, row) in self.rgb.chunks(width * 3).enumerate() {
|
||||
canvas.put_row(y, row);
|
||||
}
|
||||
canvas
|
||||
}
|
||||
|
||||
/// The picture as PNG bytes.
|
||||
pub fn to_png(&self) -> Result<Vec<u8>, String> {
|
||||
self.canvas().to_png()
|
||||
}
|
||||
|
||||
/// The picture as a base64 PNG, for the journey to a client.
|
||||
pub fn to_png_base64(&self) -> Result<String, String> {
|
||||
self.canvas().to_png_base64()
|
||||
}
|
||||
|
||||
/// Write the picture into `dir`, named for when and where it arrived.
|
||||
pub fn save_png(
|
||||
&self,
|
||||
dir: &std::path::Path,
|
||||
freq_hz: u64,
|
||||
) -> Result<std::path::PathBuf, String> {
|
||||
self.canvas()
|
||||
.save_png(dir, freq_hz, self.mode, &stamp(self.started_ms))
|
||||
}
|
||||
}
|
||||
|
||||
/// `YYYYMMDDTHHMMSSZ` for a millisecond timestamp, for file names that sort.
|
||||
fn stamp(ms: i64) -> String {
|
||||
let secs = ms.div_euclid(1000);
|
||||
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
let (hour, minute, second) = (rest / 3600, (rest % 3600) / 60, rest % 60);
|
||||
format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}Z")
|
||||
}
|
||||
|
||||
/// Days since the Unix epoch to a calendar date (Howard Hinnant's algorithm).
|
||||
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
enum State {
|
||||
/// Listening for a header.
|
||||
Searching,
|
||||
Receiving(Box<Reception>),
|
||||
}
|
||||
|
||||
struct Reception {
|
||||
mode: &'static SstvMode,
|
||||
canvas: ImageCanvas,
|
||||
/// Absolute sample index at which the next transmitted line begins.
|
||||
next_line: f64,
|
||||
/// Next image row to write.
|
||||
row: u16,
|
||||
started_ms: i64,
|
||||
missing_syncs: u32,
|
||||
/// Set until the first line has been decoded. A VIS header ends exactly
|
||||
/// where the picture begins, so the first line is already aligned — and a
|
||||
/// search would find the header's own 30 ms stop bit, which is at the sync
|
||||
/// frequency and sits immediately before the picture.
|
||||
first_line: bool,
|
||||
/// Robot 36 sends one chroma channel per line and expects the decoder to
|
||||
/// carry the other over from the line before.
|
||||
last_chroma_r: Option<Vec<u8>>,
|
||||
last_chroma_b: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub struct SstvDecoder {
|
||||
sample_rate: u32,
|
||||
config: SstvConfig,
|
||||
demod: FreqDemod,
|
||||
/// Instantaneous frequency, one entry per audio sample. Delayed by the
|
||||
/// demodulator's group delay, which is constant and so shifts the whole
|
||||
/// stream — header and lines alike — without disturbing their spacing.
|
||||
freqs: Vec<f32>,
|
||||
/// Absolute index of `freqs[0]`, so positions survive the buffer being
|
||||
/// trimmed.
|
||||
base: u64,
|
||||
/// Absolute index the header search has already covered.
|
||||
searched_to: u64,
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl SstvDecoder {
|
||||
pub fn new(sample_rate: u32, config: SstvConfig) -> Self {
|
||||
Self {
|
||||
sample_rate,
|
||||
config,
|
||||
demod: FreqDemod::new(sample_rate),
|
||||
freqs: Vec::new(),
|
||||
base: 0,
|
||||
searched_to: 0,
|
||||
state: State::Searching,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a picture is currently arriving.
|
||||
pub fn is_receiving(&self) -> bool {
|
||||
matches!(self.state, State::Receiving(_))
|
||||
}
|
||||
|
||||
/// Feed a block of mono audio. Returns whatever it produced.
|
||||
pub fn process_samples(&mut self, samples: &[f32]) -> Vec<SstvEvent> {
|
||||
self.demod.process_into(samples, &mut self.freqs);
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
let progressed = match self.state {
|
||||
State::Searching => self.try_start(&mut events),
|
||||
State::Receiving(_) => self.try_line(&mut events),
|
||||
};
|
||||
if !progressed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.trim();
|
||||
events
|
||||
}
|
||||
|
||||
/// Abandon a reception in progress, returning the picture so far.
|
||||
pub fn reset(&mut self) -> Vec<SstvEvent> {
|
||||
let mut events = Vec::new();
|
||||
if let State::Receiving(reception) = std::mem::replace(&mut self.state, State::Searching) {
|
||||
events.push(SstvEvent::Complete(finish(&reception)));
|
||||
}
|
||||
self.demod.reset();
|
||||
self.freqs.clear();
|
||||
self.base = 0;
|
||||
self.searched_to = 0;
|
||||
events
|
||||
}
|
||||
|
||||
/// Look for a header, or for a bare sync pulse when the mode is forced.
|
||||
fn try_start(&mut self, events: &mut Vec<SstvEvent>) -> bool {
|
||||
let from = self.searched_to.saturating_sub(self.base) as usize;
|
||||
if from >= self.freqs.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(hit) = find_vis(&self.freqs, self.sample_rate, from) {
|
||||
if let Some(mode) = mode_for_vis(hit.code) {
|
||||
self.begin(mode, self.base + hit.image_start as u64, events);
|
||||
return true;
|
||||
}
|
||||
// A header that parses but names a mode this decoder does not know
|
||||
// is still a header: skip past it rather than finding it again.
|
||||
tracing::debug!(vis = hit.code, "SSTV: unsupported mode");
|
||||
self.searched_to = self.base + hit.image_start as u64;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tuning in mid-transmission means no header to find. With a mode
|
||||
// named in the configuration, the first sync pulse is enough to start.
|
||||
if let Some(mode) = self.config.force_mode.and_then(mode_for_vis) {
|
||||
if let Some(sync) = self.find_sync(from, self.freqs.len(), mode) {
|
||||
let line_start = (self.base + sync as u64) as f64
|
||||
- ms_to_samples(mode.sync_offset_ms, self.sample_rate);
|
||||
self.begin_at(mode, line_start.max(0.0), events);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing yet. Rewind the cursor by a header's worth of samples before
|
||||
// marking the buffer searched: audio arrives in blocks of a few
|
||||
// milliseconds, so the search regularly runs over a start bit that is
|
||||
// only half here. Advancing past it would retire the header for good
|
||||
// on the strength of a partial view of it.
|
||||
let unsearchable = ms_to_samples(HEADER_SPAN_MS, self.sample_rate) as u64;
|
||||
let end = self.base + self.freqs.len() as u64;
|
||||
self.searched_to = self.searched_to.max(end.saturating_sub(unsearchable));
|
||||
false
|
||||
}
|
||||
|
||||
fn begin(&mut self, mode: &'static SstvMode, image_start: u64, events: &mut Vec<SstvEvent>) {
|
||||
self.begin_at(mode, image_start as f64, events);
|
||||
}
|
||||
|
||||
fn begin_at(&mut self, mode: &'static SstvMode, line_start: f64, events: &mut Vec<SstvEvent>) {
|
||||
events.push(SstvEvent::Started {
|
||||
vis: mode.vis,
|
||||
mode: mode.name,
|
||||
width: mode.width,
|
||||
height: mode.height,
|
||||
});
|
||||
self.state = State::Receiving(Box::new(Reception {
|
||||
mode,
|
||||
canvas: ImageCanvas::new(usize::from(mode.width), usize::from(mode.height)),
|
||||
next_line: line_start,
|
||||
row: 0,
|
||||
started_ms: now_ms(),
|
||||
missing_syncs: 0,
|
||||
first_line: true,
|
||||
last_chroma_r: None,
|
||||
last_chroma_b: None,
|
||||
}));
|
||||
}
|
||||
|
||||
/// Decode one transmitted line, if all of it has arrived.
|
||||
fn try_line(&mut self, events: &mut Vec<SstvEvent>) -> bool {
|
||||
let State::Receiving(reception) = &mut self.state else {
|
||||
return false;
|
||||
};
|
||||
let mode = reception.mode;
|
||||
let line_samples = ms_to_samples(mode.line_ms, self.sample_rate);
|
||||
let margin = ms_to_samples(SYNC_SEARCH_MS, self.sample_rate);
|
||||
|
||||
let start = reception.next_line;
|
||||
// Enough for the line's own scans, and for the sync search to reach as
|
||||
// far ahead as it looks — no further. Demanding the search margin past
|
||||
// the end of every line would cost the last line of every picture,
|
||||
// which is exactly where the transmission stops.
|
||||
let sync_reach = start + ms_to_samples(mode.sync_offset_ms, self.sample_rate) + margin;
|
||||
let end = (start + line_samples).max(sync_reach);
|
||||
let available = (self.base + self.freqs.len() as u64) as f64;
|
||||
if end > available {
|
||||
return false;
|
||||
}
|
||||
// The line may begin before what is still buffered if the caller fed a
|
||||
// huge block; nothing can be done about that but skip forward.
|
||||
if start < self.base as f64 {
|
||||
reception.next_line = self.base as f64;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Line up on the sync pulse near where this line is predicted to be.
|
||||
let expected_sync = start + ms_to_samples(mode.sync_offset_ms, self.sample_rate);
|
||||
let from = (expected_sync - margin).max(self.base as f64) as u64;
|
||||
let to = (expected_sync + margin) as u64;
|
||||
let searching = !matches!(&self.state, State::Receiving(r) if r.first_line);
|
||||
let found = if searching {
|
||||
self.find_sync_between(from, to, mode)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let State::Receiving(reception) = &mut self.state else {
|
||||
return false;
|
||||
};
|
||||
let start = match found {
|
||||
Some(sync_at) => {
|
||||
reception.missing_syncs = 0;
|
||||
let error = sync_at as f64 - expected_sync;
|
||||
reception.next_line += error * SYNC_CORRECTION;
|
||||
reception.next_line
|
||||
}
|
||||
None if reception.first_line => start,
|
||||
None => {
|
||||
reception.missing_syncs += 1;
|
||||
start
|
||||
}
|
||||
};
|
||||
|
||||
if reception.missing_syncs >= MISSING_SYNC_LIMIT {
|
||||
let image = finish(reception);
|
||||
self.state = State::Searching;
|
||||
self.searched_to = self.base + self.freqs.len() as u64;
|
||||
events.push(SstvEvent::Complete(image));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sample every scan of the line, then colour the rows.
|
||||
let mut scans: Vec<(Channel, Vec<u8>)> = Vec::new();
|
||||
for (channel, offset_ms, ms) in mode.scans() {
|
||||
let pixels = mode.scan_pixels(channel);
|
||||
let at = start + ms_to_samples(offset_ms, self.sample_rate);
|
||||
let values = sample_scan(&self.freqs, self.base, at, ms, pixels, self.sample_rate);
|
||||
scans.push((channel, values));
|
||||
}
|
||||
|
||||
let State::Receiving(reception) = &mut self.state else {
|
||||
return false;
|
||||
};
|
||||
let rows = compose_rows(reception, &scans);
|
||||
for (offset, row) in rows.into_iter().enumerate() {
|
||||
let line = reception.row + offset as u16;
|
||||
reception.canvas.put_row(usize::from(line), &row);
|
||||
events.push(SstvEvent::Row { line, rgb: row });
|
||||
}
|
||||
reception.first_line = false;
|
||||
reception.row += mode.lines_per_transmission;
|
||||
reception.next_line += line_samples;
|
||||
|
||||
if reception.row >= mode.height {
|
||||
let image = finish(reception);
|
||||
self.state = State::Searching;
|
||||
self.searched_to = self.base + self.freqs.len() as u64;
|
||||
events.push(SstvEvent::Complete(image));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// First sync pulse of about the right length in `freqs[from..to]`,
|
||||
/// as an index of its leading edge.
|
||||
///
|
||||
/// Works on a smoothed copy of the window: a sync pulse is 1200 Hz, where
|
||||
/// the raw per-sample estimate swings by ±95 Hz, so single samples cross
|
||||
/// and re-cross the threshold throughout a pulse and no run is ever long
|
||||
/// enough. Pixels are sampled from the raw signal, where averaging over
|
||||
/// the pixel does the same job without blurring across its edges.
|
||||
fn find_sync(&self, from: usize, to: usize, mode: &SstvMode) -> Option<usize> {
|
||||
let want = sync_ms(mode);
|
||||
let min_run = (ms_to_samples(want, self.sample_rate) * 0.6) as usize;
|
||||
// A sync pulse ends. Silence and a dead carrier demodulate to near
|
||||
// zero, which is below the threshold too, and without an upper bound a
|
||||
// decoder left running on an empty channel finds sync everywhere and
|
||||
// fills the picture with noise it invented.
|
||||
let max_run = (ms_to_samples(want, self.sample_rate) * 3.0) as usize;
|
||||
let to = to.min(self.freqs.len());
|
||||
if from >= to {
|
||||
return None;
|
||||
}
|
||||
// Reach past the end of the search window by a whole pulse: a sync
|
||||
// starting at the last moment the window allows still has to be
|
||||
// measurable to its full length, or it is rejected for being short and
|
||||
// the line it belongs to goes unaligned.
|
||||
let pad = ms_to_samples(want + 2.0, self.sample_rate) as usize;
|
||||
let window_from = from.saturating_sub(pad);
|
||||
let window_to = (to + pad).min(self.freqs.len());
|
||||
let smoothed = crate::demod::smooth(
|
||||
&self.freqs[window_from..window_to],
|
||||
ms_to_samples(1.0, self.sample_rate) as usize,
|
||||
);
|
||||
|
||||
let mut i = from - window_from;
|
||||
let scan_to = to - window_from;
|
||||
while i < scan_to {
|
||||
if smoothed[i] >= SYNC_THRESHOLD_HZ {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut run = 0;
|
||||
while i + run < smoothed.len() && smoothed[i + run] < SYNC_THRESHOLD_HZ {
|
||||
run += 1;
|
||||
}
|
||||
if run >= min_run && run <= max_run {
|
||||
return Some(window_from + i);
|
||||
}
|
||||
i += run.max(1);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// As [`Self::find_sync`], over an absolute index range.
|
||||
fn find_sync_between(&self, from: u64, to: u64, mode: &SstvMode) -> Option<u64> {
|
||||
let from = from.saturating_sub(self.base) as usize;
|
||||
let to = to.saturating_sub(self.base) as usize;
|
||||
if from >= self.freqs.len() {
|
||||
return None;
|
||||
}
|
||||
self.find_sync(from, to, mode)
|
||||
.map(|at| self.base + at as u64)
|
||||
}
|
||||
|
||||
/// Drop what is behind the decoder, so a long reception does not grow the
|
||||
/// buffer without bound.
|
||||
fn trim(&mut self) {
|
||||
let keep_from = match &self.state {
|
||||
State::Searching => {
|
||||
let history = ms_to_samples(SEARCH_HISTORY_MS, self.sample_rate) as u64;
|
||||
(self.base + self.freqs.len() as u64).saturating_sub(history)
|
||||
}
|
||||
State::Receiving(reception) => {
|
||||
let margin = ms_to_samples(SYNC_SEARCH_MS * 2.0, self.sample_rate) as u64;
|
||||
(reception.next_line as u64).saturating_sub(margin)
|
||||
}
|
||||
};
|
||||
if keep_from <= self.base {
|
||||
return;
|
||||
}
|
||||
let drop = (keep_from - self.base) as usize;
|
||||
if drop >= self.freqs.len() {
|
||||
self.freqs.clear();
|
||||
} else {
|
||||
self.freqs.drain(..drop);
|
||||
}
|
||||
self.base = keep_from;
|
||||
self.searched_to = self.searched_to.max(self.base);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sync pulse length of a mode, read out of its own segment list.
|
||||
fn sync_ms(mode: &SstvMode) -> f64 {
|
||||
mode.segments
|
||||
.iter()
|
||||
.find_map(|segment| match segment {
|
||||
crate::mode::Segment::Sync(ms) => Some(*ms),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(9.0)
|
||||
}
|
||||
|
||||
/// Milliseconds since the epoch, for stamping a picture with when it arrived.
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
fn ms_to_samples(ms: f64, sample_rate: u32) -> f64 {
|
||||
ms / 1000.0 * f64::from(sample_rate)
|
||||
}
|
||||
|
||||
/// Average the frequency across each pixel's window and turn it into a level.
|
||||
///
|
||||
/// The middle 60% of the window is used: a pixel's edges carry the
|
||||
/// demodulator's transition from the pixel before, and including them smears
|
||||
/// every edge in the picture.
|
||||
fn sample_scan(
|
||||
freqs: &[f32],
|
||||
base: u64,
|
||||
start: f64,
|
||||
ms: f64,
|
||||
pixels: usize,
|
||||
sample_rate: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(pixels);
|
||||
let width = ms_to_samples(ms, sample_rate) / pixels as f64;
|
||||
for x in 0..pixels {
|
||||
let pixel_start = start + width * x as f64;
|
||||
let from = (pixel_start + width * 0.2 - base as f64).max(0.0) as usize;
|
||||
let to = ((pixel_start + width * 0.8 - base as f64).max(0.0) as usize).min(freqs.len());
|
||||
// A pixel narrower than a sample still has to produce one.
|
||||
let (from, to) = if to > from {
|
||||
(from, to)
|
||||
} else {
|
||||
let at = (pixel_start - base as f64).max(0.0) as usize;
|
||||
(
|
||||
at.min(freqs.len().saturating_sub(1)),
|
||||
(at + 1).min(freqs.len()),
|
||||
)
|
||||
};
|
||||
if to <= from {
|
||||
out.push(0);
|
||||
continue;
|
||||
}
|
||||
let window = &freqs[from..to];
|
||||
let mean = window.iter().sum::<f32>() / window.len() as f32;
|
||||
out.push(level_from_hz(mean));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Turn one line's scans into image rows.
|
||||
fn compose_rows(reception: &mut Reception, scans: &[(Channel, Vec<u8>)]) -> Vec<Vec<u8>> {
|
||||
let mode = reception.mode;
|
||||
let width = usize::from(mode.width);
|
||||
let find = |channel: Channel| scans.iter().find(|(c, _)| *c == channel).map(|(_, v)| v);
|
||||
|
||||
match mode.color {
|
||||
ColorModel::Rgb => {
|
||||
let red = find(Channel::Red);
|
||||
let green = find(Channel::Green);
|
||||
let blue = find(Channel::Blue);
|
||||
let mut row = vec![0u8; width * 3];
|
||||
for x in 0..width {
|
||||
row[x * 3] = red.and_then(|c| c.get(x).copied()).unwrap_or(0);
|
||||
row[x * 3 + 1] = green.and_then(|c| c.get(x).copied()).unwrap_or(0);
|
||||
row[x * 3 + 2] = blue.and_then(|c| c.get(x).copied()).unwrap_or(0);
|
||||
}
|
||||
vec![row]
|
||||
}
|
||||
ColorModel::YCrCb => {
|
||||
let luma = find(Channel::LumaOdd).cloned().unwrap_or_default();
|
||||
let cr = find(Channel::ChromaR).cloned().unwrap_or_default();
|
||||
let cb = find(Channel::ChromaB).cloned().unwrap_or_default();
|
||||
vec![ycrcb_row(&luma, &cr, &cb, width)]
|
||||
}
|
||||
ColorModel::YCrCbAlternating => {
|
||||
// This line carries one chroma channel; the other is the one from
|
||||
// the line before, which is what the mode expects a decoder to do.
|
||||
let luma = find(Channel::LumaOdd).cloned().unwrap_or_default();
|
||||
let chroma = find(Channel::ChromaAlternating)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if reception.row.is_multiple_of(2) {
|
||||
reception.last_chroma_r = Some(chroma);
|
||||
} else {
|
||||
reception.last_chroma_b = Some(chroma);
|
||||
}
|
||||
let neutral = vec![128u8; luma.len().max(1) / 2];
|
||||
let cr = reception
|
||||
.last_chroma_r
|
||||
.clone()
|
||||
.unwrap_or_else(|| neutral.clone());
|
||||
let cb = reception.last_chroma_b.clone().unwrap_or(neutral);
|
||||
vec![ycrcb_row(&luma, &cr, &cb, width)]
|
||||
}
|
||||
ColorModel::YCrCbPaired => {
|
||||
let odd = find(Channel::LumaOdd).cloned().unwrap_or_default();
|
||||
let even = find(Channel::LumaEven).cloned().unwrap_or_default();
|
||||
let cr = find(Channel::ChromaR).cloned().unwrap_or_default();
|
||||
let cb = find(Channel::ChromaB).cloned().unwrap_or_default();
|
||||
vec![
|
||||
ycrcb_row(&odd, &cr, &cb, width),
|
||||
ycrcb_row(&even, &cr, &cb, width),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One RGB row from luminance and chrominance, stretching the chroma scans
|
||||
/// across the width when they are narrower than it.
|
||||
fn ycrcb_row(luma: &[u8], cr: &[u8], cb: &[u8], width: usize) -> Vec<u8> {
|
||||
let mut row = vec![0u8; width * 3];
|
||||
let pick = |channel: &[u8], x: usize| -> f32 {
|
||||
if channel.is_empty() {
|
||||
return 128.0;
|
||||
}
|
||||
let at = x * channel.len() / width.max(1);
|
||||
f32::from(channel[at.min(channel.len() - 1)])
|
||||
};
|
||||
for x in 0..width {
|
||||
let y = if luma.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
let at = x * luma.len() / width.max(1);
|
||||
f32::from(luma[at.min(luma.len() - 1)])
|
||||
};
|
||||
let (r, g, b) = ycrcb_to_rgb(y, pick(cr, x), pick(cb, x));
|
||||
row[x * 3] = r;
|
||||
row[x * 3 + 1] = g;
|
||||
row[x * 3 + 2] = b;
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
/// The inverse of the studio-swing conversion SSTV encoders use.
|
||||
fn ycrcb_to_rgb(y: f32, cr: f32, cb: f32) -> (u8, u8, u8) {
|
||||
let r = 298.082 * y / 256.0 + 408.583 * cr / 256.0 - 222.921;
|
||||
let g = 298.082 * y / 256.0 - 100.291 * cb / 256.0 - 208.120 * cr / 256.0 + 135.576;
|
||||
let b = 298.082 * y / 256.0 + 516.412 * cb / 256.0 - 276.836;
|
||||
(
|
||||
r.clamp(0.0, 255.0) as u8,
|
||||
g.clamp(0.0, 255.0) as u8,
|
||||
b.clamp(0.0, 255.0) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
fn finish(reception: &Reception) -> SstvImage {
|
||||
SstvImage {
|
||||
vis: reception.mode.vis,
|
||||
mode: reception.mode.name,
|
||||
width: reception.mode.width,
|
||||
height: reception.mode.height,
|
||||
lines: reception.canvas.filled_rows() as u16,
|
||||
complete: reception.row >= reception.mode.height,
|
||||
rgb: reception.canvas.rgb().to_vec(),
|
||||
started_ms: reception.started_ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_stamps_are_utc_and_sort_in_time_order() {
|
||||
// Known instants, checked against `date -u -r <secs>`.
|
||||
assert_eq!(stamp(0), "19700101T000000Z");
|
||||
assert_eq!(stamp(1_000_000_000_000), "20010909T014640Z");
|
||||
assert_eq!(stamp(1_770_000_000_000), "20260202T024000Z");
|
||||
// Sorting the names sorts the pictures.
|
||||
assert!(stamp(1_770_000_000_000) < stamp(1_770_000_001_000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Instantaneous frequency estimation.
|
||||
//!
|
||||
//! SSTV carries every pixel as a frequency between 1500 and 2300 Hz, and every
|
||||
//! line boundary as a 1200 Hz pulse, so one measurement serves the whole
|
||||
//! decoder: the frequency of the signal at each sample. A Hilbert transform
|
||||
//! FIR forms the analytic signal and the phase difference between consecutive
|
||||
//! samples gives the frequency.
|
||||
//!
|
||||
//! The same approach drives the WEFAX decoder, which maps the result straight
|
||||
//! to luminance. Here the frequency itself is the output, because the VIS
|
||||
//! header and the sync detector read tones far outside the pixel band.
|
||||
//!
|
||||
//! Block-based linear processing, per `docs/Optimization-Guidelines.md`: the
|
||||
//! FIR runs over a contiguous `[tail | samples]` buffer so the inner loop is
|
||||
//! straight indexing the compiler can vectorise.
|
||||
|
||||
use std::f32::consts::PI;
|
||||
|
||||
/// Taps for the Hilbert transform FIR. Odd, so the delay is a whole sample.
|
||||
const HILBERT_TAPS: usize = 65;
|
||||
|
||||
/// Group delay of the FIR, in samples.
|
||||
const HILBERT_DELAY: usize = HILBERT_TAPS / 2;
|
||||
|
||||
/// Taps for the input band-pass. Long enough to be worth having, short enough
|
||||
/// that its delay is a couple of milliseconds.
|
||||
const BANDPASS_TAPS: usize = 127;
|
||||
|
||||
/// The band SSTV lives in: sync at 1200 Hz, black at 1500, white at 2300.
|
||||
const BAND_LOW_HZ: f32 = 900.0;
|
||||
const BAND_HIGH_HZ: f32 = 2700.0;
|
||||
|
||||
/// Produces instantaneous frequency in Hz from real audio samples.
|
||||
pub struct FreqDemod {
|
||||
/// Band-pass, applied first. A phase-difference frequency detector answers
|
||||
/// whatever is loudest, so hiss outside the SSTV band steers the estimate
|
||||
/// even when the signal is much stronger — the picture tears rather than
|
||||
/// grows grainy. Every real decoder filters to the band first.
|
||||
bandpass: Vec<f32>,
|
||||
bandpass_tail: Vec<f32>,
|
||||
coeffs: [f32; HILBERT_TAPS],
|
||||
/// The last `HILBERT_TAPS - 1` input samples, priming the next block.
|
||||
tail: Vec<f32>,
|
||||
prev_i: f32,
|
||||
prev_q: f32,
|
||||
/// `sample_rate / 2π`, the constant turning phase step into Hz.
|
||||
hz_per_radian: f32,
|
||||
}
|
||||
|
||||
impl FreqDemod {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
Self {
|
||||
bandpass: design_bandpass_fir(sample_rate),
|
||||
bandpass_tail: vec![0.0; BANDPASS_TAPS - 1],
|
||||
coeffs: design_hilbert_fir(),
|
||||
tail: vec![0.0; HILBERT_TAPS - 1],
|
||||
prev_i: 0.0,
|
||||
prev_q: 0.0,
|
||||
hz_per_radian: sample_rate as f32 / (2.0 * PI),
|
||||
}
|
||||
}
|
||||
|
||||
/// Band-pass a block, carrying the filter's state across the seam.
|
||||
fn filter(&mut self, samples: &[f32]) -> Vec<f32> {
|
||||
let taps = BANDPASS_TAPS;
|
||||
let tail_len = taps - 1;
|
||||
let mut work = Vec::with_capacity(tail_len + samples.len());
|
||||
work.extend_from_slice(&self.bandpass_tail);
|
||||
work.extend_from_slice(samples);
|
||||
|
||||
let mut out = Vec::with_capacity(samples.len());
|
||||
for i in 0..samples.len() {
|
||||
let window = &work[i..i + taps];
|
||||
let mut acc = 0.0f32;
|
||||
for k in 0..taps {
|
||||
acc += self.bandpass[k] * window[taps - 1 - k];
|
||||
}
|
||||
out.push(acc);
|
||||
}
|
||||
let work_len = work.len();
|
||||
self.bandpass_tail
|
||||
.copy_from_slice(&work[work_len - tail_len..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Frequency in Hz for each input sample, appended to `out`.
|
||||
///
|
||||
/// Output is delayed by the FIR's group delay, which is constant and so
|
||||
/// affects only the absolute timing of the whole stream, not the spacing
|
||||
/// between the events in it.
|
||||
pub fn process_into(&mut self, samples: &[f32], out: &mut Vec<f32>) {
|
||||
if samples.is_empty() {
|
||||
return;
|
||||
}
|
||||
let samples = self.filter(samples);
|
||||
let samples = samples.as_slice();
|
||||
let taps = HILBERT_TAPS;
|
||||
let tail_len = taps - 1;
|
||||
|
||||
let mut work = Vec::with_capacity(tail_len + samples.len());
|
||||
work.extend_from_slice(&self.tail);
|
||||
work.extend_from_slice(samples);
|
||||
|
||||
out.reserve(samples.len());
|
||||
for i in 0..samples.len() {
|
||||
let window = &work[i..i + taps];
|
||||
let mut q = 0.0f32;
|
||||
for k in 0..taps {
|
||||
q += self.coeffs[k] * window[taps - 1 - k];
|
||||
}
|
||||
// In phase with the quadrature output: the input, delayed by the
|
||||
// FIR's own group delay.
|
||||
let i_val = window[HILBERT_DELAY];
|
||||
|
||||
// f = |arg(z[n] · conj(z[n-1]))| · fs / 2π
|
||||
let di = i_val * self.prev_i + q * self.prev_q;
|
||||
let dq = q * self.prev_i - i_val * self.prev_q;
|
||||
out.push(dq.atan2(di).abs() * self.hz_per_radian);
|
||||
|
||||
self.prev_i = i_val;
|
||||
self.prev_q = q;
|
||||
}
|
||||
|
||||
let work_len = work.len();
|
||||
self.tail.copy_from_slice(&work[work_len - tail_len..]);
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.bandpass_tail.fill(0.0);
|
||||
self.tail.fill(0.0);
|
||||
self.prev_i = 0.0;
|
||||
self.prev_q = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Boxcar mean over `window` samples, centred, returning one value per input.
|
||||
///
|
||||
/// The per-sample estimate ripples — badly at the low end of the band, where
|
||||
/// the Hilbert approximation is weakest: a clean 1200 Hz tone reads anywhere
|
||||
/// between 1110 and 1300 Hz sample to sample, though its mean is exact. Pixels
|
||||
/// are averaged over their own window and so come out right regardless, but
|
||||
/// anything that classifies a single sample by frequency — the VIS bits, the
|
||||
/// sync pulses — has to look at a mean or it is reading the ripple.
|
||||
pub fn smooth(freqs: &[f32], window: usize) -> Vec<f32> {
|
||||
let window = window.max(1);
|
||||
if freqs.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Prefix sums in f64: a minute of audio is three million samples, and a
|
||||
// running f32 total drifts long before that.
|
||||
let mut prefix = Vec::with_capacity(freqs.len() + 1);
|
||||
prefix.push(0.0f64);
|
||||
for &freq in freqs {
|
||||
prefix.push(prefix[prefix.len() - 1] + f64::from(freq));
|
||||
}
|
||||
let half = window / 2;
|
||||
(0..freqs.len())
|
||||
.map(|i| {
|
||||
// Shrinks at the ends rather than reaching past them.
|
||||
let from = i.saturating_sub(half);
|
||||
let to = (i + window - half).min(freqs.len());
|
||||
((prefix[to] - prefix[from]) / (to - from) as f64) as f32
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Windowed-sinc band-pass over the SSTV band, Hamming-windowed and
|
||||
/// linear-phase, so every frequency in the band is delayed alike.
|
||||
fn design_bandpass_fir(sample_rate: u32) -> Vec<f32> {
|
||||
let sr = sample_rate as f64;
|
||||
let low = f64::from(BAND_LOW_HZ) / sr;
|
||||
let high = (f64::from(BAND_HIGH_HZ) / sr).min(0.499);
|
||||
let m = (BANDPASS_TAPS - 1) as f64;
|
||||
let mid = m / 2.0;
|
||||
let sinc = |x: f64| {
|
||||
if x.abs() < 1e-9 {
|
||||
1.0
|
||||
} else {
|
||||
(std::f64::consts::PI * x).sin() / (std::f64::consts::PI * x)
|
||||
}
|
||||
};
|
||||
let mut coeffs = Vec::with_capacity(BANDPASS_TAPS);
|
||||
for i in 0..BANDPASS_TAPS {
|
||||
let n = i as f64 - mid;
|
||||
// Difference of two low-passes is a band-pass.
|
||||
let ideal = 2.0 * high * sinc(2.0 * high * n) - 2.0 * low * sinc(2.0 * low * n);
|
||||
let window = 0.54 - 0.46 * (2.0 * std::f64::consts::PI * i as f64 / m).cos();
|
||||
coeffs.push((ideal * window) as f32);
|
||||
}
|
||||
coeffs
|
||||
}
|
||||
|
||||
/// Type III FIR approximating a 90° phase shift: h[n] = 2/(πn) for odd n,
|
||||
/// Blackman-windowed. Independent of sample rate, so the decoder can run at
|
||||
/// whatever rate the audio arrives in.
|
||||
fn design_hilbert_fir() -> [f32; HILBERT_TAPS] {
|
||||
let mut coeffs = [0.0f32; HILBERT_TAPS];
|
||||
let m = (HILBERT_TAPS - 1) as f64;
|
||||
let mid = m / 2.0;
|
||||
for (i, coeff) in coeffs.iter_mut().enumerate() {
|
||||
let n = i as f64 - mid;
|
||||
let ni = n.round() as i64;
|
||||
if ni != 0 && ni % 2 != 0 {
|
||||
let h = 2.0 / (std::f64::consts::PI * n);
|
||||
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / m).cos()
|
||||
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / m).cos();
|
||||
*coeff = (h * w) as f32;
|
||||
}
|
||||
}
|
||||
coeffs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tone(freq: f32, sample_rate: u32, samples: usize) -> Vec<f32> {
|
||||
(0..samples)
|
||||
.map(|n| (2.0 * PI * freq * n as f32 / sample_rate as f32).sin())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Measured on the settled part of the output: the first `HILBERT_TAPS`
|
||||
/// samples are the filter filling up.
|
||||
fn measure(freq: f32, sample_rate: u32) -> f32 {
|
||||
let mut demod = FreqDemod::new(sample_rate);
|
||||
let mut out = Vec::new();
|
||||
demod.process_into(
|
||||
&tone(freq, sample_rate, sample_rate as usize / 10),
|
||||
&mut out,
|
||||
);
|
||||
let settled = &out[HILBERT_TAPS * 2..];
|
||||
settled.iter().sum::<f32>() / settled.len() as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_tones_sstv_is_made_of() {
|
||||
for rate in [8000u32, 11025, 44100, 48000] {
|
||||
for freq in [1200.0f32, 1500.0, 1900.0, 2300.0] {
|
||||
let measured = measure(freq, rate);
|
||||
assert!(
|
||||
(measured - freq).abs() < 5.0,
|
||||
"{rate} Hz: {freq} Hz tone measured as {measured:.1} Hz",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks are whatever size the audio pipeline hands over, and a tone that
|
||||
/// straddles two of them must not produce a discontinuity at the seam.
|
||||
#[test]
|
||||
fn block_boundaries_do_not_disturb_the_estimate() {
|
||||
let rate = 48_000;
|
||||
let samples = tone(1900.0, rate, 9600);
|
||||
let mut whole = FreqDemod::new(rate);
|
||||
let mut expected = Vec::new();
|
||||
whole.process_into(&samples, &mut expected);
|
||||
|
||||
let mut split = FreqDemod::new(rate);
|
||||
let mut actual = Vec::new();
|
||||
for chunk in samples.chunks(137) {
|
||||
split.process_into(chunk, &mut actual);
|
||||
}
|
||||
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
for (i, (a, b)) in actual.iter().zip(&expected).enumerate() {
|
||||
assert!((a - b).abs() < 0.01, "sample {i}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follows_a_step_between_tones_within_a_pixel() {
|
||||
let rate = 48_000;
|
||||
let mut samples = tone(1500.0, rate, 4800);
|
||||
samples.extend(tone(2300.0, rate, 4800));
|
||||
let mut demod = FreqDemod::new(rate);
|
||||
let mut out = Vec::new();
|
||||
demod.process_into(&samples, &mut out);
|
||||
|
||||
// Well before the step it reads black; well after it, white. The step
|
||||
// itself takes the filter's length to pass through.
|
||||
let before = out[4800 - 200..4800 - 100].iter().sum::<f32>() / 100.0;
|
||||
let after = out[4800 + 200..4800 + 300].iter().sum::<f32>() / 100.0;
|
||||
assert!(
|
||||
(before - 1500.0).abs() < 10.0,
|
||||
"before the step: {before:.1} Hz"
|
||||
);
|
||||
assert!(
|
||||
(after - 2300.0).abs() < 10.0,
|
||||
"after the step: {after:.1} Hz"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Turning an image into an SSTV signal.
|
||||
//!
|
||||
//! This exists so the decoder can be held to a picture rather than to a
|
||||
//! description of one: the tests encode a known image, decode the audio back
|
||||
//! and compare. Nothing in the receive path uses it.
|
||||
//!
|
||||
//! It is written to the same mode table the decoder reads, which makes a
|
||||
//! round-trip a test of the decoder and not of the timings — the timings are
|
||||
//! checked separately, against the published line durations, in [`crate::mode`].
|
||||
|
||||
use crate::mode::{Channel, Segment, SstvMode, BLACK_HZ, SYNC_HZ, WHITE_HZ};
|
||||
|
||||
/// A stretch of constant tone.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Tone {
|
||||
pub hz: f32,
|
||||
pub ms: f64,
|
||||
}
|
||||
|
||||
/// Frequency for an 8-bit level: the inverse of [`crate::mode::level_from_hz`].
|
||||
pub fn hz_from_level(level: u8) -> f32 {
|
||||
BLACK_HZ + (WHITE_HZ - BLACK_HZ) * f32::from(level) / 255.0
|
||||
}
|
||||
|
||||
/// The tones of a VIS header announcing `vis`.
|
||||
pub fn vis_tones(vis: u8) -> Vec<Tone> {
|
||||
let mut tones = vec![
|
||||
Tone {
|
||||
hz: 1900.0,
|
||||
ms: 300.0,
|
||||
},
|
||||
Tone {
|
||||
hz: 1200.0,
|
||||
ms: 10.0,
|
||||
},
|
||||
Tone {
|
||||
hz: 1900.0,
|
||||
ms: 300.0,
|
||||
},
|
||||
Tone {
|
||||
hz: 1200.0,
|
||||
ms: 30.0,
|
||||
}, // start bit
|
||||
];
|
||||
let mut ones = 0;
|
||||
for bit in 0..7 {
|
||||
let set = vis & (1 << bit) != 0;
|
||||
if set {
|
||||
ones += 1;
|
||||
}
|
||||
tones.push(Tone {
|
||||
hz: if set { 1100.0 } else { 1300.0 },
|
||||
ms: 30.0,
|
||||
});
|
||||
}
|
||||
// Even parity over the seven data bits.
|
||||
tones.push(Tone {
|
||||
hz: if ones % 2 == 1 { 1100.0 } else { 1300.0 },
|
||||
ms: 30.0,
|
||||
});
|
||||
tones.push(Tone {
|
||||
hz: 1200.0,
|
||||
ms: 30.0,
|
||||
}); // stop bit
|
||||
tones
|
||||
}
|
||||
|
||||
/// RGB source for the encoder: `width * height * 3` bytes.
|
||||
pub struct Frame<'a> {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub rgb: &'a [u8],
|
||||
}
|
||||
|
||||
impl Frame<'_> {
|
||||
fn pixel(&self, x: usize, y: usize) -> (u8, u8, u8) {
|
||||
let x = x.min(self.width.saturating_sub(1));
|
||||
let y = y.min(self.height.saturating_sub(1));
|
||||
let at = (y * self.width + x) * 3;
|
||||
(self.rgb[at], self.rgb[at + 1], self.rgb[at + 2])
|
||||
}
|
||||
|
||||
/// The colour components SSTV actually sends, for a pixel.
|
||||
fn ycrcb(&self, x: usize, y: usize) -> (u8, u8, u8) {
|
||||
let (r, g, b) = self.pixel(x, y);
|
||||
let (r, g, b) = (f32::from(r), f32::from(g), f32::from(b));
|
||||
let y_val = 16.0 + (0.003_906 * ((65.738 * r) + (129.057 * g) + (25.064 * b)));
|
||||
let cr = 128.0 + (0.003_906 * ((112.439 * r) + (-94.154 * g) + (-18.285 * b)));
|
||||
let cb = 128.0 + (0.003_906 * ((-37.945 * r) + (-74.494 * g) + (112.439 * b)));
|
||||
(
|
||||
y_val.clamp(0.0, 255.0) as u8,
|
||||
cr.clamp(0.0, 255.0) as u8,
|
||||
cb.clamp(0.0, 255.0) as u8,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode `frame` in `mode`, returning the tones of the whole transmission.
|
||||
pub fn encode_tones(mode: &SstvMode, frame: &Frame<'_>) -> Vec<Tone> {
|
||||
let mut tones = vis_tones(mode.vis);
|
||||
let per_line = usize::from(mode.lines_per_transmission);
|
||||
let transmissions = usize::from(mode.height) / per_line;
|
||||
|
||||
for transmission in 0..transmissions {
|
||||
let top = transmission * per_line;
|
||||
for segment in mode.segments {
|
||||
match *segment {
|
||||
Segment::Sync(ms) => tones.push(Tone { hz: SYNC_HZ, ms }),
|
||||
Segment::Gap(ms) => tones.push(Tone { hz: BLACK_HZ, ms }),
|
||||
Segment::Scan { channel, ms } => {
|
||||
// A chroma scan carries fewer pixels than the image is
|
||||
// wide, and takes proportionally less time per pixel.
|
||||
let pixels = mode.scan_pixels(channel);
|
||||
let pixel_ms = ms / pixels as f64;
|
||||
for x in 0..pixels {
|
||||
let level = channel_level(mode, frame, channel, x, top, transmission);
|
||||
tones.push(Tone {
|
||||
hz: hz_from_level(level),
|
||||
ms: pixel_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tones
|
||||
}
|
||||
|
||||
fn channel_level(
|
||||
mode: &SstvMode,
|
||||
frame: &Frame<'_>,
|
||||
channel: Channel,
|
||||
x: usize,
|
||||
top: usize,
|
||||
transmission: usize,
|
||||
) -> u8 {
|
||||
match channel {
|
||||
Channel::Red => frame.pixel(x, top).0,
|
||||
Channel::Green => frame.pixel(x, top).1,
|
||||
Channel::Blue => frame.pixel(x, top).2,
|
||||
Channel::LumaOdd => frame.ycrcb(x, top).0,
|
||||
Channel::LumaEven => frame.ycrcb(x, top + 1).0,
|
||||
Channel::ChromaR => chroma(mode, frame, x, top, true),
|
||||
Channel::ChromaB => chroma(mode, frame, x, top, false),
|
||||
// Robot 36 sends R-Y on odd transmitted lines and B-Y on even ones.
|
||||
Channel::ChromaAlternating => chroma(mode, frame, x, top, transmission.is_multiple_of(2)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Chroma scans are half the width of the image in the Robot modes, so each
|
||||
/// value covers two pixels; PD averages the two image lines of the pair too.
|
||||
fn chroma(mode: &SstvMode, frame: &Frame<'_>, x: usize, top: usize, want_cr: bool) -> u8 {
|
||||
let scale = usize::from(mode.width) / mode.scan_pixels(Channel::ChromaR).max(1);
|
||||
let x0 = x * scale;
|
||||
let mut total = 0u32;
|
||||
let mut count = 0u32;
|
||||
let rows = usize::from(mode.lines_per_transmission);
|
||||
for row in 0..rows {
|
||||
for dx in 0..scale {
|
||||
let (_, cr, cb) = frame.ycrcb(x0 + dx, top + row);
|
||||
total += u32::from(if want_cr { cr } else { cb });
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
(total / count.max(1)) as u8
|
||||
}
|
||||
|
||||
/// Render tones to audio at `sample_rate`, with continuous phase so the
|
||||
/// demodulator sees no step at a tone boundary that isn't in the signal.
|
||||
pub fn render(tones: &[Tone], sample_rate: u32) -> Vec<f32> {
|
||||
let sr = f64::from(sample_rate);
|
||||
let mut out =
|
||||
Vec::with_capacity((tones.iter().map(|t| t.ms).sum::<f64>() / 1000.0 * sr) as usize);
|
||||
let mut phase = 0.0f64;
|
||||
// Each tone's *end* is rounded to a sample, rather than its length: a
|
||||
// pixel of 25.5 samples rounded up on its own puts a whole line 600
|
||||
// samples late by the end of it, which is a timing error no receiver
|
||||
// should have to chase and no transmitter would produce.
|
||||
let mut elapsed_ms = 0.0f64;
|
||||
let mut emitted = 0usize;
|
||||
for tone in tones {
|
||||
elapsed_ms += tone.ms;
|
||||
let end = (elapsed_ms / 1000.0 * sr).round() as usize;
|
||||
let samples = end.saturating_sub(emitted);
|
||||
emitted = end;
|
||||
let step = 2.0 * std::f64::consts::PI * f64::from(tone.hz) / sr;
|
||||
for _ in 0..samples {
|
||||
out.push(phase.sin() as f32);
|
||||
phase += step;
|
||||
if phase > std::f64::consts::TAU {
|
||||
phase -= std::f64::consts::TAU;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Encode a frame straight to audio.
|
||||
pub fn encode(mode: &SstvMode, frame: &Frame<'_>, sample_rate: u32) -> Vec<f32> {
|
||||
render(&encode_tones(mode, frame), sample_rate)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mode::{level_from_hz, mode_for_vis};
|
||||
|
||||
#[test]
|
||||
fn levels_survive_the_trip_through_frequency() {
|
||||
for level in [0u8, 1, 64, 128, 200, 255] {
|
||||
assert_eq!(level_from_hz(hz_from_level(level)), level);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transmission_lasts_as_long_as_the_mode_says() {
|
||||
let mode = mode_for_vis(44).expect("Martin M1");
|
||||
let rgb = vec![128u8; 320 * 256 * 3];
|
||||
let frame = Frame {
|
||||
width: 320,
|
||||
height: 256,
|
||||
rgb: &rgb,
|
||||
};
|
||||
let audio = encode(mode, &frame, 48_000);
|
||||
// Header plus 256 lines, within a line of the published duration.
|
||||
let header_s = 0.94;
|
||||
let expected = header_s + mode.frame_secs();
|
||||
let actual = audio.len() as f64 / 48_000.0;
|
||||
assert!(
|
||||
(actual - expected).abs() < mode.line_ms / 1000.0,
|
||||
"encoded {actual:.2} s, expected {expected:.2} s",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Assembling decoded lines into an image, and getting it out of the process.
|
||||
//!
|
||||
//! Rows arrive one at a time and the picture is worth looking at before it is
|
||||
//! finished, so the assembler holds a full-size RGB canvas from the start and
|
||||
//! fills it in. An unfinished frame is grey below the last decoded row rather
|
||||
//! than black, which reads as "not here yet" instead of "received as black".
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::Engine;
|
||||
|
||||
/// Value the canvas starts at: mid-grey, for rows not yet received.
|
||||
const UNWRITTEN: u8 = 96;
|
||||
|
||||
pub struct ImageCanvas {
|
||||
width: usize,
|
||||
height: usize,
|
||||
rgb: Vec<u8>,
|
||||
/// Highest row index written, plus one.
|
||||
filled_rows: usize,
|
||||
}
|
||||
|
||||
impl ImageCanvas {
|
||||
pub fn new(width: usize, height: usize) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
rgb: vec![UNWRITTEN; width * height * 3],
|
||||
filled_rows: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.height
|
||||
}
|
||||
|
||||
/// Rows written so far.
|
||||
pub fn filled_rows(&self) -> usize {
|
||||
self.filled_rows
|
||||
}
|
||||
|
||||
/// Write one row of RGB triples. Rows past the bottom of the image are
|
||||
/// dropped: a transmission that runs long is not a reason to grow.
|
||||
pub fn put_row(&mut self, y: usize, row: &[u8]) {
|
||||
if y >= self.height {
|
||||
return;
|
||||
}
|
||||
let at = y * self.width * 3;
|
||||
let take = row.len().min(self.width * 3);
|
||||
self.rgb[at..at + take].copy_from_slice(&row[..take]);
|
||||
self.filled_rows = self.filled_rows.max(y + 1);
|
||||
}
|
||||
|
||||
pub fn row(&self, y: usize) -> Option<&[u8]> {
|
||||
if y >= self.height {
|
||||
return None;
|
||||
}
|
||||
let at = y * self.width * 3;
|
||||
Some(&self.rgb[at..at + self.width * 3])
|
||||
}
|
||||
|
||||
pub fn rgb(&self) -> &[u8] {
|
||||
&self.rgb
|
||||
}
|
||||
|
||||
/// Encode the canvas as a PNG.
|
||||
pub fn to_png(&self) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut out, self.width as u32, self.height as u32);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder
|
||||
.write_header()
|
||||
.map_err(|e| format!("PNG header: {e}"))?;
|
||||
writer
|
||||
.write_image_data(&self.rgb)
|
||||
.map_err(|e| format!("PNG data: {e}"))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The PNG, base64-encoded for the journey to a browser.
|
||||
pub fn to_png_base64(&self) -> Result<String, String> {
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(self.to_png()?))
|
||||
}
|
||||
|
||||
/// Write the PNG into `dir`, named for when and where it was received.
|
||||
pub fn save_png(
|
||||
&self,
|
||||
dir: &Path,
|
||||
freq_hz: u64,
|
||||
mode_name: &str,
|
||||
stamp: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
|
||||
let slug: String = mode_name
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let path = dir.join(format!("SSTV_{stamp}_{freq_hz}_{slug}.png"));
|
||||
std::fs::write(&path, self.to_png()?)
|
||||
.map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rows_land_where_they_are_put_and_the_rest_stays_unwritten() {
|
||||
let mut canvas = ImageCanvas::new(4, 3);
|
||||
canvas.put_row(1, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
assert_eq!(canvas.row(1).unwrap()[0..3], [1, 2, 3]);
|
||||
assert_eq!(canvas.row(0).unwrap()[0], UNWRITTEN);
|
||||
assert_eq!(canvas.filled_rows(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_past_the_bottom_is_dropped_rather_than_growing_the_image() {
|
||||
let mut canvas = ImageCanvas::new(2, 2);
|
||||
canvas.put_row(9, &[1, 2, 3, 4, 5, 6]);
|
||||
assert_eq!(canvas.filled_rows(), 0);
|
||||
assert_eq!(canvas.rgb().len(), 2 * 2 * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_a_png_a_decoder_can_read_back() {
|
||||
let mut canvas = ImageCanvas::new(2, 2);
|
||||
canvas.put_row(0, &[255, 0, 0, 0, 255, 0]);
|
||||
let png_bytes = canvas.to_png().expect("png");
|
||||
let decoder = png::Decoder::new(png_bytes.as_slice());
|
||||
let mut reader = decoder.read_info().expect("png info");
|
||||
let mut buf = vec![0; reader.output_buffer_size()];
|
||||
let info = reader.next_frame(&mut buf).expect("png frame");
|
||||
assert_eq!((info.width, info.height), (2, 2));
|
||||
assert_eq!(&buf[0..6], &[255, 0, 0, 0, 255, 0]);
|
||||
assert!(!canvas.to_png_base64().expect("base64").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! SSTV (Slow-Scan Television) decoder.
|
||||
//!
|
||||
//! Pure Rust, covering Martin, Scottie, Robot, PD and Wraase SC2-180, with the
|
||||
//! mode taken from the VIS header that precedes every transmission. Rows are
|
||||
//! emitted as they arrive so a picture can be watched building up.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent};
|
||||
//!
|
||||
//! let mut decoder = SstvDecoder::new(48_000, SstvConfig::default());
|
||||
//! # let audio: Vec<f32> = Vec::new();
|
||||
//! for event in decoder.process_samples(&audio) {
|
||||
//! match event {
|
||||
//! SstvEvent::Started { mode, .. } => println!("receiving {mode}"),
|
||||
//! SstvEvent::Row { line, .. } => println!("line {line}"),
|
||||
//! SstvEvent::Complete(image) => println!("{} lines", image.lines),
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod config;
|
||||
pub mod decoder;
|
||||
pub mod demod;
|
||||
pub mod encode;
|
||||
pub mod image;
|
||||
pub mod mode;
|
||||
pub mod vis;
|
||||
|
||||
pub use config::SstvConfig;
|
||||
pub use decoder::{SstvDecoder, SstvEvent, SstvImage};
|
||||
pub use image::ImageCanvas;
|
||||
pub use mode::{mode_for_vis, SstvMode, MODES};
|
||||
@@ -0,0 +1,615 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! SSTV mode table: what a VIS code means in timings, geometry and colour.
|
||||
//!
|
||||
//! Every mode transmits a line as a sequence of *segments* — a sync pulse, a
|
||||
//! porch or separator at a fixed tone, and one scan per colour channel. The
|
||||
//! decoder needs only the segment layout and the offset of each scan within
|
||||
//! the line, so that is what a [`SstvMode`] is: a list of segments plus the
|
||||
//! rules for turning the scans back into pixels.
|
||||
//!
|
||||
//! Timings follow the published mode specifications (JL Barber, N7CXI,
|
||||
//! "Proposal for SSTV Mode Specifications", 2000), which is the same table
|
||||
//! MMSSTV, QSSTV and slowrx work from.
|
||||
|
||||
/// Tone that marks a line boundary, in Hz.
|
||||
pub const SYNC_HZ: f32 = 1200.0;
|
||||
/// Tone for black, in Hz.
|
||||
pub const BLACK_HZ: f32 = 1500.0;
|
||||
/// Tone for white, in Hz.
|
||||
pub const WHITE_HZ: f32 = 2300.0;
|
||||
|
||||
/// What a scan segment carries.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Channel {
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
/// Luminance for the odd (first) image line of the pair.
|
||||
LumaOdd,
|
||||
/// Luminance for the even (second) image line of a PD pair.
|
||||
LumaEven,
|
||||
/// R-Y chrominance.
|
||||
ChromaR,
|
||||
/// B-Y chrominance.
|
||||
ChromaB,
|
||||
/// Robot 36 alternates R-Y and B-Y between transmitted lines: odd lines
|
||||
/// carry R-Y, even lines B-Y, and each is held over both.
|
||||
ChromaAlternating,
|
||||
}
|
||||
|
||||
/// One piece of a transmitted line.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Segment {
|
||||
/// Sync pulse at [`SYNC_HZ`].
|
||||
Sync(f64),
|
||||
/// Porch, separator or gap at a fixed tone; the tone itself is not decoded.
|
||||
Gap(f64),
|
||||
/// A scan carrying pixels for one channel.
|
||||
Scan { channel: Channel, ms: f64 },
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
pub fn duration_ms(&self) -> f64 {
|
||||
match *self {
|
||||
Segment::Sync(ms) | Segment::Gap(ms) => ms,
|
||||
Segment::Scan { ms, .. } => ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the scans of a line become pixels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ColorModel {
|
||||
/// Scans are red, green and blue directly (Martin, Scottie, Wraase).
|
||||
Rgb,
|
||||
/// Y plus one alternating chroma channel per line (Robot 36).
|
||||
YCrCbAlternating,
|
||||
/// Y, R-Y and B-Y in every line (Robot 72).
|
||||
YCrCb,
|
||||
/// Two image lines per transmitted line: Y odd, R-Y, B-Y, Y even (PD).
|
||||
YCrCbPaired,
|
||||
}
|
||||
|
||||
/// A decodable SSTV mode.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SstvMode {
|
||||
/// VIS code as sent in the header.
|
||||
pub vis: u8,
|
||||
/// Human-readable name, e.g. "Martin M1".
|
||||
pub name: &'static str,
|
||||
/// Pixels across.
|
||||
pub width: u16,
|
||||
/// Image lines in a full frame.
|
||||
pub height: u16,
|
||||
/// Transmitted line duration in milliseconds.
|
||||
pub line_ms: f64,
|
||||
/// Segments in transmission order.
|
||||
pub segments: &'static [Segment],
|
||||
pub color: ColorModel,
|
||||
/// Image lines produced by one transmitted line (2 for PD, otherwise 1).
|
||||
pub lines_per_transmission: u16,
|
||||
/// Offset from the start of a line to the leading edge of its sync pulse.
|
||||
/// Zero for most modes; Scottie sends the sync in the middle of the line,
|
||||
/// so a line detected at its sync starts before it.
|
||||
pub sync_offset_ms: f64,
|
||||
}
|
||||
|
||||
impl SstvMode {
|
||||
/// Total of every segment, which must equal [`SstvMode::line_ms`].
|
||||
pub fn segments_ms(&self) -> f64 {
|
||||
self.segments.iter().map(Segment::duration_ms).sum()
|
||||
}
|
||||
|
||||
/// Start offset, in milliseconds from the line start, of each scan.
|
||||
pub fn scans(&self) -> Vec<(Channel, f64, f64)> {
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0.0;
|
||||
for segment in self.segments {
|
||||
if let Segment::Scan { channel, ms } = *segment {
|
||||
out.push((channel, at, ms));
|
||||
}
|
||||
at += segment.duration_ms();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pixels carried by a scan of this channel.
|
||||
///
|
||||
/// Chroma is sent at half the width in the Robot modes — the eye takes
|
||||
/// colour more coarsely than brightness, and the saving is what makes 36
|
||||
/// seconds possible. PD sends chroma at full width and saves its time by
|
||||
/// sharing one pair of chroma scans between two image lines instead.
|
||||
pub fn scan_pixels(&self, channel: Channel) -> usize {
|
||||
let width = usize::from(self.width);
|
||||
match channel {
|
||||
Channel::ChromaR | Channel::ChromaB | Channel::ChromaAlternating
|
||||
if self.color != ColorModel::YCrCbPaired =>
|
||||
{
|
||||
width / 2
|
||||
}
|
||||
_ => width,
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds a full frame takes to send.
|
||||
pub fn frame_secs(&self) -> f64 {
|
||||
self.line_ms * f64::from(self.height) / f64::from(self.lines_per_transmission) / 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Martin — sync, porch, then green, blue, red, each followed by a separator.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MARTIN_M1: &[Segment] = &[
|
||||
Segment::Sync(4.862),
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 146.432,
|
||||
},
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 146.432,
|
||||
},
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 146.432,
|
||||
},
|
||||
Segment::Gap(0.572),
|
||||
];
|
||||
|
||||
const MARTIN_M2: &[Segment] = &[
|
||||
Segment::Sync(4.862),
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 73.216,
|
||||
},
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 73.216,
|
||||
},
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 73.216,
|
||||
},
|
||||
Segment::Gap(0.572),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scottie — the sync pulse sits between the blue and red scans, so a line
|
||||
// starts one separator before the green scan and the sync of the *previous*
|
||||
// line is what marks it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SCOTTIE_S1: &[Segment] = &[
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 138.240,
|
||||
},
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 138.240,
|
||||
},
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 138.240,
|
||||
},
|
||||
];
|
||||
|
||||
const SCOTTIE_S2: &[Segment] = &[
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 88.064,
|
||||
},
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 88.064,
|
||||
},
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 88.064,
|
||||
},
|
||||
];
|
||||
|
||||
const SCOTTIE_DX: &[Segment] = &[
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 345.6,
|
||||
},
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 345.6,
|
||||
},
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 345.6,
|
||||
},
|
||||
];
|
||||
|
||||
/// Scottie's sync arrives after the green and blue scans: 1.5 + 138.24 + 1.5 +
|
||||
/// 138.24 for S1, and the equivalent for the others.
|
||||
const fn scottie_sync_offset(scan_ms: f64) -> f64 {
|
||||
1.5 + scan_ms + 1.5 + scan_ms
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Robot — luminance plus chrominance, the chroma scans at half the width.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROBOT_36: &[Segment] = &[
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(3.0),
|
||||
Segment::Scan {
|
||||
channel: Channel::LumaOdd,
|
||||
ms: 88.0,
|
||||
},
|
||||
Segment::Gap(4.5),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::ChromaAlternating,
|
||||
ms: 44.0,
|
||||
},
|
||||
];
|
||||
|
||||
const ROBOT_72: &[Segment] = &[
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(3.0),
|
||||
Segment::Scan {
|
||||
channel: Channel::LumaOdd,
|
||||
ms: 138.0,
|
||||
},
|
||||
Segment::Gap(4.5),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::ChromaR,
|
||||
ms: 69.0,
|
||||
},
|
||||
Segment::Gap(4.5),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::ChromaB,
|
||||
ms: 69.0,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PD — one transmitted line carries two image lines: the luminance of both,
|
||||
// with a single pair of chroma scans shared between them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
macro_rules! pd_segments {
|
||||
($name:ident, $scan:expr) => {
|
||||
const $name: &[Segment] = &[
|
||||
Segment::Sync(20.0),
|
||||
Segment::Gap(2.08),
|
||||
Segment::Scan {
|
||||
channel: Channel::LumaOdd,
|
||||
ms: $scan,
|
||||
},
|
||||
Segment::Scan {
|
||||
channel: Channel::ChromaR,
|
||||
ms: $scan,
|
||||
},
|
||||
Segment::Scan {
|
||||
channel: Channel::ChromaB,
|
||||
ms: $scan,
|
||||
},
|
||||
Segment::Scan {
|
||||
channel: Channel::LumaEven,
|
||||
ms: $scan,
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
pd_segments!(PD_50, 91.520);
|
||||
pd_segments!(PD_90, 170.240);
|
||||
pd_segments!(PD_120, 121.600);
|
||||
pd_segments!(PD_160, 195.584);
|
||||
pd_segments!(PD_180, 183.040);
|
||||
pd_segments!(PD_240, 244.672);
|
||||
pd_segments!(PD_290, 228.800);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wraase SC2-180 — red, green, blue in that order after one porch.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WRAASE_SC2_180: &[Segment] = &[
|
||||
Segment::Sync(5.5225),
|
||||
Segment::Gap(0.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 235.0,
|
||||
},
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 235.0,
|
||||
},
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 235.0,
|
||||
},
|
||||
];
|
||||
|
||||
/// Every mode this decoder knows, in VIS order.
|
||||
pub static MODES: &[SstvMode] = &[
|
||||
SstvMode {
|
||||
vis: 8,
|
||||
name: "Robot 36",
|
||||
width: 320,
|
||||
height: 240,
|
||||
line_ms: 150.0,
|
||||
segments: ROBOT_36,
|
||||
color: ColorModel::YCrCbAlternating,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 12,
|
||||
name: "Robot 72",
|
||||
width: 320,
|
||||
height: 240,
|
||||
line_ms: 300.0,
|
||||
segments: ROBOT_72,
|
||||
color: ColorModel::YCrCb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 40,
|
||||
name: "Martin M2",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 226.798,
|
||||
segments: MARTIN_M2,
|
||||
color: ColorModel::Rgb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 44,
|
||||
name: "Martin M1",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 446.446,
|
||||
segments: MARTIN_M1,
|
||||
color: ColorModel::Rgb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 55,
|
||||
name: "Wraase SC2-180",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 711.0225,
|
||||
segments: WRAASE_SC2_180,
|
||||
color: ColorModel::Rgb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 56,
|
||||
name: "Scottie S2",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 277.692,
|
||||
segments: SCOTTIE_S2,
|
||||
color: ColorModel::Rgb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: scottie_sync_offset(88.064),
|
||||
},
|
||||
SstvMode {
|
||||
vis: 60,
|
||||
name: "Scottie S1",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 428.22,
|
||||
segments: SCOTTIE_S1,
|
||||
color: ColorModel::Rgb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: scottie_sync_offset(138.240),
|
||||
},
|
||||
SstvMode {
|
||||
vis: 76,
|
||||
name: "Scottie DX",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 1050.3,
|
||||
segments: SCOTTIE_DX,
|
||||
color: ColorModel::Rgb,
|
||||
lines_per_transmission: 1,
|
||||
sync_offset_ms: scottie_sync_offset(345.6),
|
||||
},
|
||||
SstvMode {
|
||||
vis: 93,
|
||||
name: "PD50",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 388.16,
|
||||
segments: PD_50,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 94,
|
||||
name: "PD290",
|
||||
width: 800,
|
||||
height: 616,
|
||||
line_ms: 937.28,
|
||||
segments: PD_290,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 95,
|
||||
name: "PD120",
|
||||
width: 640,
|
||||
height: 496,
|
||||
line_ms: 508.48,
|
||||
segments: PD_120,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 96,
|
||||
name: "PD180",
|
||||
width: 640,
|
||||
height: 496,
|
||||
line_ms: 754.24,
|
||||
segments: PD_180,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 97,
|
||||
name: "PD240",
|
||||
width: 640,
|
||||
height: 496,
|
||||
line_ms: 1000.768,
|
||||
segments: PD_240,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 98,
|
||||
name: "PD160",
|
||||
width: 512,
|
||||
height: 400,
|
||||
line_ms: 804.416,
|
||||
segments: PD_160,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
SstvMode {
|
||||
vis: 99,
|
||||
name: "PD90",
|
||||
width: 320,
|
||||
height: 256,
|
||||
line_ms: 703.04,
|
||||
segments: PD_90,
|
||||
color: ColorModel::YCrCbPaired,
|
||||
lines_per_transmission: 2,
|
||||
sync_offset_ms: 0.0,
|
||||
},
|
||||
];
|
||||
|
||||
/// Look a mode up by the VIS code that announced it.
|
||||
pub fn mode_for_vis(vis: u8) -> Option<&'static SstvMode> {
|
||||
MODES.iter().find(|mode| mode.vis == vis)
|
||||
}
|
||||
|
||||
/// Map an instantaneous frequency to an 8-bit level: 1500 Hz is black, 2300 Hz
|
||||
/// white. Frequencies outside the band clamp rather than wrap, so a sync pulse
|
||||
/// that lands inside a scan reads as black instead of as bright noise.
|
||||
pub fn level_from_hz(hz: f32) -> u8 {
|
||||
let level = (hz - BLACK_HZ) * (255.0 / (WHITE_HZ - BLACK_HZ));
|
||||
level.clamp(0.0, 255.0).round() as u8
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The segment list and the published line time are two statements of the
|
||||
// same fact, entered by hand from the specification. If a digit is wrong in
|
||||
// one it is unlikely to be wrong identically in the other.
|
||||
#[test]
|
||||
fn segments_add_up_to_the_published_line_time() {
|
||||
for mode in MODES {
|
||||
let sum = mode.segments_ms();
|
||||
assert!(
|
||||
(sum - mode.line_ms).abs() < 0.001,
|
||||
"{}: segments total {:.4} ms, line time says {:.4} ms",
|
||||
mode.name,
|
||||
sum,
|
||||
mode.line_ms,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vis_codes_are_unique_and_resolvable() {
|
||||
for mode in MODES {
|
||||
assert_eq!(mode_for_vis(mode.vis).map(|m| m.name), Some(mode.name));
|
||||
}
|
||||
let mut codes: Vec<u8> = MODES.iter().map(|m| m.vis).collect();
|
||||
codes.sort_unstable();
|
||||
let count = codes.len();
|
||||
codes.dedup();
|
||||
assert_eq!(codes.len(), count, "two modes claim the same VIS code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mode_scans_enough_channels_for_its_colour_model() {
|
||||
for mode in MODES {
|
||||
let scans = mode.scans();
|
||||
let expected = match mode.color {
|
||||
ColorModel::Rgb => 3,
|
||||
ColorModel::YCrCbAlternating => 2,
|
||||
ColorModel::YCrCb => 3,
|
||||
ColorModel::YCrCbPaired => 4,
|
||||
};
|
||||
assert_eq!(
|
||||
scans.len(),
|
||||
expected,
|
||||
"{} has {} scans",
|
||||
mode.name,
|
||||
scans.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Frame durations are what operators know these modes by — the number in
|
||||
// the name is the number of seconds.
|
||||
#[test]
|
||||
fn frame_durations_match_the_names() {
|
||||
for (vis, secs) in [(8u8, 36.0), (12, 72.0), (93, 50.0), (99, 90.0), (95, 126.0)] {
|
||||
let mode = mode_for_vis(vis).expect("mode in table");
|
||||
let actual = mode.frame_secs();
|
||||
assert!(
|
||||
(actual - secs).abs() < 1.5,
|
||||
"{} takes {:.1} s, expected about {:.0} s",
|
||||
mode.name,
|
||||
actual,
|
||||
secs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_span_black_to_white_and_clamp_outside() {
|
||||
assert_eq!(level_from_hz(BLACK_HZ), 0);
|
||||
assert_eq!(level_from_hz(WHITE_HZ), 255);
|
||||
assert_eq!(level_from_hz(1900.0), 128);
|
||||
assert_eq!(level_from_hz(SYNC_HZ), 0, "a sync pulse must read as black");
|
||||
assert_eq!(level_from_hz(3000.0), 255);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! VIS header detection.
|
||||
//!
|
||||
//! Every transmission announces its mode in a fixed preamble:
|
||||
//!
|
||||
//! | Part | Tone | Duration |
|
||||
//! |------|------|----------|
|
||||
//! | Leader | 1900 Hz | 300 ms |
|
||||
//! | Break | 1200 Hz | 10 ms |
|
||||
//! | Leader | 1900 Hz | 300 ms |
|
||||
//! | Start bit | 1200 Hz | 30 ms |
|
||||
//! | 7 data bits, LSB first | 1100 Hz = 1, 1300 Hz = 0 | 30 ms each |
|
||||
//! | Even parity | as above | 30 ms |
|
||||
//! | Stop bit | 1200 Hz | 30 ms |
|
||||
//!
|
||||
//! The detector looks for the start bit standing behind a leader, reads the
|
||||
//! eight bits that follow, and checks the parity. Parity is the only integrity
|
||||
//! check the header has, so a code that fails it is discarded rather than
|
||||
//! guessed at — decoding 114 seconds of Martin M1 as Scottie DX produces a
|
||||
//! convincing-looking image of nothing.
|
||||
|
||||
/// Tone durations, in milliseconds.
|
||||
const BIT_MS: f64 = 30.0;
|
||||
const LEADER_MS: f64 = 300.0;
|
||||
|
||||
/// How far a tone may sit from its nominal frequency and still be recognised.
|
||||
/// Wide enough for a rig tuned a little off, narrow enough that 1100, 1200,
|
||||
/// 1300 and 1900 Hz stay distinct.
|
||||
const TONE_TOLERANCE_HZ: f32 = 60.0;
|
||||
|
||||
const LEADER_HZ: f32 = 1900.0;
|
||||
const START_HZ: f32 = 1200.0;
|
||||
const ONE_HZ: f32 = 1100.0;
|
||||
const ZERO_HZ: f32 = 1300.0;
|
||||
|
||||
/// A VIS header found in the stream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct VisHit {
|
||||
/// The code, which names the mode.
|
||||
pub code: u8,
|
||||
/// Index just past the stop bit: where the image itself begins.
|
||||
pub image_start: usize,
|
||||
}
|
||||
|
||||
fn near(freq: f32, target: f32) -> bool {
|
||||
(freq - target).abs() <= TONE_TOLERANCE_HZ
|
||||
}
|
||||
|
||||
/// Mean frequency over the middle 60% of a bit cell, which keeps the filter's
|
||||
/// transitions at either edge out of the measurement.
|
||||
fn bit_frequency(freqs: &[f32], start: f64, samples_per_bit: f64) -> Option<f32> {
|
||||
let from = (start + samples_per_bit * 0.2).round() as usize;
|
||||
let to = (start + samples_per_bit * 0.8).round() as usize;
|
||||
if to <= from || to > freqs.len() {
|
||||
return None;
|
||||
}
|
||||
let window = &freqs[from..to];
|
||||
Some(window.iter().sum::<f32>() / window.len() as f32)
|
||||
}
|
||||
|
||||
/// Search `freqs` for a VIS header, starting at `from`.
|
||||
///
|
||||
/// Returns the first header whose parity checks out. `freqs` is instantaneous
|
||||
/// frequency in Hz, one entry per audio sample.
|
||||
pub fn find_vis(freqs: &[f32], sample_rate: u32, from: usize) -> Option<VisHit> {
|
||||
let sr = f64::from(sample_rate);
|
||||
// Header tones are 10 ms at the shortest, so a millisecond of averaging
|
||||
// costs nothing and takes the demodulator's ripple — ±95 Hz at 1200 Hz —
|
||||
// out of tones that are 100 Hz apart.
|
||||
let smoothed = crate::demod::smooth(freqs, (sr / 1000.0).round() as usize);
|
||||
let freqs = smoothed.as_slice();
|
||||
let samples_per_bit = BIT_MS / 1000.0 * sr;
|
||||
let leader_samples = (LEADER_MS / 1000.0 * sr) as usize;
|
||||
// The start bit must be at least this long to be one.
|
||||
let min_start_run = (samples_per_bit * 0.7) as usize;
|
||||
|
||||
// A leader has to precede the start bit. Half of one is enough evidence,
|
||||
// and asking for less than the full 300 ms means the search still works on
|
||||
// a buffer that begins part-way through the header.
|
||||
let leader_needed = leader_samples / 2;
|
||||
|
||||
// The bits themselves may run to the very end of what has arrived so far;
|
||||
// reading them is what decides whether there is enough, not this bound.
|
||||
let mut i = from.max(leader_needed);
|
||||
while i < freqs.len() {
|
||||
if !near(freqs[i], START_HZ) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Measure the run of start tone.
|
||||
let mut run = 0usize;
|
||||
while i + run < freqs.len() && near(freqs[i + run], START_HZ) {
|
||||
run += 1;
|
||||
}
|
||||
if run < min_start_run {
|
||||
i += run.max(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// What came before it: the leader. Sampled rather than scanned in
|
||||
// full, since only its identity matters, not its exact length.
|
||||
let leader_from = i - leader_needed;
|
||||
let leader_hits = freqs[leader_from..i]
|
||||
.iter()
|
||||
.step_by(16)
|
||||
.filter(|&&f| near(f, LEADER_HZ))
|
||||
.count();
|
||||
let leader_total = freqs[leader_from..i].iter().step_by(16).count();
|
||||
if leader_total == 0 || (leader_hits as f64) < 0.7 * leader_total as f64 {
|
||||
i += run;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bits follow the start bit, which the run just measured. Use the run's
|
||||
// own end rather than a nominal offset, so a start bit stretched or
|
||||
// clipped by the filter does not shift every bit after it.
|
||||
let bits_start = (i + run) as f64;
|
||||
let mut bits = [false; 8];
|
||||
let mut readable = true;
|
||||
for (index, bit) in bits.iter_mut().enumerate() {
|
||||
let at = bits_start + samples_per_bit * index as f64;
|
||||
match bit_frequency(freqs, at, samples_per_bit) {
|
||||
Some(freq) if near(freq, ONE_HZ) => *bit = true,
|
||||
Some(freq) if near(freq, ZERO_HZ) => *bit = false,
|
||||
_ => {
|
||||
readable = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !readable {
|
||||
i += run;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Seven data bits, LSB first, then even parity over them.
|
||||
let code = bits[..7]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(0u8, |acc, (index, &set)| acc | (u8::from(set) << index));
|
||||
let ones = bits[..7].iter().filter(|&&b| b).count() + usize::from(bits[7]);
|
||||
if ones % 2 != 0 {
|
||||
i += run;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Past the stop bit is the image.
|
||||
let image_start = (bits_start + samples_per_bit * 9.0).round() as usize;
|
||||
return Some(VisHit { code, image_start });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::encode::{vis_tones, Tone};
|
||||
|
||||
fn freqs_from_tones(tones: &[Tone], sample_rate: u32) -> Vec<f32> {
|
||||
let mut out = Vec::new();
|
||||
for tone in tones {
|
||||
let samples = (tone.ms / 1000.0 * f64::from(sample_rate)).round() as usize;
|
||||
out.extend(std::iter::repeat_n(tone.hz, samples));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_every_code_the_mode_table_knows() {
|
||||
for mode in crate::mode::MODES {
|
||||
let freqs = freqs_from_tones(&vis_tones(mode.vis), 48_000);
|
||||
let hit = find_vis(&freqs, 48_000, 0)
|
||||
.unwrap_or_else(|| panic!("{} header not found", mode.name));
|
||||
assert_eq!(
|
||||
hit.code, mode.vis,
|
||||
"{} decoded as VIS {}",
|
||||
mode.name, hit.code
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_image_starts_after_the_stop_bit() {
|
||||
let sample_rate = 48_000;
|
||||
let freqs = freqs_from_tones(&vis_tones(44), sample_rate);
|
||||
let hit = find_vis(&freqs, sample_rate, 0).expect("header");
|
||||
// Header is 300 + 10 + 300 ms of leader and break, then ten 30 ms bits.
|
||||
let expected = ((300.0 + 10.0 + 300.0 + 300.0) / 1000.0 * f64::from(sample_rate)) as usize;
|
||||
let slack = sample_rate as usize / 100; // 10 ms
|
||||
assert!(
|
||||
hit.image_start.abs_diff(expected) < slack,
|
||||
"image starts at {}, expected about {expected}",
|
||||
hit.image_start,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_with_broken_parity_is_not_a_header() {
|
||||
let sample_rate = 48_000;
|
||||
let mut tones = vis_tones(44);
|
||||
// Flip the parity bit alone: seven data bits still say Martin M1, but
|
||||
// nothing now vouches for them.
|
||||
let parity = tones.len() - 2;
|
||||
tones[parity].hz = if tones[parity].hz == ONE_HZ {
|
||||
ZERO_HZ
|
||||
} else {
|
||||
ONE_HZ
|
||||
};
|
||||
let freqs = freqs_from_tones(&tones, sample_rate);
|
||||
assert_eq!(find_vis(&freqs, sample_rate, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tones_without_a_leader_are_not_a_header() {
|
||||
let sample_rate = 48_000;
|
||||
let mut tones = vis_tones(44);
|
||||
// Same bits, but the leader before them is a pixel-band tone — which is
|
||||
// what a passing image looks like.
|
||||
tones[0].hz = 2000.0;
|
||||
tones[2].hz = 2000.0;
|
||||
let freqs = freqs_from_tones(&tones, sample_rate);
|
||||
assert_eq!(find_vis(&freqs, sample_rate, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_part_way_into_the_buffer_is_still_found() {
|
||||
let sample_rate = 48_000;
|
||||
let mut freqs = vec![1750.0f32; sample_rate as usize]; // a second of picture
|
||||
freqs.extend(freqs_from_tones(&vis_tones(60), sample_rate));
|
||||
let hit = find_vis(&freqs, sample_rate, 0).expect("header");
|
||||
assert_eq!(hit.code, 60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Decode what was encoded, and compare the pictures.
|
||||
//!
|
||||
//! A decoder for a picture format can only really be tested against a picture.
|
||||
//! These tests build a test card, transmit it in each mode through the
|
||||
//! encoder, and hold the decoder to what comes back — pixel by pixel, with a
|
||||
//! tolerance that accounts for the round trip through frequency and, for the
|
||||
//! colour modes, through a chroma channel at half the width.
|
||||
//!
|
||||
//! What this does not test is the timing table itself: the encoder reads the
|
||||
//! same numbers as the decoder, so a wrong line duration would cancel out.
|
||||
//! That is checked in `mode.rs` against the published line times instead.
|
||||
|
||||
use trx_sstv::encode::{encode, Frame};
|
||||
use trx_sstv::mode::mode_for_vis;
|
||||
use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent, SstvImage};
|
||||
|
||||
const SAMPLE_RATE: u32 = 48_000;
|
||||
|
||||
/// A test card with something for every part of the decoder to get wrong:
|
||||
/// vertical colour bars catch channels swapped or shifted, the horizontal
|
||||
/// gradient catches a line-timing drift, and the corner blocks catch a picture
|
||||
/// that arrives upside down or mirrored.
|
||||
fn test_card(width: usize, height: usize) -> Vec<u8> {
|
||||
let mut rgb = vec![0u8; width * height * 3];
|
||||
let bars: [(u8, u8, u8); 8] = [
|
||||
(255, 255, 255),
|
||||
(255, 255, 0),
|
||||
(0, 255, 255),
|
||||
(0, 255, 0),
|
||||
(255, 0, 255),
|
||||
(255, 0, 0),
|
||||
(0, 0, 255),
|
||||
(0, 0, 0),
|
||||
];
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let at = (y * width + x) * 3;
|
||||
let (r, g, b) = if y < height / 2 {
|
||||
bars[x * bars.len() / width]
|
||||
} else {
|
||||
let ramp = (x * 255 / width.max(1)) as u8;
|
||||
let down = (y * 255 / height.max(1)) as u8;
|
||||
(ramp, down, 255 - ramp)
|
||||
};
|
||||
rgb[at] = r;
|
||||
rgb[at + 1] = g;
|
||||
rgb[at + 2] = b;
|
||||
}
|
||||
}
|
||||
// Corner marks: red top-left, blue bottom-right.
|
||||
for y in 0..height.min(8) {
|
||||
for x in 0..width.min(8) {
|
||||
let at = (y * width + x) * 3;
|
||||
rgb[at] = 255;
|
||||
rgb[at + 1] = 0;
|
||||
rgb[at + 2] = 0;
|
||||
}
|
||||
}
|
||||
for y in height.saturating_sub(8)..height {
|
||||
for x in width.saturating_sub(8)..width {
|
||||
let at = (y * width + x) * 3;
|
||||
rgb[at] = 0;
|
||||
rgb[at + 1] = 0;
|
||||
rgb[at + 2] = 255;
|
||||
}
|
||||
}
|
||||
rgb
|
||||
}
|
||||
|
||||
/// Run audio through the decoder in blocks the size a sound card delivers.
|
||||
///
|
||||
/// A little silence is fed after the signal, as a receiver that keeps
|
||||
/// listening supplies: the demodulator is a filter, so the last millisecond of
|
||||
/// any transmission needs the samples after it before it can be read.
|
||||
fn decode(audio: &[f32]) -> (Vec<SstvImage>, usize) {
|
||||
let mut tail = audio.to_vec();
|
||||
tail.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize / 20));
|
||||
let audio = tail.as_slice();
|
||||
|
||||
let mut decoder = SstvDecoder::new(SAMPLE_RATE, SstvConfig::default());
|
||||
let mut images = Vec::new();
|
||||
let mut rows = 0;
|
||||
for block in audio.chunks(1024) {
|
||||
for event in decoder.process_samples(block) {
|
||||
match event {
|
||||
SstvEvent::Row { .. } => rows += 1,
|
||||
SstvEvent::Complete(image) => images.push(image),
|
||||
SstvEvent::Started { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
(images, rows)
|
||||
}
|
||||
|
||||
/// Mean absolute error per colour channel between two same-sized images.
|
||||
fn mean_error(a: &[u8], b: &[u8]) -> f64 {
|
||||
assert_eq!(a.len(), b.len());
|
||||
let total: u64 = a
|
||||
.iter()
|
||||
.zip(b)
|
||||
.map(|(x, y)| u64::from(x.abs_diff(*y)))
|
||||
.sum();
|
||||
total as f64 / a.len() as f64
|
||||
}
|
||||
|
||||
/// Error over the part of the picture away from channel edges, where a decoder
|
||||
/// that is a pixel out on a hard colour boundary would otherwise dominate.
|
||||
fn interior_error(mode_width: usize, height: usize, sent: &[u8], got: &[u8]) -> f64 {
|
||||
let mut total = 0u64;
|
||||
let mut count = 0u64;
|
||||
for y in 2..height.saturating_sub(2) {
|
||||
for x in 4..mode_width.saturating_sub(4) {
|
||||
// Skip the columns where the bars change, which is where a
|
||||
// half-pixel timing difference shows up as a whole-colour error.
|
||||
if x % (mode_width / 8) < 3 {
|
||||
continue;
|
||||
}
|
||||
let at = (y * mode_width + x) * 3;
|
||||
for channel in 0..3 {
|
||||
total += u64::from(sent[at + channel].abs_diff(got[at + channel]));
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
total as f64 / count.max(1) as f64
|
||||
}
|
||||
|
||||
fn round_trip(vis: u8, tolerance: f64) {
|
||||
let mode = mode_for_vis(vis).expect("mode in table");
|
||||
let width = usize::from(mode.width);
|
||||
let height = usize::from(mode.height);
|
||||
let sent = test_card(width, height);
|
||||
let frame = Frame {
|
||||
width,
|
||||
height,
|
||||
rgb: &sent,
|
||||
};
|
||||
let audio = encode(mode, &frame, SAMPLE_RATE);
|
||||
|
||||
let (images, rows) = decode(&audio);
|
||||
assert_eq!(
|
||||
images.len(),
|
||||
1,
|
||||
"{}: expected one picture, got {}",
|
||||
mode.name,
|
||||
images.len()
|
||||
);
|
||||
let image = &images[0];
|
||||
assert!(
|
||||
image.complete,
|
||||
"{}: reception did not reach the bottom",
|
||||
mode.name
|
||||
);
|
||||
assert_eq!(image.mode, mode.name);
|
||||
assert_eq!(
|
||||
image.lines, mode.height,
|
||||
"{}: {} of {} lines",
|
||||
mode.name, image.lines, mode.height
|
||||
);
|
||||
assert_eq!(
|
||||
rows, height,
|
||||
"{}: emitted {rows} rows for {height} lines",
|
||||
mode.name
|
||||
);
|
||||
|
||||
let error = interior_error(width, height, &sent, &image.rgb);
|
||||
assert!(
|
||||
error < tolerance,
|
||||
"{}: mean error {error:.1} levels, tolerance {tolerance:.1}",
|
||||
mode.name,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn martin_m1_round_trips() {
|
||||
round_trip(44, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn martin_m2_round_trips() {
|
||||
round_trip(40, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scottie_s1_round_trips() {
|
||||
round_trip(60, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scottie_s2_round_trips() {
|
||||
round_trip(56, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraase_sc2_180_round_trips() {
|
||||
round_trip(55, 6.0);
|
||||
}
|
||||
|
||||
// The colour-difference modes lose chroma resolution by design, so the bars
|
||||
// bleed into one another at their edges; the tolerance is on the interior.
|
||||
#[test]
|
||||
fn robot_72_round_trips() {
|
||||
round_trip(12, 14.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn robot_36_round_trips() {
|
||||
// One chroma channel per line, the other carried over from the line
|
||||
// before, so alternate lines are a line stale in one channel.
|
||||
round_trip(8, 26.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pd90_round_trips() {
|
||||
round_trip(99, 14.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pd120_round_trips() {
|
||||
round_trip(95, 14.0);
|
||||
}
|
||||
|
||||
/// Silence before and after is the normal case — a receiver is not started at
|
||||
/// the instant the transmission does.
|
||||
#[test]
|
||||
fn survives_silence_around_the_transmission() {
|
||||
let mode = mode_for_vis(44).expect("Martin M1");
|
||||
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||
let sent = test_card(width, height);
|
||||
let frame = Frame {
|
||||
width,
|
||||
height,
|
||||
rgb: &sent,
|
||||
};
|
||||
let mut audio = vec![0.0f32; SAMPLE_RATE as usize * 2];
|
||||
audio.extend(encode(mode, &frame, SAMPLE_RATE));
|
||||
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize));
|
||||
|
||||
let (images, _) = decode(&audio);
|
||||
assert_eq!(
|
||||
images.len(),
|
||||
1,
|
||||
"expected one picture from a transmission in silence"
|
||||
);
|
||||
assert!(images[0].complete);
|
||||
}
|
||||
|
||||
/// A transmission cut off part-way is what a fade or a shut-down transmitter
|
||||
/// produces. The lines that did arrive are worth keeping.
|
||||
#[test]
|
||||
fn a_truncated_transmission_still_yields_its_lines() {
|
||||
let mode = mode_for_vis(44).expect("Martin M1");
|
||||
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||
let sent = test_card(width, height);
|
||||
let frame = Frame {
|
||||
width,
|
||||
height,
|
||||
rgb: &sent,
|
||||
};
|
||||
let full = encode(mode, &frame, SAMPLE_RATE);
|
||||
// Two thirds of the picture, then silence for long enough that the decoder
|
||||
// stops waiting for the rest.
|
||||
let mut audio = full[..full.len() * 2 / 3].to_vec();
|
||||
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize * 5));
|
||||
|
||||
let (images, _) = decode(&audio);
|
||||
assert_eq!(
|
||||
images.len(),
|
||||
1,
|
||||
"a cut-off transmission produced no picture"
|
||||
);
|
||||
let image = &images[0];
|
||||
assert!(
|
||||
!image.complete,
|
||||
"a two-thirds transmission reported as complete"
|
||||
);
|
||||
assert!(
|
||||
image.lines > mode.height / 2 && image.lines < mode.height,
|
||||
"{} lines of {} arrived",
|
||||
image.lines,
|
||||
mode.height,
|
||||
);
|
||||
// What did arrive is the top of the picture, and it is right.
|
||||
let rows = usize::from(image.lines).saturating_sub(4);
|
||||
let error = mean_error(&sent[..width * rows * 3], &image.rgb[..width * rows * 3]);
|
||||
assert!(
|
||||
error < 12.0,
|
||||
"the lines that arrived are wrong: mean error {error:.1}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two pictures back to back: the decoder has to finish the first and pick up
|
||||
/// the header of the second.
|
||||
#[test]
|
||||
fn decodes_a_second_transmission_after_the_first() {
|
||||
let mode = mode_for_vis(40).expect("Martin M2");
|
||||
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||
let sent = test_card(width, height);
|
||||
let frame = Frame {
|
||||
width,
|
||||
height,
|
||||
rgb: &sent,
|
||||
};
|
||||
let one = encode(mode, &frame, SAMPLE_RATE);
|
||||
|
||||
let mut audio = one.clone();
|
||||
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize / 2));
|
||||
audio.extend(one);
|
||||
|
||||
let (images, _) = decode(&audio);
|
||||
assert_eq!(
|
||||
images.len(),
|
||||
2,
|
||||
"expected two pictures, got {}",
|
||||
images.len()
|
||||
);
|
||||
assert!(
|
||||
images.iter().all(|image| image.complete),
|
||||
"a picture did not finish"
|
||||
);
|
||||
}
|
||||
|
||||
/// Noise on the signal is the normal condition on HF. The picture should
|
||||
/// degrade, not fall apart.
|
||||
#[test]
|
||||
fn decodes_through_noise() {
|
||||
let mode = mode_for_vis(44).expect("Martin M1");
|
||||
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||
let sent = test_card(width, height);
|
||||
let frame = Frame {
|
||||
width,
|
||||
height,
|
||||
rgb: &sent,
|
||||
};
|
||||
let clean = encode(mode, &frame, SAMPLE_RATE);
|
||||
|
||||
// Deterministic pseudo-noise at about 20 dB below the signal.
|
||||
let mut seed = 0x5eed_1234u32;
|
||||
let noisy: Vec<f32> = clean
|
||||
.iter()
|
||||
.map(|sample| {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
let noise = (seed >> 8) as f32 / f32::from(u16::MAX) / 256.0 - 0.5;
|
||||
sample + noise * 0.2
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (images, _) = decode(&noisy);
|
||||
assert_eq!(images.len(), 1, "noise cost the whole picture");
|
||||
let image = &images[0];
|
||||
assert!(image.complete, "noise cost the bottom of the picture");
|
||||
let error = interior_error(width, height, &sent, &image.rgb);
|
||||
assert!(error < 20.0, "mean error through noise {error:.1} levels");
|
||||
}
|
||||
|
||||
/// The sound card that plays the signal and the one that records it never
|
||||
/// agree exactly. A part-per-thousand error is far worse than reality and the
|
||||
/// picture should still stand up.
|
||||
#[test]
|
||||
fn tolerates_a_transmitter_clock_that_runs_fast() {
|
||||
let mode = mode_for_vis(44).expect("Martin M1");
|
||||
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||
let sent = test_card(width, height);
|
||||
let frame = Frame {
|
||||
width,
|
||||
height,
|
||||
rgb: &sent,
|
||||
};
|
||||
// Encoding at a slightly different rate and decoding at 48 kHz is exactly
|
||||
// a clock error: every duration is stretched by the same factor.
|
||||
let audio = encode(mode, &frame, 48_048);
|
||||
|
||||
let (images, _) = decode(&audio);
|
||||
assert_eq!(images.len(), 1, "a 0.1% clock error cost the picture");
|
||||
let error = interior_error(width, height, &sent, &images[0].rgb);
|
||||
assert!(
|
||||
error < 12.0,
|
||||
"mean error with a fast clock {error:.1} levels"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -747,7 +747,12 @@ function elementById(id) {
|
||||
api.applyLayout(savedLayoutName(), { persist: false });
|
||||
}
|
||||
}
|
||||
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
|
||||
const overflowOrder = [
|
||||
".operator-layout-picker",
|
||||
".header-style-pick",
|
||||
"#header-share-btn",
|
||||
"#theme-toggle"
|
||||
];
|
||||
function anchorMenu(button, menu) {
|
||||
const rect = button.getBoundingClientRect();
|
||||
if (menu.parentElement !== document.body) document.body.appendChild(menu);
|
||||
@@ -880,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");
|
||||
@@ -927,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");
|
||||
@@ -1060,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?.();
|
||||
},
|
||||
@@ -1524,6 +1532,7 @@ var TAB_ORDER = [
|
||||
"bookmarks",
|
||||
"digital-modes",
|
||||
"map",
|
||||
"satellites",
|
||||
"statistics",
|
||||
"recorder",
|
||||
"settings",
|
||||
@@ -1534,6 +1543,7 @@ var TAB_PATHS = {
|
||||
bookmarks: "/bookmarks",
|
||||
"digital-modes": "/digital-modes",
|
||||
map: "/map",
|
||||
satellites: "/satellites",
|
||||
statistics: "/statistics",
|
||||
recorder: "/recorder",
|
||||
settings: "/settings",
|
||||
@@ -1555,6 +1565,44 @@ function updateTabHistory(name, replace = false) {
|
||||
if (replace) window.history.replaceState({}, "", nextUrl);
|
||||
else window.history.pushState({}, "", nextUrl);
|
||||
}
|
||||
var FREQ_MULTIPLIER = { k: 1e3, m: 1e6, g: 1e9 };
|
||||
function parseFrequencyParam(raw) {
|
||||
if (typeof raw !== "string") return null;
|
||||
const text = raw.trim().toLowerCase().replace(/hz$/, "").trim();
|
||||
const match = /^(\d+(?:\.\d+)?)\s*([kmg]?)$/.exec(text);
|
||||
if (!match) return null;
|
||||
const value = Number(match[1]) * (FREQ_MULTIPLIER[match[2] ?? ""] ?? 1);
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return Math.round(value);
|
||||
}
|
||||
function parseModeParam(raw) {
|
||||
const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
|
||||
return /^[A-Z]{2,4}$/.test(mode) ? mode : null;
|
||||
}
|
||||
function parseTuneLink(search) {
|
||||
const params = new URLSearchParams(search);
|
||||
const rig = (params.get("rig") || "").trim();
|
||||
return {
|
||||
rig: rig || null,
|
||||
freqHz: parseFrequencyParam(params.get("f")),
|
||||
mode: parseModeParam(params.get("mode")),
|
||||
bandwidthHz: parseFrequencyParam(params.get("bw"))
|
||||
};
|
||||
}
|
||||
function tuneLinkSearch(search, link) {
|
||||
const params = new URLSearchParams(search);
|
||||
const set = (key, value) => {
|
||||
if (value == null || value === "") params.delete(key);
|
||||
else params.set(key, value);
|
||||
};
|
||||
const hz = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? String(Math.round(value)) : null;
|
||||
set("rig", link.rig);
|
||||
set("f", hz(link.freqHz));
|
||||
set("mode", parseModeParam(link.mode));
|
||||
set("bw", hz(link.bandwidthHz));
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
|
||||
// src/features/radio/auto-bandwidth.ts
|
||||
function clampPercent(value) {
|
||||
@@ -1705,6 +1753,7 @@ var pluginGroups = {
|
||||
"/background-decode.js",
|
||||
"/sat.js",
|
||||
"/wefax.js",
|
||||
"/sstv.js",
|
||||
"/ais.js",
|
||||
"/vdes.js",
|
||||
"/aprs.js",
|
||||
@@ -1712,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: [],
|
||||
@@ -1978,6 +2028,7 @@ function applyAuthRestrictions() {
|
||||
function applyCapabilities(caps) {
|
||||
if (!caps) return;
|
||||
lastHasTx = !!caps.tx;
|
||||
bandwidthControlSupported = !!caps.filter_controls;
|
||||
if (signalVisualBlockEl) signalVisualBlockEl.style.display = "";
|
||||
const pttBtn2 = document.getElementById("ptt-btn");
|
||||
const txPowerCol = document.getElementById("tx-power-col");
|
||||
@@ -2196,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);
|
||||
}
|
||||
};
|
||||
@@ -2325,6 +2376,7 @@ var jogAngle = 0;
|
||||
var lastClientCount = null;
|
||||
var lastLocked = false;
|
||||
var sdrSquelchSupported = false;
|
||||
var bandwidthControlSupported = false;
|
||||
var previousTuneState = null;
|
||||
function savePreviousTuneState() {
|
||||
previousTuneState = {
|
||||
@@ -3506,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();
|
||||
@@ -3520,6 +3579,8 @@ function resetDecoderStateOnRigSwitch() {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = "--";
|
||||
});
|
||||
refreshDecodeBars();
|
||||
window.trxPluginRuntime.rerenderAll();
|
||||
}
|
||||
function resetWfmStereoIndicator() {
|
||||
if (!wfmStFlagEl) return;
|
||||
@@ -4410,6 +4471,85 @@ window.buildAisVesselUrl = function(mmsi) {
|
||||
if (!aisVesselUrlBase || !isFiniteNumber(Number(mmsi))) return null;
|
||||
return `${aisVesselUrlBase}${String(mmsi)}`;
|
||||
};
|
||||
var incomingTuneLink = parseTuneLink(window.location.search);
|
||||
var tuneLinkPhase = "pending";
|
||||
var tuneLinkSyncTimer = null;
|
||||
function currentTuneLink() {
|
||||
const bandwidthHz = bandwidthControlSupported && isFiniteNumber(currentBandwidthHz) && currentBandwidthHz > 0 ? currentBandwidthHz : null;
|
||||
return {
|
||||
rig: lastActiveRigId || null,
|
||||
freqHz: isFiniteNumber(lastFreqHz) ? lastFreqHz : null,
|
||||
mode: lastModeName || null,
|
||||
bandwidthHz
|
||||
};
|
||||
}
|
||||
function tuneLinkUrl() {
|
||||
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||||
return `${window.location.origin}${window.location.pathname}${search}${window.location.hash}`;
|
||||
}
|
||||
function scheduleTuneLinkSync() {
|
||||
if (tuneLinkPhase !== "live") return;
|
||||
if (window.trx?.modules.vchan?.isOnVirtual() === true) return;
|
||||
if (tuneLinkSyncTimer != null) return;
|
||||
tuneLinkSyncTimer = window.setTimeout(() => {
|
||||
tuneLinkSyncTimer = null;
|
||||
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||||
if (search === window.location.search) return;
|
||||
window.history.replaceState({}, "", `${window.location.pathname}${search}${window.location.hash}`);
|
||||
}, 500);
|
||||
}
|
||||
async function applyTuneLink(link) {
|
||||
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||||
if (!wanted) return;
|
||||
if (authRole === "rx") {
|
||||
showHint("Read-only session — link not applied", 2500);
|
||||
return;
|
||||
}
|
||||
if (link.rig && headerRigSwitchSelect && link.rig !== lastActiveRigId && lastRigIds.includes(link.rig)) {
|
||||
headerRigSwitchSelect.value = link.rig;
|
||||
await switchRigFromSelect(headerRigSwitchSelect);
|
||||
}
|
||||
if (link.mode && link.mode !== lastModeName && modeEl) {
|
||||
const known = Array.from(modeEl.options).some((option) => option.value === link.mode);
|
||||
if (known) {
|
||||
modeEl.value = link.mode;
|
||||
await applyModeFromPicker();
|
||||
} else {
|
||||
showHint(`Rig has no ${link.mode} mode`, 2500);
|
||||
}
|
||||
}
|
||||
if (link.freqHz != null) {
|
||||
try {
|
||||
setRigFrequency(link.freqHz);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
if (link.bandwidthHz != null && bandwidthControlSupported && spectrumBwInput) {
|
||||
spectrumBwInput.value = String(link.bandwidthHz / 1e3);
|
||||
await applyBandwidthFromInput();
|
||||
}
|
||||
}
|
||||
function followTuneLinkOnce() {
|
||||
if (tuneLinkPhase !== "pending") return;
|
||||
tuneLinkPhase = "applying";
|
||||
void applyTuneLink(incomingTuneLink).finally(() => {
|
||||
tuneLinkPhase = "live";
|
||||
scheduleTuneLinkSync();
|
||||
});
|
||||
}
|
||||
async function copyTuneLink() {
|
||||
const url = tuneLinkUrl();
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showHint("Link copied", 1500);
|
||||
} catch {
|
||||
window.trxUi?.notify("Clipboard unavailable — the address bar holds the link", { kind: "error" });
|
||||
}
|
||||
}
|
||||
var shareLinkBtn = document.getElementById("header-share-btn");
|
||||
if (shareLinkBtn) shareLinkBtn.addEventListener("click", () => {
|
||||
void copyTuneLink();
|
||||
});
|
||||
function render(update) {
|
||||
if (!update) return;
|
||||
if (update.server_version) serverVersion = update.server_version;
|
||||
@@ -4660,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";
|
||||
@@ -4696,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);
|
||||
}
|
||||
@@ -4922,6 +5062,8 @@ function render(update) {
|
||||
swrBar.style.width = "0%";
|
||||
swrValue.textContent = "SWR --";
|
||||
}
|
||||
if (update.status) followTuneLinkOnce();
|
||||
scheduleTuneLinkSync();
|
||||
}
|
||||
function scheduleReconnect(delayMs = 1e3) {
|
||||
if (reconnectTimer) return;
|
||||
@@ -5714,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);
|
||||
@@ -5743,7 +5886,10 @@ function navigateToTab(name, options = {}) {
|
||||
updateTabHistory(name, replaceHistory);
|
||||
}
|
||||
scheduleSpectrumLayout();
|
||||
void loadPluginsForTab(name).catch((error) => {
|
||||
if (leavingSatellites) window.clearSatPredictionDom?.();
|
||||
void loadPluginsForTab(name).then(() => {
|
||||
if (name === "satellites") window.refreshSatPredictions?.();
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
if (name === "map") {
|
||||
@@ -6190,9 +6336,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);
|
||||
@@ -7468,9 +7611,17 @@ 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"
|
||||
]);
|
||||
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") {
|
||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
@@ -7516,7 +7667,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+18
-4
@@ -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));
|
||||
@@ -177,6 +182,9 @@ function initializeFtxDecoder(config) {
|
||||
});
|
||||
}
|
||||
return {
|
||||
// The rig that heard it, kept so the mini view can tell a decode of the
|
||||
// rig on screen from one a background rig made on another band.
|
||||
rig_id: message.rig_id ?? null,
|
||||
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||
ts_ms: message.ts_ms,
|
||||
snr_db: message.snr_db,
|
||||
@@ -188,7 +196,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 +212,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 +236,11 @@ function initializeFtxDecoder(config) {
|
||||
prune();
|
||||
render();
|
||||
},
|
||||
reset
|
||||
reset,
|
||||
rerender: () => {
|
||||
bridge.updateFt8Bar?.();
|
||||
render();
|
||||
}
|
||||
});
|
||||
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-PAKFJPA2.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-PAKFJPA2.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-PAKFJPA2.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 {
|
||||
@@ -158,10 +167,10 @@ 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" });
|
||||
@@ -227,8 +236,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 +247,6 @@ window.trxPluginRuntime.registerDecoder({
|
||||
onBatch: onServerHfAprsBatch,
|
||||
restore: onServerHfAprsBatch,
|
||||
reset: resetHfAprsHistoryView,
|
||||
prune: pruneHfAprsHistoryView
|
||||
prune: pruneHfAprsHistoryView,
|
||||
rerender: renderHfAprsHistory
|
||||
});
|
||||
|
||||
@@ -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,7 @@ function normalizeServerWsprMessage(msg) {
|
||||
station,
|
||||
rfHz,
|
||||
history: {
|
||||
rig_id: msg.rig_id ?? null,
|
||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms,
|
||||
snr_db: msg.snr_db,
|
||||
@@ -101,10 +106,10 @@ 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 (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
@@ -251,7 +256,7 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
||||
})();
|
||||
});
|
||||
function onServerWspr(msg) {
|
||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
@@ -267,5 +272,6 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
||||
onBatch: onServerWsprBatch,
|
||||
restore: onServerWsprBatch,
|
||||
prune: pruneWsprHistoryView,
|
||||
reset: resetWsprHistoryView
|
||||
reset: resetWsprHistoryView,
|
||||
rerender: renderWsprHistory
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -99,6 +107,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<option value="phosphor">Phosphor</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="header-share-btn" class="header-bar-btn header-share-btn" type="button" aria-label="Copy a link to this frequency" title="Copy a link to this frequency">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M6.5 9.5a2.6 2.6 0 0 0 3.9.3l2.2-2.2a2.6 2.6 0 0 0-3.7-3.7l-1 1"/><path d="M9.5 6.5a2.6 2.6 0 0 0-3.9-.3L3.4 8.4a2.6 2.6 0 0 0 3.7 3.7l1-1"/></svg>
|
||||
</button>
|
||||
<button id="theme-toggle" class="header-bar-btn" type="button" aria-label="Toggle dark or light theme">Light</button>
|
||||
<button id="header-auth-btn" class="header-bar-btn" type="button" style="display:none;" aria-label="Login or Logout">Login</button>
|
||||
</div>
|
||||
@@ -160,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>
|
||||
@@ -179,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>
|
||||
@@ -581,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">
|
||||
@@ -643,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;">
|
||||
@@ -650,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>
|
||||
@@ -854,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">
|
||||
@@ -896,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">
|
||||
@@ -1053,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">
|
||||
|
||||
@@ -1345,7 +1345,10 @@ small { color: var(--text-muted); }
|
||||
.top-bar-actions > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.header-bar-btn.header-audio-btn {
|
||||
/* Square icon buttons in the top bar: an icon with no box of its own has no
|
||||
size to draw at, so the button gives it one. */
|
||||
.header-bar-btn.header-audio-btn,
|
||||
.header-bar-btn.header-share-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
min-height: 0;
|
||||
@@ -1360,10 +1363,15 @@ small { color: var(--text-muted); }
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-audio-btn svg {
|
||||
.header-audio-btn svg,
|
||||
.header-share-btn svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* The link icon is drawn in strokes, not fills, and wants room around it. */
|
||||
.header-share-btn svg {
|
||||
padding: 1px;
|
||||
}
|
||||
.header-audio-btn.audio-active {
|
||||
color: #00d17f;
|
||||
border-color: #00d17f;
|
||||
@@ -1709,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; }
|
||||
@@ -2584,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;
|
||||
}
|
||||
@@ -2602,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;
|
||||
@@ -2731,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 {
|
||||
@@ -3802,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"],
|
||||
@@ -3884,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 {
|
||||
@@ -4005,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;
|
||||
@@ -4022,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%;
|
||||
@@ -4060,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;
|
||||
@@ -4094,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;
|
||||
@@ -4123,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;
|
||||
}
|
||||
@@ -4813,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 {
|
||||
@@ -4936,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 {
|
||||
@@ -6127,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",
|
||||
"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, };
|
||||
|
||||
|
||||
@@ -34,7 +34,10 @@ import {
|
||||
import { decodeCbor as decodeCborPayload } from "./core/cbor.js";
|
||||
import {
|
||||
TAB_ORDER,
|
||||
type TuneLink,
|
||||
parseTuneLink,
|
||||
tabFromPath as tabFromPathname,
|
||||
tuneLinkSearch,
|
||||
updateTabHistory,
|
||||
} from "./features/navigation/routes.js";
|
||||
import { estimateOccupiedBandwidth } from "./features/radio/auto-bandwidth.js";
|
||||
@@ -382,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;
|
||||
@@ -609,6 +615,7 @@ function applyAuthRestrictions() {
|
||||
function applyCapabilities(caps: RigCapabilities | null) {
|
||||
if (!caps) return;
|
||||
lastHasTx = !!caps.tx;
|
||||
bandwidthControlSupported = !!caps.filter_controls;
|
||||
if (signalVisualBlockEl) signalVisualBlockEl.style.display = "";
|
||||
|
||||
// PTT / TX controls
|
||||
@@ -854,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);
|
||||
}
|
||||
};
|
||||
@@ -1012,6 +1019,9 @@ let jogAngle = 0;
|
||||
let lastClientCount: number | null = null;
|
||||
let lastLocked = false;
|
||||
let sdrSquelchSupported = false;
|
||||
// Whether this rig takes a bandwidth at all — a CAT rig without filter control
|
||||
// has no use for the `bw` of a tune link, in either direction.
|
||||
let bandwidthControlSupported = false;
|
||||
// ── Previous-state tracking for "B" hotkey ────────────────────────────────────
|
||||
let previousTuneState: PreviousTuneState | null = null;
|
||||
|
||||
@@ -2198,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;
|
||||
@@ -2217,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() {
|
||||
@@ -3267,6 +3294,110 @@ window.buildAisVesselUrl = function(mmsi: unknown) {
|
||||
return `${aisVesselUrlBase}${String(mmsi)}`;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tune links
|
||||
//
|
||||
// The address bar is the share link: whatever the dial is on is in the query
|
||||
// string, and a link someone else opens puts the radio there. Read the
|
||||
// incoming link once, at load, before anything of ours rewrites the URL.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const incomingTuneLink: TuneLink = parseTuneLink(window.location.search);
|
||||
// "pending" until the first state arrives, "applying" while the link is being
|
||||
// followed, "live" once the URL is ours to keep up to date.
|
||||
let tuneLinkPhase: "pending" | "applying" | "live" = "pending";
|
||||
let tuneLinkSyncTimer: number | null = null;
|
||||
|
||||
function currentTuneLink(): TuneLink {
|
||||
const bandwidthHz = bandwidthControlSupported && isFiniteNumber(currentBandwidthHz) && currentBandwidthHz > 0
|
||||
? currentBandwidthHz
|
||||
: null;
|
||||
return {
|
||||
rig: lastActiveRigId || null,
|
||||
freqHz: isFiniteNumber(lastFreqHz) ? lastFreqHz : null,
|
||||
mode: lastModeName || null,
|
||||
bandwidthHz,
|
||||
};
|
||||
}
|
||||
|
||||
/** The link as someone else would open it. */
|
||||
function tuneLinkUrl(): string {
|
||||
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||||
return `${window.location.origin}${window.location.pathname}${search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
function scheduleTuneLinkSync() {
|
||||
if (tuneLinkPhase !== "live") return;
|
||||
// A virtual channel is a tab's own slice, not where the rig is pointed —
|
||||
// publishing it as the rig's link would send someone to another frequency.
|
||||
if (window.trx?.modules.vchan?.isOnVirtual() === true) return;
|
||||
if (tuneLinkSyncTimer != null) return;
|
||||
tuneLinkSyncTimer = window.setTimeout(() => {
|
||||
tuneLinkSyncTimer = null;
|
||||
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||||
if (search === window.location.search) return;
|
||||
// replaceState, not push: tuning is not navigation, and a dial sweep would
|
||||
// otherwise bury the back button under a hundred entries.
|
||||
window.history.replaceState({}, "", `${window.location.pathname}${search}${window.location.hash}`);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function applyTuneLink(link: TuneLink) {
|
||||
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||||
if (!wanted) return;
|
||||
if (authRole === "rx") {
|
||||
showHint("Read-only session — link not applied", 2500);
|
||||
return;
|
||||
}
|
||||
// Rig first: the rest of the link describes state on that rig.
|
||||
if (link.rig && headerRigSwitchSelect && link.rig !== lastActiveRigId && lastRigIds.includes(link.rig)) {
|
||||
headerRigSwitchSelect.value = link.rig;
|
||||
await switchRigFromSelect(headerRigSwitchSelect);
|
||||
}
|
||||
// Mode before frequency: the mode change carries a default bandwidth with
|
||||
// it, which an explicit bw in the link then overrides.
|
||||
if (link.mode && link.mode !== lastModeName && modeEl) {
|
||||
const known = Array.from(modeEl.options).some((option) => option.value === link.mode);
|
||||
if (known) {
|
||||
modeEl.value = link.mode;
|
||||
await applyModeFromPicker();
|
||||
} else {
|
||||
showHint(`Rig has no ${link.mode} mode`, 2500);
|
||||
}
|
||||
}
|
||||
if (link.freqHz != null) {
|
||||
// setRigFrequency throws on a frequency this rig cannot reach, having
|
||||
// already told the user which one it was.
|
||||
try { setRigFrequency(link.freqHz); } catch { /* reported to the user */ }
|
||||
}
|
||||
if (link.bandwidthHz != null && bandwidthControlSupported && spectrumBwInput) {
|
||||
spectrumBwInput.value = String(link.bandwidthHz / 1000);
|
||||
await applyBandwidthFromInput();
|
||||
}
|
||||
}
|
||||
|
||||
function followTuneLinkOnce() {
|
||||
if (tuneLinkPhase !== "pending") return;
|
||||
tuneLinkPhase = "applying";
|
||||
void applyTuneLink(incomingTuneLink).finally(() => {
|
||||
tuneLinkPhase = "live";
|
||||
scheduleTuneLinkSync();
|
||||
});
|
||||
}
|
||||
|
||||
async function copyTuneLink() {
|
||||
const url = tuneLinkUrl();
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showHint("Link copied", 1500);
|
||||
} catch {
|
||||
window.trxUi?.notify("Clipboard unavailable — the address bar holds the link", { kind: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
const shareLinkBtn = document.getElementById("header-share-btn") as HTMLButtonElement | null;
|
||||
if (shareLinkBtn) shareLinkBtn.addEventListener("click", () => { void copyTuneLink(); });
|
||||
|
||||
function render(update: AppUpdate) {
|
||||
if (!update) return;
|
||||
if (update.server_version) serverVersion = update.server_version;
|
||||
@@ -3563,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;
|
||||
@@ -3601,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);
|
||||
}
|
||||
@@ -3842,6 +3974,10 @@ function render(update: AppUpdate) {
|
||||
swrBar.style.width = "0%";
|
||||
swrValue.textContent = "SWR --";
|
||||
}
|
||||
// Follow the link that opened the page once there is a rig to point, then
|
||||
// keep the address bar on whatever the dial does next.
|
||||
if (update.status) followTuneLinkOnce();
|
||||
scheduleTuneLinkSync();
|
||||
}
|
||||
|
||||
function scheduleReconnect(delayMs = 1000) {
|
||||
@@ -4729,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);
|
||||
@@ -4769,7 +4906,13 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
|
||||
updateTabHistory(name, replaceHistory);
|
||||
}
|
||||
scheduleSpectrumLayout();
|
||||
void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); });
|
||||
// Its countdowns tick every second, so the page stops when it is not on screen.
|
||||
if (leavingSatellites) window.clearSatPredictionDom?.();
|
||||
void loadPluginsForTab(name).then(() => {
|
||||
// Passes go stale while the page is closed, so each visit reloads them.
|
||||
// The first visit is what imports the module, which renders on its own.
|
||||
if (name === "satellites") window.refreshSatPredictions?.();
|
||||
}).catch((error: unknown) => { console.error(error); });
|
||||
if (name === "map") {
|
||||
_initMapWhenReady();
|
||||
}
|
||||
@@ -5121,10 +5264,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);
|
||||
@@ -6452,9 +6591,15 @@ 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",
|
||||
]);
|
||||
|
||||
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") {
|
||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
@@ -6504,7 +6649,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+65
-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",
|
||||
@@ -37,3 +38,66 @@ export function updateTabHistory(name: TabName, replace = false): void {
|
||||
if (replace) window.history.replaceState({}, "", nextUrl);
|
||||
else window.history.pushState({}, "", nextUrl);
|
||||
}
|
||||
|
||||
/** What a shared link says to listen to: `?rig=sdr&f=14074000&mode=USB&bw=3000`. */
|
||||
export interface TuneLink {
|
||||
rig: string | null;
|
||||
freqHz: number | null;
|
||||
mode: string | null;
|
||||
bandwidthHz: number | null;
|
||||
}
|
||||
|
||||
const FREQ_MULTIPLIER: Readonly<Record<string, number>> = { k: 1e3, m: 1e6, g: 1e9 };
|
||||
|
||||
/**
|
||||
* Read a frequency written for a person: bare Hz as the canonical form, but
|
||||
* `7074k` and `14.074M` are what someone typing a link by hand reaches for.
|
||||
* Returns Hz, or null for anything that is not a positive frequency.
|
||||
*/
|
||||
export function parseFrequencyParam(raw: string | null | undefined): number | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const text = raw.trim().toLowerCase().replace(/hz$/, "").trim();
|
||||
const match = /^(\d+(?:\.\d+)?)\s*([kmg]?)$/.exec(text);
|
||||
if (!match) return null;
|
||||
const value = Number(match[1]) * (FREQ_MULTIPLIER[match[2] ?? ""] ?? 1);
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
/** Mode names run from FM to VDES; anything else in the URL is not one. */
|
||||
function parseModeParam(raw: string | null | undefined): string | null {
|
||||
const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
|
||||
return /^[A-Z]{2,4}$/.test(mode) ? mode : null;
|
||||
}
|
||||
|
||||
export function parseTuneLink(search: string): TuneLink {
|
||||
const params = new URLSearchParams(search);
|
||||
const rig = (params.get("rig") || "").trim();
|
||||
return {
|
||||
rig: rig || null,
|
||||
freqHz: parseFrequencyParam(params.get("f")),
|
||||
mode: parseModeParam(params.get("mode")),
|
||||
bandwidthHz: parseFrequencyParam(params.get("bw")),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the tune parameters of a query string, leaving anything else in it
|
||||
* alone — the address bar is the share link, so it has to keep up with the
|
||||
* dial without discarding whatever else a page put there.
|
||||
*/
|
||||
export function tuneLinkSearch(search: string, link: TuneLink): string {
|
||||
const params = new URLSearchParams(search);
|
||||
const set = (key: string, value: string | null) => {
|
||||
if (value == null || value === "") params.delete(key);
|
||||
else params.set(key, value);
|
||||
};
|
||||
const hz = (value: number | null) =>
|
||||
(typeof value === "number" && Number.isFinite(value) && value > 0 ? String(Math.round(value)) : null);
|
||||
set("rig", link.rig);
|
||||
set("f", hz(link.freqHz));
|
||||
set("mode", parseModeParam(link.mode));
|
||||
set("bw", hz(link.bandwidthHz));
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
@@ -251,6 +256,9 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
||||
});
|
||||
}
|
||||
return {
|
||||
// The rig that heard it, kept so the mini view can tell a decode of the
|
||||
// rig on screen from one a background rig made on another band.
|
||||
rig_id: message.rig_id ?? null,
|
||||
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||
ts_ms: message.ts_ms,
|
||||
snr_db: message.snr_db,
|
||||
@@ -262,7 +270,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 +288,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 +311,7 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
||||
restore: receiveBatch,
|
||||
prune: () => { prune(); render(); },
|
||||
reset,
|
||||
rerender: () => { bridge.updateFt8Bar?.(); render(); },
|
||||
});
|
||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { hostCore, hostState } from "./host.js";
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import {
|
||||
aprsAgeText,
|
||||
aprsPacketCategory,
|
||||
@@ -90,23 +91,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 {
|
||||
@@ -192,10 +201,11 @@ 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" });
|
||||
@@ -266,8 +276,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 +289,5 @@ renderHfAprsHistory();
|
||||
restore: onServerHfAprsBatch,
|
||||
reset: resetHfAprsHistoryView,
|
||||
prune: pruneHfAprsHistoryView,
|
||||
rerender: renderHfAprsHistory,
|
||||
});
|
||||
|
||||
@@ -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,7 @@ interface WsprMessage {
|
||||
snr_db?: number | undefined;
|
||||
dt_s?: number | undefined;
|
||||
freq_hz?: number | undefined;
|
||||
rig_id?: string | null | undefined;
|
||||
receiver?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -102,10 +104,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 +134,7 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
|
||||
station,
|
||||
rfHz,
|
||||
history: {
|
||||
rig_id: msg.rig_id ?? null,
|
||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms,
|
||||
snr_db: msg.snr_db,
|
||||
@@ -142,10 +147,11 @@ 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);
|
||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
@@ -312,7 +318,7 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
||||
});
|
||||
|
||||
function onServerWspr(msg: WsprMessage): void {
|
||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
@@ -330,4 +336,5 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
||||
restore: onServerWsprBatch,
|
||||
prune: pruneWsprHistoryView,
|
||||
reset: resetWsprHistoryView,
|
||||
rerender: renderWsprHistory,
|
||||
});
|
||||
|
||||
@@ -327,7 +327,9 @@ function elementById<T extends HTMLElement>(id: string): T {
|
||||
|
||||
// Secondary controls, in the order they leave the bar when it gets tight.
|
||||
// Audio, record and the rig picker are the operating controls and stay.
|
||||
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
|
||||
const overflowOrder = [
|
||||
".operator-layout-picker", ".header-style-pick", "#header-share-btn", "#theme-toggle",
|
||||
];
|
||||
|
||||
// Anchored at paint time in fixed coordinates. An absolutely positioned
|
||||
// dropdown is clipped by any scrolling ancestor and trapped inside the
|
||||
@@ -497,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");
|
||||
@@ -539,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,18 @@ const BEACON = {
|
||||
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
|
||||
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
||||
};
|
||||
// A second rig listening in the background, on its own band. 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.
|
||||
const OTHER_RIG_VESSEL = {
|
||||
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
|
||||
lat: 51.92, lon: 4.48, rig_id: "rig-b",
|
||||
};
|
||||
|
||||
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
|
||||
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" });
|
||||
const fixture = await startWebFixture({
|
||||
spectrum: true, decodes: [VESSEL, BEACON, OTHER_RIG_VESSEL], mode: "AIS",
|
||||
});
|
||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||
|
||||
try {
|
||||
@@ -41,11 +50,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 +68,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 +103,11 @@ 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");
|
||||
// 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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+54
-1
@@ -17,7 +17,8 @@ async function loadRoutes(window) {
|
||||
write: false,
|
||||
});
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports, window });
|
||||
// The sandbox is bare; the browser globals the module actually uses go in.
|
||||
vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports, window, URLSearchParams });
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
@@ -38,3 +39,55 @@ test("all top-level tabs have stable round-trip routes", async () => {
|
||||
assert.equal(calls[0][0], "push");
|
||||
assert.equal(calls[0][3], "/statistics?rig=one#panel");
|
||||
});
|
||||
|
||||
// A tune link is what one operator sends another: it has to survive being
|
||||
// typed by hand, and it has to come back out as the frequency that went in.
|
||||
test("tune links parse the frequencies people write", async () => {
|
||||
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
|
||||
for (const [text, hz] of [
|
||||
["14074000", 14_074_000], ["14074000hz", 14_074_000],
|
||||
["7074k", 7_074_000], ["14.074M", 14_074_000], ["1.2G", 1_200_000_000],
|
||||
[" 145500k ", 145_500_000],
|
||||
]) {
|
||||
assert.equal(routes.parseFrequencyParam(text), hz, `${text} did not read as ${hz} Hz`);
|
||||
}
|
||||
for (const bad of ["", " ", "abc", "-7074", "0", "14,074", "1e6", null, undefined]) {
|
||||
assert.equal(routes.parseFrequencyParam(bad), null, `${bad} was read as a frequency`);
|
||||
}
|
||||
});
|
||||
|
||||
test("tune links carry rig, frequency, mode and bandwidth", async () => {
|
||||
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
|
||||
assert.deepEqual({ ...routes.parseTuneLink("?rig=sdr&f=14.074M&mode=usb&bw=3k") }, {
|
||||
rig: "sdr", freqHz: 14_074_000, mode: "USB", bandwidthHz: 3000,
|
||||
});
|
||||
// A link with nothing to say must not tune anything.
|
||||
assert.deepEqual({ ...routes.parseTuneLink("?tab=map") }, {
|
||||
rig: null, freqHz: null, mode: null, bandwidthHz: null,
|
||||
});
|
||||
// Junk in a parameter is not a frequency, and must not become one.
|
||||
assert.deepEqual({ ...routes.parseTuneLink("?f=&mode=NOTAMODE&bw=wide") }, {
|
||||
rig: null, freqHz: null, mode: null, bandwidthHz: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("writing a tune link canonicalises it and leaves other parameters alone", async () => {
|
||||
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
|
||||
const search = routes.tuneLinkSearch("?theme=dark&f=7074k", {
|
||||
rig: "sdr", freqHz: 14_074_000.4, mode: "usb", bandwidthHz: 3000,
|
||||
});
|
||||
const params = new URLSearchParams(search);
|
||||
assert.equal(params.get("theme"), "dark", "an unrelated parameter was dropped");
|
||||
assert.equal(params.get("f"), "14074000", "the frequency was not written as whole Hz");
|
||||
assert.equal(params.get("mode"), "USB");
|
||||
assert.equal(params.get("bw"), "3000");
|
||||
assert.equal(params.get("rig"), "sdr");
|
||||
// Round trip: what was written reads back as what went in.
|
||||
assert.deepEqual({ ...routes.parseTuneLink(search) }, {
|
||||
rig: "sdr", freqHz: 14_074_000, mode: "USB", bandwidthHz: 3000,
|
||||
});
|
||||
// Nothing known yet means no stale tune parameters left behind.
|
||||
const cleared = routes.tuneLinkSearch(search, { rig: null, freqHz: null, mode: null, bandwidthHz: null });
|
||||
assert.equal(cleared, "?theme=dark");
|
||||
assert.equal(routes.tuneLinkSearch("", { rig: null, freqHz: null, mode: null, bandwidthHz: null }), "");
|
||||
});
|
||||
|
||||
+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");
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// A link is how one operator tells another where to listen. Opening one has to
|
||||
// put the radio there, and the address bar has to keep up with the dial after
|
||||
// that — a link that goes stale the moment someone tunes is worse than none,
|
||||
// because it looks like it works.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||
|
||||
/* global document, window */
|
||||
|
||||
const LINK_HZ = 14_074_000;
|
||||
const LINK_BW = 2_800;
|
||||
const TUNED_HZ = 7_040_000;
|
||||
|
||||
const fixture = await startWebFixture({ spectrum: true });
|
||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||
|
||||
const dial = () => page.evaluate(() => ({
|
||||
freqHz: window.lastFreqHz,
|
||||
mode: document.getElementById("mode").value,
|
||||
search: window.location.search,
|
||||
path: window.location.pathname,
|
||||
}));
|
||||
|
||||
// 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 });
|
||||
|
||||
// Opening the link tunes the radio: frequency, mode and bandwidth.
|
||||
await page.goto(`${fixture.origin}/?f=14.074M&mode=USB&bw=${LINK_BW}`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#content").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const opened = await dial();
|
||||
assert.equal(opened.freqHz, LINK_HZ, `the link left the radio on ${opened.freqHz} Hz`);
|
||||
assert.equal(opened.mode, "USB", `the link left the radio in ${opened.mode}`);
|
||||
const bandwidth = await page.evaluate(() => window.currentBandwidthHz);
|
||||
assert.equal(bandwidth, LINK_BW, `the link left the bandwidth at ${bandwidth} Hz`);
|
||||
|
||||
// Hand-written frequencies are canonicalised in place, so what the operator
|
||||
// copies back out is the same link in the form the app writes.
|
||||
const params = new URLSearchParams(opened.search);
|
||||
assert.equal(params.get("f"), String(LINK_HZ), `the URL kept "${params.get("f")}"`);
|
||||
assert.equal(params.get("mode"), "USB");
|
||||
|
||||
// Tuning by hand rewrites the link. This is the part that makes the address
|
||||
// bar shareable at any moment rather than only at load.
|
||||
await tuneByHand("7.040M");
|
||||
await page.waitForTimeout(1500);
|
||||
const tuned = await dial();
|
||||
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
|
||||
assert.equal(new URLSearchParams(tuned.search).get("f"), String(TUNED_HZ),
|
||||
`the URL still reads "${tuned.search}" after tuning`);
|
||||
|
||||
// Tuning is not navigation: a swept dial must not bury the back button.
|
||||
const historyLength = await page.evaluate(() => window.history.length);
|
||||
await 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");
|
||||
|
||||
// Moving between tabs keeps the tune parameters: the path changes, the link
|
||||
// does not stop being a link.
|
||||
await page.locator('.tab[data-tab="map"]').click();
|
||||
await page.waitForTimeout(800);
|
||||
const onMap = await dial();
|
||||
assert.equal(onMap.path, "/map", `the map tab landed on ${onMap.path}`);
|
||||
assert.equal(new URLSearchParams(onMap.search).get("f"), "7100000",
|
||||
`the map tab dropped the tune parameters: "${onMap.search}"`);
|
||||
|
||||
// A link naming a rig selects it before applying the rest.
|
||||
await page.goto(`${fixture.origin}/?rig=rig-b&f=${TUNED_HZ}&mode=CW`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#content").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(2500);
|
||||
const switched = await page.evaluate(() => ({
|
||||
rig: document.getElementById("header-rig-switch-select")?.value ?? null,
|
||||
selected: window.location.search,
|
||||
mode: document.getElementById("mode").value,
|
||||
}));
|
||||
assert.equal(switched.rig, "rig-b", `the link left the client on rig "${switched.rig}"`);
|
||||
assert.equal(switched.mode, "CW", `the link left the radio in ${switched.mode}`);
|
||||
|
||||
// A link with nothing to say tunes nothing, and the address bar fills in
|
||||
// with where the radio already is.
|
||||
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#content").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(2000);
|
||||
const bare = await dial();
|
||||
assert.equal(new URLSearchParams(bare.search).get("f"), String(bare.freqHz),
|
||||
`a bare load wrote "${bare.search}" for a radio on ${bare.freqHz} Hz`);
|
||||
|
||||
assert.deepEqual(runtimeErrors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fixture.close();
|
||||
}
|
||||
@@ -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
|
||||
@@ -249,6 +253,24 @@ export async function startWebFixture({
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/set_freq") {
|
||||
const hz = Number(url.searchParams.get("hz"));
|
||||
if (Number.isFinite(hz) && hz > 0) status.status.freq = { hz: Math.round(hz) };
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/set_mode") {
|
||||
const next = (url.searchParams.get("mode") || "").toUpperCase();
|
||||
if (next) status.status.mode = next;
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/set_bandwidth") {
|
||||
const hz = Number(url.searchParams.get("hz"));
|
||||
if (Number.isFinite(hz) && hz > 0 && status.filter) status.filter.bandwidth_hz = Math.round(hz);
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/select_rig" && request.method === "POST") {
|
||||
const remote = url.searchParams.get("remote");
|
||||
if (remote) {
|
||||
@@ -347,9 +369,11 @@ export async function startWebFixture({
|
||||
});
|
||||
// Varying, as a real one is: the client skips a frame identical to the
|
||||
// last, so a repeated payload exercises none of the state-update path.
|
||||
// The variation rides on the S-meter rather than the dial — a rig that
|
||||
// wandered a kilohertz every frame could never be told to tune.
|
||||
const frame = () => JSON.stringify({
|
||||
...status,
|
||||
status: { ...status.status, freq: { hz: 100_000_000 + (Date.now() % 1000) } },
|
||||
status: { ...status.status, rx: { ...status.status.rx, sig: meterDb + ((Date.now() % 3) - 1) } },
|
||||
});
|
||||
response.write(`data: ${frame()}\n\n`);
|
||||
const timer = setInterval(() => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user