Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0680c3ebed |
@@ -24,12 +24,6 @@ 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:
|
||||
@@ -47,8 +41,7 @@ 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 (logging init, name normalization)
|
||||
trx-config/ # Client + server config structs, loader, validators (~2,500 LOC)
|
||||
trx-app/ # Shared application helpers (config paths, logging init)
|
||||
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
|
||||
@@ -56,7 +49,6 @@ src/
|
||||
trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT
|
||||
trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC)
|
||||
trx-client/ # Client binary: remote connection, frontend spawning (~1,500 LOC)
|
||||
trx-logbook/ # Station log: QSO record, ADIF reader/writer, append-only store
|
||||
trx-frontend/ # Frontend trait (FrontendSpawner), runtime context
|
||||
trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth
|
||||
trx-frontend-http-json/ # JSON-over-TCP control frontend
|
||||
|
||||
Generated
+4
-49
@@ -2412,16 +2412,6 @@ 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"
|
||||
@@ -3041,6 +3031,10 @@ dependencies = [
|
||||
name = "trx-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@@ -3121,7 +3115,6 @@ dependencies = [
|
||||
"toml",
|
||||
"tracing",
|
||||
"trx-app",
|
||||
"trx-config",
|
||||
"trx-core",
|
||||
"trx-frontend",
|
||||
"trx-frontend-http",
|
||||
@@ -3131,23 +3124,6 @@ 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"
|
||||
@@ -3156,16 +3132,13 @@ 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",
|
||||
@@ -3218,7 +3191,6 @@ dependencies = [
|
||||
"base64",
|
||||
"brotli 7.0.0",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
@@ -3227,13 +3199,11 @@ dependencies = [
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"trx-core",
|
||||
"trx-frontend",
|
||||
"trx-logbook",
|
||||
"trx-protocol",
|
||||
"ts-rs",
|
||||
"uuid",
|
||||
@@ -3272,19 +3242,6 @@ dependencies = [
|
||||
"rustfft",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-logbook"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-protocol"
|
||||
version = "0.1.0"
|
||||
@@ -3337,14 +3294,12 @@ dependencies = [
|
||||
"trx-app",
|
||||
"trx-aprs",
|
||||
"trx-backend",
|
||||
"trx-config",
|
||||
"trx-core",
|
||||
"trx-cw",
|
||||
"trx-decode-log",
|
||||
"trx-ftx",
|
||||
"trx-protocol",
|
||||
"trx-reporting",
|
||||
"trx-sstv",
|
||||
"trx-vdes",
|
||||
"trx-wefax",
|
||||
"trx-wspr",
|
||||
|
||||
@@ -18,7 +18,6 @@ members = [
|
||||
"src/trx-core",
|
||||
"src/trx-protocol",
|
||||
"src/trx-app",
|
||||
"src/trx-config",
|
||||
"src/trx-reporting",
|
||||
"src/trx-server",
|
||||
"src/trx-server/trx-backend",
|
||||
@@ -26,7 +25,6 @@ members = [
|
||||
"src/trx-server/trx-backend/trx-backend-ft450d",
|
||||
"src/trx-server/trx-backend/trx-backend-soapysdr",
|
||||
"src/trx-client",
|
||||
"src/trx-client/trx-logbook",
|
||||
"src/trx-client/trx-frontend",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http",
|
||||
"src/trx-client/trx-frontend/trx-frontend-http-json",
|
||||
|
||||
@@ -93,17 +93,13 @@ 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, copy `trx-rs.toml.example` — a commented example covering every
|
||||
setting — and edit it by hand:
|
||||
Alternatively, generate example configs and edit them by hand:
|
||||
|
||||
```bash
|
||||
cp trx-rs.toml.example trx-rs.toml
|
||||
./target/release/trx-server --check-config --config trx-rs.toml
|
||||
./target/release/trx-server --print-config > trx-server.toml
|
||||
./target/release/trx-client --print-config > trx-client.toml
|
||||
```
|
||||
|
||||
`--check-config` reports everything wrong with a config without starting
|
||||
anything. `--print-config` prints the same settings without comments.
|
||||
|
||||
### 4. Run
|
||||
|
||||
```bash
|
||||
@@ -111,9 +107,6 @@ anything. `--print-config` prints the same settings without comments.
|
||||
./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
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
# 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.
|
||||
+23
-251
@@ -17,61 +17,30 @@ frontends.
|
||||
|
||||
## Configuration
|
||||
|
||||
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.
|
||||
Both `trx-server` and `trx-client` use TOML configuration files. Use
|
||||
`--print-config` to generate a fully commented example.
|
||||
|
||||
### File Locations
|
||||
|
||||
Both binaries use the same lookup order:
|
||||
|
||||
**trx-server** lookup order:
|
||||
1. `--config <FILE>`
|
||||
2. `./trx-rs.toml`
|
||||
3. `~/.config/trx-rs/trx-rs.toml`
|
||||
4. `/etc/trx-rs/trx-rs.toml`
|
||||
2. `./trx-server.toml`
|
||||
3. `~/.trx-server.toml`
|
||||
4. `~/.config/trx-rs/server.toml`
|
||||
5. `/etc/trx-rs/server.toml`
|
||||
|
||||
**trx-client** lookup order:
|
||||
1. `--config <FILE>`
|
||||
2. `./trx-client.toml`
|
||||
3. `~/.config/trx-rs/client.toml`
|
||||
4. `/etc/trx-rs/client.toml`
|
||||
|
||||
CLI arguments override config file values.
|
||||
|
||||
### Checking a Config
|
||||
### Environment Variables
|
||||
|
||||
`--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.
|
||||
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by
|
||||
both server and client.
|
||||
|
||||
### Server Options
|
||||
|
||||
@@ -127,7 +96,6 @@ credentials inline and is readable by group or others is flagged at startup.
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
|
||||
| `tokens_file` | string | — | Read tokens from this file, one per line |
|
||||
|
||||
#### `[audio]`
|
||||
|
||||
@@ -153,13 +121,6 @@ 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]`
|
||||
|
||||
@@ -236,29 +197,6 @@ 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:
|
||||
@@ -302,32 +240,12 @@ 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]`
|
||||
|
||||
@@ -336,31 +254,6 @@ A name in any of those maps that no remote answers to is a config error.
|
||||
| `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]`
|
||||
|
||||
@@ -368,11 +261,7 @@ With `enabled = true`, at least one passphrase must be set.
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Enable Hamlib rigctl |
|
||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||
| `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.
|
||||
| `port` | u16 | `4532` | Bind port |
|
||||
|
||||
#### `[frontends.http_json]`
|
||||
|
||||
@@ -382,17 +271,13 @@ older single `port` key and `--rigctl-port` are ignored.
|
||||
| `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_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` |
|
||||
| `server_port` | u16 | `4531` | Server audio port |
|
||||
| `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 |
|
||||
@@ -402,129 +287,16 @@ older single `port` key and `--rigctl-port` are ignored.
|
||||
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`, `--check-config`, `--strict-config`, `--rig`,
|
||||
`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only.
|
||||
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
|
||||
`--port`. SDR options are file-only.
|
||||
|
||||
**trx-client:**
|
||||
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`,
|
||||
`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`,
|
||||
`--http-port`, `--rigctl-listen`, `--http-json-listen`, `--http-json-port`,
|
||||
`--callsign`.
|
||||
|
||||
`--listen` on the server overrides the bind address of both the control
|
||||
listener and every rig's audio listener.
|
||||
|
||||
---
|
||||
|
||||
## Multiple Rigs in the Web UI
|
||||
|
||||
A client connected to several rigs decodes all of them at once, whichever one
|
||||
is on screen. The rig picker in the header decides which rig the UI is about,
|
||||
and each page answers that differently:
|
||||
|
||||
| Page | Shows |
|
||||
|------|-------|
|
||||
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
|
||||
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
|
||||
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. HF APRS is a source of its own there, filtered apart from VHF APRS. |
|
||||
| Statistics | The whole station, including the per-rig comparison. |
|
||||
|
||||
Switching rigs repaints the radio and digital modes pages for the rig now
|
||||
selected. Nothing is lost by switching: the traffic other rigs heard is still
|
||||
held, and switching back brings it up again. The one exception is the CW pane,
|
||||
which is a single running stream of copied text rather than a list of frames,
|
||||
so it starts empty on the rig you switch to.
|
||||
|
||||
Each browser tab keeps its own selection, so two tabs can watch two rigs.
|
||||
|
||||
---
|
||||
|
||||
## Logbook
|
||||
|
||||
The **Logbook** tab keeps the station's contacts and speaks ADIF, so a log can
|
||||
be uploaded to LoTW, eQSL, Club Log or QRZ, or moved to another logger.
|
||||
|
||||
An entry opens pre-filled with six fields and no more: the frequency, mode and
|
||||
name of the rig you are on, the time from the server's clock, and — when the
|
||||
entry was started from a decode or a map station — that station's callsign and
|
||||
locator. Signal reports, name, comment and the rest are yours to fill in; a
|
||||
report in particular is never guessed, because an FT8 SNR is not what was sent.
|
||||
|
||||
Your own callsign and locator are not per-contact fields. They are station
|
||||
identity: the callsign comes from `[trx-client.general].callsign`, the locator
|
||||
from the rig's position, and both are shown once at the top of the panel. The
|
||||
operator defaults to the station callsign and can be changed for the session,
|
||||
which is what a multi-operator station needs.
|
||||
|
||||
**Times are the server's**, in UTC — the server is the machine at the radio. If
|
||||
the browser's clock disagrees by more than a second the entry says so rather
|
||||
than logging a time you did not expect.
|
||||
|
||||
**A decode is not a contact.** The decoders are receive-only, so a **Log**
|
||||
button on an FT8 or APRS row opens an entry with what was heard in it and logs
|
||||
nothing by itself. Digital QSOs made in WSJT-X come in through Import ADIF, the
|
||||
way every other logger takes them.
|
||||
|
||||
| Action | What it does |
|
||||
|--------|--------------|
|
||||
| Log contact | Writes the entry and opens a fresh one |
|
||||
| Export ADIF | Downloads the log — filtered, if a filter is set |
|
||||
| Import ADIF | Reads a file, skipping contacts already held and reporting what could not be read |
|
||||
| Worked before | Shows, as you type a callsign, which bands and modes it has been worked on |
|
||||
|
||||
Two contacts are treated as the same when the callsign, band and mode match and
|
||||
the times are within two minutes: two loggers rarely stamp a QSO to the same
|
||||
minute, one recording when it started and the other when it was typed.
|
||||
|
||||
The log is a JSON Lines file, appended one contact at a time so a crash costs at
|
||||
most the contact being written. It lives in your data directory by default;
|
||||
`[trx-client.logbook].path` moves it, for a station that keeps its log on a
|
||||
backed-up volume.
|
||||
|
||||
### Ham radio layout
|
||||
|
||||
The **Ham radio** operator layout opens on the logbook with the transceiver
|
||||
controls around it — the arrangement for working the bands, where logging the
|
||||
contact is the task and the radio is the instrument. It is offered only where
|
||||
the selected rig can transmit; a receiver has no contacts to log.
|
||||
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
|
||||
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
|
||||
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -20,26 +20,14 @@ fn main() {
|
||||
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]
|
||||
[(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)
|
||||
}
|
||||
if ((x + y) / 16) % 2 == 0 { (240, 240, 40) } else { (20, 20, 90) }
|
||||
};
|
||||
rgb[at] = r;
|
||||
rgb[at + 1] = g;
|
||||
@@ -51,17 +39,9 @@ fn main() {
|
||||
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");
|
||||
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 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());
|
||||
@@ -78,14 +58,7 @@ fn main() {
|
||||
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
|
||||
);
|
||||
std::fs::write(format!("{dir}/{name}-decoded.png"), canvas.to_png().expect("png")).expect("write");
|
||||
println!("{}: {} lines, complete={}", mode.name, image.lines, image.complete);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,61 +79,6 @@ pub struct SstvImage {
|
||||
pub started_ms: i64,
|
||||
}
|
||||
|
||||
impl SstvImage {
|
||||
/// The picture as a canvas, for saving or encoding.
|
||||
pub fn canvas(&self) -> ImageCanvas {
|
||||
let width = usize::from(self.width);
|
||||
let mut canvas = ImageCanvas::new(width, usize::from(self.height));
|
||||
for (y, row) in self.rgb.chunks(width * 3).enumerate() {
|
||||
canvas.put_row(y, row);
|
||||
}
|
||||
canvas
|
||||
}
|
||||
|
||||
/// The picture as PNG bytes.
|
||||
pub fn to_png(&self) -> Result<Vec<u8>, String> {
|
||||
self.canvas().to_png()
|
||||
}
|
||||
|
||||
/// The picture as a base64 PNG, for the journey to a client.
|
||||
pub fn to_png_base64(&self) -> Result<String, String> {
|
||||
self.canvas().to_png_base64()
|
||||
}
|
||||
|
||||
/// Write the picture into `dir`, named for when and where it arrived.
|
||||
pub fn save_png(
|
||||
&self,
|
||||
dir: &std::path::Path,
|
||||
freq_hz: u64,
|
||||
) -> Result<std::path::PathBuf, String> {
|
||||
self.canvas()
|
||||
.save_png(dir, freq_hz, self.mode, &stamp(self.started_ms))
|
||||
}
|
||||
}
|
||||
|
||||
/// `YYYYMMDDTHHMMSSZ` for a millisecond timestamp, for file names that sort.
|
||||
fn stamp(ms: i64) -> String {
|
||||
let secs = ms.div_euclid(1000);
|
||||
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
let (hour, minute, second) = (rest / 3600, (rest % 3600) / 60, rest % 60);
|
||||
format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}Z")
|
||||
}
|
||||
|
||||
/// Days since the Unix epoch to a calendar date (Howard Hinnant's algorithm).
|
||||
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
enum State {
|
||||
/// Listening for a header.
|
||||
Searching,
|
||||
@@ -247,8 +192,7 @@ impl SstvDecoder {
|
||||
// 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);
|
||||
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;
|
||||
}
|
||||
@@ -440,8 +384,7 @@ impl SstvDecoder {
|
||||
if from >= self.freqs.len() {
|
||||
return None;
|
||||
}
|
||||
self.find_sync(from, to, mode)
|
||||
.map(|at| self.base + at as u64)
|
||||
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
|
||||
@@ -518,10 +461,7 @@ fn sample_scan(
|
||||
(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()),
|
||||
)
|
||||
(at.min(freqs.len().saturating_sub(1)), (at + 1).min(freqs.len()))
|
||||
};
|
||||
if to <= from {
|
||||
out.push(0);
|
||||
@@ -563,19 +503,14 @@ fn compose_rows(reception: &mut Reception, scans: &[(Channel, Vec<u8>)]) -> Vec<
|
||||
// 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();
|
||||
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 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)]
|
||||
}
|
||||
@@ -642,18 +577,3 @@ fn finish(reception: &Reception) -> SstvImage {
|
||||
started_ms: reception.started_ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_stamps_are_utc_and_sort_in_time_order() {
|
||||
// Known instants, checked against `date -u -r <secs>`.
|
||||
assert_eq!(stamp(0), "19700101T000000Z");
|
||||
assert_eq!(stamp(1_000_000_000_000), "20010909T014640Z");
|
||||
assert_eq!(stamp(1_770_000_000_000), "20260202T024000Z");
|
||||
// Sorting the names sorts the pictures.
|
||||
assert!(stamp(1_770_000_000_000) < stamp(1_770_000_001_000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,13 +176,7 @@ fn design_bandpass_fir(sample_rate: u32) -> Vec<f32> {
|
||||
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 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;
|
||||
@@ -229,10 +223,7 @@ mod tests {
|
||||
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,
|
||||
);
|
||||
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
|
||||
}
|
||||
@@ -285,13 +276,7 @@ mod tests {
|
||||
// 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"
|
||||
);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,22 +29,10 @@ pub fn hz_from_level(level: u8) -> f32 {
|
||||
/// 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
|
||||
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 {
|
||||
@@ -52,20 +40,11 @@ pub fn vis_tones(vis: u8) -> Vec<Tone> {
|
||||
if set {
|
||||
ones += 1;
|
||||
}
|
||||
tones.push(Tone {
|
||||
hz: if set { 1100.0 } else { 1300.0 },
|
||||
ms: 30.0,
|
||||
});
|
||||
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.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
|
||||
}
|
||||
|
||||
@@ -118,10 +97,7 @@ pub fn encode_tones(mode: &SstvMode, frame: &Frame<'_>) -> Vec<Tone> {
|
||||
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.push(Tone { hz: hz_from_level(level), ms: pixel_ms });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,8 +149,7 @@ fn chroma(mode: &SstvMode, frame: &Frame<'_>, x: usize, top: usize, want_cr: boo
|
||||
/// 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 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
|
||||
@@ -220,11 +195,7 @@ mod tests {
|
||||
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 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;
|
||||
|
||||
@@ -107,8 +107,7 @@ impl ImageCanvas {
|
||||
.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()))?;
|
||||
std::fs::write(&path, self.to_png()?).map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,40 +147,22 @@ impl SstvMode {
|
||||
const MARTIN_M1: &[Segment] = &[
|
||||
Segment::Sync(4.862),
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 146.432,
|
||||
},
|
||||
Segment::Scan { channel: Channel::Green, ms: 146.432 },
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 146.432,
|
||||
},
|
||||
Segment::Scan { channel: Channel::Blue, ms: 146.432 },
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 146.432,
|
||||
},
|
||||
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::Scan { channel: Channel::Green, ms: 73.216 },
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 73.216,
|
||||
},
|
||||
Segment::Scan { channel: Channel::Blue, ms: 73.216 },
|
||||
Segment::Gap(0.572),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 73.216,
|
||||
},
|
||||
Segment::Scan { channel: Channel::Red, ms: 73.216 },
|
||||
Segment::Gap(0.572),
|
||||
];
|
||||
|
||||
@@ -192,59 +174,32 @@ const MARTIN_M2: &[Segment] = &[
|
||||
|
||||
const SCOTTIE_S1: &[Segment] = &[
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Green,
|
||||
ms: 138.240,
|
||||
},
|
||||
Segment::Scan { channel: Channel::Green, ms: 138.240 },
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 138.240,
|
||||
},
|
||||
Segment::Scan { channel: Channel::Blue, ms: 138.240 },
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 138.240,
|
||||
},
|
||||
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::Scan { channel: Channel::Green, ms: 88.064 },
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 88.064,
|
||||
},
|
||||
Segment::Scan { channel: Channel::Blue, ms: 88.064 },
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 88.064,
|
||||
},
|
||||
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::Scan { channel: Channel::Green, ms: 345.6 },
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Blue,
|
||||
ms: 345.6,
|
||||
},
|
||||
Segment::Scan { channel: Channel::Blue, ms: 345.6 },
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::Red,
|
||||
ms: 345.6,
|
||||
},
|
||||
Segment::Scan { channel: Channel::Red, ms: 345.6 },
|
||||
];
|
||||
|
||||
/// Scottie's sync arrives after the green and blue scans: 1.5 + 138.24 + 1.5 +
|
||||
@@ -260,37 +215,22 @@ const fn scottie_sync_offset(scan_ms: f64) -> f64 {
|
||||
const ROBOT_36: &[Segment] = &[
|
||||
Segment::Sync(9.0),
|
||||
Segment::Gap(3.0),
|
||||
Segment::Scan {
|
||||
channel: Channel::LumaOdd,
|
||||
ms: 88.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,
|
||||
},
|
||||
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::Scan { channel: Channel::LumaOdd, ms: 138.0 },
|
||||
Segment::Gap(4.5),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::ChromaR,
|
||||
ms: 69.0,
|
||||
},
|
||||
Segment::Scan { channel: Channel::ChromaR, ms: 69.0 },
|
||||
Segment::Gap(4.5),
|
||||
Segment::Gap(1.5),
|
||||
Segment::Scan {
|
||||
channel: Channel::ChromaB,
|
||||
ms: 69.0,
|
||||
},
|
||||
Segment::Scan { channel: Channel::ChromaB, ms: 69.0 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -303,22 +243,10 @@ macro_rules! pd_segments {
|
||||
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,
|
||||
},
|
||||
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 },
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -338,186 +266,87 @@ pd_segments!(PD_290, 228.800);
|
||||
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,
|
||||
},
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -548,9 +377,7 @@ mod tests {
|
||||
assert!(
|
||||
(sum - mode.line_ms).abs() < 0.001,
|
||||
"{}: segments total {:.4} ms, line time says {:.4} ms",
|
||||
mode.name,
|
||||
sum,
|
||||
mode.line_ms,
|
||||
mode.name, sum, mode.line_ms,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -577,13 +404,7 @@ mod tests {
|
||||
ColorModel::YCrCb => 3,
|
||||
ColorModel::YCrCbPaired => 4,
|
||||
};
|
||||
assert_eq!(
|
||||
scans.len(),
|
||||
expected,
|
||||
"{} has {} scans",
|
||||
mode.name,
|
||||
scans.len()
|
||||
);
|
||||
assert_eq!(scans.len(), expected, "{} has {} scans", mode.name, scans.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,10 +417,7 @@ mod tests {
|
||||
let actual = mode.frame_secs();
|
||||
assert!(
|
||||
(actual - secs).abs() < 1.5,
|
||||
"{} takes {:.1} s, expected about {:.0} s",
|
||||
mode.name,
|
||||
actual,
|
||||
secs,
|
||||
"{} takes {:.1} s, expected about {:.0} s", mode.name, actual, secs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,11 +174,7 @@ mod tests {
|
||||
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
|
||||
);
|
||||
assert_eq!(hit.code, mode.vis, "{} decoded as VIS {}", mode.name, hit.code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,8 +188,7 @@ mod tests {
|
||||
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,
|
||||
"image starts at {}, expected about {expected}", hit.image_start,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,11 +199,7 @@ mod tests {
|
||||
// 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
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -134,38 +134,16 @@ fn round_trip(vis: u8, tolerance: f64) {
|
||||
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 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()
|
||||
);
|
||||
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!(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
|
||||
);
|
||||
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!(
|
||||
@@ -231,21 +209,13 @@ 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 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_eq!(images.len(), 1, "expected one picture from a transmission in silence");
|
||||
assert!(images[0].complete);
|
||||
}
|
||||
|
||||
@@ -256,11 +226,7 @@ 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 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.
|
||||
@@ -268,29 +234,17 @@ fn a_truncated_transmission_still_yields_its_lines() {
|
||||
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"
|
||||
);
|
||||
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.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,
|
||||
"{} 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}"
|
||||
);
|
||||
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
|
||||
@@ -300,11 +254,7 @@ 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 frame = Frame { width, height, rgb: &sent };
|
||||
let one = encode(mode, &frame, SAMPLE_RATE);
|
||||
|
||||
let mut audio = one.clone();
|
||||
@@ -312,16 +262,8 @@ fn decodes_a_second_transmission_after_the_first() {
|
||||
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"
|
||||
);
|
||||
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
|
||||
@@ -331,11 +273,7 @@ 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 frame = Frame { width, height, rgb: &sent };
|
||||
let clean = encode(mode, &frame, SAMPLE_RATE);
|
||||
|
||||
// Deterministic pseudo-noise at about 20 dB below the signal.
|
||||
@@ -365,11 +303,7 @@ 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,
|
||||
};
|
||||
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);
|
||||
@@ -377,8 +311,5 @@ fn tolerates_a_transmitter_clock_that_runs_fast() {
|
||||
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"
|
||||
);
|
||||
assert!(error < 12.0, "mean error with a fast clock {error:.1} levels");
|
||||
}
|
||||
|
||||
@@ -9,5 +9,9 @@ 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"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// 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
|
||||
|
||||
//! Shared application helpers.
|
||||
//!
|
||||
//! Configuration types and their loader live in the `trx-config` crate.
|
||||
|
||||
pub mod config;
|
||||
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;
|
||||
|
||||
@@ -19,52 +19,6 @@
|
||||
//! 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)
|
||||
@@ -99,47 +53,6 @@ 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());
|
||||
@@ -21,7 +21,6 @@ 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_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,
|
||||
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,
|
||||
};
|
||||
use trx_core::decode::DecodedMessage;
|
||||
use trx_frontend::VChanAudioCmd;
|
||||
@@ -533,9 +533,7 @@ async fn handle_single_rig_connection(
|
||||
| AUDIO_MSG_LRPT_IMAGE
|
||||
| AUDIO_MSG_LRPT_PROGRESS
|
||||
| AUDIO_MSG_WEFAX_DECODE
|
||||
| AUDIO_MSG_WEFAX_PROGRESS
|
||||
| AUDIO_MSG_SSTV_DECODE
|
||||
| AUDIO_MSG_SSTV_PROGRESS,
|
||||
| AUDIO_MSG_WEFAX_PROGRESS,
|
||||
payload,
|
||||
)) => {
|
||||
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
||||
|
||||
+1214
-5
File diff suppressed because it is too large
Load Diff
+8
-142
@@ -20,7 +20,6 @@ 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;
|
||||
@@ -51,12 +50,6 @@ 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>,
|
||||
@@ -81,7 +74,7 @@ struct Cli {
|
||||
/// rigctl frontend listen address
|
||||
#[arg(long = "rigctl-listen")]
|
||||
rigctl_listen: Option<IpAddr>,
|
||||
/// Deprecated: ignored, use [frontends.rigctl].rig_ports
|
||||
/// rigctl frontend listen port
|
||||
#[arg(long = "rigctl-port")]
|
||||
rigctl_port: Option<u16>,
|
||||
/// JSON TCP frontend listen address
|
||||
@@ -116,65 +109,6 @@ 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>,
|
||||
@@ -201,44 +135,20 @@ async fn async_init() -> DynResult<AppState> {
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
let loaded = if let Some(ref path) = cli.config {
|
||||
ClientConfig::load_from_file(path)?
|
||||
let (cfg, config_path) = if let Some(ref path) = cli.config {
|
||||
let cfg = ClientConfig::load_from_file(path)?;
|
||||
(cfg, Some(path.clone()))
|
||||
} else {
|
||||
ClientConfig::load_from_default_paths()?
|
||||
};
|
||||
let config_path = loaded.path.clone();
|
||||
cfg.validate()
|
||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||
|
||||
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());
|
||||
init_logging(cfg.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
|
||||
@@ -279,10 +189,6 @@ async fn async_init() -> DynResult<AppState> {
|
||||
.http
|
||||
.decode_history_retention_min_by_rig
|
||||
.clone();
|
||||
frontend_runtime.http_ui.logbook_path = cfg.logbook.path.clone();
|
||||
// The callsign the station is on the air with, which every contact is
|
||||
// logged under and which the operator defaults to.
|
||||
frontend_runtime.http_ui.station_callsign = cfg.general.callsign.clone();
|
||||
|
||||
// Resolve remote entries: CLI --url > [[remotes]] > legacy [remote] > error
|
||||
let resolved_remotes: Vec<RemoteEntry> = if let Some(ref url) = cli.url {
|
||||
@@ -292,14 +198,10 @@ 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,
|
||||
token_file: None,
|
||||
},
|
||||
auth: config::RemoteAuthConfig { token },
|
||||
poll_interval_ms,
|
||||
}]
|
||||
} else {
|
||||
@@ -360,30 +262,6 @@ 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()
|
||||
@@ -488,13 +366,6 @@ 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 {
|
||||
@@ -508,8 +379,6 @@ 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(),
|
||||
@@ -658,9 +527,6 @@ 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(_) => {}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+103
-275
@@ -20,22 +20,35 @@ 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, 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};
|
||||
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate};
|
||||
|
||||
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;
|
||||
|
||||
// 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, 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);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteClientConfig {
|
||||
@@ -44,12 +57,6 @@ 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.
|
||||
@@ -498,100 +505,6 @@ 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,
|
||||
@@ -599,33 +512,7 @@ async fn handle_spectrum_connection(
|
||||
) -> RigResult<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
// 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);
|
||||
let mut interval = time::interval(SPECTRUM_POLL_INTERVAL);
|
||||
// Cache the token outside the poll loop to avoid cloning it every 50ms.
|
||||
let cached_token = config.token.clone();
|
||||
|
||||
@@ -1308,13 +1195,92 @@ 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;
|
||||
@@ -1509,8 +1475,6 @@ 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())),
|
||||
@@ -1556,8 +1520,6 @@ 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())),
|
||||
@@ -1573,130 +1535,6 @@ 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());
|
||||
@@ -1710,8 +1548,6 @@ 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())),
|
||||
@@ -1744,8 +1580,6 @@ 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())),
|
||||
@@ -1770,8 +1604,6 @@ 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())),
|
||||
@@ -1812,8 +1644,6 @@ 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())),
|
||||
@@ -1887,8 +1717,6 @@ 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, SstvMessage, VdesMessage,
|
||||
WefaxMessage, WsprMessage,
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
||||
WsprMessage,
|
||||
};
|
||||
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
||||
use trx_core::{DynResult, RigRequest, RigState};
|
||||
@@ -233,7 +233,6 @@ pub struct DecodeHistoryContext {
|
||||
pub ft2: DecodeHistory<Ft8Message>,
|
||||
pub wspr: DecodeHistory<WsprMessage>,
|
||||
pub wefax: DecodeHistory<WefaxMessage>,
|
||||
pub sstv: DecodeHistory<SstvMessage>,
|
||||
}
|
||||
|
||||
impl Default for DecodeHistoryContext {
|
||||
@@ -249,7 +248,6 @@ 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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,10 +290,6 @@ pub struct HttpUiConfig {
|
||||
pub bandplan_region: String,
|
||||
pub decode_history_retention_min: u64,
|
||||
pub decode_history_retention_min_by_rig: HashMap<String, u64>,
|
||||
/// Where the station log is kept; `None` takes the default.
|
||||
pub logbook_path: Option<String>,
|
||||
/// The callsign contacts are logged under, and the operator by default.
|
||||
pub station_callsign: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for HttpUiConfig {
|
||||
@@ -309,8 +303,6 @@ impl Default for HttpUiConfig {
|
||||
bandplan_region: "iaru_r1".to_string(),
|
||||
decode_history_retention_min: 24 * 60,
|
||||
decode_history_retention_min_by_rig: HashMap::new(),
|
||||
logbook_path: None,
|
||||
station_callsign: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ build = "build.rs"
|
||||
[dependencies]
|
||||
trx-core = { path = "../../../trx-core" }
|
||||
trx-frontend = { path = ".." }
|
||||
trx-logbook = { path = "../../trx-logbook" }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
trx-protocol = { path = "../../../../src/trx-protocol" }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
@@ -32,6 +30,3 @@ pickledb = "0.5"
|
||||
dirs = "6"
|
||||
uuid = { workspace = true }
|
||||
ts-rs = "12.0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
@@ -144,22 +140,18 @@ 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 rigMessages = aisRigMessages();
|
||||
const vessels = aisLatestByVessel(rigMessages);
|
||||
const vessels = aisLatestByVessel(aisMessageHistory);
|
||||
if (aisVesselCountEl) {
|
||||
const count = vessels.length;
|
||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (aisLatestSeenEl) {
|
||||
const latest = rigMessages[0];
|
||||
const latest = aisMessageHistory[0];
|
||||
if (!latest) {
|
||||
aisLatestSeenEl.textContent = "No traffic yet";
|
||||
} else {
|
||||
@@ -230,9 +222,7 @@ 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 && isActiveRigDecode(msg.rig_id)
|
||||
);
|
||||
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||
if (!isAis || messages.length === 0) {
|
||||
aisBarOverlay.style.display = "none";
|
||||
@@ -277,7 +267,7 @@ function renderAisHistory() {
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const message of aisRigMessages()) {
|
||||
for (const message of aisMessageHistory) {
|
||||
fragment.appendChild(renderAisRow(message));
|
||||
}
|
||||
aisMessagesEl.replaceChildren(fragment);
|
||||
@@ -309,10 +299,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([], {
|
||||
@@ -352,9 +342,8 @@ if (aisFilterInput) {
|
||||
});
|
||||
}
|
||||
function onServerAis(msg) {
|
||||
const message = normalizeServerAisMessage(msg);
|
||||
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
||||
addAisMessage(message);
|
||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||
addAisMessage(normalizeServerAisMessage(msg));
|
||||
}
|
||||
updateAisSummary();
|
||||
window.trxPluginRuntime.registerDecoder({
|
||||
@@ -364,10 +353,6 @@ 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);
|
||||
|
||||
@@ -633,12 +633,9 @@ function elementById(id) {
|
||||
compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" },
|
||||
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
|
||||
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
|
||||
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
|
||||
// Working the bands: the log is the task and the radio is the instrument,
|
||||
// so this one opens on the logbook with the controls a keystroke away.
|
||||
ham: { label: "Ham radio", unavailable: "Ham radio needs a rig that can transmit", advanced: true, audio: true, scheduler: false, preferredTab: "logbook", capability: "ham" }
|
||||
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" }
|
||||
};
|
||||
const layoutCapabilities = { broadcast: false, digital: false, ham: false };
|
||||
const layoutCapabilities = { broadcast: false, digital: false };
|
||||
let activeRigId = null;
|
||||
const layoutSections = [
|
||||
{ id: "advanced-radio-controls", key: "advanced" },
|
||||
@@ -888,7 +885,7 @@ function elementById(id) {
|
||||
if (restoreFocus) more.focus();
|
||||
};
|
||||
api.closeMobileOverlays = closeMore;
|
||||
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||
if (!source) return;
|
||||
const item = document.createElement("button");
|
||||
@@ -935,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", "sstv"]]
|
||||
["Broadcast & images", ["rds", "sat", "wefax"]]
|
||||
];
|
||||
groups.forEach(([label, ids]) => {
|
||||
const group = document.createElement("optgroup");
|
||||
@@ -1068,9 +1065,6 @@ var runtime = {
|
||||
plugin.prune();
|
||||
return true;
|
||||
},
|
||||
rerenderAll() {
|
||||
for (const plugin of decoders.values()) plugin.rerender?.();
|
||||
},
|
||||
syncMapAll() {
|
||||
for (const plugin of decoders.values()) plugin.syncMap?.();
|
||||
},
|
||||
@@ -1533,10 +1527,8 @@ function decodeCbor(buffer) {
|
||||
var TAB_ORDER = [
|
||||
"main",
|
||||
"bookmarks",
|
||||
"logbook",
|
||||
"digital-modes",
|
||||
"map",
|
||||
"satellites",
|
||||
"statistics",
|
||||
"recorder",
|
||||
"settings",
|
||||
@@ -1545,10 +1537,8 @@ var TAB_ORDER = [
|
||||
var TAB_PATHS = {
|
||||
main: "/",
|
||||
bookmarks: "/bookmarks",
|
||||
logbook: "/logbook",
|
||||
"digital-modes": "/digital-modes",
|
||||
map: "/map",
|
||||
satellites: "/satellites",
|
||||
statistics: "/statistics",
|
||||
recorder: "/recorder",
|
||||
settings: "/settings",
|
||||
@@ -1758,7 +1748,6 @@ var pluginGroups = {
|
||||
"/background-decode.js",
|
||||
"/sat.js",
|
||||
"/wefax.js",
|
||||
"/sstv.js",
|
||||
"/ais.js",
|
||||
"/vdes.js",
|
||||
"/aprs.js",
|
||||
@@ -1766,10 +1755,8 @@ var pluginGroups = {
|
||||
],
|
||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||
satellites: ["/satellite-predictions.js"],
|
||||
statistics: ["/map-core.js"],
|
||||
bookmarks: ["/bookmarks.js"],
|
||||
logbook: ["/logbook.js"],
|
||||
recorder: [],
|
||||
settings: ["/vchan.js", "/scheduler.js"]
|
||||
};
|
||||
@@ -1794,7 +1781,7 @@ async function loadPlugins(group) {
|
||||
for (const path of pluginGroups[group]) await loadPlugin(path);
|
||||
}
|
||||
async function loadEagerPlugins() {
|
||||
await Promise.all(["digital-modes", "bookmarks", "logbook", "settings"].map(loadPlugins));
|
||||
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
|
||||
}
|
||||
async function loadPluginsForTab(tab) {
|
||||
await loadPlugins(tab);
|
||||
@@ -2253,7 +2240,7 @@ function currentDecodeHistoryRetentionMs() {
|
||||
}
|
||||
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
||||
window.applyDecodeHistoryRetention = function() {
|
||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"]) {
|
||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) {
|
||||
window.trxPluginRuntime.prune(decoder);
|
||||
}
|
||||
};
|
||||
@@ -2911,9 +2898,9 @@ function applyRigList(activeRigId, rigIds, displayNames) {
|
||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||
updateRigSubtitle(lastActiveRigId);
|
||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
||||
if (rigListChanged) {
|
||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
||||
window.trx.modules.bookmarks?.populateScopePicker();
|
||||
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
|
||||
}
|
||||
@@ -2956,10 +2943,7 @@ function refreshOperatorLayoutCapabilities() {
|
||||
));
|
||||
window.trxUi?.setLayoutCapabilities({
|
||||
broadcast: rigModes.some((modes) => modes.includes("WFM")),
|
||||
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))),
|
||||
// A logbook is for contacts made, so the layout built around it is offered
|
||||
// where a rig can transmit.
|
||||
ham: serverRigs.some((rig) => rig?.tx === true)
|
||||
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode)))
|
||||
});
|
||||
}
|
||||
function showHint(msg, duration) {
|
||||
@@ -3567,13 +3551,6 @@ 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();
|
||||
@@ -3588,8 +3565,6 @@ function resetDecoderStateOnRigSwitch() {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = "--";
|
||||
});
|
||||
refreshDecodeBars();
|
||||
window.trxPluginRuntime.rerenderAll();
|
||||
}
|
||||
function resetWfmStereoIndicator() {
|
||||
if (!wfmStFlagEl) return;
|
||||
@@ -4809,7 +4784,10 @@ 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);
|
||||
}
|
||||
refreshDecodeBars();
|
||||
if (window.updateAisBar) window.updateAisBar();
|
||||
if (window.updateVdesBar) window.updateVdesBar();
|
||||
if (window.updateAprsBar) window.updateAprsBar();
|
||||
if (window.updateFt8Bar) window.updateFt8Bar();
|
||||
for (const d of decoderRegistry) {
|
||||
if (d.activation !== "toggle") continue;
|
||||
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||||
@@ -4842,9 +4820,6 @@ 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);
|
||||
}
|
||||
@@ -5865,7 +5840,6 @@ 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);
|
||||
@@ -5895,11 +5869,7 @@ function navigateToTab(name, options = {}) {
|
||||
updateTabHistory(name, replaceHistory);
|
||||
}
|
||||
scheduleSpectrumLayout();
|
||||
if (leavingSatellites) window.clearSatPredictionDom?.();
|
||||
void loadPluginsForTab(name).then(() => {
|
||||
if (name === "satellites") window.refreshSatPredictions?.();
|
||||
flushPendingDecodeStats();
|
||||
}).catch((error) => {
|
||||
void loadPluginsForTab(name).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
if (name === "map") {
|
||||
@@ -6010,9 +5980,7 @@ async function initializeApp() {
|
||||
showAuthGate(allowGuest);
|
||||
}
|
||||
}
|
||||
var settingsUiReady = false;
|
||||
function initSettingsUI() {
|
||||
settingsUiReady = true;
|
||||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
||||
window.trx.modules.scheduler?.wireEvents();
|
||||
if (window.trx.modules.backgroundDecode) {
|
||||
@@ -6283,9 +6251,7 @@ Object.defineProperties(trxState, {
|
||||
} }
|
||||
});
|
||||
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
|
||||
void loadEagerPlugins().then(() => {
|
||||
if (settingsUiReady) initSettingsUI();
|
||||
}).catch((error) => {
|
||||
void loadEagerPlugins().catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
void initializeApp();
|
||||
@@ -6350,6 +6316,9 @@ 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);
|
||||
@@ -7625,43 +7594,11 @@ function updateDecodeStatus(text) {
|
||||
if (el && el.textContent !== "Receiving") el.textContent = text;
|
||||
}
|
||||
}
|
||||
var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
|
||||
"lrpt_image",
|
||||
"lrpt_progress",
|
||||
"wefax",
|
||||
"wefax_progress",
|
||||
"sstv",
|
||||
"sstv_progress"
|
||||
]);
|
||||
var pendingDecodeStats = [];
|
||||
var PENDING_DECODE_STATS_MAX = 5e4;
|
||||
function recordDecodeStat(kind, rig, tsMs) {
|
||||
const stats = window.trx.modules.map;
|
||||
if (stats) {
|
||||
stats.statsRecordDecode(kind, rig, tsMs);
|
||||
return;
|
||||
}
|
||||
pendingDecodeStats.push({ kind, rig, tsMs });
|
||||
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
|
||||
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
|
||||
}
|
||||
}
|
||||
function flushPendingDecodeStats() {
|
||||
const stats = window.trx.modules.map;
|
||||
if (!stats || pendingDecodeStats.length === 0) return;
|
||||
for (const entry of pendingDecodeStats.splice(0)) {
|
||||
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
|
||||
}
|
||||
stats.scheduleStatsRender();
|
||||
}
|
||||
function scheduleStatsRenderIfLoaded() {
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
function dispatchDecodeMessage(msg, skipStats = false) {
|
||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
|
||||
scheduleStatsRenderIfLoaded();
|
||||
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
|
||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
}
|
||||
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
|
||||
@@ -7705,11 +7642,11 @@ function loadDecodeHistoryOnMainThread(onReady, onError) {
|
||||
}
|
||||
function restoreDecodeHistoryGroup(kind, messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
|
||||
for (const msg of messages) {
|
||||
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
||||
}
|
||||
scheduleStatsRenderIfLoaded();
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
window.trxPluginRuntime.restore(kind, messages);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
collapseAprsDuplicates,
|
||||
normalizeAprsPacket,
|
||||
renderAprsPacketRow
|
||||
} from "./chunk-REYSUJQ4.js";
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
} from "./chunk-OPEIVJGD.js";
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
@@ -86,25 +82,20 @@ function aprsFilterMatch(pkt) {
|
||||
].filter(Boolean).join(" ").toUpperCase();
|
||||
return haystack.includes(aprsFilterText);
|
||||
}
|
||||
function aprsRigPackets() {
|
||||
return forActiveRig(aprsPacketHistory);
|
||||
}
|
||||
function aprsVisiblePackets() {
|
||||
const rigPackets = aprsRigPackets();
|
||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
|
||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
||||
return packets.filter(aprsFilterMatch);
|
||||
}
|
||||
function updateAprsSummary() {
|
||||
const rigPackets = aprsRigPackets();
|
||||
const visible = aprsVisiblePackets();
|
||||
if (aprsTotalCountEl) {
|
||||
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
||||
}
|
||||
if (aprsVisibleCountEl) {
|
||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||
}
|
||||
if (aprsLatestSeenEl) {
|
||||
const latest = rigPackets[0];
|
||||
const latest = aprsPacketHistory[0];
|
||||
if (!latest) {
|
||||
aprsLatestSeenEl.textContent = "No packets yet";
|
||||
} else {
|
||||
@@ -139,9 +130,6 @@ function renderAprsRow(pkt, isFresh) {
|
||||
},
|
||||
onCopy: (text) => {
|
||||
void copyAprsCoords(text);
|
||||
},
|
||||
onLog: (call, gridsquare) => {
|
||||
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? void 0, decoder: "aprs" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -165,9 +153,7 @@ 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 && isActiveRigDecode(p.rig_id)
|
||||
);
|
||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
||||
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) {
|
||||
@@ -229,11 +215,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" });
|
||||
@@ -291,9 +277,8 @@ if (aprsFilterInput) {
|
||||
});
|
||||
}
|
||||
function onServerAprs(pkt) {
|
||||
const packet = normalizeServerAprsPacket(pkt);
|
||||
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
|
||||
addAprsPacket(packet);
|
||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||
}
|
||||
renderAprsHistory();
|
||||
window.trxPluginRuntime.registerDecoder({
|
||||
@@ -303,10 +288,6 @@ 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);
|
||||
|
||||
+47
-116
@@ -19,11 +19,9 @@ var bgdWindow = window;
|
||||
let bookmarkList = [];
|
||||
let statusInterval = null;
|
||||
let bgdDirty = false;
|
||||
let statusByBookmark = /* @__PURE__ */ new Map();
|
||||
let lastStatus = null;
|
||||
function initBackgroundDecode(rigId, role) {
|
||||
backgroundDecodeRole = role;
|
||||
currentRigId = rigId || hostState.lastActiveRigId || null;
|
||||
currentRigId = rigId || null;
|
||||
if (currentRigId) loadBackgroundDecode();
|
||||
startStatusPolling();
|
||||
}
|
||||
@@ -110,7 +108,7 @@ var bgdWindow = window;
|
||||
}
|
||||
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||
renderBookmarkChecklist();
|
||||
const isControl = isControlRole();
|
||||
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||
const panel = document.getElementById("background-decode-panel");
|
||||
if (panel) {
|
||||
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
|
||||
@@ -121,10 +119,6 @@ var bgdWindow = window;
|
||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||
syncSaveButton();
|
||||
}
|
||||
function currentFilterText() {
|
||||
return document.getElementById("bgd-bookmark-filter")?.value ?? "";
|
||||
}
|
||||
function renderBookmarkChecklist(filterText = "") {
|
||||
const container = document.getElementById("bgd-bookmark-checklist");
|
||||
@@ -140,49 +134,20 @@ var bgdWindow = window;
|
||||
return text.indexOf(filter) >= 0;
|
||||
}) : all;
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
|
||||
renderSelectionSummary();
|
||||
container.innerHTML = '<div class="bgd-checklist-empty">' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "</div>";
|
||||
return;
|
||||
}
|
||||
filtered.forEach(function(bookmark) {
|
||||
const row = document.createElement("label");
|
||||
row.className = "bgd-checklist-row";
|
||||
row.dataset.bmId = bookmark.id;
|
||||
const decoders = bookmarkDecoderKinds(bookmark);
|
||||
const selected = selectedIds.has(bookmark.id);
|
||||
if (selected) row.classList.add("is-selected");
|
||||
row.innerHTML = '<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz)) + '<span class="bgd-checklist-mode">' + escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span></span>" + stateBadgeHtml(bookmark.id, selected);
|
||||
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
||||
row.innerHTML = '<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span>";
|
||||
row.querySelector("input")?.addEventListener("change", function(e) {
|
||||
onChecklistToggle(bookmark.id, e.currentTarget.checked);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
renderSelectionSummary();
|
||||
}
|
||||
function emptyListText(supportedCount) {
|
||||
if (supportedCount > 0) return "No bookmark matches that filter.";
|
||||
if (bookmarkList.length > 0) {
|
||||
return "None of your bookmarks name a decoder that can run in the background. Give one a decoder on the Bookmarks tab to list it here.";
|
||||
}
|
||||
return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
|
||||
}
|
||||
function stateBadgeHtml(bookmarkId, selected) {
|
||||
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
|
||||
const entry = statusByBookmark.get(bookmarkId);
|
||||
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
|
||||
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '"><span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
|
||||
}
|
||||
function renderSelectionSummary() {
|
||||
const el = document.getElementById("bgd-selection-summary");
|
||||
if (!el) return;
|
||||
const selected = currentConfig?.bookmark_ids.length ?? 0;
|
||||
if (selected === 0) {
|
||||
el.textContent = "Nothing selected — background decoding is idle.";
|
||||
return;
|
||||
}
|
||||
const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
|
||||
const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
|
||||
el.textContent = currentConfig?.enabled ? `${noun}, ${String(active)} decoding now.` : `${noun}. Switch Enabled on to start decoding them.`;
|
||||
}
|
||||
function onChecklistToggle(bookmarkId, checked) {
|
||||
if (!currentConfig) {
|
||||
@@ -217,7 +182,7 @@ var bgdWindow = window;
|
||||
}).catch(function(err) {
|
||||
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||
}).finally(function() {
|
||||
syncSaveButton();
|
||||
if (btn) btn.disabled = false;
|
||||
});
|
||||
}
|
||||
async function resetBackgroundDecode() {
|
||||
@@ -245,83 +210,59 @@ var bgdWindow = window;
|
||||
});
|
||||
}
|
||||
function renderStatus(status) {
|
||||
lastStatus = status;
|
||||
statusByBookmark = new Map(
|
||||
(status.entries ?? []).filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0).map((entry) => [entry.bookmark_id, entry])
|
||||
);
|
||||
renderSpanSummary();
|
||||
renderBookmarkChecklist(currentFilterText());
|
||||
}
|
||||
function renderSpanSummary() {
|
||||
const el = document.getElementById("bgd-span-summary");
|
||||
if (!el) return;
|
||||
const status = lastStatus;
|
||||
if (!status) {
|
||||
el.textContent = "";
|
||||
const card = document.getElementById("background-decode-status-card");
|
||||
if (!card) return;
|
||||
const entries = status.entries ?? [];
|
||||
if (!entries.length) {
|
||||
card.textContent = "No background decode bookmarks configured.";
|
||||
return;
|
||||
}
|
||||
if (!status.active_rig) {
|
||||
el.textContent = "This rig is not the one playing audio.";
|
||||
el.dataset.tone = "warn";
|
||||
return;
|
||||
}
|
||||
const centre = typeof status.center_hz === "number" && Number.isFinite(status.center_hz) ? formatFreq(status.center_hz) : null;
|
||||
const half = typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0 ? formatFreq(status.sample_rate / 2) : null;
|
||||
el.dataset.tone = "";
|
||||
el.textContent = centre && half ? `Span ${centre} ±${half}` : centre ? `Centre ${centre}` : "";
|
||||
}
|
||||
function stateHelp(state) {
|
||||
switch (state) {
|
||||
case "active":
|
||||
return "Decoding on a hidden channel.";
|
||||
case "out_of_span":
|
||||
return "Outside the span the rig is tuned across, so it cannot be heard from here.";
|
||||
case "waiting_for_spectrum":
|
||||
return "Waiting for the first spectrum frame from the rig.";
|
||||
case "waiting_for_user":
|
||||
return "Nobody is listening to this rig, so no audio is being pulled.";
|
||||
case "missing_bookmark":
|
||||
return "The bookmark this was selected from is gone.";
|
||||
case "no_supported_decoders":
|
||||
return "No decoder that runs in the background can decode this bookmark.";
|
||||
case "disabled":
|
||||
return "Background decoding is switched off.";
|
||||
case "handled_by_scheduler":
|
||||
case "scheduler_has_control":
|
||||
return "The scheduler is running this bookmark instead.";
|
||||
case "handled_by_virtual_channel":
|
||||
return "A virtual channel is already on this frequency.";
|
||||
case "pending":
|
||||
return "Selected, and not started yet — save to apply.";
|
||||
default:
|
||||
return "Selected, but not decoding.";
|
||||
const summary = [];
|
||||
if (status.active_rig) {
|
||||
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
||||
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
||||
} else {
|
||||
summary.push("This rig is not currently selected for audio.");
|
||||
}
|
||||
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
||||
html += '<div class="bgd-status-list">';
|
||||
entries.forEach(function(entry) {
|
||||
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
||||
const parts = [];
|
||||
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
||||
if (entry.mode) parts.push(entry.mode);
|
||||
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
||||
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
||||
}
|
||||
html += '<div class="bgd-status-row"><div><div class="bgd-status-name">' + escHtml(name) + '</div><div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div></div><div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '"><svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' + escHtml(prettyState(entry.state)) + "</div></div>";
|
||||
});
|
||||
html += "</div>";
|
||||
card.innerHTML = html;
|
||||
}
|
||||
function prettyState(state) {
|
||||
switch (state) {
|
||||
case "active":
|
||||
return "Decoding";
|
||||
return "✓ Active";
|
||||
case "out_of_span":
|
||||
return "Out of span";
|
||||
return "△ Out of span";
|
||||
case "waiting_for_spectrum":
|
||||
return "Waiting for spectrum";
|
||||
return "△ Waiting";
|
||||
case "waiting_for_user":
|
||||
return "Nobody listening";
|
||||
return "△ No user";
|
||||
case "missing_bookmark":
|
||||
return "Bookmark gone";
|
||||
return "✗ Missing";
|
||||
case "no_supported_decoders":
|
||||
return "No decoder";
|
||||
return "✗ Unsupported";
|
||||
case "disabled":
|
||||
return "Off";
|
||||
return "△ Disabled";
|
||||
case "handled_by_scheduler":
|
||||
return "△ Scheduler";
|
||||
case "scheduler_has_control":
|
||||
return "Scheduler has it";
|
||||
return "△ Scheduler";
|
||||
case "handled_by_virtual_channel":
|
||||
return "On a channel";
|
||||
case "pending":
|
||||
return "Not saved";
|
||||
return "△ VChan";
|
||||
default:
|
||||
return "Idle";
|
||||
return "△ Inactive";
|
||||
}
|
||||
}
|
||||
function setCheckbox(id, value) {
|
||||
@@ -344,21 +285,13 @@ var bgdWindow = window;
|
||||
function markBgdDirty() {
|
||||
if (bgdDirty) return;
|
||||
bgdDirty = true;
|
||||
syncSaveButton();
|
||||
const btn = document.getElementById("background-decode-save-btn");
|
||||
if (btn) btn.classList.add("sch-dirty");
|
||||
}
|
||||
function clearBgdDirty() {
|
||||
bgdDirty = false;
|
||||
syncSaveButton();
|
||||
}
|
||||
function syncSaveButton() {
|
||||
const btn = document.getElementById("background-decode-save-btn");
|
||||
if (!btn) return;
|
||||
btn.classList.toggle("sch-dirty", bgdDirty);
|
||||
btn.disabled = !bgdDirty || !isControlRole();
|
||||
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
|
||||
}
|
||||
function isControlRole() {
|
||||
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||
if (btn) btn.classList.remove("sch-dirty");
|
||||
}
|
||||
function showToast(msg, isError) {
|
||||
const el = document.getElementById("background-decode-toast");
|
||||
@@ -378,7 +311,7 @@ var bgdWindow = window;
|
||||
return bm.id;
|
||||
});
|
||||
currentConfig.bookmark_ids = ids;
|
||||
renderBookmarkChecklist(currentFilterText());
|
||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||
markBgdDirty();
|
||||
}
|
||||
function deselectAllBookmarks() {
|
||||
@@ -386,7 +319,7 @@ var bgdWindow = window;
|
||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||
}
|
||||
currentConfig.bookmark_ids = [];
|
||||
renderBookmarkChecklist(currentFilterText());
|
||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||
markBgdDirty();
|
||||
}
|
||||
function wireBackgroundDecodeEvents() {
|
||||
@@ -401,9 +334,7 @@ var bgdWindow = window;
|
||||
if (enabledCb && !enabledCb._wired) {
|
||||
enabledCb._wired = true;
|
||||
enabledCb.addEventListener("change", function() {
|
||||
if (currentConfig) currentConfig.enabled = enabledCb.checked;
|
||||
markBgdDirty();
|
||||
renderBookmarkChecklist(currentFilterText());
|
||||
});
|
||||
}
|
||||
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
||||
|
||||
+12
-51
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
@@ -152,33 +148,14 @@ function initializeFtxDecoder(config) {
|
||||
second: "2-digit"
|
||||
});
|
||||
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||
if (station) {
|
||||
const log = document.createElement("button");
|
||||
log.type = "button";
|
||||
log.className = "ft8-log-btn";
|
||||
log.textContent = "Log";
|
||||
log.title = `Start a log entry for ${station}`;
|
||||
log.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const details = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||
bridge.logContact?.({
|
||||
call: station,
|
||||
gridsquare: details.find((detail) => detail.station === station)?.grid,
|
||||
decoder: id
|
||||
});
|
||||
});
|
||||
row.appendChild(log);
|
||||
}
|
||||
return row;
|
||||
};
|
||||
const render = () => {
|
||||
prune();
|
||||
if (!messagesElement) return;
|
||||
const rigMessages = forActiveRig(history);
|
||||
const fragment = document.createDocumentFragment();
|
||||
let count = 0;
|
||||
for (const message of rigMessages) {
|
||||
for (const message of history) {
|
||||
if (count >= 200) break;
|
||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||
fragment.appendChild(renderRow(message));
|
||||
@@ -186,26 +163,20 @@ function initializeFtxDecoder(config) {
|
||||
}
|
||||
messagesElement.replaceChildren(fragment);
|
||||
};
|
||||
const plotLocator = (message) => {
|
||||
const normalize = (message) => {
|
||||
const raw = message.message ?? "";
|
||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
||||
if (grids.length === 0) return;
|
||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||
const frequency = displayFrequency(message.freq_hz);
|
||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||
...message,
|
||||
freq_hz: frequency ?? message.freq_hz,
|
||||
locator_details: locatorDetails
|
||||
});
|
||||
};
|
||||
const normalize = (message) => {
|
||||
const frequency = displayFrequency(message.freq_hz);
|
||||
plotLocator(message);
|
||||
if (grids.length > 0) {
|
||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||
...message,
|
||||
freq_hz: frequency ?? message.freq_hz,
|
||||
locator_details: locatorDetails
|
||||
});
|
||||
}
|
||||
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,
|
||||
@@ -217,9 +188,7 @@ function initializeFtxDecoder(config) {
|
||||
};
|
||||
const receiveBatch = (messages) => {
|
||||
if (messages.length === 0) return;
|
||||
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
|
||||
status.textContent = "Receiving";
|
||||
}
|
||||
if (status) status.textContent = "Receiving";
|
||||
history = messages.map(normalize).reverse().concat(history);
|
||||
prune();
|
||||
bridge.setFt8FamilyBarDecoder?.(id);
|
||||
@@ -233,7 +202,7 @@ function initializeFtxDecoder(config) {
|
||||
bridge.clearMapMarkersByType?.(id);
|
||||
};
|
||||
const barFrames = () => {
|
||||
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5 && isActiveRigDecode(message.rig_id)).slice(0, 8);
|
||||
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
|
||||
let html = "";
|
||||
for (const message of recent) {
|
||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||
@@ -257,15 +226,7 @@ function initializeFtxDecoder(config) {
|
||||
prune();
|
||||
render();
|
||||
},
|
||||
reset,
|
||||
rerender: () => {
|
||||
bridge.updateFt8Bar?.();
|
||||
render();
|
||||
},
|
||||
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||
syncMap: () => {
|
||||
for (const message of [...history].reverse()) plotLocator(message);
|
||||
}
|
||||
reset
|
||||
});
|
||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||
const updatePeriod = () => {
|
||||
+1
-20
@@ -214,16 +214,6 @@ function summarizeAprsPayload(packet) {
|
||||
const text = info.replace(/^[>;<?]/, "").trim();
|
||||
return text.length ? text : null;
|
||||
}
|
||||
function maidenheadForLatLon(lat, lon) {
|
||||
const adjustedLon = lon + 180;
|
||||
const adjustedLat = lat + 90;
|
||||
const field = String.fromCharCode(65 + Math.floor(adjustedLon / 20)) + String.fromCharCode(65 + Math.floor(adjustedLat / 10));
|
||||
const square = String(Math.floor(adjustedLon % 20 / 2)) + String(Math.floor(adjustedLat % 10));
|
||||
const subLon = adjustedLon % 2 * 60 / 5;
|
||||
const subLat = adjustedLat % 1 * 60 / 2.5;
|
||||
const sub = String.fromCharCode(97 + Math.floor(subLon)) + String.fromCharCode(97 + Math.floor(subLat));
|
||||
return `${field}${square}${sub}`.toUpperCase();
|
||||
}
|
||||
function renderAprsPacketRow(packet, options = {}) {
|
||||
const row = document.createElement("details");
|
||||
row.className = "aprs-packet";
|
||||
@@ -234,7 +224,7 @@ function renderAprsPacketRow(packet, options = {}) {
|
||||
const summary = summarizeAprsPayload(packet);
|
||||
const hasPosition = packet.lat != null && packet.lon != null;
|
||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
|
||||
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>>${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` + (options.onLog && packet.srcCall ? '<button class="aprs-inline-btn" type="button" data-aprs-log="1">Log</button>' : "") + `</div></div>`;
|
||||
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>>${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div></div>`;
|
||||
row.querySelectorAll("[data-aprs-map]").forEach((element) => {
|
||||
element.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
@@ -243,15 +233,6 @@ function renderAprsPacketRow(packet, options = {}) {
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon);
|
||||
});
|
||||
});
|
||||
const logButton = row.querySelector("[data-aprs-log]");
|
||||
if (logButton) {
|
||||
logButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const grid = packet.lat != null && packet.lon != null ? maidenheadForLatLon(packet.lat, packet.lon) : null;
|
||||
options.onLog?.(packet.srcCall ?? "", grid);
|
||||
});
|
||||
}
|
||||
const copyButton = row.querySelector("[data-aprs-copy]");
|
||||
if (copyButton) {
|
||||
copyButton.addEventListener("click", (event) => {
|
||||
@@ -1,18 +0,0 @@
|
||||
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,6 +1,3 @@
|
||||
import {
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
@@ -28,7 +25,7 @@ var CW_BAR_LINE_GAP_MS = 5e3;
|
||||
var cwLastAppendTime = 0;
|
||||
var cwTonePickerRaf = null;
|
||||
var cwBarHistory = [];
|
||||
var cwBarCurrentLines = /* @__PURE__ */ new Map();
|
||||
var cwBarCurrentLine = null;
|
||||
var cwBarDismissedAtMs = 0;
|
||||
var cwAutoLocalOverride = null;
|
||||
function escapeCwHtml(input) {
|
||||
@@ -53,22 +50,20 @@ cwWindow.applyCwAutoUiFromServer = function(enabled) {
|
||||
if (cwAutoLocalOverride !== null) return;
|
||||
applyCwAutoUi(enabled);
|
||||
};
|
||||
function cwBarFlushCurrentLine(key) {
|
||||
const line = cwBarCurrentLines.get(key);
|
||||
cwBarCurrentLines.delete(key);
|
||||
if (line?.text.trim()) {
|
||||
cwBarHistory.unshift(line);
|
||||
function cwBarFlushCurrentLine() {
|
||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||
cwBarHistory.unshift(cwBarCurrentLine);
|
||||
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 && 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 recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : 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";
|
||||
@@ -313,7 +308,7 @@ function resetCwHistoryView() {
|
||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||
cwLastAppendTime = 0;
|
||||
cwBarHistory = [];
|
||||
cwBarCurrentLines.clear();
|
||||
cwBarCurrentLine = null;
|
||||
updateCwBar();
|
||||
drawCwTonePicker();
|
||||
}
|
||||
@@ -329,9 +324,8 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
||||
})();
|
||||
});
|
||||
function onServerCw(evt) {
|
||||
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
|
||||
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
|
||||
if (evt.text && cwOutputEl && forSelectedRig) {
|
||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||
if (evt.text && cwOutputEl) {
|
||||
const now = Date.now();
|
||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
||||
const line = document.createElement("div");
|
||||
@@ -352,29 +346,25 @@ function onServerCw(evt) {
|
||||
}
|
||||
if (evt.text) {
|
||||
const now = Date.now();
|
||||
const rigId = evt.rig_id ?? null;
|
||||
const key = rigId ?? "";
|
||||
if (evt.text === "\n") {
|
||||
cwBarFlushCurrentLine(key);
|
||||
cwBarFlushCurrentLine();
|
||||
} else {
|
||||
let line = cwBarCurrentLines.get(key);
|
||||
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||
cwBarFlushCurrentLine(key);
|
||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||
cwBarFlushCurrentLine();
|
||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||
cwBarCurrentLines.set(key, line);
|
||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||
}
|
||||
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));
|
||||
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));
|
||||
}
|
||||
updateCwBar();
|
||||
}
|
||||
if (cwSignalIndicator && forSelectedRig) {
|
||||
if (cwSignalIndicator) {
|
||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||
}
|
||||
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
|
||||
if (!cwAutoInput || cwAutoInput.checked) {
|
||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||
}
|
||||
@@ -399,14 +389,7 @@ cwWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "cw",
|
||||
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();
|
||||
}
|
||||
reset: resetCwHistoryView
|
||||
});
|
||||
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", "sstv"];
|
||||
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||
var workerScope = self;
|
||||
function decodeCborUint(view, bytes, state, additional) {
|
||||
const offset = state.offset;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
initializeFtxDecoder
|
||||
} from "./chunk-WU5EWJX4.js";
|
||||
import "./chunk-S57W63QN.js";
|
||||
} from "./chunk-O2Y7YEVQ.js";
|
||||
import "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/ft2.ts
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
initializeFtxDecoder
|
||||
} from "./chunk-WU5EWJX4.js";
|
||||
import "./chunk-S57W63QN.js";
|
||||
} from "./chunk-O2Y7YEVQ.js";
|
||||
import "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/ft4.ts
|
||||
|
||||
@@ -2,8 +2,7 @@ import {
|
||||
initializeFt8FamilyBar,
|
||||
initializeFtxDecoder,
|
||||
installFtxCompatibilityHelpers
|
||||
} from "./chunk-WU5EWJX4.js";
|
||||
import "./chunk-S57W63QN.js";
|
||||
} from "./chunk-O2Y7YEVQ.js";
|
||||
import "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/ft8.ts
|
||||
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
collapseAprsDuplicates,
|
||||
normalizeAprsPacket,
|
||||
renderAprsPacketRow
|
||||
} from "./chunk-REYSUJQ4.js";
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
} from "./chunk-OPEIVJGD.js";
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
@@ -71,26 +67,21 @@ function hfAprsFilterMatch(pkt) {
|
||||
].filter(Boolean).join(" ").toUpperCase();
|
||||
return haystack.includes(hfAprsFilterText);
|
||||
}
|
||||
function hfAprsRigPackets() {
|
||||
return forActiveRig(hfAprsPacketHistory);
|
||||
}
|
||||
function hfAprsVisiblePackets() {
|
||||
const rigPackets = hfAprsRigPackets();
|
||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
|
||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
||||
return packets.filter(hfAprsFilterMatch);
|
||||
}
|
||||
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||
function updateHfAprsSummary() {
|
||||
const rigPackets = hfAprsRigPackets();
|
||||
const visible = hfAprsVisiblePackets();
|
||||
if (hfAprsTotalCountEl) {
|
||||
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
||||
}
|
||||
if (hfAprsVisibleCountEl) {
|
||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||
}
|
||||
if (hfAprsLatestSeenEl) {
|
||||
const latest = rigPackets[0];
|
||||
const latest = hfAprsPacketHistory[0];
|
||||
if (!latest) {
|
||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||
} else {
|
||||
@@ -149,32 +140,17 @@ function resetHfAprsHistoryView() {
|
||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||
hfAprsPacketHistory = [];
|
||||
renderHfAprsHistory();
|
||||
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
|
||||
}
|
||||
function pruneHfAprsHistoryView() {
|
||||
pruneHfAprsPacketHistory();
|
||||
renderHfAprsHistory();
|
||||
}
|
||||
function plotHfAprsPacket(pkt) {
|
||||
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
|
||||
hfAprsWindow.aprsMapAddStation(
|
||||
pkt.srcCall ?? "",
|
||||
pkt.lat,
|
||||
pkt.lon,
|
||||
pkt.info ?? "",
|
||||
pkt.symbolTable,
|
||||
pkt.symbolCode,
|
||||
pkt,
|
||||
"hf_aprs"
|
||||
);
|
||||
}
|
||||
function addHfAprsPacket(pkt) {
|
||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||
pkt._tsMs = tsMs;
|
||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
hfAprsPacketHistory.unshift(pkt);
|
||||
pruneHfAprsPacketHistory();
|
||||
plotHfAprsPacket(pkt);
|
||||
scheduleHfAprsHistoryRender();
|
||||
}
|
||||
function normalizeServerHfAprsPacket(pkt) {
|
||||
@@ -182,14 +158,13 @@ function normalizeServerHfAprsPacket(pkt) {
|
||||
}
|
||||
function onServerHfAprsBatch(packets) {
|
||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const pkt of packets) {
|
||||
const next = normalizeServerHfAprsPacket(pkt);
|
||||
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
plotHfAprsPacket(next);
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
@@ -252,9 +227,8 @@ if (hfAprsFilterInput) {
|
||||
});
|
||||
}
|
||||
function onServerHfAprs(pkt) {
|
||||
const packet = normalizeServerHfAprsPacket(pkt);
|
||||
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||
addHfAprsPacket(packet);
|
||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
||||
}
|
||||
renderHfAprsHistory();
|
||||
window.trxPluginRuntime.registerDecoder({
|
||||
@@ -263,10 +237,5 @@ window.trxPluginRuntime.registerDecoder({
|
||||
onBatch: onServerHfAprsBatch,
|
||||
restore: onServerHfAprsBatch,
|
||||
reset: resetHfAprsHistoryView,
|
||||
prune: pruneHfAprsHistoryView,
|
||||
rerender: renderHfAprsHistory,
|
||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||
syncMap: () => {
|
||||
for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry);
|
||||
}
|
||||
prune: pruneHfAprsHistoryView
|
||||
});
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// src/plugins/logbook.ts
|
||||
var bridge = window;
|
||||
var el = (id) => document.getElementById(id);
|
||||
var form = el("log-entry-form");
|
||||
var callInput = el("log-call");
|
||||
var freqInput = el("log-freq");
|
||||
var modeInput = el("log-mode");
|
||||
var rstSentInput = el("log-rst-sent");
|
||||
var rstRcvdInput = el("log-rst-rcvd");
|
||||
var gridInput = el("log-grid");
|
||||
var nameInput = el("log-name");
|
||||
var commentInput = el("log-comment");
|
||||
var operatorInput = el("log-operator");
|
||||
var rowsBody = el("log-rows");
|
||||
var summaryEl = el("log-summary");
|
||||
var workedEl = el("log-worked-before");
|
||||
var clockEl = el("log-clock");
|
||||
var stationCallEl = el("log-station-callsign");
|
||||
var stationRigEl = el("log-station-rig-name");
|
||||
var stationGridEl = el("log-station-grid");
|
||||
var filterCall = el("log-filter-call");
|
||||
var filterBand = el("log-filter-band");
|
||||
var filterMode = el("log-filter-mode");
|
||||
var importBtn = el("log-import-btn");
|
||||
var importFile = el("log-import-file");
|
||||
var exportLink = el("log-export-btn");
|
||||
var clearBtn = el("log-clear-btn");
|
||||
var saveBtn = el("log-save-btn");
|
||||
var entryStartedAt = null;
|
||||
var entryRigId = null;
|
||||
var entryRigName = null;
|
||||
var entryGrid = null;
|
||||
var qsos = [];
|
||||
var workedRequest = 0;
|
||||
function notify(message, kind) {
|
||||
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0);
|
||||
else hostCore.showHint(message, 2e3);
|
||||
}
|
||||
async function getJson(path) {
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||
return await response.json();
|
||||
}
|
||||
function formatFreq(hz) {
|
||||
if (!Number.isFinite(hz) || hz <= 0) return "";
|
||||
return String(Number((hz / 1e6).toFixed(6)));
|
||||
}
|
||||
function parseFreq(text) {
|
||||
const value = Number(text.trim().replace(/\s+/g, "").replace(",", "."));
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return value < 1e5 ? Math.round(value * 1e6) : Math.round(value);
|
||||
}
|
||||
function utcDate(iso) {
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
|
||||
}
|
||||
function utcTime(iso) {
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19);
|
||||
}
|
||||
async function openEntry(seed = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (hostState.lastActiveRigId) params.set("remote", hostState.lastActiveRigId);
|
||||
if (seed.decoder) params.set("decoder", seed.decoder);
|
||||
if (seed.call) params.set("call", seed.call);
|
||||
if (seed.gridsquare) params.set("gridsquare", seed.gridsquare);
|
||||
try {
|
||||
const prefill = await getJson(`/api/logbook/prefill?${params.toString()}`);
|
||||
applyPrefill(prefill);
|
||||
} catch (error) {
|
||||
console.error("logbook prefill failed", error);
|
||||
}
|
||||
}
|
||||
function applyPrefill(prefill) {
|
||||
entryStartedAt = prefill.started_at;
|
||||
entryRigId = prefill.rig_id;
|
||||
entryRigName = prefill.my_rig;
|
||||
if (freqInput) freqInput.value = formatFreq(prefill.freq_hz);
|
||||
if (modeInput) modeInput.value = prefill.submode ?? prefill.mode;
|
||||
if (callInput && prefill.call) callInput.value = prefill.call;
|
||||
if (gridInput && prefill.gridsquare) gridInput.value = prefill.gridsquare;
|
||||
if (stationRigEl) stationRigEl.textContent = prefill.my_rig ?? "no rig";
|
||||
showClock(prefill);
|
||||
updateWorkedBefore();
|
||||
}
|
||||
function showClock(prefill) {
|
||||
if (!clockEl) return;
|
||||
const time = utcTime(prefill.started_at);
|
||||
const drift = Math.abs(Date.now() - prefill.epoch_ms);
|
||||
clockEl.textContent = drift > 1e3 ? `${time}Z · your clock is ${(drift / 1e3).toFixed(0)}s out` : `${time}Z`;
|
||||
clockEl.classList.toggle("is-adrift", drift > 1e3);
|
||||
}
|
||||
function resetEntry() {
|
||||
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput]) {
|
||||
if (input) input.value = "";
|
||||
}
|
||||
if (workedEl) workedEl.textContent = "";
|
||||
void openEntry();
|
||||
}
|
||||
async function submitEntry(event) {
|
||||
event.preventDefault();
|
||||
const call = callInput?.value.trim() ?? "";
|
||||
if (!call) {
|
||||
notify("A contact needs a callsign", "error");
|
||||
callInput?.focus();
|
||||
return;
|
||||
}
|
||||
const freqHz = parseFreq(freqInput?.value ?? "");
|
||||
if (freqHz == null) {
|
||||
notify("A contact needs a frequency", "error");
|
||||
freqInput?.focus();
|
||||
return;
|
||||
}
|
||||
const typedMode = (modeInput?.value ?? "").trim().toUpperCase();
|
||||
const isSideband = typedMode === "USB" || typedMode === "LSB";
|
||||
const body = {
|
||||
// The server stamps the time; this is the one it gave when the entry
|
||||
// opened, so a contact logged five minutes later keeps the time it started.
|
||||
started_at: entryStartedAt,
|
||||
call,
|
||||
freq_hz: freqHz,
|
||||
mode: isSideband ? "SSB" : typedMode,
|
||||
submode: isSideband ? typedMode : null,
|
||||
rst_sent: rstSentInput?.value ?? null,
|
||||
rst_rcvd: rstRcvdInput?.value ?? null,
|
||||
gridsquare: gridInput?.value ?? null,
|
||||
name: nameInput?.value ?? null,
|
||||
comment: commentInput?.value ?? null,
|
||||
station_callsign: stationCallEl?.textContent?.trim() ?? null,
|
||||
operator: operatorInput?.value ?? null,
|
||||
my_gridsquare: entryGrid,
|
||||
my_rig: entryRigName,
|
||||
rig_id: entryRigId
|
||||
};
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
try {
|
||||
const response = await fetch("/api/logbook", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({}));
|
||||
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
|
||||
}
|
||||
notify(`${call} logged`);
|
||||
resetEntry();
|
||||
await refreshLog();
|
||||
} catch (error) {
|
||||
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
} finally {
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
function updateWorkedBefore() {
|
||||
if (!workedEl) return;
|
||||
const call = callInput?.value.trim() ?? "";
|
||||
if (call.length < 3) {
|
||||
workedEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
const request = ++workedRequest;
|
||||
void getJson(
|
||||
`/api/logbook/worked/${encodeURIComponent(call)}`
|
||||
).then((answer) => {
|
||||
if (request !== workedRequest || !workedEl) return;
|
||||
if (answer.worked.length === 0) {
|
||||
workedEl.textContent = "Not worked before";
|
||||
workedEl.classList.remove("is-worked");
|
||||
return;
|
||||
}
|
||||
const where = answer.worked.map((entry) => `${entry.band} ${entry.mode}`).join(", ");
|
||||
workedEl.textContent = `Worked before: ${where}`;
|
||||
workedEl.classList.add("is-worked");
|
||||
}).catch(() => {
|
||||
});
|
||||
}
|
||||
function currentQuery() {
|
||||
const params = new URLSearchParams();
|
||||
const call = filterCall?.value.trim();
|
||||
if (call) params.set("call", call);
|
||||
if (filterBand?.value) params.set("band", filterBand.value);
|
||||
if (filterMode?.value) params.set("mode", filterMode.value);
|
||||
return params.toString();
|
||||
}
|
||||
async function refreshLog() {
|
||||
try {
|
||||
const query = currentQuery();
|
||||
const answer = await getJson(
|
||||
`/api/logbook${query ? `?${query}` : ""}`
|
||||
);
|
||||
qsos = answer.qsos;
|
||||
renderRows();
|
||||
renderFilterOptions();
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = query ? `${String(qsos.length)} of ${String(answer.total)} contacts` : `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
|
||||
} catch (error) {
|
||||
console.error("logbook read failed", error);
|
||||
}
|
||||
}
|
||||
function renderRows() {
|
||||
if (!rowsBody) return;
|
||||
if (qsos.length === 0) {
|
||||
rowsBody.innerHTML = '<tr><td colspan="10" class="log-empty">No contacts yet. Work someone and log them here, or bring a log in with Import ADIF.</td></tr>';
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const qso of qsos) {
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.qsoId = qso.id;
|
||||
const cells = [
|
||||
utcDate(qso.started_at),
|
||||
utcTime(qso.started_at),
|
||||
qso.call,
|
||||
qso.band ?? "",
|
||||
qso.submode ?? qso.mode,
|
||||
qso.rst_sent ?? "",
|
||||
qso.rst_rcvd ?? "",
|
||||
qso.gridsquare ?? "",
|
||||
qso.my_rig ?? ""
|
||||
];
|
||||
for (const [index, value] of cells.entries()) {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = value ?? "";
|
||||
if (index === 2) cell.className = "log-cell-call";
|
||||
row.appendChild(cell);
|
||||
}
|
||||
const actions = document.createElement("td");
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "log-row-btn";
|
||||
remove.textContent = "Delete";
|
||||
remove.setAttribute("aria-label", `Delete the contact with ${qso.call}`);
|
||||
remove.addEventListener("click", () => {
|
||||
void deleteQso(qso);
|
||||
});
|
||||
actions.appendChild(remove);
|
||||
row.appendChild(actions);
|
||||
fragment.appendChild(row);
|
||||
}
|
||||
rowsBody.replaceChildren(fragment);
|
||||
}
|
||||
function renderFilterOptions() {
|
||||
for (const [select, values] of [
|
||||
[filterBand, [...new Set(qsos.map((q) => q.band).filter((b) => !!b))]],
|
||||
[filterMode, [...new Set(qsos.map((q) => q.submode ?? q.mode).filter(Boolean))]]
|
||||
]) {
|
||||
if (!select) continue;
|
||||
const chosen = select.value;
|
||||
const known = new Set([...select.options].map((option) => option.value));
|
||||
for (const value of [...values].sort()) {
|
||||
if (known.has(value)) continue;
|
||||
select.add(new Option(value, value));
|
||||
}
|
||||
select.value = chosen;
|
||||
}
|
||||
}
|
||||
async function deleteQso(qso) {
|
||||
const confirmed = await bridge.trxUi.confirm({
|
||||
title: "Delete this contact?",
|
||||
message: `${qso.call} on ${qso.band ?? formatFreq(qso.freq_hz)} will be removed from the log.`,
|
||||
confirmLabel: "Delete"
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||
await refreshLog();
|
||||
} catch (error) {
|
||||
notify(`Could not delete: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
}
|
||||
}
|
||||
async function importAdif(file) {
|
||||
try {
|
||||
const response = await fetch("/api/logbook/import", { method: "POST", body: await file.arrayBuffer() });
|
||||
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||
const outcome = await response.json();
|
||||
const parts = [`${String(outcome.added)} added`];
|
||||
if (outcome.duplicate > 0) parts.push(`${String(outcome.duplicate)} already held`);
|
||||
if (outcome.rejected.length > 0) parts.push(`${String(outcome.rejected.length)} not readable`);
|
||||
notify(parts.join(", "));
|
||||
await refreshLog();
|
||||
} catch (error) {
|
||||
notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
}
|
||||
}
|
||||
function renderStation() {
|
||||
const callsign = hostState.ownerCallsign ?? "";
|
||||
if (stationCallEl) stationCallEl.textContent = callsign || "no callsign set";
|
||||
if (operatorInput && !operatorInput.value) operatorInput.value = callsign;
|
||||
if (stationGridEl) {
|
||||
const grid = hostState.serverLat != null && hostState.serverLon != null ? hostCore.latLonToMaidenhead(hostState.serverLat, hostState.serverLon) : "";
|
||||
entryGrid = grid || null;
|
||||
stationGridEl.textContent = grid;
|
||||
}
|
||||
}
|
||||
form?.addEventListener("submit", (event) => {
|
||||
void submitEntry(event);
|
||||
});
|
||||
clearBtn?.addEventListener("click", resetEntry);
|
||||
callInput?.addEventListener("input", updateWorkedBefore);
|
||||
for (const control of [filterCall, filterBand, filterMode]) {
|
||||
control?.addEventListener("input", () => {
|
||||
void refreshLog();
|
||||
});
|
||||
control?.addEventListener("change", () => {
|
||||
void refreshLog();
|
||||
});
|
||||
}
|
||||
importBtn?.addEventListener("click", () => {
|
||||
importFile?.click();
|
||||
});
|
||||
importFile?.addEventListener("change", () => {
|
||||
const file = importFile.files?.[0];
|
||||
if (file) void importAdif(file);
|
||||
importFile.value = "";
|
||||
});
|
||||
bridge.logContact = (seed) => {
|
||||
bridge.navigateToTab?.("logbook");
|
||||
void openEntry(seed).then(() => callInput?.focus());
|
||||
};
|
||||
renderStation();
|
||||
void openEntry();
|
||||
void refreshLog();
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
aprsSymbolSprite
|
||||
} from "./chunk-REYSUJQ4.js";
|
||||
} from "./chunk-OPEIVJGD.js";
|
||||
|
||||
// src/map-core.ts
|
||||
function mapEl(id) {
|
||||
@@ -60,7 +60,7 @@ var mapWindow = window;
|
||||
const decodeContactPaths = /* @__PURE__ */ new Map();
|
||||
let selectedMapQsoKey = null;
|
||||
const mapMarkers = /* @__PURE__ */ new Set();
|
||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||
const MAP_FILTER_ALL_KEY = "__all";
|
||||
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
|
||||
@@ -128,12 +128,6 @@ var mapWindow = window;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
function aprsEntrySource(entry) {
|
||||
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||
}
|
||||
function aprsStationKey(call, source) {
|
||||
return source === "aprs" ? call : `${source}:${call}`;
|
||||
}
|
||||
function refreshAprsTrack(call, entry) {
|
||||
if (!entry) return;
|
||||
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
||||
@@ -155,7 +149,7 @@ var mapWindow = window;
|
||||
lineJoin: "round",
|
||||
interactive: false
|
||||
});
|
||||
track.__trxType = aprsEntrySource(entry);
|
||||
track.__trxType = "aprs";
|
||||
track._aprsCall = call;
|
||||
entry.track = track;
|
||||
}
|
||||
@@ -387,7 +381,6 @@ var mapWindow = window;
|
||||
}
|
||||
function mapSourceLabel(type) {
|
||||
if (type === "bookmark") return "Bookmarks";
|
||||
if (type === "hf_aprs") return "HF APRS";
|
||||
return String(type || "").toUpperCase();
|
||||
}
|
||||
function locatorFilterColor(type) {
|
||||
@@ -403,7 +396,6 @@ var mapWindow = window;
|
||||
if (type === "vdes") return "#a78bfa";
|
||||
if (type === "sat") return "#f59e0b";
|
||||
if (type === "aprs") return "#00d17f";
|
||||
if (type === "hf_aprs") return "#fb7185";
|
||||
return locatorFilterColor(type);
|
||||
}
|
||||
function bandForHz(hz) {
|
||||
@@ -939,8 +931,10 @@ var mapWindow = window;
|
||||
}
|
||||
}
|
||||
for (const entry of stationMarkers.values()) {
|
||||
if (!entry?.visibleInHistoryWindow) continue;
|
||||
availableSources.add(aprsEntrySource(entry));
|
||||
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
|
||||
availableSources.add("aprs");
|
||||
break;
|
||||
}
|
||||
}
|
||||
const bandMap = /* @__PURE__ */ new Map();
|
||||
for (const entry of locatorMarkers.values()) {
|
||||
@@ -972,7 +966,7 @@ var mapWindow = window;
|
||||
for (const key of Array.from(mapLocatorFilter.bands)) {
|
||||
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
||||
}
|
||||
const sourceItems = ["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
|
||||
const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
|
||||
key,
|
||||
label: mapSourceLabel(key),
|
||||
color: mapSourceColor(key),
|
||||
@@ -1034,10 +1028,9 @@ var mapWindow = window;
|
||||
}
|
||||
return parts.join(" ").toLowerCase();
|
||||
}
|
||||
if (type === "aprs" || type === "hf_aprs") {
|
||||
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||
const entry = stationMarkers.get(key);
|
||||
const call = entry?.call ?? key;
|
||||
if (type === "aprs") {
|
||||
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||
const entry = stationMarkers.get(call);
|
||||
const info = entry?.info ? String(entry.info) : "";
|
||||
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
||||
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
||||
@@ -1207,10 +1200,9 @@ var mapWindow = window;
|
||||
}
|
||||
};
|
||||
mapWindow.clearMapMarkersByType = function(type) {
|
||||
if (type === "aprs" || type === "hf_aprs") {
|
||||
if (type === "aprs") {
|
||||
selectedAprsTrackCall = null;
|
||||
stationMarkers.forEach((entry, key) => {
|
||||
if (aprsEntrySource(entry) !== type) return;
|
||||
stationMarkers.forEach((entry) => {
|
||||
if (entry && entry.marker) {
|
||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||
mapMarkers.delete(entry.marker);
|
||||
@@ -1219,8 +1211,8 @@ var mapWindow = window;
|
||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||
mapMarkers.delete(entry.track);
|
||||
}
|
||||
stationMarkers.delete(key);
|
||||
});
|
||||
stationMarkers.clear();
|
||||
return;
|
||||
}
|
||||
if (type === "ais") {
|
||||
@@ -1456,15 +1448,13 @@ var mapWindow = window;
|
||||
if (!ll) return;
|
||||
const entry = stationMarkers.get(marker._aprsCall);
|
||||
if (!entry) return;
|
||||
const source = aprsEntrySource(entry);
|
||||
const call = entry.call ?? String(marker._aprsCall);
|
||||
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||
refreshAprsTrack(String(marker._aprsCall), entry);
|
||||
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
|
||||
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
|
||||
entry.track.addTo(aprsMap);
|
||||
}
|
||||
selectedAprsTrackCall = String(marker._aprsCall);
|
||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
|
||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
|
||||
return;
|
||||
}
|
||||
if (marker._aisMmsi) {
|
||||
@@ -1858,33 +1848,28 @@ var mapWindow = window;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function _aprsAddMarkerToMap(key, entry) {
|
||||
function _aprsAddMarkerToMap(call, entry) {
|
||||
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
||||
refreshAprsTrack(key, entry);
|
||||
const source = aprsEntrySource(entry);
|
||||
const call = entry.call ?? key;
|
||||
refreshAprsTrack(call, entry);
|
||||
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
||||
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
||||
const color = mapSourceColor(source);
|
||||
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
|
||||
radius: 6,
|
||||
color,
|
||||
fillColor: color,
|
||||
color: "#00d17f",
|
||||
fillColor: "#00d17f",
|
||||
fillOpacity: 0.8
|
||||
}).addTo(aprsMap).bindPopup(popupContent);
|
||||
marker.__trxType = source;
|
||||
marker.__trxType = "aprs";
|
||||
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
|
||||
marker._aprsCall = key;
|
||||
marker._aprsCall = call;
|
||||
entry.marker = marker;
|
||||
mapMarkers.add(marker);
|
||||
}
|
||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
|
||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
|
||||
const nextPoint = [lat, lon];
|
||||
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
||||
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
||||
const stationSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||
const key = aprsStationKey(call, stationSource);
|
||||
const existing = stationMarkers.get(key);
|
||||
const existing = stationMarkers.get(call);
|
||||
if (existing) {
|
||||
existing.pkt = pkt;
|
||||
existing.lat = lat;
|
||||
@@ -1903,8 +1888,7 @@ var mapWindow = window;
|
||||
} else if (prevPoint) {
|
||||
prevPoint.tsMs = tsMs;
|
||||
}
|
||||
existing.call = call;
|
||||
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
|
||||
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
|
||||
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
||||
existing.marker.setLatLng([lat, lon]);
|
||||
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
||||
@@ -1915,8 +1899,7 @@ var mapWindow = window;
|
||||
track: null,
|
||||
trackHistory: [{ lat, lon, tsMs }],
|
||||
trackPoints: [nextPoint],
|
||||
type: stationSource,
|
||||
call,
|
||||
type: "aprs",
|
||||
pkt,
|
||||
lat,
|
||||
lon,
|
||||
@@ -1925,9 +1908,9 @@ var mapWindow = window;
|
||||
symbolCode,
|
||||
rigIds: new Set(msgRigId ? [msgRigId] : [])
|
||||
};
|
||||
stationMarkers.set(key, entry);
|
||||
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
|
||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
|
||||
stationMarkers.set(call, entry);
|
||||
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
|
||||
if (aprsMap) scheduleDecodeMapMaintenance();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ 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"),
|
||||
@@ -16,12 +17,29 @@ 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")
|
||||
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")
|
||||
};
|
||||
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);
|
||||
@@ -30,19 +48,37 @@ 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 (view === "history") renderSatHistoryTable();
|
||||
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();
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -239,6 +275,200 @@ 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
@@ -1,228 +0,0 @@
|
||||
// src/plugins/satellite-predictions.ts
|
||||
var predWindow = window;
|
||||
var dom = {
|
||||
page: document.getElementById("tab-satellites"),
|
||||
filter: document.getElementById("sat-pred-filter"),
|
||||
minElevation: document.getElementById("sat-pred-min-el"),
|
||||
category: document.getElementById("sat-pred-category"),
|
||||
currentList: document.getElementById("sat-pred-current-list"),
|
||||
upcomingList: document.getElementById("sat-pred-list"),
|
||||
currentSection: document.getElementById("sat-pred-current-section"),
|
||||
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
|
||||
status: document.getElementById("sat-pred-status")
|
||||
};
|
||||
var PAGE_SIZE = 50;
|
||||
var predShowAll = false;
|
||||
var predData = [];
|
||||
var predFilterText = "";
|
||||
var predMinEl = 0;
|
||||
var predCategory = "all";
|
||||
var predSatCount = 0;
|
||||
var predCountdownTimer = null;
|
||||
function isPageVisible() {
|
||||
return !!dom.page && dom.page.style.display !== "none";
|
||||
}
|
||||
function azToCardinal(deg) {
|
||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
||||
}
|
||||
function formatPredTime(ms) {
|
||||
const d = new Date(ms);
|
||||
const now = /* @__PURE__ */ new Date();
|
||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
return `${day}${hh}:${mm}`;
|
||||
}
|
||||
function formatPredDuration(s) {
|
||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||
return `${s}s`;
|
||||
}
|
||||
function formatCountdown(ms) {
|
||||
const totalSec = Math.max(0, Math.floor(ms / 1e3));
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
function elevationClass(deg) {
|
||||
if (deg >= 45) return "sat-pred-el-high";
|
||||
if (deg >= 10) return "sat-pred-el-mid";
|
||||
return "sat-pred-el-low";
|
||||
}
|
||||
function stopCountdownTimer() {
|
||||
if (predCountdownTimer) {
|
||||
clearInterval(predCountdownTimer);
|
||||
predCountdownTimer = null;
|
||||
}
|
||||
}
|
||||
function startCountdownTimer(container) {
|
||||
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
|
||||
if (countdownEls.length === 0) return;
|
||||
predCountdownTimer = setInterval(() => {
|
||||
if (!isPageVisible()) {
|
||||
stopCountdownTimer();
|
||||
return;
|
||||
}
|
||||
const n = Date.now();
|
||||
let anyActive = false;
|
||||
for (const el of countdownEls) {
|
||||
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
||||
const rem = los - n;
|
||||
if (rem > 0) {
|
||||
el.textContent = formatCountdown(rem);
|
||||
anyActive = true;
|
||||
} else {
|
||||
el.textContent = "0:00";
|
||||
}
|
||||
}
|
||||
if (!anyActive) {
|
||||
stopCountdownTimer();
|
||||
render(filtered());
|
||||
}
|
||||
}, 1e3);
|
||||
}
|
||||
function buildCurrentPassRow(pass, now) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row-current";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
const remaining = Math.max(0, pass.los_ms - now);
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
function buildUpcomingPassRow(pass) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
function filtered() {
|
||||
let items = predData;
|
||||
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
|
||||
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
|
||||
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
|
||||
return items;
|
||||
}
|
||||
function applyFilters() {
|
||||
render(filtered());
|
||||
}
|
||||
var filterInput = dom.filter;
|
||||
filterInput?.addEventListener("input", () => {
|
||||
predFilterText = filterInput.value.trim().toUpperCase();
|
||||
applyFilters();
|
||||
});
|
||||
var minElevationSelect = dom.minElevation;
|
||||
minElevationSelect?.addEventListener("change", () => {
|
||||
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
|
||||
applyFilters();
|
||||
});
|
||||
var categorySelect = dom.category;
|
||||
categorySelect?.addEventListener("change", () => {
|
||||
predCategory = categorySelect.value;
|
||||
applyFilters();
|
||||
});
|
||||
function render(passes, error) {
|
||||
stopCountdownTimer();
|
||||
if (error) {
|
||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||
if (dom.currentSection) dom.currentSection.style.display = "none";
|
||||
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
||||
if (dom.status) dom.status.textContent = error;
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(passes) || passes.length === 0) {
|
||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||
if (dom.currentSection) dom.currentSection.style.display = "none";
|
||||
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
||||
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
|
||||
if (dom.currentList) {
|
||||
if (current.length === 0) {
|
||||
dom.currentList.innerHTML = "";
|
||||
} else {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||
dom.currentList.replaceChildren(frag);
|
||||
}
|
||||
}
|
||||
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
|
||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
|
||||
if (dom.upcomingList) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||
if (hiddenCount > 0) {
|
||||
const moreRow = document.createElement("div");
|
||||
moreRow.className = "sat-pred-row";
|
||||
moreRow.style.cursor = "pointer";
|
||||
moreRow.style.textAlign = "center";
|
||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
|
||||
moreRow.addEventListener("click", () => {
|
||||
predShowAll = true;
|
||||
render(filtered());
|
||||
});
|
||||
frag.appendChild(moreRow);
|
||||
}
|
||||
dom.upcomingList.replaceChildren(frag);
|
||||
}
|
||||
if (dom.status) {
|
||||
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
||||
if (predSatCount > 0) text += ` · ${predSatCount} satellites tracked`;
|
||||
dom.status.textContent = text;
|
||||
}
|
||||
if (current.length > 0 && isPageVisible()) {
|
||||
startCountdownTimer(dom.currentList);
|
||||
}
|
||||
}
|
||||
async function load() {
|
||||
if (dom.status) dom.status.textContent = "Loading predictions…";
|
||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||
try {
|
||||
const resp = await fetch("/sat_passes");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
predSatCount = data.satellite_count || 0;
|
||||
if (data.error) {
|
||||
predData = [];
|
||||
render([], data.error);
|
||||
} else {
|
||||
predData = data.passes || [];
|
||||
render(filtered());
|
||||
}
|
||||
} catch (error) {
|
||||
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
function clearPredictionDom() {
|
||||
stopCountdownTimer();
|
||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||
}
|
||||
predWindow.clearSatPredictionDom = clearPredictionDom;
|
||||
predWindow.refreshSatPredictions = function() {
|
||||
predShowAll = false;
|
||||
void load();
|
||||
};
|
||||
@@ -1,264 +0,0 @@
|
||||
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,7 +1,3 @@
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
@@ -64,21 +60,17 @@ 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 = rigMessages.length;
|
||||
const count = vdesMessageHistory.length;
|
||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (vdesLatestSeenEl) {
|
||||
const latest = rigMessages[0];
|
||||
const latest = vdesMessageHistory[0];
|
||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||
}
|
||||
}
|
||||
@@ -145,7 +137,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 && isActiveRigDecode(msg.rig_id)).slice(0, 6);
|
||||
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
||||
if (!isVdes || messages.length === 0) {
|
||||
vdesBarOverlay.style.display = "none";
|
||||
vdesBarOverlay.innerHTML = "";
|
||||
@@ -190,7 +182,7 @@ function renderVdesHistory() {
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const message of vdesRigMessages()) {
|
||||
for (const message of vdesMessageHistory) {
|
||||
fragment.appendChild(renderVdesRow(message));
|
||||
}
|
||||
vdesMessagesEl.replaceChildren(fragment);
|
||||
@@ -217,10 +209,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([], {
|
||||
@@ -259,8 +251,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);
|
||||
}
|
||||
@@ -277,10 +269,6 @@ 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,7 +1,3 @@
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
@@ -106,17 +102,13 @@ function paintLine(lineBytes) {
|
||||
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||
wefaxLiveLineCount++;
|
||||
}
|
||||
function wefaxRigImages() {
|
||||
return forActiveRig(wefaxImageHistory);
|
||||
}
|
||||
function renderWefaxLatestCard() {
|
||||
if (!wefaxDom.liveLatest) return;
|
||||
const rigImages = wefaxRigImages();
|
||||
if (rigImages.length === 0) {
|
||||
if (wefaxImageHistory.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 = rigImages[0];
|
||||
const img = wefaxImageHistory[0];
|
||||
if (!img) return;
|
||||
const ts = img._ts || "--";
|
||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||
@@ -137,7 +129,7 @@ function renderWefaxLatestCard() {
|
||||
wefaxDom.liveLatest.innerHTML = html;
|
||||
}
|
||||
function getWefaxFilteredHistory() {
|
||||
let items = wefaxRigImages();
|
||||
let items = wefaxImageHistory;
|
||||
if (wefaxFilterText) {
|
||||
items = items.filter(function(i) {
|
||||
const haystack = [
|
||||
@@ -181,7 +173,7 @@ function renderWefaxHistoryTable() {
|
||||
}
|
||||
wefaxDom.historyList.replaceChildren(fragment);
|
||||
if (wefaxDom.historyCount) {
|
||||
const total = wefaxRigImages().length;
|
||||
const total = wefaxImageHistory.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`;
|
||||
}
|
||||
@@ -216,7 +208,6 @@ 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;
|
||||
@@ -243,7 +234,6 @@ 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`;
|
||||
@@ -338,11 +328,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||
onMessage: onServerWefax,
|
||||
restore: restoreWefaxHistory,
|
||||
prune: pruneWefaxHistoryView,
|
||||
reset: resetWefaxHistoryView,
|
||||
rerender: () => {
|
||||
renderWefaxLatestCard();
|
||||
renderWefaxHistoryTable();
|
||||
}
|
||||
reset: resetWefaxHistoryView
|
||||
});
|
||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "wefax_progress",
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
@@ -68,10 +64,10 @@ function renderWsprRow(msg) {
|
||||
function renderWsprHistory() {
|
||||
pruneWsprMessageHistory();
|
||||
if (!wsprMessagesEl) return;
|
||||
const rigMessages = forActiveRig(wsprMessageHistory);
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const message of rigMessages) {
|
||||
fragment.appendChild(renderWsprRow(message));
|
||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
||||
const message = wsprMessageHistory[i];
|
||||
if (message) fragment.appendChild(renderWsprRow(message));
|
||||
}
|
||||
wsprMessagesEl.replaceChildren(fragment);
|
||||
}
|
||||
@@ -94,8 +90,6 @@ function normalizeServerWsprMessage(msg) {
|
||||
station,
|
||||
rfHz,
|
||||
history: {
|
||||
rig_id: msg.rig_id ?? null,
|
||||
_rfHz: rfHz,
|
||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms,
|
||||
snr_db: msg.snr_db,
|
||||
@@ -107,11 +101,16 @@ 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";
|
||||
plotWsprLocator(msg);
|
||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
||||
});
|
||||
}
|
||||
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||
normalized.push(next.history);
|
||||
}
|
||||
@@ -251,19 +250,15 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
||||
}
|
||||
})();
|
||||
});
|
||||
function plotWsprLocator(msg) {
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
|
||||
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
...rfHz === null ? {} : { freq_hz: rfHz }
|
||||
});
|
||||
}
|
||||
function onServerWspr(msg) {
|
||||
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
plotWsprLocator(msg);
|
||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
||||
});
|
||||
}
|
||||
addWsprMessage(next.history);
|
||||
}
|
||||
wsprWindow.trxPluginRuntime.registerDecoder({
|
||||
@@ -272,10 +267,5 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
||||
onBatch: onServerWsprBatch,
|
||||
restore: onServerWsprBatch,
|
||||
prune: pruneWsprHistoryView,
|
||||
reset: resetWsprHistoryView,
|
||||
rerender: renderWsprHistory,
|
||||
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||
syncMap: () => {
|
||||
for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message);
|
||||
}
|
||||
reset: resetWsprHistoryView
|
||||
});
|
||||
|
||||
@@ -22,10 +22,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||
<symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol>
|
||||
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
|
||||
<symbol id="icon-logbook" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 2h8a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M5.5 2v12"/><path d="M7.5 5.5h3M7.5 8h3M7.5 10.5h2"/></symbol>
|
||||
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
|
||||
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
|
||||
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
|
||||
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
|
||||
@@ -54,25 +52,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
||||
<span class="tab-label">Bookmarks</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="logbook">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-logbook"/></svg>
|
||||
<span class="tab-label">Logbook</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="digital-modes" aria-label="Digital modes">
|
||||
<button class="tab" data-tab="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>
|
||||
@@ -176,14 +163,13 @@ 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" 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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="spectrum-controls-spacer" aria-hidden="true"></div>
|
||||
<div id="spectrum-level-row">
|
||||
<label class="overview-control spectrum-field" id="spectrum-peak-hold-label"><span class="spectrum-field-name">Peak Hold</span>
|
||||
<label class="overview-control" id="spectrum-peak-hold-label">Peak Hold
|
||||
<select id="overview-peak-hold" class="status-input">
|
||||
<option value="0">Off</option>
|
||||
<option value="500">0.5 s</option>
|
||||
@@ -196,10 +182,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<option value="60000">60 s</option>
|
||||
</select>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
@@ -485,111 +471,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-logbook" class="tab-panel" style="display:none;">
|
||||
<div id="logbook-panel" class="log-panel">
|
||||
<!-- Station: who the contacts are logged under, and from where. Shown
|
||||
once rather than typed into every entry. -->
|
||||
<div class="log-station">
|
||||
<div class="log-station-identity">
|
||||
<span class="log-station-call" id="log-station-callsign">--</span>
|
||||
<label class="log-station-operator">
|
||||
<span>Operator</span>
|
||||
<input type="text" id="log-operator" class="status-input" autocomplete="off"
|
||||
spellcheck="false" aria-label="Operator callsign" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="log-station-rig">
|
||||
<span id="log-station-rig-name">--</span>
|
||||
<span id="log-station-grid" class="log-station-grid"></span>
|
||||
<span id="log-clock" class="log-clock" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entry: opens pre-filled with the six fields the radio and the clock
|
||||
can answer for. Everything else is the operator's. -->
|
||||
<form id="log-entry-form" class="log-entry" autocomplete="off">
|
||||
<div class="log-entry-grid">
|
||||
<label class="log-field log-field-call">
|
||||
<span>Callsign</span>
|
||||
<input type="text" id="log-call" class="status-input" required
|
||||
spellcheck="false" aria-describedby="log-worked-before" />
|
||||
</label>
|
||||
<label class="log-field">
|
||||
<span>Frequency</span>
|
||||
<input type="text" id="log-freq" class="status-input" inputmode="decimal" />
|
||||
</label>
|
||||
<label class="log-field log-field-narrow">
|
||||
<span>Mode</span>
|
||||
<input type="text" id="log-mode" class="status-input" spellcheck="false" />
|
||||
</label>
|
||||
<label class="log-field log-field-narrow">
|
||||
<span>RST sent</span>
|
||||
<input type="text" id="log-rst-sent" class="status-input" spellcheck="false" />
|
||||
</label>
|
||||
<label class="log-field log-field-narrow">
|
||||
<span>RST rcvd</span>
|
||||
<input type="text" id="log-rst-rcvd" class="status-input" spellcheck="false" />
|
||||
</label>
|
||||
<label class="log-field log-field-narrow">
|
||||
<span>Locator</span>
|
||||
<input type="text" id="log-grid" class="status-input" spellcheck="false" />
|
||||
</label>
|
||||
<label class="log-field">
|
||||
<span>Name</span>
|
||||
<input type="text" id="log-name" class="status-input" />
|
||||
</label>
|
||||
<label class="log-field">
|
||||
<span>Comment</span>
|
||||
<input type="text" id="log-comment" class="status-input" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="log-entry-actions">
|
||||
<span id="log-worked-before" class="log-worked" aria-live="polite"></span>
|
||||
<span class="log-entry-buttons">
|
||||
<button type="button" id="log-clear-btn" class="log-secondary-btn">Clear</button>
|
||||
<button type="submit" id="log-save-btn" class="log-primary-btn">Log contact</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- The log itself. -->
|
||||
<div class="log-list-toolbar">
|
||||
<input type="text" id="log-filter-call" class="status-input" placeholder="Callsign…"
|
||||
aria-label="Filter by callsign" />
|
||||
<select id="log-filter-band" class="status-input" aria-label="Filter by band">
|
||||
<option value="">All bands</option>
|
||||
</select>
|
||||
<select id="log-filter-mode" class="status-input" aria-label="Filter by mode">
|
||||
<option value="">All modes</option>
|
||||
</select>
|
||||
<span class="log-toolbar-spacer"></span>
|
||||
<a id="log-export-btn" class="log-secondary-btn" href="/api/logbook/export.adi"
|
||||
download>Export ADIF</a>
|
||||
<button type="button" id="log-import-btn" class="log-secondary-btn">Import ADIF</button>
|
||||
<input type="file" id="log-import-file" accept=".adi,.adif,text/plain" hidden />
|
||||
</div>
|
||||
<div class="log-table-wrap">
|
||||
<table class="log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Callsign</th>
|
||||
<th scope="col">Band</th>
|
||||
<th scope="col">Mode</th>
|
||||
<th scope="col">Sent</th>
|
||||
<th scope="col">Rcvd</th>
|
||||
<th scope="col">Locator</th>
|
||||
<th scope="col">Rig</th>
|
||||
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="log-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="log-summary" class="log-summary" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-bookmarks" class="tab-panel" style="display:none;">
|
||||
<div class="bm-toolbar">
|
||||
<select id="bm-scope-picker" class="status-input" aria-label="Bookmark scope">
|
||||
@@ -703,7 +584,6 @@ 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">
|
||||
@@ -766,12 +646,6 @@ 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;">
|
||||
@@ -779,56 +653,6 @@ 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>
|
||||
@@ -1033,6 +857,7 @@ 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">
|
||||
@@ -1074,6 +899,50 @@ 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">
|
||||
@@ -1187,51 +1056,6 @@ 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">
|
||||
@@ -1577,31 +1401,30 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
|
||||
<div id="background-decode-panel" class="sch-panel">
|
||||
<div class="sch-toast" id="background-decode-toast" role="alert" aria-live="polite" style="display:none;"></div>
|
||||
<!-- Now Playing status card (moved to top) -->
|
||||
<div class="now-playing-card">
|
||||
<div id="background-decode-status-card" class="sch-status-card">No background decode bookmarks configured.</div>
|
||||
</div>
|
||||
<div class="sch-section">
|
||||
<div class="sch-section-title">Background Decode</div>
|
||||
<p class="bgd-intro">
|
||||
Decodes saved bookmarks on hidden channels while you work another band.
|
||||
Needs an SDR rig, and a bookmark is only decoded while it falls inside
|
||||
the span the rig is tuned across.
|
||||
</p>
|
||||
<div class="bgd-controls">
|
||||
<label class="bgd-enable">
|
||||
<input type="checkbox" id="background-decode-enabled" />
|
||||
<span>Enabled</span>
|
||||
<div class="sch-section-title">Configuration</div>
|
||||
<div class="sch-row">
|
||||
<label class="sch-label bgd-toggle-wrap">Background decode
|
||||
<span class="bgd-toggle-row">
|
||||
<input type="checkbox" id="background-decode-enabled" />
|
||||
<span>Enable hidden background decoder channels</span>
|
||||
</span>
|
||||
</label>
|
||||
<span id="bgd-span-summary" class="bgd-span" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="bgd-list-toolbar">
|
||||
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter"
|
||||
placeholder="Filter bookmarks…" aria-label="Filter bookmarks" />
|
||||
<div class="sch-row" style="flex-direction:column;gap:0.5rem;">
|
||||
<label class="sch-label" style="min-width:100%;">Bookmarks
|
||||
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter" placeholder="Filter bookmarks..." />
|
||||
</label>
|
||||
<div class="bgd-select-actions">
|
||||
<button type="button" id="bgd-select-all-btn" class="bgd-select-btn" aria-label="Select every bookmark">All</button>
|
||||
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Select no bookmarks">None</button>
|
||||
<button type="button" id="bgd-select-all-btn" class="bgd-select-btn" aria-label="Select all bookmarks">Select All</button>
|
||||
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Deselect all bookmarks">Deselect All</button>
|
||||
</div>
|
||||
<div id="bgd-bookmark-checklist" class="bgd-checklist"></div>
|
||||
</div>
|
||||
<div id="bgd-bookmark-checklist" class="bgd-checklist" role="group"
|
||||
aria-label="Bookmarks decoded in the background"></div>
|
||||
<div id="bgd-selection-summary" class="bgd-summary" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="sch-actions">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,17 +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"),
|
||||
logbook: path.join(sourceDir, "plugins", "logbook.ts"),
|
||||
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
||||
"map-core": path.join(sourceDir, "map-core.ts"),
|
||||
screenshot: path.join(sourceDir, "screenshot.ts"),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs",
|
||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.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, sstv_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, recorder_enabled: boolean, };
|
||||
|
||||
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
||||
|
||||
@@ -78,10 +78,6 @@ 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>,
|
||||
/**
|
||||
@@ -115,7 +111,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, sstv_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, 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, };
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ interface TrxUi {
|
||||
confirm(options?: { title?: string; message?: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>;
|
||||
notify(message: string, options?: { kind?: "info" | "error" | "success" | "warning"; duration?: number; action?: { label?: string; run(): void } | null }): HTMLDivElement;
|
||||
setActiveRig(rigId: string | null): void;
|
||||
setLayoutCapabilities(capabilities?: Partial<Record<"broadcast" | "digital" | "ham", boolean>>): void;
|
||||
setLayoutCapabilities(capabilities?: Partial<Record<"broadcast" | "digital", boolean>>): void;
|
||||
setButtonState(element: HTMLButtonElement | null, options?: { active?: boolean; activeLabel?: string; inactiveLabel?: string; busy?: boolean; disabled?: boolean }): void;
|
||||
closeMobileOverlays?(restoreFocus?: boolean): void;
|
||||
syncSelectedTab(container: HTMLElement | null, selected: Element): void;
|
||||
@@ -385,14 +385,11 @@ 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;
|
||||
@@ -861,7 +858,7 @@ function currentDecodeHistoryRetentionMs() {
|
||||
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
||||
|
||||
window.applyDecodeHistoryRetention = function() {
|
||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"]) {
|
||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) {
|
||||
window.trxPluginRuntime.prune(decoder);
|
||||
}
|
||||
};
|
||||
@@ -1468,13 +1465,9 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
|
||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||
updateRigSubtitle(lastActiveRigId);
|
||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||
// Told every time, not only when the list changes: these modules load lazily
|
||||
// and can miss the one telling there was, which left them holding no rig and
|
||||
// showing an empty panel until the operator switched rigs. Both ignore a rig
|
||||
// they already have.
|
||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
||||
if (rigListChanged) {
|
||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
||||
window.trx.modules.bookmarks?.populateScopePicker();
|
||||
void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
|
||||
}
|
||||
@@ -1522,9 +1515,6 @@ function refreshOperatorLayoutCapabilities() {
|
||||
window.trxUi?.setLayoutCapabilities({
|
||||
broadcast: rigModes.some((modes) => modes.includes("WFM")),
|
||||
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))),
|
||||
// A logbook is for contacts made, so the layout built around it is offered
|
||||
// where a rig can transmit.
|
||||
ham: serverRigs.some((rig) => rig?.tx === true),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2215,17 +2205,6 @@ 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;
|
||||
@@ -2245,12 +2224,6 @@ 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() {
|
||||
@@ -3701,7 +3674,10 @@ 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);
|
||||
}
|
||||
refreshDecodeBars();
|
||||
if (window.updateAisBar) window.updateAisBar();
|
||||
if (window.updateVdesBar) window.updateVdesBar();
|
||||
if (window.updateAprsBar) window.updateAprsBar();
|
||||
if (window.updateFt8Bar) window.updateFt8Bar();
|
||||
// Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong.
|
||||
for (const d of decoderRegistry) {
|
||||
if (d.activation !== "toggle") continue;
|
||||
@@ -3736,11 +3712,7 @@ function render(update: AppUpdate) {
|
||||
for (const [key, entry] of Object.entries(_decoderToggles)) {
|
||||
syncDecoderToggle(entry, !!update[key], entry.label);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
// WEFAX toggle sync (plugin-owned, belt-and-suspenders alongside _decoderToggles).
|
||||
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
|
||||
window.syncWefaxToggle(update.wefax_decode_enabled);
|
||||
}
|
||||
@@ -4872,7 +4844,6 @@ 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);
|
||||
@@ -4913,15 +4884,7 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
|
||||
updateTabHistory(name, replaceHistory);
|
||||
}
|
||||
scheduleSpectrumLayout();
|
||||
// Its countdowns tick every second, so the page stops when it is not on screen.
|
||||
if (leavingSatellites) window.clearSatPredictionDom?.();
|
||||
void loadPluginsForTab(name).then(() => {
|
||||
// Passes go stale while the page is closed, so each visit reloads them.
|
||||
// The first visit is what imports the module, which renders on its own.
|
||||
if (name === "satellites") window.refreshSatPredictions?.();
|
||||
// The map module owns the decode log; this is the moment it can exist.
|
||||
flushPendingDecodeStats();
|
||||
}).catch((error: unknown) => { console.error(error); });
|
||||
void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); });
|
||||
if (name === "map") {
|
||||
_initMapWhenReady();
|
||||
}
|
||||
@@ -5043,12 +5006,7 @@ async function initializeApp() {
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the session got far enough to wire the settings panels up, so the
|
||||
// plugins can be wired again if they turn up after it did.
|
||||
let settingsUiReady = false;
|
||||
|
||||
function initSettingsUI() {
|
||||
settingsUiReady = true;
|
||||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
||||
window.trx.modules.scheduler?.wireEvents();
|
||||
if (window.trx.modules.backgroundDecode) {
|
||||
@@ -5201,15 +5159,7 @@ window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules
|
||||
|
||||
// Load plugin scripts now that window.trx is populated. Dynamic scripts are
|
||||
// async so they must not be created before the namespace they depend on exists.
|
||||
//
|
||||
// Wire the settings panels again once they are here: the session can be up
|
||||
// before these modules finish importing, and the wiring at that point silently
|
||||
// skipped whichever had not arrived. A panel that missed it had no rig, no
|
||||
// data, and dead buttons — for the rest of the session, since nothing came
|
||||
// back to it. Both entry points are safe to run twice.
|
||||
void loadEagerPlugins()
|
||||
.then(() => { if (settingsUiReady) initSettingsUI(); })
|
||||
.catch((error: unknown) => { console.error(error); });
|
||||
void loadEagerPlugins().catch((error: unknown) => { console.error(error); });
|
||||
|
||||
// Start the app
|
||||
void initializeApp();
|
||||
@@ -5286,6 +5236,10 @@ 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);
|
||||
@@ -6613,55 +6567,11 @@ function updateDecodeStatus(text: string) {
|
||||
if (el && el.textContent !== "Receiving") el.textContent = text;
|
||||
}
|
||||
}
|
||||
// Picture decoders produce one message per image and a stream of progress
|
||||
// updates; neither is a spot, and counting them would swamp the statistics.
|
||||
const IMAGE_DECODE_KINDS = new Set([
|
||||
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
|
||||
]);
|
||||
|
||||
// The decode log the Statistics page counts lives in the map module, which is
|
||||
// lazy: it arrives when the Map or Statistics tab is first opened, long after
|
||||
// the history replayed and the live decodes started coming. Recording into a
|
||||
// module that is not there yet dropped every one of them, and nothing replayed
|
||||
// them afterwards -- so the page opened empty and only filled in from decodes
|
||||
// heard after it, which is why it took a reload (landing on the tab, so the
|
||||
// module loads at startup) to show the whole picture. Hold them here until
|
||||
// the module arrives, then hand them over.
|
||||
interface PendingDecodeStat { kind: string; rig: string | null; tsMs: number | undefined }
|
||||
const pendingDecodeStats: PendingDecodeStat[] = [];
|
||||
// The module's own log keeps 50k entries; there is no point holding more here.
|
||||
const PENDING_DECODE_STATS_MAX = 50_000;
|
||||
|
||||
function recordDecodeStat(kind: string, rig: string | null, tsMs?: number) {
|
||||
const stats = window.trx.modules.map;
|
||||
if (stats) {
|
||||
stats.statsRecordDecode(kind, rig, tsMs);
|
||||
return;
|
||||
}
|
||||
pendingDecodeStats.push({ kind, rig, tsMs });
|
||||
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
|
||||
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
function flushPendingDecodeStats() {
|
||||
const stats = window.trx.modules.map;
|
||||
if (!stats || pendingDecodeStats.length === 0) return;
|
||||
for (const entry of pendingDecodeStats.splice(0)) {
|
||||
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
|
||||
}
|
||||
stats.scheduleStatsRender();
|
||||
}
|
||||
|
||||
function scheduleStatsRenderIfLoaded() {
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
|
||||
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
|
||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
|
||||
scheduleStatsRenderIfLoaded();
|
||||
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
|
||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6709,11 +6619,11 @@ function loadDecodeHistoryOnMainThread(onReady: (groups: DecodeHistoryGroups) =>
|
||||
function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
// Record statistics for restored history messages.
|
||||
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
|
||||
for (const msg of messages) {
|
||||
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
||||
}
|
||||
scheduleStatsRenderIfLoaded();
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
window.trxPluginRuntime.restore(kind, messages);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
export {};
|
||||
|
||||
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"] as const;
|
||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"] as const;
|
||||
type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number];
|
||||
type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
|
||||
interface DecodeState { offset: number }
|
||||
|
||||
+1
-3
@@ -3,7 +3,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
export const TAB_ORDER = [
|
||||
"main", "bookmarks", "logbook", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
|
||||
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
|
||||
] as const;
|
||||
|
||||
export type TabName = typeof TAB_ORDER[number];
|
||||
@@ -11,10 +11,8 @@ export type TabName = typeof TAB_ORDER[number];
|
||||
export const TAB_PATHS: Readonly<Record<TabName, string>> = {
|
||||
main: "/",
|
||||
bookmarks: "/bookmarks",
|
||||
logbook: "/logbook",
|
||||
"digital-modes": "/digital-modes",
|
||||
map: "/map",
|
||||
satellites: "/satellites",
|
||||
statistics: "/statistics",
|
||||
recorder: "/recorder",
|
||||
settings: "/settings",
|
||||
|
||||
@@ -31,7 +31,7 @@ function mapEl(id: string): MapElement {
|
||||
return element as MapElement;
|
||||
}
|
||||
|
||||
type DecoderSource = "ais" | "vdes" | "aprs" | "hf_aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
|
||||
type DecoderSource = "ais" | "vdes" | "aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
|
||||
interface TrackPoint { lat: number; lon: number; tsMs: number }
|
||||
interface DecodeDetail {
|
||||
station?: string | null; source?: string | null; target?: string | null;
|
||||
@@ -83,9 +83,6 @@ interface MapEntry {
|
||||
bookmarks?: Array<Record<string, unknown>>;
|
||||
bounds?: Leaflet.LatLngBoundsExpression;
|
||||
symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
|
||||
/** Callsign as heard. The map key qualifies it with the source, so that a
|
||||
* station worked on both 144 MHz and 30 m keeps one marker per band. */
|
||||
call?: string;
|
||||
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
|
||||
pathKey?: string; sourceGrid?: string; targetGrid?: string;
|
||||
from?: LatLon; to?: LatLon;
|
||||
@@ -174,7 +171,7 @@ interface MapWindow {
|
||||
clearMapMarkersByType?(type: DecoderSource): void;
|
||||
navigateToAprsMap?(lat: number, lon: number): void;
|
||||
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
|
||||
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage, source?: "aprs" | "hf_aprs"): void;
|
||||
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage): void;
|
||||
aisMapAddVessel?(message: MapMessage): void;
|
||||
vdesMapAddPoint?(message: MapMessage): void;
|
||||
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
|
||||
@@ -231,7 +228,7 @@ const mapWindow = window as unknown as MapWindow;
|
||||
const decodeContactPaths = new Map<string, MapEntry>();
|
||||
let selectedMapQsoKey: string | null = null;
|
||||
const mapMarkers = new Set<TrxLayer>();
|
||||
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||
/** Chip key that clears a selection rather than naming a band or a source. */
|
||||
const MAP_FILTER_ALL_KEY = "__all";
|
||||
@@ -305,18 +302,6 @@ const mapWindow = window as unknown as MapWindow;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** The source a station marker belongs to, defaulting to VHF APRS for
|
||||
* entries stored before HF APRS had a source of its own. */
|
||||
function aprsEntrySource(entry: MapEntry | null | undefined): DecoderSource {
|
||||
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||
}
|
||||
|
||||
/** Map key for a station: the callsign alone on VHF, source-qualified on HF,
|
||||
* so one station heard on both bands keeps a marker for each. */
|
||||
function aprsStationKey(call: string, source: DecoderSource): string {
|
||||
return source === "aprs" ? call : `${source}:${call}`;
|
||||
}
|
||||
|
||||
function refreshAprsTrack(call: string, entry: MapEntry): void {
|
||||
if (!entry) return;
|
||||
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
||||
@@ -338,7 +323,7 @@ const mapWindow = window as unknown as MapWindow;
|
||||
lineJoin: "round",
|
||||
interactive: false,
|
||||
}) as unknown as TrxLayer;
|
||||
track.__trxType = aprsEntrySource(entry);
|
||||
track.__trxType = "aprs";
|
||||
track._aprsCall = call;
|
||||
entry.track = track;
|
||||
}
|
||||
@@ -590,7 +575,6 @@ const mapWindow = window as unknown as MapWindow;
|
||||
|
||||
function mapSourceLabel(type: DecoderSource): string {
|
||||
if (type === "bookmark") return "Bookmarks";
|
||||
if (type === "hf_aprs") return "HF APRS";
|
||||
return String(type || "").toUpperCase();
|
||||
}
|
||||
|
||||
@@ -610,8 +594,6 @@ const mapWindow = window as unknown as MapWindow;
|
||||
if (type === "vdes") return "#a78bfa";
|
||||
if (type === "sat") return "#f59e0b";
|
||||
if (type === "aprs") return "#00d17f";
|
||||
// Far enough from the VHF green to tell the two apart at a glance.
|
||||
if (type === "hf_aprs") return "#fb7185";
|
||||
return locatorFilterColor(type);
|
||||
}
|
||||
|
||||
@@ -1206,8 +1188,10 @@ const mapWindow = window as unknown as MapWindow;
|
||||
}
|
||||
}
|
||||
for (const entry of stationMarkers.values()) {
|
||||
if (!entry?.visibleInHistoryWindow) continue;
|
||||
availableSources.add(aprsEntrySource(entry));
|
||||
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
|
||||
availableSources.add("aprs");
|
||||
break;
|
||||
}
|
||||
}
|
||||
const bandMap = new Map<string, FilterChip>();
|
||||
for (const entry of locatorMarkers.values()) {
|
||||
@@ -1241,7 +1225,7 @@ const mapWindow = window as unknown as MapWindow;
|
||||
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
||||
}
|
||||
|
||||
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
|
||||
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
|
||||
.filter((key) => availableSources.has(key))
|
||||
.map((key) => ({
|
||||
key,
|
||||
@@ -1312,10 +1296,9 @@ const mapWindow = window as unknown as MapWindow;
|
||||
}
|
||||
return parts.join(" ").toLowerCase();
|
||||
}
|
||||
if (type === "aprs" || type === "hf_aprs") {
|
||||
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||
const entry = stationMarkers.get(key);
|
||||
const call = entry?.call ?? key;
|
||||
if (type === "aprs") {
|
||||
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||
const entry = stationMarkers.get(call);
|
||||
const info = entry?.info ? String(entry.info) : "";
|
||||
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
||||
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
||||
@@ -1513,10 +1496,9 @@ const mapWindow = window as unknown as MapWindow;
|
||||
};
|
||||
|
||||
mapWindow.clearMapMarkersByType = function(type) {
|
||||
if (type === "aprs" || type === "hf_aprs") {
|
||||
if (type === "aprs") {
|
||||
selectedAprsTrackCall = null;
|
||||
stationMarkers.forEach((entry, key) => {
|
||||
if (aprsEntrySource(entry) !== type) return;
|
||||
stationMarkers.forEach((entry) => {
|
||||
if (entry && entry.marker) {
|
||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||
mapMarkers.delete(entry.marker);
|
||||
@@ -1525,8 +1507,8 @@ const mapWindow = window as unknown as MapWindow;
|
||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||
mapMarkers.delete(entry.track);
|
||||
}
|
||||
stationMarkers.delete(key);
|
||||
});
|
||||
stationMarkers.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1799,15 +1781,13 @@ const mapWindow = window as unknown as MapWindow;
|
||||
if (!ll) return;
|
||||
const entry = stationMarkers.get(marker._aprsCall);
|
||||
if (!entry) return;
|
||||
const source = aprsEntrySource(entry);
|
||||
const call = entry.call ?? String(marker._aprsCall);
|
||||
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||
refreshAprsTrack(String(marker._aprsCall), entry);
|
||||
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
|
||||
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
|
||||
entry.track.addTo(aprsMap);
|
||||
}
|
||||
selectedAprsTrackCall = String(marker._aprsCall);
|
||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
|
||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2290,33 +2270,28 @@ const mapWindow = window as unknown as MapWindow;
|
||||
return null;
|
||||
}
|
||||
|
||||
function _aprsAddMarkerToMap(key: string, entry: MapEntry): void {
|
||||
function _aprsAddMarkerToMap(call: string, entry: MapEntry): void {
|
||||
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
||||
refreshAprsTrack(key, entry);
|
||||
const source = aprsEntrySource(entry);
|
||||
const call = entry.call ?? key;
|
||||
refreshAprsTrack(call, entry);
|
||||
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
||||
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
||||
const color = mapSourceColor(source);
|
||||
const marker = (icon
|
||||
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
|
||||
: L.circleMarker([entry.lat, entry.lon], {
|
||||
radius: 6, color, fillColor: color, fillOpacity: 0.8
|
||||
radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8
|
||||
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
|
||||
marker.__trxType = source;
|
||||
marker.__trxType = "aprs";
|
||||
marker.__trxRigIds = entry.rigIds || new Set();
|
||||
marker._aprsCall = key;
|
||||
marker._aprsCall = call;
|
||||
entry.marker = marker;
|
||||
mapMarkers.add(marker);
|
||||
}
|
||||
|
||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
|
||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
|
||||
const nextPoint: Leaflet.LatLngTuple = [lat, lon];
|
||||
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
||||
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
||||
const stationSource: DecoderSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||
const key = aprsStationKey(call, stationSource);
|
||||
const existing = stationMarkers.get(key);
|
||||
const existing = stationMarkers.get(call);
|
||||
if (existing) {
|
||||
existing.pkt = pkt;
|
||||
existing.lat = lat;
|
||||
@@ -2335,8 +2310,7 @@ const mapWindow = window as unknown as MapWindow;
|
||||
} else if (prevPoint) {
|
||||
prevPoint.tsMs = tsMs;
|
||||
}
|
||||
existing.call = call;
|
||||
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
|
||||
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
|
||||
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
||||
existing.marker.setLatLng([lat, lon]);
|
||||
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
||||
@@ -2347,8 +2321,7 @@ const mapWindow = window as unknown as MapWindow;
|
||||
track: null,
|
||||
trackHistory: [{ lat, lon, tsMs }],
|
||||
trackPoints: [nextPoint],
|
||||
type: stationSource,
|
||||
call,
|
||||
type: "aprs",
|
||||
pkt,
|
||||
lat,
|
||||
lon,
|
||||
@@ -2357,9 +2330,9 @@ const mapWindow = window as unknown as MapWindow;
|
||||
symbolCode,
|
||||
rigIds: new Set(msgRigId ? [msgRigId] : []),
|
||||
};
|
||||
stationMarkers.set(key, entry);
|
||||
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
|
||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
|
||||
stationMarkers.set(call, entry);
|
||||
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
|
||||
if (aprsMap) scheduleDecodeMapMaintenance();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
type PluginGroup =
|
||||
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
|
||||
| "bookmarks" | "logbook" | "recorder" | "settings";
|
||||
type PluginGroup = "digital-modes" | "map-data" | "map" | "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
|
||||
@@ -13,14 +11,12 @@ 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", "/sstv.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js",
|
||||
"/sat.js", "/wefax.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"],
|
||||
logbook: ["/logbook.js"],
|
||||
recorder: [],
|
||||
settings: ["/vchan.js", "/scheduler.js"],
|
||||
};
|
||||
@@ -48,7 +44,7 @@ async function loadPlugins(group: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function loadEagerPlugins(): Promise<void> {
|
||||
await Promise.all(["digital-modes", "bookmarks", "logbook", "settings"].map(loadPlugins));
|
||||
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
|
||||
}
|
||||
|
||||
export async function loadPluginsForTab(tab: string): Promise<void> {
|
||||
|
||||
@@ -81,7 +81,6 @@ 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),
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// 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,7 +6,6 @@ import { hostCore, hostState } from "./host.js";
|
||||
|
||||
export {};
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||
|
||||
interface AisMessage {
|
||||
@@ -191,27 +190,20 @@ 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 rigMessages = aisRigMessages();
|
||||
const vessels = aisLatestByVessel(rigMessages);
|
||||
const vessels = aisLatestByVessel(aisMessageHistory);
|
||||
if (aisVesselCountEl) {
|
||||
const count = vessels.length;
|
||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
if (aisLatestSeenEl) {
|
||||
const latest = rigMessages[0];
|
||||
const latest = aisMessageHistory[0];
|
||||
if (!latest) {
|
||||
aisLatestSeenEl.textContent = "No traffic yet";
|
||||
} else {
|
||||
@@ -322,9 +314,7 @@ 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 && isActiveRigDecode(msg.rig_id),
|
||||
);
|
||||
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||
if (!isAis || messages.length === 0) {
|
||||
aisBarOverlay.style.display = "none";
|
||||
@@ -378,7 +368,7 @@ function renderAisHistory() {
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const message of aisRigMessages()) {
|
||||
for (const message of aisMessageHistory) {
|
||||
fragment.appendChild(renderAisRow(message));
|
||||
}
|
||||
aisMessagesEl.replaceChildren(fragment);
|
||||
@@ -417,11 +407,10 @@ 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([], {
|
||||
@@ -463,9 +452,8 @@ if (aisFilterInput) {
|
||||
}
|
||||
|
||||
function onServerAis(msg: AisMessage): void {
|
||||
const message = normalizeServerAisMessage(msg);
|
||||
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
||||
addAisMessage(message);
|
||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||
addAisMessage(normalizeServerAisMessage(msg));
|
||||
}
|
||||
|
||||
updateAisSummary();
|
||||
@@ -476,7 +464,6 @@ updateAisSummary();
|
||||
restore: onServerAisBatch,
|
||||
reset: resetAisHistoryView,
|
||||
prune: pruneAisHistoryView,
|
||||
rerender: () => { updateAisBar(); renderAisHistory(); },
|
||||
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
|
||||
});
|
||||
|
||||
@@ -324,21 +324,6 @@ export interface AprsRowOptions {
|
||||
onMap?: (lat: number, lon: number) => void;
|
||||
/** Puts a frame's coordinates on the clipboard. */
|
||||
onCopy?: (text: string, button: HTMLElement) => void;
|
||||
/** Opens a log entry for the station that sent the frame. */
|
||||
onLog?: (call: string, gridsquare: string | null) => void;
|
||||
}
|
||||
|
||||
/** A position as a six-character locator, which is what a log wants. */
|
||||
function maidenheadForLatLon(lat: number, lon: number): string {
|
||||
const adjustedLon = lon + 180;
|
||||
const adjustedLat = lat + 90;
|
||||
const field = String.fromCharCode(65 + Math.floor(adjustedLon / 20))
|
||||
+ String.fromCharCode(65 + Math.floor(adjustedLat / 10));
|
||||
const square = String(Math.floor((adjustedLon % 20) / 2)) + String(Math.floor(adjustedLat % 10));
|
||||
const subLon = ((adjustedLon % 2) * 60) / 5;
|
||||
const subLat = ((adjustedLat % 1) * 60) / 2.5;
|
||||
const sub = String.fromCharCode(97 + Math.floor(subLon)) + String.fromCharCode(97 + Math.floor(subLat));
|
||||
return `${field}${square}${sub}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement {
|
||||
@@ -385,7 +370,6 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions
|
||||
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") +
|
||||
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") +
|
||||
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
||||
(options.onLog && packet.srcCall ? '<button class="aprs-inline-btn" type="button" data-aprs-log="1">Log</button>' : "") +
|
||||
`</div>` +
|
||||
`</div>`;
|
||||
|
||||
@@ -397,17 +381,6 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number);
|
||||
});
|
||||
});
|
||||
const logButton = row.querySelector<HTMLElement>("[data-aprs-log]");
|
||||
if (logButton) {
|
||||
logButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const grid = packet.lat != null && packet.lon != null
|
||||
? maidenheadForLatLon(packet.lat, packet.lon)
|
||||
: null;
|
||||
options.onLog?.(packet.srcCall ?? "", grid);
|
||||
});
|
||||
}
|
||||
const copyButton = row.querySelector<HTMLElement>("[data-aprs-copy]");
|
||||
if (copyButton) {
|
||||
copyButton.addEventListener("click", (event) => {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import { hostCore, hostState } from "./host.js";
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import {
|
||||
aprsAgeText,
|
||||
aprsPacketCategory,
|
||||
@@ -23,7 +22,6 @@ interface AprsBridge {
|
||||
aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
|
||||
getDecodeRigMeta?: () => unknown;
|
||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
|
||||
updateAprsBar?: () => void;
|
||||
clearAprsBar?: () => void;
|
||||
closeAprsBar?: () => void;
|
||||
@@ -108,29 +106,21 @@ 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 rigPackets = aprsRigPackets();
|
||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
|
||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
||||
return packets.filter(aprsFilterMatch);
|
||||
}
|
||||
|
||||
function updateAprsSummary() {
|
||||
const rigPackets = aprsRigPackets();
|
||||
const visible = aprsVisiblePackets();
|
||||
if (aprsTotalCountEl) {
|
||||
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
||||
}
|
||||
if (aprsVisibleCountEl) {
|
||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||
}
|
||||
if (aprsLatestSeenEl) {
|
||||
const latest = rigPackets[0];
|
||||
const latest = aprsPacketHistory[0];
|
||||
if (!latest) {
|
||||
aprsLatestSeenEl.textContent = "No packets yet";
|
||||
} else {
|
||||
@@ -165,9 +155,6 @@ function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
||||
distance: aprsDistanceText(pkt),
|
||||
onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); },
|
||||
onCopy: (text) => { void copyAprsCoords(text); },
|
||||
onLog: (call, gridsquare) => {
|
||||
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? undefined, decoder: "aprs" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -192,9 +179,7 @@ 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 && isActiveRigDecode(p.rig_id),
|
||||
);
|
||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
||||
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) {
|
||||
@@ -271,12 +256,11 @@ 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" });
|
||||
@@ -340,9 +324,8 @@ if (aprsFilterInput) {
|
||||
|
||||
// --- Server-side APRS decode handler ---
|
||||
function onServerAprs(pkt: AprsPacket): void {
|
||||
const packet = normalizeServerAprsPacket(pkt);
|
||||
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
|
||||
addAprsPacket(packet);
|
||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||
}
|
||||
|
||||
renderAprsHistory();
|
||||
@@ -353,7 +336,6 @@ 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); },
|
||||
});
|
||||
|
||||
+61
-146
@@ -66,17 +66,10 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
let bookmarkList: Bookmark[] = [];
|
||||
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let bgdDirty = false;
|
||||
/** Last polled state per bookmark, so a row can say what it is doing. */
|
||||
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
|
||||
let lastStatus: BackgroundDecodeStatus | null = null;
|
||||
|
||||
function initBackgroundDecode(rigId: string | null, role: string | null): void {
|
||||
backgroundDecodeRole = role;
|
||||
// The panel used to take whatever rig it was handed at load and wait to be
|
||||
// told again. Loading before the rig list arrives handed it null, and the
|
||||
// next telling only came when the operator switched rigs, so the panel sat
|
||||
// empty and silent. The host knows the rig; ask it.
|
||||
currentRigId = rigId || hostState.lastActiveRigId || null;
|
||||
currentRigId = rigId || null;
|
||||
if (currentRigId) loadBackgroundDecode();
|
||||
startStatusPolling();
|
||||
}
|
||||
@@ -178,7 +171,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||
renderBookmarkChecklist();
|
||||
|
||||
const isControl = isControlRole();
|
||||
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||
const panel = document.getElementById("background-decode-panel");
|
||||
if (panel) {
|
||||
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
|
||||
@@ -189,11 +182,6 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
function currentFilterText(): string {
|
||||
return (document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value ?? "";
|
||||
}
|
||||
|
||||
function renderBookmarkChecklist(filterText = ""): void {
|
||||
@@ -215,65 +203,26 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
: all;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
|
||||
renderSelectionSummary();
|
||||
container.innerHTML = '<div class="bgd-checklist-empty">' +
|
||||
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.forEach(function (bookmark) {
|
||||
const row = document.createElement("label");
|
||||
row.className = "bgd-checklist-row";
|
||||
row.dataset.bmId = bookmark.id;
|
||||
const decoders = bookmarkDecoderKinds(bookmark);
|
||||
const selected = selectedIds.has(bookmark.id);
|
||||
if (selected) row.classList.add("is-selected");
|
||||
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
||||
row.innerHTML =
|
||||
'<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
||||
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
||||
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
||||
'<span class="bgd-checklist-meta">'
|
||||
+ escHtml(formatFreq(bookmark.freq_hz)) + '<span class="bgd-checklist-mode">'
|
||||
+ escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span></span>'
|
||||
+ stateBadgeHtml(bookmark.id, selected);
|
||||
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
|
||||
row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) {
|
||||
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
renderSelectionSummary();
|
||||
}
|
||||
|
||||
/** Why the list is empty, in the terms the operator can act on. */
|
||||
function emptyListText(supportedCount: number): string {
|
||||
if (supportedCount > 0) return "No bookmark matches that filter.";
|
||||
if (bookmarkList.length > 0) {
|
||||
return "None of your bookmarks name a decoder that can run in the background."
|
||||
+ " Give one a decoder on the Bookmarks tab to list it here.";
|
||||
}
|
||||
return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
|
||||
}
|
||||
|
||||
/** The live state of a selected bookmark, as the row's own badge. */
|
||||
function stateBadgeHtml(bookmarkId: string, selected: boolean): string {
|
||||
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
|
||||
const entry = statusByBookmark.get(bookmarkId);
|
||||
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
|
||||
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '">'
|
||||
+ '<span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
|
||||
}
|
||||
|
||||
function renderSelectionSummary(): void {
|
||||
const el = document.getElementById("bgd-selection-summary");
|
||||
if (!el) return;
|
||||
const selected = currentConfig?.bookmark_ids.length ?? 0;
|
||||
if (selected === 0) {
|
||||
el.textContent = "Nothing selected — background decoding is idle.";
|
||||
return;
|
||||
}
|
||||
const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
|
||||
const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
|
||||
el.textContent = currentConfig?.enabled
|
||||
? `${noun}, ${String(active)} decoding now.`
|
||||
: `${noun}. Switch Enabled on to start decoding them.`;
|
||||
}
|
||||
|
||||
function onChecklistToggle(bookmarkId: string, checked: boolean): void {
|
||||
@@ -311,7 +260,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||
})
|
||||
.finally(function () {
|
||||
syncSaveButton();
|
||||
if (btn) btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -346,77 +295,58 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
}
|
||||
|
||||
function renderStatus(status: BackgroundDecodeStatus): void {
|
||||
lastStatus = status;
|
||||
statusByBookmark = new Map(
|
||||
(status.entries ?? [])
|
||||
.filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0)
|
||||
.map((entry) => [entry.bookmark_id as string, entry]),
|
||||
);
|
||||
renderSpanSummary();
|
||||
// Rows carry the state, so the list repaints — keeping the filter the
|
||||
// operator typed and the selection they have not saved yet.
|
||||
renderBookmarkChecklist(currentFilterText());
|
||||
}
|
||||
|
||||
/** What the rig is listening across, which is what decides whether a
|
||||
* selected bookmark can be decoded at all. */
|
||||
function renderSpanSummary(): void {
|
||||
const el = document.getElementById("bgd-span-summary");
|
||||
if (!el) return;
|
||||
const status = lastStatus;
|
||||
if (!status) {
|
||||
el.textContent = "";
|
||||
const card = document.getElementById("background-decode-status-card");
|
||||
if (!card) return;
|
||||
const entries = status.entries ?? [];
|
||||
if (!entries.length) {
|
||||
card.textContent = "No background decode bookmarks configured.";
|
||||
return;
|
||||
}
|
||||
if (!status.active_rig) {
|
||||
el.textContent = "This rig is not the one playing audio.";
|
||||
el.dataset.tone = "warn";
|
||||
return;
|
||||
const summary = [];
|
||||
if (status.active_rig) {
|
||||
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
||||
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
||||
} else {
|
||||
summary.push("This rig is not currently selected for audio.");
|
||||
}
|
||||
const centre = typeof status.center_hz === "number" && Number.isFinite(status.center_hz)
|
||||
? formatFreq(status.center_hz)
|
||||
: null;
|
||||
const half = typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0
|
||||
? formatFreq(status.sample_rate / 2)
|
||||
: null;
|
||||
el.dataset.tone = "";
|
||||
el.textContent = centre && half ? `Span ${centre} ±${half}` : centre ? `Centre ${centre}` : "";
|
||||
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
||||
html += '<div class="bgd-status-list">';
|
||||
entries.forEach(function (entry) {
|
||||
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
||||
const parts = [];
|
||||
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
||||
if (entry.mode) parts.push(entry.mode);
|
||||
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
||||
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
||||
}
|
||||
html +=
|
||||
'<div class="bgd-status-row">' +
|
||||
'<div>' +
|
||||
'<div class="bgd-status-name">' + escHtml(name) + '</div>' +
|
||||
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' +
|
||||
'<svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' +
|
||||
escHtml(prettyState(entry.state)) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += "</div>";
|
||||
card.innerHTML = html;
|
||||
}
|
||||
|
||||
/** The one-line reason behind a state, for the row's tooltip. */
|
||||
function stateHelp(state: string | undefined): string {
|
||||
switch (state) {
|
||||
case "active": return "Decoding on a hidden channel.";
|
||||
case "out_of_span": return "Outside the span the rig is tuned across, so it cannot be heard from here.";
|
||||
case "waiting_for_spectrum": return "Waiting for the first spectrum frame from the rig.";
|
||||
case "waiting_for_user": return "Nobody is listening to this rig, so no audio is being pulled.";
|
||||
case "missing_bookmark": return "The bookmark this was selected from is gone.";
|
||||
case "no_supported_decoders": return "No decoder that runs in the background can decode this bookmark.";
|
||||
case "disabled": return "Background decoding is switched off.";
|
||||
case "handled_by_scheduler":
|
||||
case "scheduler_has_control": return "The scheduler is running this bookmark instead.";
|
||||
case "handled_by_virtual_channel": return "A virtual channel is already on this frequency.";
|
||||
case "pending": return "Selected, and not started yet — save to apply.";
|
||||
default: return "Selected, but not decoding.";
|
||||
}
|
||||
}
|
||||
|
||||
// The dot beside the word already says whether this is running, so the
|
||||
// words no longer carry a tick or a triangle of their own.
|
||||
function prettyState(state: string | undefined): string {
|
||||
switch (state) {
|
||||
case "active": return "Decoding";
|
||||
case "out_of_span": return "Out of span";
|
||||
case "waiting_for_spectrum": return "Waiting for spectrum";
|
||||
case "waiting_for_user": return "Nobody listening";
|
||||
case "missing_bookmark": return "Bookmark gone";
|
||||
case "no_supported_decoders": return "No decoder";
|
||||
case "disabled": return "Off";
|
||||
case "handled_by_scheduler":
|
||||
case "scheduler_has_control": return "Scheduler has it";
|
||||
case "handled_by_virtual_channel": return "On a channel";
|
||||
case "pending": return "Not saved";
|
||||
default: return "Idle";
|
||||
case "active": return "\u2713 Active";
|
||||
case "out_of_span": return "\u25B3 Out of span";
|
||||
case "waiting_for_spectrum": return "\u25B3 Waiting";
|
||||
case "waiting_for_user": return "\u25B3 No user";
|
||||
case "missing_bookmark": return "\u2717 Missing";
|
||||
case "no_supported_decoders": return "\u2717 Unsupported";
|
||||
case "disabled": return "\u25B3 Disabled";
|
||||
case "handled_by_scheduler": return "\u25B3 Scheduler";
|
||||
case "scheduler_has_control": return "\u25B3 Scheduler";
|
||||
case "handled_by_virtual_channel": return "\u25B3 VChan";
|
||||
default: return "\u25B3 Inactive";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,25 +380,14 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
function markBgdDirty() {
|
||||
if (bgdDirty) return;
|
||||
bgdDirty = true;
|
||||
syncSaveButton();
|
||||
const btn = document.getElementById("background-decode-save-btn");
|
||||
if (btn) btn.classList.add("sch-dirty");
|
||||
}
|
||||
|
||||
function clearBgdDirty() {
|
||||
bgdDirty = false;
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
/** Save offers itself only when there is something to save. */
|
||||
function syncSaveButton() {
|
||||
const btn = document.getElementById("background-decode-save-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.classList.toggle("sch-dirty", bgdDirty);
|
||||
btn.disabled = !bgdDirty || !isControlRole();
|
||||
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
|
||||
}
|
||||
|
||||
function isControlRole(): boolean {
|
||||
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||
const btn = document.getElementById("background-decode-save-btn");
|
||||
if (btn) btn.classList.remove("sch-dirty");
|
||||
}
|
||||
|
||||
function showToast(msg: string, isError: boolean): void {
|
||||
@@ -488,7 +407,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
}
|
||||
const ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
||||
currentConfig.bookmark_ids = ids;
|
||||
renderBookmarkChecklist(currentFilterText());
|
||||
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
||||
markBgdDirty();
|
||||
}
|
||||
|
||||
@@ -497,7 +416,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||
}
|
||||
currentConfig.bookmark_ids = [];
|
||||
renderBookmarkChecklist(currentFilterText());
|
||||
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
||||
markBgdDirty();
|
||||
}
|
||||
|
||||
@@ -513,11 +432,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
|
||||
if (enabledCb && !enabledCb._wired) {
|
||||
enabledCb._wired = true;
|
||||
enabledCb.addEventListener("change", function () {
|
||||
if (currentConfig) currentConfig.enabled = enabledCb.checked;
|
||||
markBgdDirty();
|
||||
renderBookmarkChecklist(currentFilterText());
|
||||
});
|
||||
enabledCb.addEventListener("change", function () { markBgdDirty(); });
|
||||
}
|
||||
|
||||
const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import { hostCore } from "./host.js";
|
||||
|
||||
import { isActiveRigDecode } from "./active-rig.js";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||
|
||||
export {};
|
||||
@@ -21,11 +20,8 @@ interface CwRenderer {
|
||||
drawPoints(points: number[], size: number, color: Rgba): void;
|
||||
}
|
||||
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: 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 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 CwToneRange {
|
||||
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
|
||||
toneSpanHz: number; lowerSideband: boolean; mode: string;
|
||||
@@ -70,10 +66,7 @@ const CW_BAR_LINE_GAP_MS = 5000;
|
||||
let cwLastAppendTime = 0;
|
||||
let cwTonePickerRaf: number | null = null;
|
||||
let cwBarHistory: CwLine[] = [];
|
||||
// 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 cwBarCurrentLine: CwLine | null = null;
|
||||
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
|
||||
@@ -108,13 +101,12 @@ cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
|
||||
applyCwAutoUi(enabled);
|
||||
};
|
||||
|
||||
function cwBarFlushCurrentLine(key: string): void {
|
||||
const line = cwBarCurrentLines.get(key);
|
||||
cwBarCurrentLines.delete(key);
|
||||
if (line?.text.trim()) {
|
||||
cwBarHistory.unshift(line);
|
||||
function cwBarFlushCurrentLine(): void {
|
||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||
cwBarHistory.unshift(cwBarCurrentLine);
|
||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||
}
|
||||
cwBarCurrentLine = null;
|
||||
}
|
||||
|
||||
function updateCwBar(): void {
|
||||
@@ -122,12 +114,9 @@ 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 && 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 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 newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||
cwBarOverlay.style.display = "none";
|
||||
@@ -413,7 +402,7 @@ function resetCwHistoryView(): void {
|
||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||
cwLastAppendTime = 0;
|
||||
cwBarHistory = [];
|
||||
cwBarCurrentLines.clear();
|
||||
cwBarCurrentLine = null;
|
||||
updateCwBar();
|
||||
drawCwTonePicker();
|
||||
}
|
||||
@@ -432,13 +421,8 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
||||
|
||||
// --- Server-side CW decode handler ---
|
||||
function onServerCw(evt: CwEvent): void {
|
||||
// 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) {
|
||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||
if (evt.text && cwOutputEl) {
|
||||
// Append decoded text to output
|
||||
const now = Date.now();
|
||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
||||
@@ -461,29 +445,25 @@ 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(key);
|
||||
cwBarFlushCurrentLine();
|
||||
} else {
|
||||
let line = cwBarCurrentLines.get(key);
|
||||
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||
cwBarFlushCurrentLine(key);
|
||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||
cwBarFlushCurrentLine();
|
||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||
cwBarCurrentLines.set(key, line);
|
||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||
}
|
||||
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));
|
||||
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));
|
||||
}
|
||||
updateCwBar();
|
||||
}
|
||||
if (cwSignalIndicator && forSelectedRig) {
|
||||
if (cwSignalIndicator) {
|
||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||
}
|
||||
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
|
||||
if (!cwAutoInput || cwAutoInput.checked) {
|
||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||
}
|
||||
@@ -511,13 +491,6 @@ 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,7 +4,6 @@
|
||||
|
||||
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";
|
||||
@@ -16,7 +15,6 @@ 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;
|
||||
}
|
||||
@@ -49,8 +47,6 @@ interface FtxBridge {
|
||||
updateFt8Bar?: () => void;
|
||||
registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
|
||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
|
||||
/** Opens a log entry from a decode. Pre-fills it; never logs by itself. */
|
||||
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
|
||||
trxUi: ConfirmApi;
|
||||
clearFt8Bar?: () => void;
|
||||
closeFt8Bar?: () => void;
|
||||
@@ -226,37 +222,14 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
||||
// A decode is not a contact: this opens an entry with what was heard in it
|
||||
// and leaves the logging to the operator.
|
||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||
if (station) {
|
||||
const log = document.createElement("button");
|
||||
log.type = "button";
|
||||
log.className = "ft8-log-btn";
|
||||
log.textContent = "Log";
|
||||
log.title = `Start a log entry for ${station}`;
|
||||
log.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const details = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||
bridge.logContact?.({
|
||||
call: station,
|
||||
gridsquare: details.find((detail) => detail.station === station)?.grid,
|
||||
decoder: id,
|
||||
});
|
||||
});
|
||||
row.appendChild(log);
|
||||
}
|
||||
return row;
|
||||
};
|
||||
const render = (): void => {
|
||||
prune();
|
||||
if (!messagesElement) return;
|
||||
// The history holds every rig's decodes, for the map; the panel is about
|
||||
// the rig the operator is working.
|
||||
const rigMessages = forActiveRig(history);
|
||||
const fragment = document.createDocumentFragment();
|
||||
let count = 0;
|
||||
for (const message of rigMessages) {
|
||||
for (const message of history) {
|
||||
if (count >= 200) break;
|
||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||
fragment.appendChild(renderRow(message));
|
||||
@@ -264,32 +237,20 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
||||
}
|
||||
messagesElement.replaceChildren(fragment);
|
||||
};
|
||||
/** Hands a decode's grid squares to the map, if the map module is loaded yet.
|
||||
* Split out of normalize so a decode can be replayed onto a map that
|
||||
* arrived later: the module is lazy, and everything decoded before it
|
||||
* loaded had nowhere to go. */
|
||||
const plotLocator = (message: FtxMessage): void => {
|
||||
const normalize = (message: FtxMessage): FtxMessage => {
|
||||
const raw = message.message ?? "";
|
||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||
const grids = locatorDetails.length > 0
|
||||
? locatorDetails.map(({ grid }) => grid)
|
||||
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
||||
if (grids.length === 0) return;
|
||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||
// Already an RF frequency on a replay, an audio offset on arrival; the
|
||||
// conversion only fires below 100 kHz, so it is right either way.
|
||||
const frequency = displayFrequency(message.freq_hz);
|
||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
|
||||
});
|
||||
};
|
||||
const normalize = (message: FtxMessage): FtxMessage => {
|
||||
const frequency = displayFrequency(message.freq_hz);
|
||||
plotLocator(message);
|
||||
if (grids.length > 0) {
|
||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
|
||||
});
|
||||
}
|
||||
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,
|
||||
@@ -301,10 +262,7 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
||||
};
|
||||
const receiveBatch = (messages: FtxMessage[]): void => {
|
||||
if (messages.length === 0) return;
|
||||
// "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";
|
||||
}
|
||||
if (status) status.textContent = "Receiving";
|
||||
history = messages.map(normalize).reverse().concat(history);
|
||||
prune();
|
||||
bridge.setFt8FamilyBarDecoder?.(id);
|
||||
@@ -319,8 +277,7 @@ 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
|
||||
&& isActiveRigDecode(message.rig_id))
|
||||
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000)
|
||||
.slice(0, 8);
|
||||
let html = "";
|
||||
for (const message of recent) {
|
||||
@@ -342,9 +299,6 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
||||
restore: receiveBatch,
|
||||
prune: () => { prune(); render(); },
|
||||
reset,
|
||||
rerender: () => { bridge.updateFt8Bar?.(); render(); },
|
||||
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||
syncMap: () => { for (const message of [...history].reverse()) plotLocator(message); },
|
||||
});
|
||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import { hostCore, hostState } from "./host.js";
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import {
|
||||
aprsAgeText,
|
||||
aprsPacketCategory,
|
||||
@@ -20,12 +19,6 @@ interface HfAprsBridge {
|
||||
getDecodeHistoryRetentionMs?: () => number;
|
||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||
navigateToAprsMap?: (lat: number, lon: number) => void;
|
||||
aprsMapAddStation?: (
|
||||
call: string, lat: number, lon: number, info: string,
|
||||
symbolTable: string | null | undefined, symbolCode: string | null | undefined,
|
||||
packet: AprsPacket, source: "aprs" | "hf_aprs",
|
||||
) => void;
|
||||
clearMapMarkersByType?: (type: string) => void;
|
||||
getDecodeRigMeta?: () => unknown;
|
||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||
@@ -97,31 +90,23 @@ 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 rigPackets = hfAprsRigPackets();
|
||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
|
||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
||||
return packets.filter(hfAprsFilterMatch);
|
||||
}
|
||||
|
||||
const collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||
|
||||
function updateHfAprsSummary() {
|
||||
const rigPackets = hfAprsRigPackets();
|
||||
const visible = hfAprsVisiblePackets();
|
||||
if (hfAprsTotalCountEl) {
|
||||
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
||||
}
|
||||
if (hfAprsVisibleCountEl) {
|
||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||
}
|
||||
if (hfAprsLatestSeenEl) {
|
||||
const latest = rigPackets[0];
|
||||
const latest = hfAprsPacketHistory[0];
|
||||
if (!latest) {
|
||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||
} else {
|
||||
@@ -183,7 +168,6 @@ function resetHfAprsHistoryView(): void {
|
||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||
hfAprsPacketHistory = [];
|
||||
renderHfAprsHistory();
|
||||
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
|
||||
}
|
||||
|
||||
function pruneHfAprsHistoryView(): void {
|
||||
@@ -191,17 +175,6 @@ function pruneHfAprsHistoryView(): void {
|
||||
renderHfAprsHistory();
|
||||
}
|
||||
|
||||
/** Hands a positioned packet to the map, if the map module is loaded yet.
|
||||
* HF traffic goes on as its own source: it is a different band and a
|
||||
* different path, and the map filter offers it separately. */
|
||||
function plotHfAprsPacket(pkt: AprsPacket): void {
|
||||
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
|
||||
hfAprsWindow.aprsMapAddStation(
|
||||
pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "",
|
||||
pkt.symbolTable, pkt.symbolCode, pkt, "hf_aprs",
|
||||
);
|
||||
}
|
||||
|
||||
function addHfAprsPacket(pkt: AprsPacket): void {
|
||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||
pkt._tsMs = tsMs;
|
||||
@@ -210,8 +183,6 @@ function addHfAprsPacket(pkt: AprsPacket): void {
|
||||
hfAprsPacketHistory.unshift(pkt);
|
||||
pruneHfAprsPacketHistory();
|
||||
|
||||
plotHfAprsPacket(pkt);
|
||||
|
||||
scheduleHfAprsHistoryRender();
|
||||
}
|
||||
|
||||
@@ -221,15 +192,13 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
|
||||
|
||||
function onServerHfAprsBatch(packets: AprsPacket[]): void {
|
||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||
const normalized: AprsPacket[] = [];
|
||||
for (const pkt of packets) {
|
||||
const next = normalizeServerHfAprsPacket(pkt);
|
||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
plotHfAprsPacket(next);
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
@@ -297,9 +266,8 @@ if (hfAprsFilterInput) {
|
||||
|
||||
// --- Server-side HF APRS decode handler ---
|
||||
function onServerHfAprs(pkt: AprsPacket): void {
|
||||
const packet = normalizeServerHfAprsPacket(pkt);
|
||||
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||
addHfAprsPacket(packet);
|
||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
||||
}
|
||||
|
||||
renderHfAprsHistory();
|
||||
@@ -310,7 +278,4 @@ renderHfAprsHistory();
|
||||
restore: onServerHfAprsBatch,
|
||||
reset: resetHfAprsHistoryView,
|
||||
prune: pruneHfAprsHistoryView,
|
||||
rerender: renderHfAprsHistory,
|
||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||
syncMap: () => { for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry); },
|
||||
});
|
||||
|
||||
@@ -22,8 +22,6 @@ export interface HostDecoderDescriptor {
|
||||
export interface HostState {
|
||||
readonly serverLat: number | null;
|
||||
readonly serverLon: number | null;
|
||||
/** The callsign this station is on the air with, from the client config. */
|
||||
readonly ownerCallsign: string | null;
|
||||
readonly authEnabled: boolean;
|
||||
readonly authRole: string | null;
|
||||
readonly lastActiveRigId: string | null;
|
||||
@@ -45,7 +43,6 @@ export interface HostCore {
|
||||
armOptimisticFrequency(frequencyHz: number): void;
|
||||
escapeMapHtml(value: string): string;
|
||||
haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
||||
latLonToMaidenhead(lat: number, lon: number): string;
|
||||
showHint(message: string, durationMs?: number): void;
|
||||
formatFreqForStep(frequencyHz: number, stepHz: number): string;
|
||||
refreshFreqDisplay(): void;
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// The station log: one panel that opens an entry, keeps the log, and takes
|
||||
// ADIF in and out.
|
||||
//
|
||||
// Times come from the server, never from here. The browser may be a phone in
|
||||
// another timezone with a clock nobody has checked, and a log is only worth
|
||||
// keeping if the times in it are the radio's.
|
||||
|
||||
import { hostCore, hostState } from "./host.js";
|
||||
|
||||
export {};
|
||||
|
||||
interface Qso {
|
||||
id: string;
|
||||
started_at: string;
|
||||
call: string;
|
||||
freq_hz: number;
|
||||
band?: string | null;
|
||||
mode: string;
|
||||
submode?: string | null;
|
||||
rst_sent?: string | null;
|
||||
rst_rcvd?: string | null;
|
||||
gridsquare?: string | null;
|
||||
name?: string | null;
|
||||
qth?: string | null;
|
||||
comment?: string | null;
|
||||
station_callsign?: string | null;
|
||||
operator?: string | null;
|
||||
my_gridsquare?: string | null;
|
||||
my_rig?: string | null;
|
||||
rig_id?: string | null;
|
||||
}
|
||||
|
||||
interface Prefill {
|
||||
started_at: string;
|
||||
epoch_ms: number;
|
||||
freq_hz: number;
|
||||
band: string | null;
|
||||
mode: string;
|
||||
submode: string | null;
|
||||
rig_id: string | null;
|
||||
my_rig: string | null;
|
||||
call: string | null;
|
||||
gridsquare: string | null;
|
||||
}
|
||||
|
||||
interface LogbookBridge {
|
||||
trxUi: {
|
||||
confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean>;
|
||||
notify?(message: string, options?: { kind?: string }): void;
|
||||
};
|
||||
navigateToTab?: (name: string) => void;
|
||||
/** Opens the panel with an entry started from a decode. */
|
||||
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
|
||||
}
|
||||
|
||||
const bridge = window as unknown as LogbookBridge;
|
||||
|
||||
const el = <T extends HTMLElement>(id: string): T | null => document.getElementById(id) as T | null;
|
||||
|
||||
const form = el<HTMLFormElement>("log-entry-form");
|
||||
const callInput = el<HTMLInputElement>("log-call");
|
||||
const freqInput = el<HTMLInputElement>("log-freq");
|
||||
const modeInput = el<HTMLInputElement>("log-mode");
|
||||
const rstSentInput = el<HTMLInputElement>("log-rst-sent");
|
||||
const rstRcvdInput = el<HTMLInputElement>("log-rst-rcvd");
|
||||
const gridInput = el<HTMLInputElement>("log-grid");
|
||||
const nameInput = el<HTMLInputElement>("log-name");
|
||||
const commentInput = el<HTMLInputElement>("log-comment");
|
||||
const operatorInput = el<HTMLInputElement>("log-operator");
|
||||
const rowsBody = el<HTMLTableSectionElement>("log-rows");
|
||||
const summaryEl = el("log-summary");
|
||||
const workedEl = el("log-worked-before");
|
||||
const clockEl = el("log-clock");
|
||||
const stationCallEl = el("log-station-callsign");
|
||||
const stationRigEl = el("log-station-rig-name");
|
||||
const stationGridEl = el("log-station-grid");
|
||||
const filterCall = el<HTMLInputElement>("log-filter-call");
|
||||
const filterBand = el<HTMLSelectElement>("log-filter-band");
|
||||
const filterMode = el<HTMLSelectElement>("log-filter-mode");
|
||||
const importBtn = el<HTMLButtonElement>("log-import-btn");
|
||||
const importFile = el<HTMLInputElement>("log-import-file");
|
||||
const exportLink = el<HTMLAnchorElement>("log-export-btn");
|
||||
const clearBtn = el<HTMLButtonElement>("log-clear-btn");
|
||||
const saveBtn = el<HTMLButtonElement>("log-save-btn");
|
||||
|
||||
/** The time the open entry was started, as the server gave it. */
|
||||
let entryStartedAt: string | null = null;
|
||||
/** The rig the open entry belongs to, likewise. */
|
||||
let entryRigId: string | null = null;
|
||||
let entryRigName: string | null = null;
|
||||
let entryGrid: string | null = null;
|
||||
let qsos: Qso[] = [];
|
||||
let workedRequest = 0;
|
||||
|
||||
function notify(message: string, kind?: string): void {
|
||||
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined);
|
||||
else hostCore.showHint(message, 2000);
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
function formatFreq(hz: number): string {
|
||||
if (!Number.isFinite(hz) || hz <= 0) return "";
|
||||
// Through Number, not a trailing-zero trim: trimming "20.000000" as a string
|
||||
// walks back through the point and leaves "2".
|
||||
return String(Number((hz / 1e6).toFixed(6)));
|
||||
}
|
||||
|
||||
/** What the operator typed, in Hz: MHz unless it is plainly already Hz. */
|
||||
function parseFreq(text: string): number | null {
|
||||
const value = Number(text.trim().replace(/\s+/g, "").replace(",", "."));
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return value < 100_000 ? Math.round(value * 1e6) : Math.round(value);
|
||||
}
|
||||
|
||||
function utcDate(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function utcTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19);
|
||||
}
|
||||
|
||||
// ── The entry ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Open an entry, pre-filled with the six fields the radio and clock answer. */
|
||||
async function openEntry(
|
||||
seed: { call?: string; gridsquare?: string | undefined; decoder?: string } = {},
|
||||
): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
if (hostState.lastActiveRigId) params.set("remote", hostState.lastActiveRigId);
|
||||
if (seed.decoder) params.set("decoder", seed.decoder);
|
||||
if (seed.call) params.set("call", seed.call);
|
||||
if (seed.gridsquare) params.set("gridsquare", seed.gridsquare);
|
||||
try {
|
||||
const prefill = await getJson<Prefill>(`/api/logbook/prefill?${params.toString()}`);
|
||||
applyPrefill(prefill);
|
||||
} catch (error: unknown) {
|
||||
console.error("logbook prefill failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
function applyPrefill(prefill: Prefill): void {
|
||||
entryStartedAt = prefill.started_at;
|
||||
entryRigId = prefill.rig_id;
|
||||
entryRigName = prefill.my_rig;
|
||||
if (freqInput) freqInput.value = formatFreq(prefill.freq_hz);
|
||||
if (modeInput) modeInput.value = prefill.submode ?? prefill.mode;
|
||||
if (callInput && prefill.call) callInput.value = prefill.call;
|
||||
if (gridInput && prefill.gridsquare) gridInput.value = prefill.gridsquare;
|
||||
if (stationRigEl) stationRigEl.textContent = prefill.my_rig ?? "no rig";
|
||||
showClock(prefill);
|
||||
updateWorkedBefore();
|
||||
}
|
||||
|
||||
/** The entry's time, and whether this browser agrees with it. */
|
||||
function showClock(prefill: Prefill): void {
|
||||
if (!clockEl) return;
|
||||
const time = utcTime(prefill.started_at);
|
||||
const drift = Math.abs(Date.now() - prefill.epoch_ms);
|
||||
clockEl.textContent = drift > 1000
|
||||
? `${time}Z · your clock is ${(drift / 1000).toFixed(0)}s out`
|
||||
: `${time}Z`;
|
||||
clockEl.classList.toggle("is-adrift", drift > 1000);
|
||||
}
|
||||
|
||||
/** Clear the entry and open a fresh one. */
|
||||
function resetEntry(): void {
|
||||
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput]) {
|
||||
if (input) input.value = "";
|
||||
}
|
||||
if (workedEl) workedEl.textContent = "";
|
||||
void openEntry();
|
||||
}
|
||||
|
||||
async function submitEntry(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
const call = callInput?.value.trim() ?? "";
|
||||
if (!call) {
|
||||
notify("A contact needs a callsign", "error");
|
||||
callInput?.focus();
|
||||
return;
|
||||
}
|
||||
const freqHz = parseFreq(freqInput?.value ?? "");
|
||||
if (freqHz == null) {
|
||||
notify("A contact needs a frequency", "error");
|
||||
freqInput?.focus();
|
||||
return;
|
||||
}
|
||||
const typedMode = (modeInput?.value ?? "").trim().toUpperCase();
|
||||
// USB and LSB are how a rig says SSB; the log wants the mode with the
|
||||
// sideband beside it, which is what other loggers read.
|
||||
const isSideband = typedMode === "USB" || typedMode === "LSB";
|
||||
const body = {
|
||||
// The server stamps the time; this is the one it gave when the entry
|
||||
// opened, so a contact logged five minutes later keeps the time it started.
|
||||
started_at: entryStartedAt,
|
||||
call,
|
||||
freq_hz: freqHz,
|
||||
mode: isSideband ? "SSB" : typedMode,
|
||||
submode: isSideband ? typedMode : null,
|
||||
rst_sent: rstSentInput?.value ?? null,
|
||||
rst_rcvd: rstRcvdInput?.value ?? null,
|
||||
gridsquare: gridInput?.value ?? null,
|
||||
name: nameInput?.value ?? null,
|
||||
comment: commentInput?.value ?? null,
|
||||
station_callsign: stationCallEl?.textContent?.trim() ?? null,
|
||||
operator: operatorInput?.value ?? null,
|
||||
my_gridsquare: entryGrid,
|
||||
my_rig: entryRigName,
|
||||
rig_id: entryRigId,
|
||||
};
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
try {
|
||||
const response = await fetch("/api/logbook", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
|
||||
}
|
||||
notify(`${call} logged`);
|
||||
resetEntry();
|
||||
await refreshLog();
|
||||
} catch (error: unknown) {
|
||||
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
} finally {
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this station has been worked, and on what. */
|
||||
function updateWorkedBefore(): void {
|
||||
if (!workedEl) return;
|
||||
const call = callInput?.value.trim() ?? "";
|
||||
if (call.length < 3) {
|
||||
workedEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
const request = ++workedRequest;
|
||||
void getJson<{ worked: { band: string; mode: string }[] }>(
|
||||
`/api/logbook/worked/${encodeURIComponent(call)}`,
|
||||
).then((answer) => {
|
||||
// A slower answer to an earlier callsign must not overwrite a later one.
|
||||
if (request !== workedRequest || !workedEl) return;
|
||||
if (answer.worked.length === 0) {
|
||||
workedEl.textContent = "Not worked before";
|
||||
workedEl.classList.remove("is-worked");
|
||||
return;
|
||||
}
|
||||
const where = answer.worked.map((entry) => `${entry.band} ${entry.mode}`).join(", ");
|
||||
workedEl.textContent = `Worked before: ${where}`;
|
||||
workedEl.classList.add("is-worked");
|
||||
}).catch(() => { /* the log will still take the contact */ });
|
||||
}
|
||||
|
||||
// ── The log ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function currentQuery(): string {
|
||||
const params = new URLSearchParams();
|
||||
const call = filterCall?.value.trim();
|
||||
if (call) params.set("call", call);
|
||||
if (filterBand?.value) params.set("band", filterBand.value);
|
||||
if (filterMode?.value) params.set("mode", filterMode.value);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
async function refreshLog(): Promise<void> {
|
||||
try {
|
||||
const query = currentQuery();
|
||||
const answer = await getJson<{ total: number; qsos: Qso[] }>(
|
||||
`/api/logbook${query ? `?${query}` : ""}`,
|
||||
);
|
||||
qsos = answer.qsos;
|
||||
renderRows();
|
||||
renderFilterOptions();
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = query
|
||||
? `${String(qsos.length)} of ${String(answer.total)} contacts`
|
||||
: `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
|
||||
} catch (error: unknown) {
|
||||
console.error("logbook read failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRows(): void {
|
||||
if (!rowsBody) return;
|
||||
if (qsos.length === 0) {
|
||||
rowsBody.innerHTML =
|
||||
'<tr><td colspan="10" class="log-empty">No contacts yet. Work someone and log them here,'
|
||||
+ " or bring a log in with Import ADIF.</td></tr>";
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const qso of qsos) {
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.qsoId = qso.id;
|
||||
const cells: (string | null | undefined)[] = [
|
||||
utcDate(qso.started_at),
|
||||
utcTime(qso.started_at),
|
||||
qso.call,
|
||||
qso.band ?? "",
|
||||
qso.submode ?? qso.mode,
|
||||
qso.rst_sent ?? "",
|
||||
qso.rst_rcvd ?? "",
|
||||
qso.gridsquare ?? "",
|
||||
qso.my_rig ?? "",
|
||||
];
|
||||
for (const [index, value] of cells.entries()) {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = value ?? "";
|
||||
if (index === 2) cell.className = "log-cell-call";
|
||||
row.appendChild(cell);
|
||||
}
|
||||
const actions = document.createElement("td");
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "log-row-btn";
|
||||
remove.textContent = "Delete";
|
||||
remove.setAttribute("aria-label", `Delete the contact with ${qso.call}`);
|
||||
remove.addEventListener("click", () => { void deleteQso(qso); });
|
||||
actions.appendChild(remove);
|
||||
row.appendChild(actions);
|
||||
fragment.appendChild(row);
|
||||
}
|
||||
rowsBody.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
/** Bands and modes the log actually holds, so the filters offer only those. */
|
||||
function renderFilterOptions(): void {
|
||||
for (const [select, values] of [
|
||||
[filterBand, [...new Set(qsos.map((q) => q.band).filter((b): b is string => !!b))]],
|
||||
[filterMode, [...new Set(qsos.map((q) => q.submode ?? q.mode).filter(Boolean))]],
|
||||
] as const) {
|
||||
if (!select) continue;
|
||||
const chosen = select.value;
|
||||
const known = new Set([...select.options].map((option) => option.value));
|
||||
for (const value of [...values].sort()) {
|
||||
if (known.has(value)) continue;
|
||||
select.add(new Option(value, value));
|
||||
}
|
||||
select.value = chosen;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteQso(qso: Qso): Promise<void> {
|
||||
const confirmed = await bridge.trxUi.confirm({
|
||||
title: "Delete this contact?",
|
||||
message: `${qso.call} on ${qso.band ?? formatFreq(qso.freq_hz)} will be removed from the log.`,
|
||||
confirmLabel: "Delete",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||
await refreshLog();
|
||||
} catch (error: unknown) {
|
||||
notify(`Could not delete: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function importAdif(file: File): Promise<void> {
|
||||
try {
|
||||
const response = await fetch("/api/logbook/import", { method: "POST", body: await file.arrayBuffer() });
|
||||
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||
const outcome = await response.json() as { added: number; duplicate: number; rejected: string[] };
|
||||
const parts = [`${String(outcome.added)} added`];
|
||||
if (outcome.duplicate > 0) parts.push(`${String(outcome.duplicate)} already held`);
|
||||
if (outcome.rejected.length > 0) parts.push(`${String(outcome.rejected.length)} not readable`);
|
||||
notify(parts.join(", "));
|
||||
await refreshLog();
|
||||
} catch (error: unknown) {
|
||||
notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Station line ────────────────────────────────────────────────────────────
|
||||
|
||||
function renderStation(): void {
|
||||
const callsign = hostState.ownerCallsign ?? "";
|
||||
if (stationCallEl) stationCallEl.textContent = callsign || "no callsign set";
|
||||
if (operatorInput && !operatorInput.value) operatorInput.value = callsign;
|
||||
if (stationGridEl) {
|
||||
const grid = hostState.serverLat != null && hostState.serverLon != null
|
||||
? hostCore.latLonToMaidenhead(hostState.serverLat, hostState.serverLon)
|
||||
: "";
|
||||
entryGrid = grid || null;
|
||||
stationGridEl.textContent = grid;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wiring ──────────────────────────────────────────────────────────────────
|
||||
|
||||
form?.addEventListener("submit", (event) => { void submitEntry(event); });
|
||||
clearBtn?.addEventListener("click", resetEntry);
|
||||
callInput?.addEventListener("input", updateWorkedBefore);
|
||||
for (const control of [filterCall, filterBand, filterMode]) {
|
||||
control?.addEventListener("input", () => { void refreshLog(); });
|
||||
control?.addEventListener("change", () => { void refreshLog(); });
|
||||
}
|
||||
importBtn?.addEventListener("click", () => { importFile?.click(); });
|
||||
importFile?.addEventListener("change", () => {
|
||||
const file = importFile.files?.[0];
|
||||
if (file) void importAdif(file);
|
||||
importFile.value = "";
|
||||
});
|
||||
|
||||
/** Start an entry from a decode, and show the operator where it went. */
|
||||
bridge.logContact = (seed) => {
|
||||
bridge.navigateToTab?.("logbook");
|
||||
void openEntry(seed).then(() => callInput?.focus());
|
||||
};
|
||||
|
||||
renderStation();
|
||||
void openEntry();
|
||||
void refreshLog();
|
||||
@@ -9,10 +9,6 @@ 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;
|
||||
@@ -26,7 +22,6 @@ export interface TrxPluginRuntime {
|
||||
reset(id: string): boolean;
|
||||
resetAll(): void;
|
||||
prune(id: string): boolean;
|
||||
rerenderAll(): void;
|
||||
syncMapAll(): void;
|
||||
clearQueued(): void;
|
||||
hasDecoder(id: string): boolean;
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
import { hostCore } from "./host.js";
|
||||
|
||||
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate } from "./satellite-types";
|
||||
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||
|
||||
type SatelliteView = "live" | "history";
|
||||
type SatelliteView = "live" | "history" | "predictions";
|
||||
interface SatelliteBridge {
|
||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||
clearSatPredictionDom?: () => void;
|
||||
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
||||
addSatMapOverlay?: (image: SatelliteImage) => void;
|
||||
clearSatMapOverlays?: () => void;
|
||||
@@ -24,14 +25,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
|
||||
//
|
||||
// Pass predictions live on their own page; see satellite-predictions.ts.
|
||||
// Predictions view: next 24 h passes for ham satellites
|
||||
|
||||
// ── 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"),
|
||||
@@ -41,13 +42,30 @@ 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 {
|
||||
@@ -60,16 +78,33 @@ 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.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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -300,6 +335,234 @@ 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
@@ -1,290 +0,0 @@
|
||||
// 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();
|
||||
};
|
||||
@@ -1,373 +0,0 @@
|
||||
// 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,7 +6,6 @@ import { hostCore } from "./host.js";
|
||||
|
||||
export {};
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||
|
||||
interface VdesMessage {
|
||||
@@ -117,24 +116,17 @@ 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 = rigMessages.length;
|
||||
const count = vdesMessageHistory.length;
|
||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (vdesLatestSeenEl) {
|
||||
const latest = rigMessages[0];
|
||||
const latest = vdesMessageHistory[0];
|
||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||
}
|
||||
}
|
||||
@@ -236,9 +228,7 @@ 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 && isActiveRigDecode(msg.rig_id))
|
||||
.slice(0, 6);
|
||||
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
||||
if (!isVdes || messages.length === 0) {
|
||||
vdesBarOverlay.style.display = "none";
|
||||
vdesBarOverlay.innerHTML = "";
|
||||
@@ -288,7 +278,7 @@ function renderVdesHistory() {
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const message of vdesRigMessages()) {
|
||||
for (const message of vdesMessageHistory) {
|
||||
fragment.appendChild(renderVdesRow(message));
|
||||
}
|
||||
vdesMessagesEl.replaceChildren(fragment);
|
||||
@@ -319,11 +309,10 @@ 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([], {
|
||||
@@ -365,8 +354,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);
|
||||
}
|
||||
@@ -385,7 +374,6 @@ 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,13 +4,11 @@
|
||||
|
||||
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;
|
||||
@@ -22,7 +20,6 @@ interface WefaxImage {
|
||||
}
|
||||
|
||||
interface WefaxProgress {
|
||||
rig_id?: string | null;
|
||||
state?: string;
|
||||
line_data?: string;
|
||||
line_count?: number;
|
||||
@@ -159,21 +156,15 @@ 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;
|
||||
const rigImages = wefaxRigImages();
|
||||
if (rigImages.length === 0) {
|
||||
if (wefaxImageHistory.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 = rigImages[0];
|
||||
const img = wefaxImageHistory[0];
|
||||
if (!img) return;
|
||||
const ts = img._ts || '--';
|
||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
||||
@@ -202,8 +193,7 @@ function renderWefaxLatestCard() {
|
||||
|
||||
// ── History view: table ─────────────────────────────────────────────
|
||||
function getWefaxFilteredHistory() {
|
||||
// Every rig's pictures are kept; the panel shows the selected rig's.
|
||||
let items = wefaxRigImages();
|
||||
let items = wefaxImageHistory;
|
||||
|
||||
if (wefaxFilterText) {
|
||||
items = items.filter(function (i) {
|
||||
@@ -263,7 +253,7 @@ function renderWefaxHistoryTable() {
|
||||
wefaxDom.historyList.replaceChildren(fragment);
|
||||
|
||||
if (wefaxDom.historyCount) {
|
||||
const total = wefaxRigImages().length;
|
||||
const total = wefaxImageHistory.length;
|
||||
const shown = items.length;
|
||||
wefaxDom.historyCount.textContent =
|
||||
total === 0
|
||||
@@ -308,9 +298,6 @@ 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) {
|
||||
@@ -344,8 +331,6 @@ 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) {
|
||||
@@ -453,7 +438,6 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||
restore: restoreWefaxHistory,
|
||||
prune: pruneWefaxHistoryView,
|
||||
reset: resetWefaxHistoryView,
|
||||
rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); },
|
||||
});
|
||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "wefax_progress",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import { hostCore } from "./host.js";
|
||||
|
||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||
|
||||
export {};
|
||||
@@ -16,10 +15,6 @@ interface WsprMessage {
|
||||
snr_db?: number | undefined;
|
||||
dt_s?: number | undefined;
|
||||
freq_hz?: number | undefined;
|
||||
rig_id?: string | null | undefined;
|
||||
/** RF frequency the spot was heard on, kept so a map replay does not
|
||||
* recompute it against wherever the dial has moved to since. */
|
||||
_rfHz?: number | null | undefined;
|
||||
receiver?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -107,12 +102,10 @@ 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 (const message of rigMessages) {
|
||||
fragment.appendChild(renderWsprRow(message));
|
||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
||||
const message = wsprMessageHistory[i];
|
||||
if (message) fragment.appendChild(renderWsprRow(message));
|
||||
}
|
||||
wsprMessagesEl.replaceChildren(fragment);
|
||||
}
|
||||
@@ -137,8 +130,6 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
|
||||
station,
|
||||
rfHz,
|
||||
history: {
|
||||
rig_id: msg.rig_id ?? null,
|
||||
_rfHz: rfHz,
|
||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms,
|
||||
snr_db: msg.snr_db,
|
||||
@@ -151,12 +142,16 @@ 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";
|
||||
plotWsprLocator(msg);
|
||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
|
||||
});
|
||||
}
|
||||
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||
normalized.push(next.history);
|
||||
}
|
||||
@@ -316,24 +311,15 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
||||
})();
|
||||
});
|
||||
|
||||
/** Hands a spot's grid squares to the map, if the map module is loaded yet.
|
||||
* The module is lazy, so a spot heard before it arrived has to be replayable. */
|
||||
function plotWsprLocator(msg: WsprMessage): void {
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
|
||||
// A replayed spot carries the frequency it was heard on; a fresh one has it
|
||||
// worked out from the dial it just arrived against.
|
||||
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
...(rfHz === null ? {} : { freq_hz: rfHz }),
|
||||
});
|
||||
}
|
||||
|
||||
function onServerWspr(msg: WsprMessage): void {
|
||||
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
plotWsprLocator(msg);
|
||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
|
||||
});
|
||||
}
|
||||
addWsprMessage(next.history);
|
||||
}
|
||||
|
||||
@@ -344,7 +330,4 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
||||
restore: onServerWsprBatch,
|
||||
prune: pruneWsprHistoryView,
|
||||
reset: resetWsprHistoryView,
|
||||
rerender: renderWsprHistory,
|
||||
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||
syncMap: () => { for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message); },
|
||||
});
|
||||
|
||||
@@ -24,8 +24,8 @@ interface ButtonStateOptions {
|
||||
disabled?: boolean;
|
||||
}
|
||||
type TabKind = "primary" | "secondary";
|
||||
type LayoutCapability = "broadcast" | "digital" | "ham";
|
||||
type LayoutName = "compact" | "broadcast" | "digital" | "full" | "ham";
|
||||
type LayoutCapability = "broadcast" | "digital";
|
||||
type LayoutName = "compact" | "broadcast" | "digital" | "full";
|
||||
interface OperatorLayout {
|
||||
label: string;
|
||||
unavailable?: string;
|
||||
@@ -191,11 +191,8 @@ function elementById<T extends HTMLElement>(id: string): T {
|
||||
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
|
||||
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
|
||||
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
|
||||
// Working the bands: the log is the task and the radio is the instrument,
|
||||
// so this one opens on the logbook with the controls a keystroke away.
|
||||
ham: { label: "Ham radio", unavailable: "Ham radio needs a rig that can transmit", advanced: true, audio: true, scheduler: false, preferredTab: "logbook", capability: "ham" },
|
||||
};
|
||||
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false, ham: false };
|
||||
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false };
|
||||
let activeRigId: string | null = null;
|
||||
|
||||
// A layout seeds the collapsible sections; it does not hold them there.
|
||||
@@ -502,7 +499,7 @@ function elementById<T extends HTMLElement>(id: string): T {
|
||||
if (restoreFocus) more.focus();
|
||||
};
|
||||
api.closeMobileOverlays = closeMore;
|
||||
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||
if (!source) return;
|
||||
const item = document.createElement("button");
|
||||
@@ -544,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", "sstv"]],
|
||||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
|
||||
];
|
||||
groups.forEach(([label, ids]) => {
|
||||
const group = document.createElement("optgroup");
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
// 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/);
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// The background decode panel: one list that both picks the bookmarks and says
|
||||
// what each one is doing. It used to be two lists of the same bookmarks — one
|
||||
// to choose from, one to read state off — and it filled neither until the
|
||||
// operator switched rigs, because it took whatever rig it was handed at load
|
||||
// and that was nothing yet.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||
|
||||
/* global document */
|
||||
|
||||
const BOOKMARKS = [
|
||||
{ id: "bm-ft8-20", name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoders: ["ft8"] },
|
||||
{ id: "bm-ft8-40", name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoders: ["ft8"] },
|
||||
{ id: "bm-ft4-20", name: "FT4 20 m", freq_hz: 14_080_000, mode: "USB", decoders: ["ft4"] },
|
||||
{ id: "bm-aprs", name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoders: ["aprs"] },
|
||||
// Nothing that runs in the background can decode CW, so it must not be offered.
|
||||
{ id: "bm-cw", name: "CW practice", freq_hz: 7_030_000, mode: "CW", decoders: ["cw"] },
|
||||
];
|
||||
|
||||
const backgroundDecode = {
|
||||
config: { remote: "rig-a", enabled: true, bookmark_ids: ["bm-ft8-20", "bm-ft8-40", "bm-aprs"] },
|
||||
status: {
|
||||
active_rig: true,
|
||||
center_hz: 14_100_000,
|
||||
sample_rate: 2_400_000,
|
||||
entries: [
|
||||
{ bookmark_id: "bm-ft8-20", bookmark_name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "active" },
|
||||
{ bookmark_id: "bm-ft8-40", bookmark_name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "out_of_span" },
|
||||
{ bookmark_id: "bm-aprs", bookmark_name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoder_kinds: ["aprs"], state: "handled_by_scheduler" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const readRows = () => page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("#bgd-bookmark-checklist .bgd-checklist-row")];
|
||||
return rows.map((row) => ({
|
||||
name: row.querySelector(".bgd-checklist-name")?.textContent ?? "",
|
||||
checked: row.querySelector("input[type=checkbox]")?.checked ?? false,
|
||||
state: row.querySelector(".bgd-state")?.dataset.state ?? null,
|
||||
stateText: row.querySelector(".bgd-state")?.textContent?.trim() ?? "",
|
||||
}));
|
||||
});
|
||||
|
||||
const openPanel = async () => {
|
||||
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#tab-settings").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(2000);
|
||||
await page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
|
||||
await page.waitForTimeout(1200);
|
||||
};
|
||||
|
||||
const fixture = await startWebFixture({ spectrum: true, bookmarks: BOOKMARKS, backgroundDecode });
|
||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 950 });
|
||||
await openPanel();
|
||||
|
||||
// Nothing was switched, clicked or reloaded: the panel asked the host which
|
||||
// rig it is on and filled itself.
|
||||
const rows = await readRows();
|
||||
assert.deepEqual(rows.map((row) => row.name), ["FT8 20 m", "FT8 40 m", "FT4 20 m", "APRS 2 m"],
|
||||
"the list does not hold the bookmarks a background channel can decode");
|
||||
assert.deepEqual(rows.map((row) => row.checked), [true, true, false, true],
|
||||
"the saved selection is not reflected in the list");
|
||||
|
||||
// Each row carries its own state, so there is no second list to consult.
|
||||
assert.deepEqual(rows.map((row) => row.state),
|
||||
["active", "out_of_span", "unselected", "handled_by_scheduler"],
|
||||
"the rows do not carry the state of the bookmark they name");
|
||||
assert.match(rows[0].stateText, /Decoding/);
|
||||
assert.match(rows[1].stateText, /Out of span/);
|
||||
|
||||
// What the rig can hear, and what the selection adds up to.
|
||||
const context = await page.evaluate(() => ({
|
||||
span: document.getElementById("bgd-span-summary")?.textContent?.trim(),
|
||||
summary: document.getElementById("bgd-selection-summary")?.textContent?.trim(),
|
||||
}));
|
||||
assert.match(context.span, /14\.1 MHz/, `the span reads "${context.span}"`);
|
||||
assert.match(context.summary, /3 bookmarks selected, 1 decoding now\./, `the summary reads "${context.summary}"`);
|
||||
|
||||
// Save has nothing to do until something changes, and says so again once it
|
||||
// has been done.
|
||||
const saveState = () => page.evaluate(() => {
|
||||
const btn = document.getElementById("background-decode-save-btn");
|
||||
return { disabled: btn.disabled, dirty: btn.classList.contains("sch-dirty") };
|
||||
});
|
||||
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save offers itself with nothing to save");
|
||||
|
||||
await page.locator('#bgd-bookmark-checklist .bgd-checklist-row:nth-child(3) input').click();
|
||||
await page.waitForTimeout(300);
|
||||
assert.deepEqual(await saveState(), { disabled: false, dirty: true }, "Save stayed inert after a change");
|
||||
|
||||
await page.locator("#background-decode-save-btn").click();
|
||||
await page.waitForTimeout(1000);
|
||||
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save stayed live after saving");
|
||||
|
||||
// The selection reached the server and comes back on the next load.
|
||||
await openPanel();
|
||||
const saved = await readRows();
|
||||
assert.deepEqual(saved.map((row) => row.checked), [true, true, true, true],
|
||||
"the newly selected bookmark did not survive a reload");
|
||||
|
||||
assert.deepEqual(runtimeErrors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fixture.close();
|
||||
}
|
||||
|
||||
// With no bookmarks at all the panel says what to do about it rather than
|
||||
// showing an empty box.
|
||||
const emptyFixture = await startWebFixture({ spectrum: true, bookmarks: [] });
|
||||
const empty = await startBrowser(chromium);
|
||||
try {
|
||||
await empty.page.setViewportSize({ width: 1400, height: 950 });
|
||||
await empty.page.goto(`${emptyFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
||||
await empty.page.locator("#tab-settings").waitFor({ state: "visible" });
|
||||
await empty.page.waitForTimeout(2000);
|
||||
await empty.page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
|
||||
await empty.page.waitForTimeout(1000);
|
||||
const text = await empty.page.evaluate(() =>
|
||||
document.getElementById("bgd-bookmark-checklist")?.textContent?.trim() ?? "");
|
||||
assert.match(text, /No bookmarks yet/, `the empty list reads "${text}"`);
|
||||
const summary = await empty.page.evaluate(() =>
|
||||
document.getElementById("bgd-selection-summary")?.textContent?.trim() ?? "");
|
||||
assert.match(summary, /Nothing selected/, `the summary reads "${summary}"`);
|
||||
} finally {
|
||||
await empty.browser.close();
|
||||
await emptyFixture.close();
|
||||
}
|
||||
|
||||
console.log("background decode panel tests passed");
|
||||
@@ -22,25 +22,9 @@ const BEACON = {
|
||||
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
|
||||
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
||||
};
|
||||
// A second rig listening in the background, on its own band. Both pages
|
||||
// describe the rig on screen, so its traffic belongs in neither the panel nor
|
||||
// the mini view — only on the map, which shows the whole station.
|
||||
// HF APRS travels a different band and a different path from the VHF list, so
|
||||
// it goes on the map under a source of its own that the filter can hide.
|
||||
const HF_BEACON = {
|
||||
type: "hf_aprs", src_call: "SP5HF-7", dest_call: "APRS", path: "WIDE2-2", info: "HF beacon",
|
||||
packet_type: "position", crc_ok: true, lat: 50.06, lon: 19.94,
|
||||
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
||||
};
|
||||
const OTHER_RIG_VESSEL = {
|
||||
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
|
||||
lat: 51.92, lon: 4.48, rig_id: "rig-b",
|
||||
};
|
||||
|
||||
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
|
||||
const fixture = await startWebFixture({
|
||||
spectrum: true, decodes: [VESSEL, BEACON, HF_BEACON, OTHER_RIG_VESSEL], mode: "AIS",
|
||||
});
|
||||
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" });
|
||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||
|
||||
try {
|
||||
@@ -57,14 +41,11 @@ 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();
|
||||
@@ -75,13 +56,11 @@ 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
|
||||
@@ -110,41 +89,8 @@ try {
|
||||
: Object.keys(collection ?? {}).length);
|
||||
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
|
||||
});
|
||||
assert.ok(markers.ais > 0, "the vessel never reached the map");
|
||||
assert.ok(markers.stations > 0, "the APRS station never reached the map");
|
||||
// Both APRS stations are on it, each under its own source: the HF one used
|
||||
// to be dropped entirely, since the HF list never plotted anything.
|
||||
const aprsSources = await page.evaluate(() => {
|
||||
const entries = [...(window.trx.modules.map?.stationMarkers ?? new Map()).entries()];
|
||||
return entries.map(([key, entry]) => `${entry?.type ?? "?"}:${entry?.call ?? key}`).sort();
|
||||
});
|
||||
assert.deepEqual(aprsSources, ["aprs:SP2SJG-9", "hf_aprs:SP5HF-7"],
|
||||
`the map holds ${JSON.stringify(aprsSources)}`);
|
||||
// And the filter offers HF APRS on a chip of its own, next to the VHF one.
|
||||
await page.locator('#map-locator-phase .map-locator-phase-btn[data-phase="type"]').click();
|
||||
await page.waitForTimeout(400);
|
||||
const chips = await page.evaluate(() => [...document.querySelectorAll("#map-locator-choice-filter .map-locator-chip")]
|
||||
.map((chip) => chip.dataset.filterKey));
|
||||
assert.ok(chips.includes("aprs") && chips.includes("hf_aprs"),
|
||||
`the Show chips are ${JSON.stringify(chips)}`);
|
||||
|
||||
// Turning that chip off takes the HF station off the map and leaves the VHF
|
||||
// one on: the two are filtered apart, which is the whole point of the split.
|
||||
await page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="hf_aprs"]').click();
|
||||
await page.waitForTimeout(500);
|
||||
const shownAfterFilter = await page.evaluate(() => {
|
||||
const map = window.trx.modules.map;
|
||||
const onMap = [];
|
||||
(map?.stationMarkers ?? new Map()).forEach((entry, key) => {
|
||||
if (entry?.marker && map.aprsMap?.hasLayer(entry.marker)) onMap.push(entry?.call ?? key);
|
||||
});
|
||||
return onMap.sort();
|
||||
});
|
||||
assert.deepEqual(shownAfterFilter, ["SP2SJG-9"],
|
||||
`hiding HF APRS left ${JSON.stringify(shownAfterFilter)} on the map`);
|
||||
// The map is the whole station's view — the one place a background rig's
|
||||
// traffic belongs — so both vessels are on it even though the panel and the
|
||||
// mini view show only the selected rig's.
|
||||
assert.equal(markers.ais, 2, `${markers.ais} of 2 vessels reached the map`);
|
||||
|
||||
assert.deepEqual(runtimeErrors, []);
|
||||
} finally {
|
||||
@@ -157,8 +103,6 @@ try {
|
||||
// left the client on its retry path and the history path untested.
|
||||
const HISTORY_AIS = 900;
|
||||
const HISTORY_APRS = 300;
|
||||
const HISTORY_FT8 = 12;
|
||||
const HISTORY_WSPR = 8;
|
||||
const historyFixture = await startWebFixture({
|
||||
spectrum: true,
|
||||
mode: "AIS",
|
||||
@@ -168,14 +112,6 @@ const historyFixture = await startWebFixture({
|
||||
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
|
||||
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||
})),
|
||||
ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({
|
||||
message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2,
|
||||
freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||
})),
|
||||
wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({
|
||||
message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5,
|
||||
freq_hz: 1500 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||
})),
|
||||
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
|
||||
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
|
||||
info: `history ${index}`, packet_type: "position", crc_ok: true,
|
||||
@@ -263,27 +199,6 @@ try {
|
||||
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
|
||||
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
|
||||
|
||||
// The Statistics page counts the same history. Its decode log lives in the
|
||||
// map module, which is lazy, so every decode that arrived before the module
|
||||
// did used to be recorded into nothing at all — the page opened empty and
|
||||
// only filled in from decodes heard afterwards, and it took a reload landing
|
||||
// on the tab (module loaded at startup, before the history) to show the lot.
|
||||
await replay.page.evaluate(() => window.navigateToTab("statistics"));
|
||||
await replay.page.waitForTimeout(1500);
|
||||
// The counters are written with toLocaleString(), so a four-figure count
|
||||
// arrives as "1,220" — whichever separator the runner's locale picks. Read
|
||||
// the digits rather than the formatting.
|
||||
const counted = await replay.page.evaluate(() => {
|
||||
const count = (id) => Number((document.getElementById(id)?.textContent ?? "").replace(/\D/g, ""));
|
||||
return { decodes: count("stats-total-decodes"), grids: count("stats-unique-grids") };
|
||||
});
|
||||
assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR,
|
||||
`the statistics counted ${counted.decodes} decodes`);
|
||||
// Grid squares come from the FT8 and WSPR spots, which had no map replay of
|
||||
// their own: the locators of everything heard before the map loaded were lost.
|
||||
assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR,
|
||||
`the statistics counted ${counted.grids} grid squares`);
|
||||
|
||||
assert.deepEqual(replay.runtimeErrors, []);
|
||||
} finally {
|
||||
await replay.browser.close();
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// The station log: the panel opens an entry pre-filled from the radio and the
|
||||
// server's clock, writes a contact, keeps it, and says whether the station has
|
||||
// been worked before. And the ham layout, which is offered only where a rig can
|
||||
// transmit -- a log is of contacts made.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||
|
||||
/* global document, window, location */
|
||||
|
||||
const HELD = [
|
||||
{
|
||||
id: "q1", started_at: "2026-08-06T14:22:00Z", call: "DL3ABC", freq_hz: 14_074_000,
|
||||
band: "20m", mode: "FT8", rst_sent: "-07", rst_rcvd: "-12", gridsquare: "JO31",
|
||||
my_rig: "Primary fixture",
|
||||
},
|
||||
{
|
||||
id: "q2", started_at: "2026-08-06T13:05:00Z", call: "SP9XYZ", freq_hz: 7_120_000,
|
||||
band: "40m", mode: "SSB", submode: "LSB", rst_sent: "59", rst_rcvd: "57",
|
||||
gridsquare: "JO90", my_rig: "Primary fixture",
|
||||
},
|
||||
];
|
||||
|
||||
const fixture = await startWebFixture({ spectrum: true, tx: true, logbook: [...HELD] });
|
||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||
|
||||
const readPanel = () => page.evaluate(() => ({
|
||||
rows: [...(document.getElementById("log-rows")?.children ?? [])].map((row) => ({
|
||||
call: row.querySelector(".log-cell-call")?.textContent ?? "",
|
||||
cells: [...row.querySelectorAll("td")].map((cell) => cell.textContent),
|
||||
})),
|
||||
summary: document.getElementById("log-summary")?.textContent ?? "",
|
||||
freq: document.getElementById("log-freq")?.value ?? "",
|
||||
mode: document.getElementById("log-mode")?.value ?? "",
|
||||
call: document.getElementById("log-call")?.value ?? "",
|
||||
grid: document.getElementById("log-grid")?.value ?? "",
|
||||
rig: document.getElementById("log-station-rig-name")?.textContent ?? "",
|
||||
clock: document.getElementById("log-clock")?.textContent ?? "",
|
||||
worked: document.getElementById("log-worked-before")?.textContent ?? "",
|
||||
}));
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 950 });
|
||||
await page.goto(`${fixture.origin}/logbook`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#tab-logbook").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// The log is there, newest first, with the band and mode of each contact.
|
||||
const opened = await readPanel();
|
||||
assert.deepEqual(opened.rows.map((row) => row.call), ["DL3ABC", "SP9XYZ"]);
|
||||
assert.equal(opened.summary, "2 contacts");
|
||||
// A phone contact shows the sideband it was worked on, not just "SSB".
|
||||
assert.ok(opened.rows[1].cells.includes("LSB"), `the row reads ${JSON.stringify(opened.rows[1].cells)}`);
|
||||
|
||||
// The entry opened pre-filled: frequency and mode from the rig, the rig's
|
||||
// name, and the time from the server.
|
||||
assert.equal(opened.freq, "100", `the frequency pre-filled as "${opened.freq}"`);
|
||||
assert.equal(opened.mode, "USB", `the mode pre-filled as "${opened.mode}"`);
|
||||
assert.equal(opened.rig, "Primary fixture");
|
||||
assert.match(opened.clock, /^\d{2}:\d{2}:\d{2}Z$/, `the clock reads "${opened.clock}"`);
|
||||
// ...and nothing else was: a report is the operator's to give.
|
||||
assert.equal(opened.call, "");
|
||||
assert.equal(await page.evaluate(() => document.getElementById("log-rst-sent")?.value), "");
|
||||
|
||||
// Worked-before answers as the callsign is typed.
|
||||
await page.locator("#log-call").fill("DL3ABC");
|
||||
await page.waitForTimeout(600);
|
||||
assert.match((await readPanel()).worked, /Worked before: 20m FT8/);
|
||||
await page.locator("#log-call").fill("OZ1NEW");
|
||||
await page.waitForTimeout(600);
|
||||
assert.match((await readPanel()).worked, /Not worked before/);
|
||||
|
||||
// Logging writes the contact and clears the entry for the next one.
|
||||
await page.locator("#log-rst-sent").fill("59");
|
||||
await page.locator("#log-rst-rcvd").fill("57");
|
||||
await page.locator("#log-save-btn").click();
|
||||
await page.waitForTimeout(900);
|
||||
const afterLogging = await readPanel();
|
||||
assert.deepEqual(afterLogging.rows.map((row) => row.call), ["OZ1NEW", "DL3ABC", "SP9XYZ"]);
|
||||
assert.equal(afterLogging.summary, "3 contacts");
|
||||
assert.equal(afterLogging.call, "", "the entry kept the callsign after logging it");
|
||||
assert.equal(afterLogging.freq, "100", "the entry did not re-open pre-filled");
|
||||
|
||||
// A decode row starts an entry rather than logging one: what was heard goes
|
||||
// into the form, and the operator finishes it.
|
||||
await page.evaluate(() => { window.navigateToTab("digital-modes"); });
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator('#tab-digital-modes .sub-tab[data-subtab="ft8"]').click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.evaluate(() => {
|
||||
window.trxPluginRuntime.dispatch("ft8", {
|
||||
message: "CQ SP2SJG JO94", snr_db: -7, dt_s: 0.2, freq_hz: 1200,
|
||||
ts_ms: Date.now(), rig_id: "rig-a",
|
||||
});
|
||||
});
|
||||
await page.waitForTimeout(600);
|
||||
await page.locator("#ft8-messages .ft8-log-btn").first().click();
|
||||
await page.waitForTimeout(900);
|
||||
const fromDecode = await page.evaluate(() => ({
|
||||
path: location.pathname,
|
||||
call: document.getElementById("log-call")?.value ?? "",
|
||||
grid: document.getElementById("log-grid")?.value ?? "",
|
||||
mode: document.getElementById("log-mode")?.value ?? "",
|
||||
rows: document.getElementById("log-rows")?.children.length ?? 0,
|
||||
}));
|
||||
assert.equal(fromDecode.path, "/logbook", `the Log button landed on ${fromDecode.path}`);
|
||||
assert.equal(fromDecode.call, "SP2SJG", "the heard callsign did not reach the entry");
|
||||
assert.equal(fromDecode.grid, "JO94", "the heard locator did not reach the entry");
|
||||
// The decoder answers for the mode, because a rig in DIG cannot.
|
||||
assert.equal(fromDecode.mode, "FT8", `the mode came through as "${fromDecode.mode}"`);
|
||||
assert.equal(fromDecode.rows, 3, "a decode logged itself instead of opening an entry");
|
||||
|
||||
// The ham layout is on offer, because this rig can transmit.
|
||||
const layouts = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
|
||||
assert.ok(layouts.includes("ham"), `the layouts are ${JSON.stringify(layouts)}`);
|
||||
// Choosing it the way the picker does. The <select> itself measures zero
|
||||
// wide in a headless window, so Playwright will not click it; the change
|
||||
// event is what the picker is wired to either way.
|
||||
await page.evaluate(() => { window.navigateToTab("main"); });
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => {
|
||||
const select = document.getElementById("operator-layout-select");
|
||||
select.value = "ham";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(900);
|
||||
const inHamLayout = await page.evaluate(() => ({
|
||||
layout: document.body.dataset.operatorLayout,
|
||||
path: location.pathname,
|
||||
}));
|
||||
assert.equal(inHamLayout.layout, "ham");
|
||||
assert.equal(inHamLayout.path, "/logbook", "the ham layout opened somewhere else");
|
||||
|
||||
assert.deepEqual(runtimeErrors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fixture.close();
|
||||
}
|
||||
|
||||
// Without a transmitter there is nothing to log, so the layout is not offered.
|
||||
const listenerFixture = await startWebFixture({ spectrum: true, tx: false });
|
||||
const listener = await startBrowser(chromium);
|
||||
try {
|
||||
await listener.page.setViewportSize({ width: 1400, height: 950 });
|
||||
await listener.page.goto(`${listenerFixture.origin}/`, { waitUntil: "domcontentloaded" });
|
||||
await listener.page.locator("#content").waitFor({ state: "visible" });
|
||||
await listener.page.waitForTimeout(2000);
|
||||
const layouts = await listener.page.evaluate(() =>
|
||||
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
|
||||
assert.ok(!layouts.includes("ham"), `a receiver was offered the ham layout: ${JSON.stringify(layouts)}`);
|
||||
} finally {
|
||||
await listener.browser.close();
|
||||
await listenerFixture.close();
|
||||
}
|
||||
|
||||
console.log("logbook tests passed");
|
||||
@@ -1,149 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// What a phone gets. A page that scrolls sideways is a page with something on
|
||||
// it nobody will find: the transmit controls spent this whole period laid out
|
||||
// at x=400 on a 390 px screen, off the side of a tray 354 px wide, reachable
|
||||
// only by a horizontal scroll with nothing to say it was there.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||
|
||||
/* global document, getComputedStyle */
|
||||
|
||||
const PHONES = [430, 390, 360];
|
||||
|
||||
const fixture = await startWebFixture({ spectrum: true, tx: true });
|
||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||
|
||||
try {
|
||||
for (const width of PHONES) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#content").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(1800);
|
||||
|
||||
const layout = await page.evaluate((viewport) => {
|
||||
const box = (element) => element.getBoundingClientRect();
|
||||
// Anything laid out past the right edge of the screen.
|
||||
const past = [];
|
||||
const walk = (element) => {
|
||||
const rect = box(element);
|
||||
if (rect.width > 0 && rect.right > viewport + 1) {
|
||||
past.push(`${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ""}`);
|
||||
}
|
||||
for (const child of element.children) walk(child);
|
||||
};
|
||||
walk(document.getElementById("content"));
|
||||
const tx = document.getElementById("tx-power-col");
|
||||
return {
|
||||
documentScroll: document.documentElement.scrollWidth,
|
||||
past: past.slice(0, 6),
|
||||
txRight: tx ? Math.round(box(tx).right) : null,
|
||||
txVisible: tx ? getComputedStyle(tx).display !== "none" : false,
|
||||
rigSelect: Math.round(box(document.getElementById("header-rig-switch-select")).width),
|
||||
};
|
||||
}, width);
|
||||
|
||||
assert.equal(layout.documentScroll, width,
|
||||
`the page scrolls sideways at ${width}px (${layout.documentScroll}px wide)`);
|
||||
assert.deepEqual(layout.past, [],
|
||||
`laid out past the screen at ${width}px: ${layout.past.join(", ")}`);
|
||||
if (layout.txVisible) {
|
||||
assert.ok(layout.txRight <= width + 1,
|
||||
`the transmit controls end at ${layout.txRight}px on a ${width}px screen`);
|
||||
}
|
||||
// The rig's name was taking 139px of a 338px bar and pushing everything
|
||||
// else into the overflow menu.
|
||||
assert.ok(layout.rigSelect <= 112,
|
||||
`the rig picker is ${layout.rigSelect}px wide at ${width}px`);
|
||||
|
||||
// The bottom nav keeps its labels — that is what makes it navigation
|
||||
// rather than five glyphs — and a label used to overflow its tab and run
|
||||
// into the next one: "Bookmarks igital mode".
|
||||
//
|
||||
// What is asserted is that a label stays inside its own tab and, if it is
|
||||
// too long for it, ends in an ellipsis. Not that it fits: how wide a
|
||||
// platform draws a word varies by more than ten per cent, so "it fits"
|
||||
// passes on the machine it was written on and fails on the next one —
|
||||
// which is what a fixed font size did here.
|
||||
const nav = await page.evaluate(() => {
|
||||
const bar = document.querySelector(".tab-bar-nav");
|
||||
return [...bar.querySelectorAll(".tab")]
|
||||
.filter((tab) => tab.getBoundingClientRect().width > 0)
|
||||
.map((tab) => {
|
||||
const label = [...tab.querySelectorAll(".tab-label, .tab-label-short")]
|
||||
.find((span) => getComputedStyle(span).display !== "none");
|
||||
const style = label ? getComputedStyle(label) : null;
|
||||
const labelBox = label?.getBoundingClientRect();
|
||||
const tabBox = tab.getBoundingClientRect();
|
||||
return {
|
||||
text: (label?.textContent ?? "").trim(),
|
||||
escapes: labelBox
|
||||
? labelBox.left < tabBox.left - 1 || labelBox.right > tabBox.right + 1
|
||||
: false,
|
||||
truncates: style?.textOverflow === "ellipsis" && style?.overflow !== "visible",
|
||||
empty: !labelBox || labelBox.width < 1,
|
||||
};
|
||||
});
|
||||
});
|
||||
assert.ok(nav.length >= 4, `the bottom nav has ${nav.length} destinations at ${width}px`);
|
||||
const escaped = nav.filter((tab) => tab.escapes).map((tab) => tab.text);
|
||||
assert.deepEqual(escaped, [],
|
||||
`labels overflowing their tab at ${width}px: ${escaped.join(", ")}`);
|
||||
const untruncatable = nav.filter((tab) => !tab.truncates).map((tab) => tab.text);
|
||||
assert.deepEqual(untruncatable, [],
|
||||
`labels that would be cut rather than ellipsised at ${width}px: ${untruncatable.join(", ")}`);
|
||||
const blank = nav.filter((tab) => tab.empty).map((tab, index) => tab.text || `#${index}`);
|
||||
assert.deepEqual(blank, [], `destinations with no label at ${width}px: ${blank.join(", ")}`);
|
||||
}
|
||||
|
||||
// A tab whose visible label is shortened for the nav still has to say what
|
||||
// it is to anything that reads the page rather than looks at it.
|
||||
const named = await page.evaluate(() => {
|
||||
const tab = document.querySelector('.tab[data-tab="digital-modes"]');
|
||||
const short = tab.querySelector(".tab-label-short");
|
||||
return {
|
||||
label: tab.getAttribute("aria-label"),
|
||||
shortHidden: short?.getAttribute("aria-hidden"),
|
||||
};
|
||||
});
|
||||
assert.equal(named.label, "Digital modes", "the shortened tab lost its full name");
|
||||
assert.equal(named.shortHidden, "true", "the short label is read out as well as shown");
|
||||
|
||||
// Hiding the map's filters leaves the bar, and the bar has to still be
|
||||
// there to bring them back — it carries the only button that does.
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(`${fixture.origin}/map`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
||||
await page.waitForTimeout(1200);
|
||||
await page.locator("#map-overlay-toggle-btn").click();
|
||||
await page.waitForTimeout(400);
|
||||
const collapsed = await page.evaluate(() => {
|
||||
const panel = document.querySelector(".map-overlay-panel");
|
||||
const style = getComputedStyle(panel);
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const button = document.getElementById("map-overlay-toggle-btn").getBoundingClientRect();
|
||||
return {
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
opacity: Number(style.opacity),
|
||||
visibility: style.visibility,
|
||||
display: style.display,
|
||||
buttonWidth: Math.round(button.width),
|
||||
};
|
||||
});
|
||||
assert.ok(collapsed.width > 0 && collapsed.height > 0,
|
||||
`the collapsed bar measures ${collapsed.width}x${collapsed.height}`);
|
||||
assert.equal(collapsed.visibility, "visible", "the collapsed bar is not visible");
|
||||
assert.notEqual(collapsed.display, "none", "the collapsed bar is display:none");
|
||||
assert.equal(collapsed.opacity, 1, `the collapsed bar is at opacity ${collapsed.opacity}`);
|
||||
assert.ok(collapsed.buttonWidth > 0, "Show Filters has no size to click");
|
||||
|
||||
assert.deepEqual(runtimeErrors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fixture.close();
|
||||
}
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
// 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,62 +196,6 @@ 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.
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// The SSTV panel: a picture arriving row by row, and what is kept once it has.
|
||||
// Watching the image build up is the point of the mode, so rows have to reach
|
||||
// the canvas as they arrive rather than at the end of a two-minute frame.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import { bundleEntry } from "./bundle-entry.mjs";
|
||||
import { createHost } from "./host-fixture.mjs";
|
||||
|
||||
/** A DOM stub with only what the plugin reaches for. */
|
||||
function makeElement(id) {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
id,
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
value: "",
|
||||
style: {},
|
||||
attributes: {},
|
||||
classList: {
|
||||
classes: new Set(),
|
||||
add(name) { this.classes.add(name); },
|
||||
remove(name) { this.classes.delete(name); },
|
||||
toggle(name, on) { if (on) this.classes.add(name); else this.classes.delete(name); },
|
||||
contains(name) { return this.classes.has(name); },
|
||||
},
|
||||
addEventListener(type, handler) { listeners.set(type, handler); },
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||||
getAttribute(name) { return this.attributes[name] ?? null; },
|
||||
click() { listeners.get("click")?.(); },
|
||||
fire(type, event) { listeners.get(type)?.(event); },
|
||||
};
|
||||
}
|
||||
|
||||
function makeCanvas(id) {
|
||||
const element = makeElement(id);
|
||||
element.width = 0;
|
||||
element.height = 0;
|
||||
const painted = [];
|
||||
const fills = [];
|
||||
element.painted = painted;
|
||||
element.fills = fills;
|
||||
element.getContext = () => ({
|
||||
fillStyle: "",
|
||||
fillRect: (...args) => { fills.push(args); },
|
||||
createImageData: (width, height) => ({
|
||||
width, height, data: new Uint8ClampedArray(width * height * 4),
|
||||
}),
|
||||
putImageData: (image, x, y) => { painted.push({ x, y, data: image.data }); },
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
async function loadPanel() {
|
||||
const elements = new Map();
|
||||
const element = (id) => {
|
||||
if (!elements.has(id)) {
|
||||
elements.set(id, id.endsWith("canvas") ? makeCanvas(id) : makeElement(id));
|
||||
}
|
||||
return elements.get(id);
|
||||
};
|
||||
// Touch every id the panel defines, so the plugin caches real stubs.
|
||||
for (const id of [
|
||||
"sstv-status", "sstv-live-view", "sstv-history-view", "sstv-live-container",
|
||||
"sstv-live-info", "sstv-live-canvas", "sstv-live-latest", "sstv-history-list",
|
||||
"sstv-history-count", "sstv-filter", "sstv-sort", "sstv-decode-toggle-btn",
|
||||
"sstv-clear-btn", "sstv-view-live", "sstv-view-history",
|
||||
]) element(id);
|
||||
|
||||
const window = { ...createHost() };
|
||||
const context = vm.createContext({
|
||||
window,
|
||||
document: { getElementById: (id) => elements.get(id) ?? null },
|
||||
atob: (data) => Buffer.from(data, "base64").toString("binary"),
|
||||
Date, Number, String, Math, Set, Uint8Array, Uint8ClampedArray, JSON, console,
|
||||
});
|
||||
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
|
||||
const source = await bundleEntry(new URL("../src/plugins/sstv.ts", import.meta.url));
|
||||
new vm.Script(runtime).runInContext(context);
|
||||
new vm.Script(source).runInContext(context);
|
||||
return { window, runtime: window.trxPluginRuntime, element };
|
||||
}
|
||||
|
||||
/** One row of RGB triples, base64 as the server sends it. */
|
||||
function rowData(width, [r, g, b]) {
|
||||
const bytes = new Uint8Array(width * 3);
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
bytes[x * 3] = r;
|
||||
bytes[x * 3 + 1] = g;
|
||||
bytes[x * 3 + 2] = b;
|
||||
}
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
}
|
||||
|
||||
test("a picture arriving paints its rows as they come", async () => {
|
||||
const { runtime, element } = await loadPanel();
|
||||
|
||||
// The header names the mode and the geometry; the canvas takes both.
|
||||
runtime.dispatch("sstv_progress", {
|
||||
state: "Receiving Martin M1", mode: "Martin M1", width: 320, height: 256, line: 0,
|
||||
});
|
||||
const canvas = element("sstv-live-canvas");
|
||||
assert.equal(canvas.width, 320, "the canvas did not take the mode's width");
|
||||
assert.equal(canvas.height, 256, "the canvas did not take the mode's height");
|
||||
assert.equal(element("sstv-live-container").style.display, "",
|
||||
"the live view stayed hidden while a picture was arriving");
|
||||
assert.match(element("sstv-status").textContent, /Martin M1/);
|
||||
|
||||
// Rows land at the line number they carry, not in arrival order: a decoder
|
||||
// that painted them in sequence would shear a picture with a dropped line.
|
||||
runtime.dispatch("sstv_progress", { line: 4, line_data: rowData(320, [255, 0, 0]) });
|
||||
runtime.dispatch("sstv_progress", { line: 2, line_data: rowData(320, [0, 0, 255]) });
|
||||
assert.deepEqual(canvas.painted.map((p) => p.y), [4, 2],
|
||||
`rows painted at ${canvas.painted.map((p) => p.y).join(",")}`);
|
||||
assert.deepEqual([...canvas.painted[0].data.slice(0, 4)], [255, 0, 0, 255], "row 4 is not red");
|
||||
assert.deepEqual([...canvas.painted[1].data.slice(0, 4)], [0, 0, 255, 255], "row 2 is not blue");
|
||||
assert.match(element("sstv-live-info").textContent, /Martin M1/);
|
||||
});
|
||||
|
||||
test("a received picture is kept, shown, and linked by file name alone", async () => {
|
||||
const { runtime, element } = await loadPanel();
|
||||
|
||||
// Recent, not a fixed date: the panel drops anything older than the history
|
||||
// retention window, so a picture stamped with the day the test was written
|
||||
// passes until that day is a day ago.
|
||||
runtime.dispatch("sstv", {
|
||||
ts_ms: Date.now() - 60_000,
|
||||
vis: 44, mode: "Martin M1", width: 320, height: 256, lines: 256, complete: true,
|
||||
path: "/home/op/.cache/trx-rs/sstv/SSTV_20260805T120000Z_14230000_Martin-M1.png",
|
||||
});
|
||||
|
||||
const latest = element("sstv-live-latest").innerHTML;
|
||||
assert.match(latest, /Martin M1/);
|
||||
assert.match(latest, /complete/);
|
||||
// The server serves pictures by file name; the path it stored is its own.
|
||||
assert.match(latest, /\/sstv-images\/SSTV_20260805T120000Z_14230000_Martin-M1\.png/);
|
||||
assert.doesNotMatch(latest, /home\/op/, "the server's filesystem path reached the page");
|
||||
|
||||
element("sstv-view-history").click();
|
||||
const history = element("sstv-history-list").innerHTML;
|
||||
assert.match(history, /Martin M1/);
|
||||
assert.match(history, /320×256/);
|
||||
assert.match(element("sstv-history-count").textContent, /1 picture/);
|
||||
});
|
||||
|
||||
test("a picture cut short is kept, and says so", async () => {
|
||||
const { runtime, element } = await loadPanel();
|
||||
|
||||
runtime.dispatch("sstv", {
|
||||
ts_ms: Date.now(), vis: 60, mode: "Scottie S1", width: 320, height: 256,
|
||||
lines: 91, complete: false, path: "/cache/SSTV_x_Scottie-S1.png",
|
||||
});
|
||||
|
||||
assert.match(element("sstv-status").textContent, /Partial/);
|
||||
element("sstv-view-history").click();
|
||||
assert.match(element("sstv-history-list").innerHTML, /91 \(partial\)/);
|
||||
});
|
||||
|
||||
test("clearing empties the panel", async () => {
|
||||
const { runtime, element } = await loadPanel();
|
||||
runtime.dispatch("sstv", { ts_ms: Date.now(), mode: "PD120", lines: 496, complete: true });
|
||||
assert.match(element("sstv-live-latest").innerHTML, /PD120/);
|
||||
|
||||
runtime.reset("sstv");
|
||||
assert.equal(element("sstv-live-latest").innerHTML, "");
|
||||
assert.equal(element("sstv-status").textContent, "Idle");
|
||||
assert.equal(element("sstv-live-container").style.display, "none");
|
||||
});
|
||||
|
||||
test("the toggle button follows the rig state", async () => {
|
||||
const { window, element } = await loadPanel();
|
||||
const button = element("sstv-decode-toggle-btn");
|
||||
|
||||
window.syncSstvToggle(true);
|
||||
assert.equal(button.textContent, "Disable SSTV");
|
||||
assert.equal(button.getAttribute("aria-pressed"), "true");
|
||||
|
||||
window.syncSstvToggle(false);
|
||||
assert.equal(button.textContent, "Enable SSTV");
|
||||
assert.equal(button.getAttribute("aria-pressed"), "false");
|
||||
});
|
||||
@@ -27,21 +27,6 @@ const dial = () => page.evaluate(() => ({
|
||||
path: window.location.pathname,
|
||||
}));
|
||||
|
||||
// Typed, not filled. The app holds back its own refreshes of the frequency
|
||||
// field from the first keystroke until Enter, so that a state update arriving
|
||||
// mid-edit does not rewrite what is being typed. `fill()` sets the value
|
||||
// without a keystroke, leaving the field unguarded: on a slow machine a state
|
||||
// update could land between the fill and the Enter and put the old frequency
|
||||
// back, and the Enter would then re-apply the frequency the radio was already
|
||||
// on. Selecting first arms the guard before a single character changes.
|
||||
async function tuneByHand(text) {
|
||||
const field = page.locator("#freq");
|
||||
await field.click();
|
||||
await field.press("ControlOrMeta+a");
|
||||
await field.pressSequentially(text);
|
||||
await field.press("Enter");
|
||||
}
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1500, height: 950 });
|
||||
|
||||
@@ -64,7 +49,8 @@ try {
|
||||
|
||||
// Tuning by hand rewrites the link. This is the part that makes the address
|
||||
// bar shareable at any moment rather than only at load.
|
||||
await tuneByHand("7.040M");
|
||||
await page.locator("#freq").fill("7.040M");
|
||||
await page.locator("#freq").press("Enter");
|
||||
await page.waitForTimeout(1500);
|
||||
const tuned = await dial();
|
||||
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
|
||||
@@ -73,7 +59,8 @@ try {
|
||||
|
||||
// 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.locator("#freq").fill("7.100M");
|
||||
await page.locator("#freq").press("Enter");
|
||||
await page.waitForTimeout(1200);
|
||||
assert.equal(await page.evaluate(() => window.history.length), historyLength,
|
||||
"tuning pushed a history entry instead of replacing one");
|
||||
|
||||
@@ -29,19 +29,11 @@ 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"] },
|
||||
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
|
||||
].map((decoder) => ({
|
||||
...decoder,
|
||||
// Which decoders a background channel can run, as the server's own registry
|
||||
// has it: the background decode panel offers a bookmark only if one of these
|
||||
// can decode it, so marking them all false left that panel with nothing.
|
||||
background_decode: ["ft8", "ft4", "ft2", "wspr", "ais", "aprs", "hf-aprs"].includes(decoder.id),
|
||||
bookmark_selectable: true,
|
||||
}));
|
||||
].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true }));
|
||||
|
||||
const CONTENT_TYPES = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
@@ -112,7 +104,7 @@ function encodeCbor(value) {
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"];
|
||||
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||
|
||||
function assetPath(urlPath) {
|
||||
// Every tab route has its own index handler on the server (see api/assets.rs),
|
||||
@@ -139,12 +131,9 @@ export async function startWebFixture({
|
||||
mode = "FM",
|
||||
history = {},
|
||||
bookmarks = [],
|
||||
backgroundDecode = null,
|
||||
logbook = [],
|
||||
bandplan = {},
|
||||
bandplanEnabled = false,
|
||||
bandplanUnauthorizedFirst = false,
|
||||
satPasses = null,
|
||||
} = {}) {
|
||||
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
||||
remote,
|
||||
@@ -214,7 +203,6 @@ 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,
|
||||
@@ -240,7 +228,6 @@ 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
|
||||
@@ -280,111 +267,6 @@ export async function startWebFixture({
|
||||
response.writeHead(200).end();
|
||||
return;
|
||||
}
|
||||
// The logbook: an in-memory station log, enough for the panel to open an
|
||||
// entry, write it, read it back and export it.
|
||||
if (url.pathname.startsWith("/api/logbook")) {
|
||||
const tail = url.pathname.slice("/api/logbook".length);
|
||||
if (tail === "/prefill") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({
|
||||
started_at: new Date().toISOString(),
|
||||
epoch_ms: Date.now(),
|
||||
freq_hz: status.status.freq.hz,
|
||||
band: "20m",
|
||||
mode: url.searchParams.get("decoder") === "ft8" ? "FT8" : "SSB",
|
||||
submode: url.searchParams.get("decoder") === "ft8" ? null : "USB",
|
||||
rig_id: url.searchParams.get("remote") ?? "rig-a",
|
||||
my_rig: "Primary fixture",
|
||||
call: url.searchParams.get("call"),
|
||||
gridsquare: url.searchParams.get("gridsquare"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (tail.startsWith("/worked/")) {
|
||||
const call = decodeURIComponent(tail.slice("/worked/".length)).toUpperCase();
|
||||
const worked = logbook
|
||||
.filter((qso) => qso.call === call)
|
||||
.map((qso) => ({ band: qso.band, mode: qso.mode }));
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ call, worked }));
|
||||
return;
|
||||
}
|
||||
if (tail === "/export.adi") {
|
||||
const body = logbook
|
||||
.map((qso) => `<CALL:${qso.call.length}>${qso.call}<EOR>\n`)
|
||||
.join("");
|
||||
response.writeHead(200, { "content-type": "text/plain" });
|
||||
response.end(body);
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && tail === "") {
|
||||
const raw = await new Promise((resolve) => {
|
||||
let text = "";
|
||||
request.on("data", (chunk) => { text += chunk; });
|
||||
request.on("end", () => resolve(text));
|
||||
});
|
||||
const input = JSON.parse(raw);
|
||||
const qso = {
|
||||
id: `qso-${String(logbook.length + 1)}`,
|
||||
started_at: input.started_at ?? new Date().toISOString(),
|
||||
call: String(input.call).toUpperCase(),
|
||||
freq_hz: input.freq_hz,
|
||||
band: "20m",
|
||||
mode: input.mode,
|
||||
submode: input.submode,
|
||||
rst_sent: input.rst_sent,
|
||||
rst_rcvd: input.rst_rcvd,
|
||||
gridsquare: input.gridsquare ? String(input.gridsquare).toUpperCase() : null,
|
||||
my_rig: input.my_rig,
|
||||
operator: input.operator,
|
||||
};
|
||||
logbook.unshift(qso);
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(qso));
|
||||
return;
|
||||
}
|
||||
if (request.method === "DELETE") {
|
||||
const id = tail.replace("/", "");
|
||||
const index = logbook.findIndex((qso) => qso.id === id);
|
||||
if (index >= 0) logbook.splice(index, 1);
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ deleted: index >= 0 }));
|
||||
return;
|
||||
}
|
||||
const wanted = (url.searchParams.get("call") ?? "").toUpperCase();
|
||||
const matching = wanted ? logbook.filter((qso) => qso.call.includes(wanted)) : logbook;
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ total: logbook.length, qsos: matching }));
|
||||
return;
|
||||
}
|
||||
// Background decode: the panel reads its config, its status, and the
|
||||
// bookmark list, and writes the config back.
|
||||
if (url.pathname.startsWith("/background-decode/")) {
|
||||
const isStatus = url.pathname.endsWith("/status");
|
||||
if (isStatus) {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(backgroundDecode?.status ?? { entries: [] }));
|
||||
return;
|
||||
}
|
||||
if (request.method === "PUT") {
|
||||
const body = await new Promise((resolve) => {
|
||||
let raw = "";
|
||||
request.on("data", (chunk) => { raw += chunk; });
|
||||
request.on("end", () => resolve(raw));
|
||||
});
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
if (backgroundDecode) backgroundDecode.config = parsed;
|
||||
} catch { /* leave the config as it was */ }
|
||||
}
|
||||
if (request.method === "DELETE" && backgroundDecode) {
|
||||
backgroundDecode.config = { remote: "rig-a", enabled: false, bookmark_ids: [] };
|
||||
}
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(backgroundDecode?.config
|
||||
?? { remote: "rig-a", enabled: false, bookmark_ids: [] }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/select_rig" && request.method === "POST") {
|
||||
const remote = url.searchParams.get("remote");
|
||||
if (remote) {
|
||||
|
||||
@@ -10,9 +10,7 @@ use actix_web::{get, HttpRequest, HttpResponse, Responder};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::{
|
||||
gz_cache_entry, static_asset_response, AssetCaching, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES,
|
||||
};
|
||||
use super::{gz_cache_entry, static_asset_response, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES};
|
||||
use crate::server::status;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -64,111 +62,49 @@ define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css");
|
||||
#[get("/")]
|
||||
pub(crate) async fn index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"text/html; charset=utf-8",
|
||||
c,
|
||||
AssetCaching::Revalidate,
|
||||
)
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/map")]
|
||||
pub(crate) async fn map_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"text/html; charset=utf-8",
|
||||
c,
|
||||
AssetCaching::Revalidate,
|
||||
)
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/digital-modes")]
|
||||
pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"text/html; charset=utf-8",
|
||||
c,
|
||||
AssetCaching::Revalidate,
|
||||
)
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/recorder")]
|
||||
pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"text/html; charset=utf-8",
|
||||
c,
|
||||
AssetCaching::Revalidate,
|
||||
)
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/settings")]
|
||||
pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"text/html; charset=utf-8",
|
||||
c,
|
||||
AssetCaching::Revalidate,
|
||||
)
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/about")]
|
||||
pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"text/html; charset=utf-8",
|
||||
c,
|
||||
AssetCaching::Revalidate,
|
||||
)
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/statistics")]
|
||||
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"text/html; charset=utf-8",
|
||||
c,
|
||||
AssetCaching::Revalidate,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/logbook")]
|
||||
pub(crate) async fn logbook_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"text/html; charset=utf-8",
|
||||
c,
|
||||
AssetCaching::Revalidate,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/satellites")]
|
||||
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"text/html; charset=utf-8",
|
||||
c,
|
||||
AssetCaching::Revalidate,
|
||||
)
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/bookmarks")]
|
||||
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"text/html; charset=utf-8",
|
||||
c,
|
||||
AssetCaching::Revalidate,
|
||||
)
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -206,13 +142,13 @@ pub(crate) async fn logo() -> impl Responder {
|
||||
#[get("/style.css")]
|
||||
pub(crate) async fn style_css(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_style_css();
|
||||
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
|
||||
static_asset_response(&req, "text/css; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/themes.css")]
|
||||
pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_themes_css();
|
||||
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
|
||||
static_asset_response(&req, "text/css; charset=utf-8", c)
|
||||
}
|
||||
|
||||
// Generated filenames are supplied only by build.rs and resolved through this
|
||||
@@ -236,52 +172,24 @@ pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path<String>) -
|
||||
let Some(entry) = generated_asset_cache().get(filename.as_str()) else {
|
||||
return HttpResponse::NotFound().finish();
|
||||
};
|
||||
static_asset_response(
|
||||
&req,
|
||||
content_type,
|
||||
entry,
|
||||
generated_asset_caching(&filename),
|
||||
)
|
||||
}
|
||||
|
||||
/// esbuild names shared chunks `chunk-<hash>.js` and leaves the entry points on
|
||||
/// a fixed name, so only the chunks are safe to keep forever.
|
||||
fn generated_asset_caching(filename: &str) -> AssetCaching {
|
||||
if filename.starts_with("chunk-") {
|
||||
AssetCaching::Immutable
|
||||
} else {
|
||||
AssetCaching::Revalidate
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a received SSTV picture out of the local cache.
|
||||
#[get("/sstv-images/{filename}")]
|
||||
pub(crate) async fn sstv_image(path: web::Path<String>) -> impl Responder {
|
||||
cached_png("sstv", &path.into_inner())
|
||||
static_asset_response(&req, content_type, entry)
|
||||
}
|
||||
|
||||
#[get("/images/{filename}")]
|
||||
pub(crate) async fn wefax_image(path: web::Path<String>) -> impl Responder {
|
||||
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 {
|
||||
let filename = path.into_inner();
|
||||
// Reject path traversal attempts.
|
||||
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 file_path = dirs::cache_dir()
|
||||
let dir = dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||
.join("trx-rs")
|
||||
.join(decoder)
|
||||
.join(filename);
|
||||
.join("wefax");
|
||||
let file_path = dir.join(&filename);
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(data) => HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
@@ -294,12 +202,7 @@ fn cached_png(decoder: &str, filename: &str) -> HttpResponse {
|
||||
#[get("/bandplan.json")]
|
||||
pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_bandplan_json();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/json; charset=utf-8",
|
||||
c,
|
||||
AssetCaching::Revalidate,
|
||||
)
|
||||
static_asset_response(&req, "application/json; charset=utf-8", c)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -317,12 +220,7 @@ pub(crate) async fn dseg14_classic_woff2() -> impl Responder {
|
||||
#[get("/vendor/opus-decoder-0.7.11.min.js")]
|
||||
pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_opus_decoder_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
c,
|
||||
AssetCaching::Immutable,
|
||||
)
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -332,18 +230,13 @@ pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
|
||||
#[get("/vendor/leaflet.js")]
|
||||
pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_leaflet_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
c,
|
||||
AssetCaching::Revalidate,
|
||||
)
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/vendor/leaflet.css")]
|
||||
pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_leaflet_css();
|
||||
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
|
||||
static_asset_response(&req, "text/css; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/vendor/marker-icon.png")]
|
||||
@@ -443,44 +336,6 @@ mod tests {
|
||||
assert!(!generated_asset_cache().contains_key("../app.js"));
|
||||
}
|
||||
|
||||
/// A browser that visited before an upgrade must not keep the old page.
|
||||
/// index.html, the stylesheets and the entry bundles all keep their URL
|
||||
/// from one build to the next, so an immutable year-long policy on them
|
||||
/// leaves a client running whatever it first downloaded — a fixed layout
|
||||
/// stays broken in the browser that happened to cache it, and no reload
|
||||
/// short of a forced one gets the fix.
|
||||
#[test]
|
||||
fn assets_that_keep_their_url_across_builds_are_revalidated() {
|
||||
for name in ["app.js", "aprs.js", "map-core.js"] {
|
||||
assert_eq!(
|
||||
generated_asset_caching(name),
|
||||
AssetCaching::Revalidate,
|
||||
"{name} is served from the same URL after every build"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
AssetCaching::Revalidate.header_value(),
|
||||
"no-cache",
|
||||
"revalidating assets must ask before they are reused"
|
||||
);
|
||||
}
|
||||
|
||||
/// Only the chunks carry a hash of their own bytes, so only they can be
|
||||
/// kept forever.
|
||||
#[test]
|
||||
fn content_addressed_chunks_stay_immutable() {
|
||||
let chunk = status::GENERATED_ASSETS
|
||||
.iter()
|
||||
.map(|(name, _)| *name)
|
||||
.find(|name| name.starts_with("chunk-"))
|
||||
.expect("the bundle splits into at least one shared chunk");
|
||||
assert_eq!(generated_asset_caching(chunk), AssetCaching::Immutable);
|
||||
assert!(
|
||||
AssetCaching::Immutable.header_value().contains("immutable"),
|
||||
"a hashed name is safe to keep"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_asset_mime_types_are_restricted() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -60,7 +60,6 @@ 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 {
|
||||
@@ -75,7 +74,6 @@ impl DecodeHistoryPayload {
|
||||
+ self.ft2.len()
|
||||
+ self.wspr.len()
|
||||
+ self.wefax.len()
|
||||
+ self.sstv.len()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +93,6 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,40 +451,10 @@ 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>,
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! The logbook over HTTP: read, write, import, export, and the clock a contact
|
||||
//! is stamped from.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::http::header;
|
||||
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse, Responder};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use trx_logbook::qso::{adif_mode_for_rig_mode, band_for_hz, mode_for_decoder};
|
||||
use trx_logbook::{LogQuery, Logbook, Qso};
|
||||
|
||||
use super::{active_rig_id_from_context, require_control};
|
||||
use crate::server::auth::AuthState;
|
||||
|
||||
/// What a contact looks like on the wire.
|
||||
///
|
||||
/// The band is sent although it is derived, because every reader of the log
|
||||
/// wants it and none of them should have to carry the band plan to get it.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct QsoView {
|
||||
#[serde(flatten)]
|
||||
qso: Qso,
|
||||
band: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl From<Qso> for QsoView {
|
||||
fn from(qso: Qso) -> Self {
|
||||
Self {
|
||||
band: qso.band(),
|
||||
qso,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A contact as the panel sends it.
|
||||
///
|
||||
/// Times come from the server (`started_at` omitted means "now"), so a browser
|
||||
/// with a wrong clock cannot write a wrong time into the log.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QsoInput {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
started_at: Option<chrono::DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
ended_at: Option<chrono::DateTime<Utc>>,
|
||||
call: String,
|
||||
freq_hz: u64,
|
||||
mode: String,
|
||||
#[serde(default)]
|
||||
submode: Option<String>,
|
||||
#[serde(default)]
|
||||
rst_sent: Option<String>,
|
||||
#[serde(default)]
|
||||
rst_rcvd: Option<String>,
|
||||
#[serde(default)]
|
||||
gridsquare: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
qth: Option<String>,
|
||||
#[serde(default)]
|
||||
comment: Option<String>,
|
||||
#[serde(default)]
|
||||
tx_pwr_w: Option<f64>,
|
||||
#[serde(default)]
|
||||
station_callsign: Option<String>,
|
||||
#[serde(default)]
|
||||
operator: Option<String>,
|
||||
#[serde(default)]
|
||||
my_gridsquare: Option<String>,
|
||||
#[serde(default)]
|
||||
my_rig: Option<String>,
|
||||
#[serde(default)]
|
||||
rig_id: Option<String>,
|
||||
}
|
||||
|
||||
fn blank_to_none(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|text| text.trim().to_string())
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
impl QsoInput {
|
||||
fn into_qso(self, existing: Option<Qso>) -> Result<Qso, String> {
|
||||
let call = self.call.trim();
|
||||
if call.is_empty() {
|
||||
return Err("a contact needs a callsign".to_string());
|
||||
}
|
||||
if self.mode.trim().is_empty() {
|
||||
return Err("a contact needs a mode".to_string());
|
||||
}
|
||||
let started_at = self
|
||||
.started_at
|
||||
.or(existing.as_ref().map(|q| q.started_at))
|
||||
.unwrap_or_else(Utc::now);
|
||||
let mut qso = Qso::new(
|
||||
self.id
|
||||
.or(existing.as_ref().map(|q| q.id.clone()))
|
||||
.unwrap_or_else(trx_logbook::new_id),
|
||||
started_at,
|
||||
call,
|
||||
self.freq_hz,
|
||||
&self.mode,
|
||||
);
|
||||
qso.ended_at = self.ended_at;
|
||||
qso.submode = blank_to_none(self.submode).map(|s| s.to_uppercase());
|
||||
qso.rst_sent = blank_to_none(self.rst_sent);
|
||||
qso.rst_rcvd = blank_to_none(self.rst_rcvd);
|
||||
qso.gridsquare = blank_to_none(self.gridsquare).map(|g| g.to_uppercase());
|
||||
qso.name = blank_to_none(self.name);
|
||||
qso.qth = blank_to_none(self.qth);
|
||||
qso.comment = blank_to_none(self.comment);
|
||||
qso.tx_pwr_w = self.tx_pwr_w;
|
||||
qso.station_callsign = blank_to_none(self.station_callsign).map(|c| c.to_uppercase());
|
||||
qso.operator = blank_to_none(self.operator).map(|c| c.to_uppercase());
|
||||
qso.my_gridsquare = blank_to_none(self.my_gridsquare).map(|g| g.to_uppercase());
|
||||
qso.my_rig = blank_to_none(self.my_rig);
|
||||
qso.rig_id = blank_to_none(self.rig_id);
|
||||
// Whatever an imported contact carried that this application does not
|
||||
// model stays with it through an edit.
|
||||
if let Some(existing) = existing {
|
||||
qso.extra = existing.extra;
|
||||
}
|
||||
Ok(qso)
|
||||
}
|
||||
}
|
||||
|
||||
fn book(logbook: &web::Data<Arc<Logbook>>) -> Arc<Logbook> {
|
||||
logbook.get_ref().clone()
|
||||
}
|
||||
|
||||
/// The rig's name as the operator knows it, which is what belongs in the log —
|
||||
/// falling back to its id, since a log entry naming nothing is worse.
|
||||
fn rig_display_name(context: &trx_frontend::FrontendRuntimeContext, rig_id: &str) -> String {
|
||||
context
|
||||
.routing
|
||||
.remote_rigs
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|rigs| {
|
||||
rigs.iter()
|
||||
.find(|rig| rig.rig_id == rig_id)
|
||||
.and_then(|rig| rig.display_name.clone())
|
||||
})
|
||||
.unwrap_or_else(|| rig_id.to_string())
|
||||
}
|
||||
|
||||
/// `GET /api/logbook` — contacts, newest first, filtered.
|
||||
#[get("/api/logbook")]
|
||||
pub async fn list_qsos(
|
||||
query: web::Query<LogQuery>,
|
||||
logbook: web::Data<Arc<Logbook>>,
|
||||
) -> impl Responder {
|
||||
let query = query.into_inner();
|
||||
let total = book(&logbook).count();
|
||||
let qsos: Vec<QsoView> = book(&logbook)
|
||||
.query(&query)
|
||||
.into_iter()
|
||||
.map(QsoView::from)
|
||||
.collect();
|
||||
HttpResponse::Ok().json(serde_json::json!({ "total": total, "qsos": qsos }))
|
||||
}
|
||||
|
||||
/// `POST /api/logbook` — write a contact.
|
||||
#[post("/api/logbook")]
|
||||
pub async fn add_qso(
|
||||
req: HttpRequest,
|
||||
input: web::Json<QsoInput>,
|
||||
logbook: web::Data<Arc<Logbook>>,
|
||||
auth_state: web::Data<AuthState>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
require_control(&req, auth_state.get_ref())?;
|
||||
let qso = match input.into_inner().into_qso(None) {
|
||||
Ok(qso) => qso,
|
||||
Err(reason) => {
|
||||
return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": reason })))
|
||||
}
|
||||
};
|
||||
match book(&logbook).put(qso) {
|
||||
Ok(saved) => Ok(HttpResponse::Ok().json(QsoView::from(saved))),
|
||||
Err(err) => Ok(HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({ "error": err.to_string() }))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUT /api/logbook/{id}` — change a contact.
|
||||
#[put("/api/logbook/{id}")]
|
||||
pub async fn edit_qso(
|
||||
req: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
input: web::Json<QsoInput>,
|
||||
logbook: web::Data<Arc<Logbook>>,
|
||||
auth_state: web::Data<AuthState>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
require_control(&req, auth_state.get_ref())?;
|
||||
let id = path.into_inner();
|
||||
let Some(existing) = book(&logbook).get(&id) else {
|
||||
return Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" })));
|
||||
};
|
||||
let mut input = input.into_inner();
|
||||
input.id = Some(id);
|
||||
let qso = match input.into_qso(Some(existing)) {
|
||||
Ok(qso) => qso,
|
||||
Err(reason) => {
|
||||
return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": reason })))
|
||||
}
|
||||
};
|
||||
match book(&logbook).put(qso) {
|
||||
Ok(saved) => Ok(HttpResponse::Ok().json(QsoView::from(saved))),
|
||||
Err(err) => Ok(HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({ "error": err.to_string() }))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /api/logbook/{id}` — forget a contact.
|
||||
#[delete("/api/logbook/{id}")]
|
||||
pub async fn delete_qso(
|
||||
req: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
logbook: web::Data<Arc<Logbook>>,
|
||||
auth_state: web::Data<AuthState>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
require_control(&req, auth_state.get_ref())?;
|
||||
match book(&logbook).delete(&path.into_inner()) {
|
||||
Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))),
|
||||
Ok(false) => {
|
||||
Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" })))
|
||||
}
|
||||
Err(err) => Ok(HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({ "error": err.to_string() }))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/logbook/export.adi` — ADIF, honouring the same filters as a read.
|
||||
#[get("/api/logbook/export.adi")]
|
||||
pub async fn export_adi(
|
||||
query: web::Query<LogQuery>,
|
||||
logbook: web::Data<Arc<Logbook>>,
|
||||
) -> impl Responder {
|
||||
let text = book(&logbook).export_adi(&query.into_inner(), env!("CARGO_PKG_VERSION"));
|
||||
let filename = format!("trx-rs-log-{}.adi", Utc::now().format("%Y%m%d"));
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "text/plain; charset=utf-8"))
|
||||
.insert_header((
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{filename}\""),
|
||||
))
|
||||
.body(text)
|
||||
}
|
||||
|
||||
/// `POST /api/logbook/import` — take an ADIF file, skipping what is already held.
|
||||
#[post("/api/logbook/import")]
|
||||
pub async fn import_adi(
|
||||
req: HttpRequest,
|
||||
body: web::Bytes,
|
||||
logbook: web::Data<Arc<Logbook>>,
|
||||
auth_state: web::Data<AuthState>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
require_control(&req, auth_state.get_ref())?;
|
||||
match book(&logbook).import_adi(&body) {
|
||||
Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)),
|
||||
Err(err) => Ok(HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({ "error": err.to_string() }))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/logbook/worked/{call}` — which bands and modes this station has been
|
||||
/// worked on.
|
||||
#[get("/api/logbook/worked/{call}")]
|
||||
pub async fn worked_before(
|
||||
path: web::Path<String>,
|
||||
logbook: web::Data<Arc<Logbook>>,
|
||||
) -> impl Responder {
|
||||
let call = path.into_inner();
|
||||
let worked: Vec<serde_json::Value> = book(&logbook)
|
||||
.worked_before(&call)
|
||||
.into_iter()
|
||||
.map(|(band, mode)| serde_json::json!({ "band": band, "mode": mode }))
|
||||
.collect();
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"call": trx_logbook::qso::normalize_call(&call),
|
||||
"worked": worked,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `GET /api/logbook/now` — the clock a contact is stamped from.
|
||||
///
|
||||
/// The panel shows the operator when their browser disagrees with it, rather
|
||||
/// than logging a time nobody expected.
|
||||
#[get("/api/logbook/now")]
|
||||
pub async fn server_now() -> impl Responder {
|
||||
let now = Utc::now();
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"utc": now.to_rfc3339(),
|
||||
"epoch_ms": now.timestamp_millis(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `GET /api/logbook/prefill` — the six fields an entry opens with.
|
||||
///
|
||||
/// Worked out on the server because that is where the rig state, the station
|
||||
/// identity and the clock all are; the panel would otherwise have to assemble
|
||||
/// them from three places and would still not have the clock.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PrefillQuery {
|
||||
/// The rig the entry is for.
|
||||
#[serde(default)]
|
||||
pub remote: Option<String>,
|
||||
/// The decoder a decode row came from, which answers for the mode when the
|
||||
/// rig only says `DIG`.
|
||||
#[serde(default)]
|
||||
pub decoder: Option<String>,
|
||||
/// A callsign and locator carried over from a decode row.
|
||||
#[serde(default)]
|
||||
pub call: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gridsquare: Option<String>,
|
||||
}
|
||||
|
||||
#[get("/api/logbook/prefill")]
|
||||
pub async fn prefill(
|
||||
query: web::Query<PrefillQuery>,
|
||||
context: web::Data<Arc<trx_frontend::FrontendRuntimeContext>>,
|
||||
state: web::Data<tokio::sync::watch::Receiver<trx_core::RigState>>,
|
||||
) -> impl Responder {
|
||||
let query = query.into_inner();
|
||||
let rig_id = query
|
||||
.remote
|
||||
.clone()
|
||||
.filter(|id| !id.is_empty())
|
||||
.or_else(|| active_rig_id_from_context(context.get_ref()));
|
||||
let rig_state = rig_id
|
||||
.as_deref()
|
||||
.and_then(|id| context.rig_state_rx(id))
|
||||
.unwrap_or_else(|| state.get_ref().clone())
|
||||
.borrow()
|
||||
.clone();
|
||||
|
||||
let freq_hz = rig_state.status.freq.hz;
|
||||
let rig_mode = format!("{:?}", rig_state.status.mode);
|
||||
// The rig cannot answer for DIG: a rig in DIG is in FT8 or FT4 depending on
|
||||
// which decoder is running, so the decoder is asked first when there is one.
|
||||
let (mode, submode) = query
|
||||
.decoder
|
||||
.as_deref()
|
||||
.and_then(mode_for_decoder)
|
||||
.or_else(|| adif_mode_for_rig_mode(&rig_mode))
|
||||
.map_or((String::new(), None), |(mode, submode)| {
|
||||
(mode.to_string(), submode.map(str::to_string))
|
||||
});
|
||||
|
||||
let now = Utc::now();
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"started_at": now.to_rfc3339(),
|
||||
"epoch_ms": now.timestamp_millis(),
|
||||
"freq_hz": freq_hz,
|
||||
"band": band_for_hz(freq_hz),
|
||||
"mode": mode,
|
||||
"submode": submode,
|
||||
"rig_id": rig_id,
|
||||
"my_rig": rig_id.as_deref().map(|id| rig_display_name(context.get_ref(), id)),
|
||||
"call": query.call.map(|c| trx_logbook::qso::normalize_call(&c)),
|
||||
"gridsquare": query.gridsquare.map(|g| g.trim().to_uppercase()),
|
||||
}))
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
mod assets;
|
||||
mod bookmarks;
|
||||
mod decoder;
|
||||
mod logbook;
|
||||
pub mod recorder;
|
||||
pub mod rig;
|
||||
mod sse;
|
||||
@@ -308,35 +307,10 @@ where
|
||||
}
|
||||
|
||||
/// Pre-compressed (gzip + brotli) + ETag-aware response for immutable embedded assets.
|
||||
/// How long a browser may hold an asset before asking about it again.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum AssetCaching {
|
||||
/// The name carries a hash of the bytes, so a change is a new URL and the
|
||||
/// old one can be kept forever.
|
||||
Immutable,
|
||||
/// Served from the same URL for the life of the deployment, with different
|
||||
/// bytes after an upgrade: index.html, the stylesheets, the entry bundles.
|
||||
/// These must be revalidated, or a browser that visited before the upgrade
|
||||
/// keeps running the old page — for a year, with the ETag never consulted,
|
||||
/// which is how a fixed layout stays broken in one browser and not another.
|
||||
/// The ETag makes the revalidation a 304 in the usual case.
|
||||
Revalidate,
|
||||
}
|
||||
|
||||
impl AssetCaching {
|
||||
pub(crate) fn header_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Immutable => "public, max-age=31536000, immutable",
|
||||
Self::Revalidate => "no-cache",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn static_asset_response(
|
||||
req: &HttpRequest,
|
||||
content_type: &'static str,
|
||||
entry: &GzCacheEntry,
|
||||
caching: AssetCaching,
|
||||
) -> HttpResponse {
|
||||
let etag = &entry.etag;
|
||||
// Check If-None-Match for conditional GET.
|
||||
@@ -345,7 +319,7 @@ fn static_asset_response(
|
||||
if val == etag || val == "*" {
|
||||
return HttpResponse::NotModified()
|
||||
.insert_header((header::ETAG, etag.to_owned()))
|
||||
.insert_header((header::CACHE_CONTROL, caching.header_value()))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=31536000, immutable"))
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
@@ -365,7 +339,7 @@ fn static_asset_response(
|
||||
.insert_header((header::CONTENT_TYPE, content_type))
|
||||
.insert_header((header::CONTENT_ENCODING, encoding))
|
||||
.insert_header((header::ETAG, etag.to_owned()))
|
||||
.insert_header((header::CACHE_CONTROL, caching.header_value()))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=31536000, immutable"))
|
||||
.body(Bytes::copy_from_slice(body))
|
||||
}
|
||||
|
||||
@@ -617,7 +591,6 @@ 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)
|
||||
@@ -629,7 +602,6 @@ 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)
|
||||
@@ -665,8 +637,6 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(assets::recorder_index)
|
||||
.service(assets::settings_index)
|
||||
.service(assets::about_index)
|
||||
.service(assets::logbook_index)
|
||||
.service(assets::satellites_index)
|
||||
.service(assets::statistics_index)
|
||||
.service(assets::bookmarks_index)
|
||||
.service(assets::favicon)
|
||||
@@ -675,7 +645,6 @@ 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)
|
||||
@@ -708,17 +677,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
// Auth endpoints
|
||||
.service(crate::server::auth::login)
|
||||
.service(crate::server::auth::logout)
|
||||
.service(crate::server::auth::session_status)
|
||||
// Logbook
|
||||
.service(logbook::list_qsos)
|
||||
.service(logbook::add_qso)
|
||||
.service(logbook::edit_qso)
|
||||
.service(logbook::delete_qso)
|
||||
.service(logbook::export_adi)
|
||||
.service(logbook::import_adi)
|
||||
.service(logbook::worked_before)
|
||||
.service(logbook::server_now)
|
||||
.service(logbook::prefill);
|
||||
.service(crate::server::auth::session_status);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -877,243 +836,6 @@ mod tests {
|
||||
// Endpoint tests using actix_web::test
|
||||
// ======================================================================
|
||||
|
||||
/// The page and the bundles it pulls in are served from the same URLs after
|
||||
/// every upgrade, so the browser has to ask whether they changed. Served
|
||||
/// as immutable for a year, a browser that visited once kept the old page
|
||||
/// and never saw a fix again -- which is how a corrected layout stays
|
||||
/// broken in one browser while every other one has it.
|
||||
#[actix_web::test]
|
||||
async fn documents_and_entry_bundles_are_revalidated_not_frozen() {
|
||||
let app = actix_test::init_service(
|
||||
App::new()
|
||||
.service(assets::index)
|
||||
.service(assets::style_css)
|
||||
.service(assets::generated_asset),
|
||||
)
|
||||
.await;
|
||||
|
||||
for path in ["/", "/style.css", "/app.js"] {
|
||||
let req = actix_test::TestRequest::get().uri(path).to_request();
|
||||
let resp = actix_test::call_service(&app, req).await;
|
||||
assert_eq!(resp.status(), 200, "{path} should be served");
|
||||
let cache_control = resp
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
assert_eq!(cache_control, "no-cache", "{path} must be revalidated");
|
||||
assert!(
|
||||
resp.headers().contains_key(header::ETAG),
|
||||
"{path} needs an ETag, or revalidating costs a full download"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Revalidation has to stay cheap: an unchanged asset answers 304, and the
|
||||
/// policy on that answer matches the one on the body.
|
||||
#[actix_web::test]
|
||||
async fn an_unchanged_document_answers_not_modified() {
|
||||
let app = actix_test::init_service(App::new().service(assets::style_css)).await;
|
||||
|
||||
let first = actix_test::call_service(
|
||||
&app,
|
||||
actix_test::TestRequest::get()
|
||||
.uri("/style.css")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
let etag = first
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("style.css carries an ETag")
|
||||
.to_owned();
|
||||
|
||||
let conditional = actix_test::call_service(
|
||||
&app,
|
||||
actix_test::TestRequest::get()
|
||||
.uri("/style.css")
|
||||
.insert_header((header::IF_NONE_MATCH, etag))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(conditional.status(), 304);
|
||||
assert_eq!(
|
||||
conditional
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no-cache")
|
||||
);
|
||||
}
|
||||
|
||||
/// Auth off: every session may write, as a station with no passphrase set.
|
||||
fn auth_state_disabled() -> crate::server::auth::AuthState {
|
||||
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
std::time::Duration::from_secs(3600),
|
||||
false,
|
||||
crate::server::auth::SameSite::Lax,
|
||||
))
|
||||
}
|
||||
|
||||
/// Auth on with no session presented, which is what a listener is.
|
||||
fn auth_state_locked() -> crate::server::auth::AuthState {
|
||||
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
|
||||
true,
|
||||
Some("listen".to_string()),
|
||||
Some("control".to_string()),
|
||||
false,
|
||||
std::time::Duration::from_secs(3600),
|
||||
false,
|
||||
crate::server::auth::SameSite::Lax,
|
||||
))
|
||||
}
|
||||
|
||||
/// A contact written over HTTP comes back out of the log, and out of an
|
||||
/// ADIF export that another logger could read.
|
||||
#[actix_web::test]
|
||||
async fn a_contact_written_over_http_is_kept_and_exported() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let logbook = std::sync::Arc::new(
|
||||
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
|
||||
);
|
||||
let app = actix_test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(logbook.clone()))
|
||||
.app_data(web::Data::new(auth_state_disabled()))
|
||||
.service(logbook::add_qso)
|
||||
.service(logbook::list_qsos)
|
||||
.service(logbook::export_adi)
|
||||
.service(logbook::worked_before),
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = actix_test::call_service(
|
||||
&app,
|
||||
actix_test::TestRequest::post()
|
||||
.uri("/api/logbook")
|
||||
.set_json(serde_json::json!({
|
||||
"call": "sp2sjg",
|
||||
"freq_hz": 14_074_000_u64,
|
||||
"mode": "FT8",
|
||||
"rst_sent": "-07",
|
||||
"gridsquare": "jo94",
|
||||
}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
let written: serde_json::Value = actix_test::read_body_json(response).await;
|
||||
// Normalised on the way in, and the band worked out from the dial.
|
||||
assert_eq!(written["call"], "SP2SJG");
|
||||
assert_eq!(written["gridsquare"], "JO94");
|
||||
assert_eq!(written["band"], "20m");
|
||||
|
||||
let listed: serde_json::Value = actix_test::call_and_read_body_json(
|
||||
&app,
|
||||
actix_test::TestRequest::get()
|
||||
.uri("/api/logbook")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(listed["total"], 1);
|
||||
assert_eq!(listed["qsos"][0]["call"], "SP2SJG");
|
||||
|
||||
let worked: serde_json::Value = actix_test::call_and_read_body_json(
|
||||
&app,
|
||||
actix_test::TestRequest::get()
|
||||
.uri("/api/logbook/worked/SP2SJG")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(worked["worked"][0]["band"], "20m");
|
||||
|
||||
let exported = actix_test::call_and_read_body(
|
||||
&app,
|
||||
actix_test::TestRequest::get()
|
||||
.uri("/api/logbook/export.adi")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
let text = String::from_utf8_lossy(&exported);
|
||||
assert!(text.contains("<CALL:6>SP2SJG"), "{text}");
|
||||
assert!(text.contains("<BAND:3>20m"), "{text}");
|
||||
assert!(text.contains("<EOR>"), "{text}");
|
||||
}
|
||||
|
||||
/// A read-only session may read the log and may not write to it.
|
||||
#[actix_web::test]
|
||||
async fn a_read_only_session_cannot_write_to_the_log() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let logbook = std::sync::Arc::new(
|
||||
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
|
||||
);
|
||||
let app = actix_test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(logbook))
|
||||
.app_data(web::Data::new(auth_state_locked()))
|
||||
.service(logbook::add_qso)
|
||||
.service(logbook::list_qsos),
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = actix_test::call_service(
|
||||
&app,
|
||||
actix_test::TestRequest::post()
|
||||
.uri("/api/logbook")
|
||||
.set_json(serde_json::json!({
|
||||
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
|
||||
}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
response.status().is_client_error(),
|
||||
"a listener wrote to the log: {}",
|
||||
response.status()
|
||||
);
|
||||
let listed: serde_json::Value = actix_test::call_and_read_body_json(
|
||||
&app,
|
||||
actix_test::TestRequest::get()
|
||||
.uri("/api/logbook")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(listed["total"], 0);
|
||||
}
|
||||
|
||||
/// A contact needs a callsign; the panel is not the only thing that has to
|
||||
/// insist on it.
|
||||
#[actix_web::test]
|
||||
async fn a_contact_without_a_callsign_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let logbook = std::sync::Arc::new(
|
||||
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
|
||||
);
|
||||
let app = actix_test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(logbook))
|
||||
.app_data(web::Data::new(auth_state_disabled()))
|
||||
.service(logbook::add_qso),
|
||||
)
|
||||
.await;
|
||||
let response = actix_test::call_service(
|
||||
&app,
|
||||
actix_test::TestRequest::post()
|
||||
.uri("/api/logbook")
|
||||
.set_json(serde_json::json!({
|
||||
"call": " ", "freq_hz": 14_074_000_u64, "mode": "FT8",
|
||||
}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 400);
|
||||
}
|
||||
|
||||
/// GET /status returns 200 with valid JSON containing rig snapshot fields.
|
||||
#[actix_web::test]
|
||||
async fn test_status_endpoint_returns_json() {
|
||||
|
||||
@@ -24,8 +24,8 @@ use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use trx_core::decode::{
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
|
||||
WefaxMessage, WsprMessage,
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
||||
WsprMessage,
|
||||
};
|
||||
use trx_frontend::FrontendRuntimeContext;
|
||||
|
||||
@@ -335,43 +335,6 @@ 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 {
|
||||
@@ -563,31 +526,6 @@ 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
|
||||
@@ -712,9 +650,6 @@ 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(_) => {}
|
||||
},
|
||||
|
||||
@@ -106,39 +106,6 @@ async fn serve(
|
||||
activity_log_map.clone(),
|
||||
);
|
||||
|
||||
// The log is opened once and shared: it is one station's log, not one per
|
||||
// rig or per session.
|
||||
let logbook_path = context
|
||||
.http_ui
|
||||
.logbook_path
|
||||
.clone()
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(trx_logbook::default_log_path);
|
||||
let logbook = match trx_logbook::Logbook::open(&logbook_path) {
|
||||
Ok(logbook) => {
|
||||
tracing::info!(
|
||||
"Logbook: {} contact(s) in {}",
|
||||
logbook.count(),
|
||||
logbook_path.display()
|
||||
);
|
||||
Arc::new(logbook)
|
||||
}
|
||||
Err(err) => {
|
||||
// A log that cannot be opened must not stop the radio working, but
|
||||
// it must be said loudly: contacts made now will not be kept.
|
||||
tracing::error!(
|
||||
"Logbook at {} could not be opened ({err}); logging is unavailable",
|
||||
logbook_path.display()
|
||||
);
|
||||
Arc::new(
|
||||
trx_logbook::Logbook::open(std::path::Path::new(
|
||||
&std::env::temp_dir().join("trx-rs-logbook.jsonl"),
|
||||
))
|
||||
.unwrap_or_else(|e| panic!("logbook fallback in the temp directory failed: {e}")),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let background_decode_path = BackgroundDecodeStore::default_path();
|
||||
let background_decode_store = Arc::new(BackgroundDecodeStore::open(&background_decode_path));
|
||||
let vchan_mgr = Arc::new(ClientChannelManager::new(
|
||||
@@ -197,7 +164,6 @@ async fn serve(
|
||||
session_rig_mgr,
|
||||
background_decode_mgr,
|
||||
recorder_mgr,
|
||||
logbook,
|
||||
)?;
|
||||
let handle = server.handle();
|
||||
tokio::spawn(async move {
|
||||
@@ -226,7 +192,6 @@ fn build_server(
|
||||
session_rig_mgr: Arc<api::SessionRigManager>,
|
||||
background_decode_mgr: Arc<BackgroundDecodeManager>,
|
||||
recorder_mgr: Arc<RecorderManager>,
|
||||
logbook: Arc<trx_logbook::Logbook>,
|
||||
) -> Result<Server, actix_web::Error> {
|
||||
let state_data = web::Data::new(state_rx);
|
||||
let rig_tx = web::Data::new(rig_tx);
|
||||
@@ -244,7 +209,6 @@ fn build_server(
|
||||
let session_rig_mgr = web::Data::new(session_rig_mgr);
|
||||
let background_decode_mgr = web::Data::new(background_decode_mgr);
|
||||
let recorder_mgr = web::Data::new(recorder_mgr);
|
||||
let logbook = web::Data::new(logbook);
|
||||
|
||||
// Extract auth config values before moving context
|
||||
let same_site = match context.http_auth.cookie_same_site.as_str() {
|
||||
@@ -303,7 +267,6 @@ fn build_server(
|
||||
.app_data(session_rig_mgr.clone())
|
||||
.app_data(background_decode_mgr.clone())
|
||||
.app_data(recorder_mgr.clone())
|
||||
.app_data(logbook.clone())
|
||||
.wrap(Compress::default())
|
||||
.wrap(
|
||||
DefaultHeaders::new()
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-logbook"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
dirs = "6"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -1,414 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! ADIF, the format every other logger reads.
|
||||
//!
|
||||
//! ADI is a tagged text format: `<FIELD:length>value`, where the length counts
|
||||
//! the bytes of the value that follows, records end at `<EOR>`, an optional
|
||||
//! header ends at `<EOH>`, and anything outside a tag is ignored — which is how
|
||||
//! files carry human notes between records.
|
||||
//!
|
||||
//! The reader is deliberately forgiving. Files in the wild have lowercase tags,
|
||||
//! CRLF line endings, no header at all, type indicators on some fields and not
|
||||
//! others, and fields this application has never heard of. None of that is a
|
||||
//! reason to refuse someone's log. The writer, by contrast, is strict: what
|
||||
//! leaves here has to be read by software that is not.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::qso::{
|
||||
adif_date, adif_time, freq_mhz_string, hz_from_mhz_string, normalize_call, parse_adif_datetime,
|
||||
Qso,
|
||||
};
|
||||
|
||||
/// Fields the [`Qso`] models itself. Everything else an import carries is kept
|
||||
/// in `extra` under its own name, so a round trip does not strip it.
|
||||
const MODELLED: &[&str] = &[
|
||||
"CALL",
|
||||
"QSO_DATE",
|
||||
"TIME_ON",
|
||||
"QSO_DATE_OFF",
|
||||
"TIME_OFF",
|
||||
"FREQ",
|
||||
"BAND",
|
||||
"MODE",
|
||||
"SUBMODE",
|
||||
"RST_SENT",
|
||||
"RST_RCVD",
|
||||
"GRIDSQUARE",
|
||||
"NAME",
|
||||
"QTH",
|
||||
"COMMENT",
|
||||
"TX_PWR",
|
||||
"STATION_CALLSIGN",
|
||||
"OPERATOR",
|
||||
"MY_GRIDSQUARE",
|
||||
"MY_RIG",
|
||||
];
|
||||
|
||||
/// What a file gave up, and what it could not.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ParseReport {
|
||||
/// Records that became a QSO.
|
||||
pub qsos: Vec<Qso>,
|
||||
/// Records that did not, and why — one line each, for showing the operator.
|
||||
pub rejected: Vec<String>,
|
||||
}
|
||||
|
||||
/// One `<NAME:LEN[:TYPE]>value` field.
|
||||
struct Field {
|
||||
name: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
/// Reads the next tag starting at `from`, returning it and where it ended.
|
||||
///
|
||||
/// Returns `None` at the end of input or on a tag that never closes, which is
|
||||
/// how a truncated file stops rather than looping.
|
||||
fn next_field(bytes: &[u8], from: usize) -> Option<(Field, usize)> {
|
||||
let open = bytes[from..].iter().position(|b| *b == b'<')? + from;
|
||||
let close = bytes[open..].iter().position(|b| *b == b'>')? + open;
|
||||
let spec = String::from_utf8_lossy(&bytes[open + 1..close]).to_string();
|
||||
let mut parts = spec.split(':');
|
||||
let name = parts.next().unwrap_or_default().trim().to_uppercase();
|
||||
|
||||
// <EOR> and <EOH> carry no length and no value.
|
||||
let Some(length_text) = parts.next() else {
|
||||
return Some((
|
||||
Field {
|
||||
name,
|
||||
value: String::new(),
|
||||
},
|
||||
close + 1,
|
||||
));
|
||||
};
|
||||
let length: usize = length_text.trim().parse().unwrap_or(0);
|
||||
let value_start = close + 1;
|
||||
// A length that runs off the end is a truncated file, not a reason to lose
|
||||
// everything before it: take what is there.
|
||||
let value_end = value_start.saturating_add(length).min(bytes.len());
|
||||
let value = String::from_utf8_lossy(&bytes[value_start..value_end]).to_string();
|
||||
Some((Field { name, value }, value_end))
|
||||
}
|
||||
|
||||
/// Read an ADI file.
|
||||
///
|
||||
/// Never fails as a whole: a record that cannot be made into a QSO is reported
|
||||
/// on [`ParseReport::rejected`] and the rest of the file is still read.
|
||||
pub fn parse_adi(input: &[u8]) -> ParseReport {
|
||||
let mut report = ParseReport::default();
|
||||
let mut position = 0usize;
|
||||
let mut fields: Vec<Field> = Vec::new();
|
||||
let mut in_header = looks_like_header(input);
|
||||
let mut record_number = 0usize;
|
||||
|
||||
while let Some((field, next)) = next_field(input, position) {
|
||||
position = next;
|
||||
match field.name.as_str() {
|
||||
"EOH" => {
|
||||
in_header = false;
|
||||
fields.clear();
|
||||
}
|
||||
"EOR" => {
|
||||
record_number += 1;
|
||||
if in_header {
|
||||
// A file with no <EOH> but an <EOR>: the header guess was
|
||||
// wrong, so treat what we have as a record.
|
||||
in_header = false;
|
||||
}
|
||||
match qso_from_fields(&fields) {
|
||||
Ok(qso) => report.qsos.push(qso),
|
||||
Err(reason) => report
|
||||
.rejected
|
||||
.push(format!("record {record_number}: {reason}")),
|
||||
}
|
||||
fields.clear();
|
||||
}
|
||||
_ => fields.push(field),
|
||||
}
|
||||
}
|
||||
|
||||
// A last record without its <EOR> is still a record someone wrote.
|
||||
if !fields.is_empty() && !in_header {
|
||||
record_number += 1;
|
||||
match qso_from_fields(&fields) {
|
||||
Ok(qso) => report.qsos.push(qso),
|
||||
Err(reason) => report
|
||||
.rejected
|
||||
.push(format!("record {record_number}: {reason}")),
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Whether the file opens with a header, i.e. anything before the first tag.
|
||||
///
|
||||
/// ADIF says a file whose first character is not `<` has a header. Plenty of
|
||||
/// exporters write no header at all, so the first `<EOR>` un-guesses this.
|
||||
fn looks_like_header(input: &[u8]) -> bool {
|
||||
match input.iter().position(|b| !b.is_ascii_whitespace()) {
|
||||
Some(first) => input[first] != b'<',
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn find<'a>(fields: &'a [Field], name: &str) -> Option<&'a str> {
|
||||
fields
|
||||
.iter()
|
||||
.find(|f| f.name == name)
|
||||
.map(|f| f.value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn qso_from_fields(fields: &[Field]) -> Result<Qso, String> {
|
||||
let call = find(fields, "CALL").ok_or("no CALL")?;
|
||||
let date = find(fields, "QSO_DATE").ok_or("no QSO_DATE")?;
|
||||
let started_at = parse_adif_datetime(date, find(fields, "TIME_ON"))
|
||||
.ok_or("QSO_DATE or TIME_ON unreadable")?;
|
||||
|
||||
// FREQ is authoritative; BAND is the fallback for logs that record only the
|
||||
// band, and its midpoint is the honest answer for "where in the band".
|
||||
let freq_hz = match find(fields, "FREQ").and_then(hz_from_mhz_string) {
|
||||
Some(hz) => hz,
|
||||
None => find(fields, "BAND")
|
||||
.and_then(band_midpoint_hz)
|
||||
.ok_or("neither FREQ nor a known BAND")?,
|
||||
};
|
||||
let mode = find(fields, "MODE").unwrap_or("SSB").to_uppercase();
|
||||
|
||||
let ended_at = find(fields, "TIME_OFF").and_then(|time| {
|
||||
let off_date = find(fields, "QSO_DATE_OFF").unwrap_or(date);
|
||||
parse_adif_datetime(off_date, Some(time))
|
||||
});
|
||||
|
||||
let mut extra = BTreeMap::new();
|
||||
for field in fields {
|
||||
if MODELLED.contains(&field.name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if field.value.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
extra.insert(field.name.clone(), field.value.trim().to_string());
|
||||
}
|
||||
|
||||
Ok(Qso {
|
||||
id: crate::new_id(),
|
||||
started_at,
|
||||
ended_at,
|
||||
call: normalize_call(call),
|
||||
freq_hz,
|
||||
mode,
|
||||
submode: find(fields, "SUBMODE").map(|s| s.to_uppercase()),
|
||||
rst_sent: find(fields, "RST_SENT").map(str::to_string),
|
||||
rst_rcvd: find(fields, "RST_RCVD").map(str::to_string),
|
||||
gridsquare: find(fields, "GRIDSQUARE").map(|g| g.to_uppercase()),
|
||||
name: find(fields, "NAME").map(str::to_string),
|
||||
qth: find(fields, "QTH").map(str::to_string),
|
||||
comment: find(fields, "COMMENT").map(str::to_string),
|
||||
tx_pwr_w: find(fields, "TX_PWR").and_then(|p| p.parse().ok()),
|
||||
station_callsign: find(fields, "STATION_CALLSIGN").map(normalize_call),
|
||||
operator: find(fields, "OPERATOR").map(normalize_call),
|
||||
my_gridsquare: find(fields, "MY_GRIDSQUARE").map(|g| g.to_uppercase()),
|
||||
my_rig: find(fields, "MY_RIG").map(str::to_string),
|
||||
rig_id: None,
|
||||
extra,
|
||||
})
|
||||
}
|
||||
|
||||
/// The middle of a named band, for logs that recorded a band and no frequency.
|
||||
fn band_midpoint_hz(band: &str) -> Option<u64> {
|
||||
crate::qso::band_midpoint_for_name(band)
|
||||
}
|
||||
|
||||
fn write_field(out: &mut String, name: &str, value: &str) {
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
out.push_str(&format!("<{}:{}>{}", name, value.len(), value));
|
||||
}
|
||||
|
||||
/// Write QSOs as an ADI file, header and all.
|
||||
pub fn write_adi(qsos: &[Qso], program_version: &str) -> String {
|
||||
let mut out = String::with_capacity(qsos.len() * 256 + 256);
|
||||
let now = Utc::now();
|
||||
out.push_str("ADIF export from trx-rs\n");
|
||||
write_field(&mut out, "ADIF_VER", "3.1.4");
|
||||
write_field(&mut out, "PROGRAMID", "trx-rs");
|
||||
write_field(&mut out, "PROGRAMVERSION", program_version);
|
||||
write_field(
|
||||
&mut out,
|
||||
"CREATED_TIMESTAMP",
|
||||
&format!("{} {}", adif_date(&now), adif_time(&now)),
|
||||
);
|
||||
out.push_str("<EOH>\n");
|
||||
|
||||
for qso in qsos {
|
||||
write_field(&mut out, "CALL", &qso.call);
|
||||
write_field(&mut out, "QSO_DATE", &adif_date(&qso.started_at));
|
||||
write_field(&mut out, "TIME_ON", &adif_time(&qso.started_at));
|
||||
if let Some(ended) = qso.ended_at {
|
||||
write_field(&mut out, "QSO_DATE_OFF", &adif_date(&ended));
|
||||
write_field(&mut out, "TIME_OFF", &adif_time(&ended));
|
||||
}
|
||||
write_field(&mut out, "FREQ", &freq_mhz_string(qso.freq_hz));
|
||||
if let Some(band) = qso.band() {
|
||||
write_field(&mut out, "BAND", band);
|
||||
}
|
||||
write_field(&mut out, "MODE", &qso.mode);
|
||||
if let Some(submode) = &qso.submode {
|
||||
write_field(&mut out, "SUBMODE", submode);
|
||||
}
|
||||
for (name, value) in [
|
||||
("RST_SENT", &qso.rst_sent),
|
||||
("RST_RCVD", &qso.rst_rcvd),
|
||||
("GRIDSQUARE", &qso.gridsquare),
|
||||
("NAME", &qso.name),
|
||||
("QTH", &qso.qth),
|
||||
("COMMENT", &qso.comment),
|
||||
("STATION_CALLSIGN", &qso.station_callsign),
|
||||
("OPERATOR", &qso.operator),
|
||||
("MY_GRIDSQUARE", &qso.my_gridsquare),
|
||||
("MY_RIG", &qso.my_rig),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
write_field(&mut out, name, value);
|
||||
}
|
||||
}
|
||||
if let Some(power) = qso.tx_pwr_w {
|
||||
write_field(&mut out, "TX_PWR", &format!("{power}"));
|
||||
}
|
||||
// Whatever another logger wrote and this one does not model leaves as
|
||||
// it arrived.
|
||||
for (name, value) in &qso.extra {
|
||||
write_field(&mut out, name, value);
|
||||
}
|
||||
out.push_str("<EOR>\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_written_log_reads_back_the_same() {
|
||||
let mut qso = Qso::new(
|
||||
"id-1".into(),
|
||||
parse_adif_datetime("20260807", Some("121530")).expect("time"),
|
||||
"sp2sjg",
|
||||
14_074_000,
|
||||
"FT8",
|
||||
);
|
||||
qso.rst_sent = Some("-07".into());
|
||||
qso.rst_rcvd = Some("-12".into());
|
||||
qso.gridsquare = Some("JO94".into());
|
||||
qso.station_callsign = Some("SP0TRX".into());
|
||||
qso.operator = Some("SP0TRX".into());
|
||||
qso.my_gridsquare = Some("JO91".into());
|
||||
qso.my_rig = Some("Shack SDR".into());
|
||||
|
||||
let text = write_adi(&[qso.clone()], "0.1.0");
|
||||
let report = parse_adi(text.as_bytes());
|
||||
assert!(report.rejected.is_empty(), "{:?}", report.rejected);
|
||||
assert_eq!(report.qsos.len(), 1);
|
||||
let back = &report.qsos[0];
|
||||
|
||||
assert_eq!(back.call, "SP2SJG");
|
||||
assert_eq!(back.freq_hz, qso.freq_hz);
|
||||
assert_eq!(back.mode, "FT8");
|
||||
assert_eq!(back.started_at, qso.started_at);
|
||||
assert_eq!(back.rst_sent, qso.rst_sent);
|
||||
assert_eq!(back.gridsquare, qso.gridsquare);
|
||||
assert_eq!(back.my_rig, qso.my_rig);
|
||||
}
|
||||
|
||||
/// Files from other loggers are irregular in ways that are not errors.
|
||||
#[test]
|
||||
fn a_file_from_another_logger_is_read_as_it_comes() {
|
||||
let text = concat!(
|
||||
"Generated by SomeLogger 4.2\r\n",
|
||||
"<adif_ver:5>3.1.0<programid:10>SomeLogger<eoh>\r\n",
|
||||
// lowercase tags, CRLF, a type indicator, no seconds on the time
|
||||
"<call:6>W1AW/4<qso_date:8>20260101<time_on:4>1200<band:3>20m",
|
||||
"<mode:3>SSB<rst_sent:2>59<rst_rcvd:2>59<my_antenna:6>Dipole<eor>\r\n",
|
||||
// a record with the fields in another order and a full frequency
|
||||
"<qso_date:8>20260102<time_on:6>235959<call:5>DL1AB<freq:6>7.0301",
|
||||
"<mode:2>CW<eor>\r\n",
|
||||
);
|
||||
let report = parse_adi(text.as_bytes());
|
||||
assert!(report.rejected.is_empty(), "{:?}", report.rejected);
|
||||
assert_eq!(report.qsos.len(), 2);
|
||||
|
||||
let first = &report.qsos[0];
|
||||
assert_eq!(first.call, "W1AW/4");
|
||||
assert_eq!(first.mode, "SSB");
|
||||
// No FREQ, so the band gives the frequency, and the band comes back.
|
||||
assert_eq!(first.band(), Some("20m"));
|
||||
// A field we do not model is kept, and comes back out on export.
|
||||
assert_eq!(
|
||||
first.extra.get("MY_ANTENNA").map(String::as_str),
|
||||
Some("Dipole")
|
||||
);
|
||||
let exported = write_adi(&report.qsos, "0.1.0");
|
||||
assert!(exported.contains("<MY_ANTENNA:6>Dipole"), "{exported}");
|
||||
|
||||
let second = &report.qsos[1];
|
||||
assert_eq!(second.call, "DL1AB");
|
||||
assert_eq!(second.freq_hz, 7_030_100);
|
||||
assert_eq!(adif_time(&second.started_at), "235959");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_that_cannot_be_a_qso_is_reported_and_the_rest_still_read() {
|
||||
let text = concat!(
|
||||
"<call:5>NOCAL<eor>", // no date
|
||||
"<qso_date:8>20260101<time_on:4>1200<mode:3>SSB<eor>", // no call
|
||||
"<call:5>SP1AB<qso_date:8>20260101<time_on:4>1200<freq:6>14.074<mode:3>FT8<eor>",
|
||||
);
|
||||
let report = parse_adi(text.as_bytes());
|
||||
assert_eq!(report.qsos.len(), 1);
|
||||
assert_eq!(report.qsos[0].call, "SP1AB");
|
||||
assert_eq!(report.rejected.len(), 2);
|
||||
assert!(
|
||||
report.rejected[0].contains("QSO_DATE"),
|
||||
"{:?}",
|
||||
report.rejected
|
||||
);
|
||||
assert!(report.rejected[1].contains("CALL"), "{:?}", report.rejected);
|
||||
}
|
||||
|
||||
/// The declared length is the length, even when the value looks longer:
|
||||
/// ADIF values may contain `<`, so the count is the only thing that can be
|
||||
/// trusted to end them.
|
||||
#[test]
|
||||
fn the_declared_length_wins_over_what_the_value_looks_like() {
|
||||
let text = "<call:5>SP1AB<qso_date:8>20260101<time_on:4>1200<freq:6>14.074<mode:3>FT8\
|
||||
<comment:11>see <notes><eor>";
|
||||
let report = parse_adi(text.as_bytes());
|
||||
assert_eq!(report.qsos.len(), 1);
|
||||
assert_eq!(report.qsos[0].comment.as_deref(), Some("see <notes>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_file_gives_up_what_it_has() {
|
||||
// The length says 20 bytes of callsign; the file ends after five.
|
||||
let text = "<call:20>SP1AB";
|
||||
let report = parse_adi(text.as_bytes());
|
||||
// No date, so it is rejected -- but the parser returned rather than
|
||||
// running off the end or looping.
|
||||
assert_eq!(report.qsos.len(), 0);
|
||||
assert_eq!(report.rejected.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_no_header_is_still_a_file() {
|
||||
let text = "<call:5>SP1AB<qso_date:8>20260101<time_on:4>1200<freq:6>14.074<mode:3>FT8<eor>";
|
||||
let report = parse_adi(text.as_bytes());
|
||||
assert_eq!(report.qsos.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Telling one contact from two.
|
||||
//!
|
||||
//! Two programs rarely stamp the same QSO to the same minute — one records when
|
||||
//! the contact started, another when the operator finished typing — so matching
|
||||
//! on an exact time duplicates half of what an import is asked to merge. The
|
||||
//! key is therefore the callsign, the band and the mode, with the time compared
|
||||
//! as a window.
|
||||
//!
|
||||
//! Two minutes is the window. It is wide enough for the disagreement between
|
||||
//! loggers and narrow enough to keep a legitimate re-work: contest rules forbid
|
||||
//! a second contact with the same station on the same band and mode, so a
|
||||
//! repeat inside two minutes is the same contact written twice, while the same
|
||||
//! station on another band an hour later is a different QSO and keys differently
|
||||
//! anyway.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Duration;
|
||||
|
||||
use crate::qso::Qso;
|
||||
|
||||
/// How far apart two records of the same contact may be stamped.
|
||||
pub const MATCH_WINDOW_MINUTES: i64 = 2;
|
||||
|
||||
/// Modes as they are compared, not as they are written.
|
||||
///
|
||||
/// A log that stored `USB` and one that stored `SSB` describe the same contact;
|
||||
/// without this an import from either would duplicate every phone QSO in the
|
||||
/// other.
|
||||
fn normalized_mode(qso: &Qso) -> String {
|
||||
match qso.mode.trim().to_uppercase().as_str() {
|
||||
"USB" | "LSB" | "SSB" => "SSB".to_string(),
|
||||
"CWR" => "CW".to_string(),
|
||||
"SAM" => "AM".to_string(),
|
||||
"WFM" => "FM".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn key(qso: &Qso) -> (String, String, String) {
|
||||
(
|
||||
qso.call.clone(),
|
||||
qso.band().unwrap_or("").to_string(),
|
||||
normalized_mode(qso),
|
||||
)
|
||||
}
|
||||
|
||||
/// An index over an existing log, for asking whether an incoming record is
|
||||
/// already held.
|
||||
pub struct DuplicateIndex {
|
||||
by_key: HashMap<(String, String, String), Vec<chrono::DateTime<chrono::Utc>>>,
|
||||
}
|
||||
|
||||
impl DuplicateIndex {
|
||||
pub fn build(existing: &[Qso]) -> Self {
|
||||
let mut by_key: HashMap<_, Vec<_>> = HashMap::new();
|
||||
for qso in existing {
|
||||
by_key.entry(key(qso)).or_default().push(qso.started_at);
|
||||
}
|
||||
Self { by_key }
|
||||
}
|
||||
|
||||
/// Whether this contact is already in the log.
|
||||
pub fn contains(&self, candidate: &Qso) -> bool {
|
||||
let window = Duration::minutes(MATCH_WINDOW_MINUTES);
|
||||
self.by_key.get(&key(candidate)).is_some_and(|times| {
|
||||
times
|
||||
.iter()
|
||||
// Compared as instants, so a contact either side of
|
||||
// midnight matches rather than falling into two days.
|
||||
.any(|held| (*held - candidate.started_at).abs() <= window)
|
||||
})
|
||||
}
|
||||
|
||||
/// Remember a contact, so a file that repeats itself does not import twice.
|
||||
pub fn insert(&mut self, qso: &Qso) {
|
||||
self.by_key
|
||||
.entry(key(qso))
|
||||
.or_default()
|
||||
.push(qso.started_at);
|
||||
}
|
||||
}
|
||||
|
||||
/// Which bands and modes a callsign has been worked on.
|
||||
pub fn worked_before(existing: &[Qso], call: &str) -> Vec<(String, String)> {
|
||||
let wanted = crate::qso::normalize_call(call);
|
||||
let mut seen: Vec<(String, String)> = existing
|
||||
.iter()
|
||||
.filter(|qso| qso.call == wanted)
|
||||
.map(|qso| (qso.band().unwrap_or("").to_string(), normalized_mode(qso)))
|
||||
.collect();
|
||||
seen.sort();
|
||||
seen.dedup();
|
||||
seen
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::qso::parse_adif_datetime;
|
||||
|
||||
fn qso(call: &str, hz: u64, mode: &str, time: &str) -> Qso {
|
||||
Qso::new(
|
||||
format!("{call}-{time}"),
|
||||
parse_adif_datetime("20260807", Some(time)).expect("time"),
|
||||
call,
|
||||
hz,
|
||||
mode,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_contact_stamped_a_minute_apart_is_one_contact() {
|
||||
let held = vec![qso("SP1AA", 14_074_000, "FT8", "120000")];
|
||||
let index = DuplicateIndex::build(&held);
|
||||
assert!(index.contains(&qso("SP1AA", 14_074_000, "FT8", "120100")));
|
||||
assert!(index.contains(&qso("SP1AA", 14_074_000, "FT8", "115900")));
|
||||
// Beyond the window it is another contact.
|
||||
assert!(!index.contains(&qso("SP1AA", 14_074_000, "FT8", "120300")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phone_matches_however_the_other_logger_spelled_it() {
|
||||
let held = vec![qso("SP1AA", 14_200_000, "SSB", "120000")];
|
||||
let index = DuplicateIndex::build(&held);
|
||||
assert!(index.contains(&qso("SP1AA", 14_200_000, "USB", "120000")));
|
||||
assert!(index.contains(&qso("SP1AA", 14_200_000, "LSB", "120030")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_band_or_another_mode_is_another_contact() {
|
||||
let held = vec![qso("SP1AA", 14_074_000, "FT8", "120000")];
|
||||
let index = DuplicateIndex::build(&held);
|
||||
// Same station, same minute, 40 m: a band change during a QSO happens.
|
||||
assert!(!index.contains(&qso("SP1AA", 7_074_000, "FT8", "120000")));
|
||||
assert!(!index.contains(&qso("SP1AA", 14_074_000, "CW", "120000")));
|
||||
assert!(!index.contains(&qso("SP2BB", 14_074_000, "FT8", "120000")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_contact_either_side_of_midnight_still_matches() {
|
||||
let before = Qso::new(
|
||||
"1".into(),
|
||||
parse_adif_datetime("20260807", Some("235930")).expect("time"),
|
||||
"SP1AA",
|
||||
14_074_000,
|
||||
"FT8",
|
||||
);
|
||||
let after = Qso::new(
|
||||
"2".into(),
|
||||
parse_adif_datetime("20260808", Some("000030")).expect("time"),
|
||||
"SP1AA",
|
||||
14_074_000,
|
||||
"FT8",
|
||||
);
|
||||
let index = DuplicateIndex::build(&[before]);
|
||||
assert!(index.contains(&after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_repeats_itself_imports_once() {
|
||||
let mut index = DuplicateIndex::build(&[]);
|
||||
let first = qso("SP1AA", 14_074_000, "FT8", "120000");
|
||||
assert!(!index.contains(&first));
|
||||
index.insert(&first);
|
||||
assert!(index.contains(&qso("SP1AA", 14_074_000, "FT8", "120030")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worked_before_answers_by_band_and_mode() {
|
||||
let held = vec![
|
||||
qso("SP1AA", 14_074_000, "FT8", "120000"),
|
||||
qso("SP1AA", 7_030_000, "CW", "130000"),
|
||||
qso("SP1AA", 14_074_000, "FT8", "140000"),
|
||||
qso("SP2BB", 14_200_000, "USB", "150000"),
|
||||
];
|
||||
let worked = worked_before(&held, "sp1aa");
|
||||
assert_eq!(
|
||||
worked,
|
||||
vec![
|
||||
("20m".to_string(), "FT8".to_string()),
|
||||
("40m".to_string(), "CW".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(worked_before(&held, "SP9ZZ").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! The station logbook: contacts, the file they live in, and ADIF in and out.
|
||||
//!
|
||||
//! One log per station rather than one per rig. Awards and uploads count the
|
||||
//! callsign, not the radio — a station worked on the second rig is still worked
|
||||
//! — so the rig is recorded on the contact instead of dividing the log by it.
|
||||
//! Where a rig stands does follow the contact, though: `my_gridsquare` comes
|
||||
//! from the rig that made it, because rigs can be in different places.
|
||||
|
||||
pub mod adif;
|
||||
pub mod dedupe;
|
||||
pub mod qso;
|
||||
pub mod store;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use dedupe::{worked_before, DuplicateIndex, MATCH_WINDOW_MINUTES};
|
||||
pub use qso::Qso;
|
||||
pub use store::LogStore;
|
||||
|
||||
/// A fresh contact identity.
|
||||
pub fn new_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Where the log lives when the configuration does not say.
|
||||
///
|
||||
/// The data directory, not the config directory the bookmarks use and not the
|
||||
/// cache directory the decode logs use: a log is neither a setting nor
|
||||
/// disposable, and cache directories get swept by cleaners.
|
||||
pub fn default_log_path() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.map(|dir| dir.join("trx-rs").join("logbook.jsonl"))
|
||||
.unwrap_or_else(|| PathBuf::from("logbook.jsonl"))
|
||||
}
|
||||
|
||||
/// What a filtered read asks for.
|
||||
#[derive(Debug, Default, Clone, Deserialize)]
|
||||
pub struct LogQuery {
|
||||
/// Substring of the callsign, case-insensitive.
|
||||
#[serde(default)]
|
||||
pub call: Option<String>,
|
||||
#[serde(default)]
|
||||
pub band: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
/// Inclusive bounds on the contact's time.
|
||||
#[serde(default)]
|
||||
pub from: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub to: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub offset: Option<usize>,
|
||||
}
|
||||
|
||||
/// What an import did.
|
||||
#[derive(Debug, Default, Clone, Serialize)]
|
||||
pub struct ImportOutcome {
|
||||
pub added: usize,
|
||||
/// Already held, by callsign, band, mode and the two-minute window.
|
||||
pub duplicate: usize,
|
||||
/// Records that could not be read as contacts, with the reason.
|
||||
pub rejected: Vec<String>,
|
||||
}
|
||||
|
||||
/// The logbook, shared by whoever serves it.
|
||||
#[derive(Clone)]
|
||||
pub struct Logbook {
|
||||
store: Arc<Mutex<LogStore>>,
|
||||
}
|
||||
|
||||
impl Logbook {
|
||||
pub fn open(path: &std::path::Path) -> std::io::Result<Self> {
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(LogStore::open(path)?)),
|
||||
})
|
||||
}
|
||||
|
||||
fn with_store<T>(&self, f: impl FnOnce(&mut LogStore) -> T) -> T {
|
||||
// A poisoned lock means a panic while holding it, not a corrupt log --
|
||||
// the file is on disk either way -- so carry on with what is there.
|
||||
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f(&mut store)
|
||||
}
|
||||
|
||||
pub fn path(&self) -> PathBuf {
|
||||
self.with_store(|store| store.path().to_path_buf())
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.with_store(|store| store.len())
|
||||
}
|
||||
|
||||
/// Contacts matching `query`, newest first.
|
||||
pub fn query(&self, query: &LogQuery) -> Vec<Qso> {
|
||||
self.with_store(|store| {
|
||||
let call = query.call.as_deref().map(str::to_uppercase);
|
||||
let band = query.band.as_deref().map(str::to_lowercase);
|
||||
let mode = query.mode.as_deref().map(str::to_uppercase);
|
||||
let matching: Vec<Qso> = store
|
||||
.all()
|
||||
.into_iter()
|
||||
.filter(|qso| {
|
||||
call.as_ref().is_none_or(|c| qso.call.contains(c.as_str()))
|
||||
&& band
|
||||
.as_ref()
|
||||
.is_none_or(|b| qso.band().is_some_and(|found| found == b))
|
||||
&& mode.as_ref().is_none_or(|m| qso.mode == *m)
|
||||
&& query.from.is_none_or(|from| qso.started_at >= from)
|
||||
&& query.to.is_none_or(|to| qso.started_at <= to)
|
||||
})
|
||||
.collect();
|
||||
let offset = query.offset.unwrap_or(0).min(matching.len());
|
||||
let end = query
|
||||
.limit
|
||||
.map_or(matching.len(), |limit| (offset + limit).min(matching.len()));
|
||||
matching[offset..end].to_vec()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<Qso> {
|
||||
self.with_store(|store| store.get(id).cloned())
|
||||
}
|
||||
|
||||
/// Add a contact, or replace one with the same id.
|
||||
pub fn put(&self, qso: Qso) -> std::io::Result<Qso> {
|
||||
self.with_store(|store| store.put(qso.clone()))?;
|
||||
Ok(qso)
|
||||
}
|
||||
|
||||
pub fn delete(&self, id: &str) -> std::io::Result<bool> {
|
||||
self.with_store(|store| store.delete(id))
|
||||
}
|
||||
|
||||
/// Which bands and modes a callsign has been worked on.
|
||||
pub fn worked_before(&self, call: &str) -> Vec<(String, String)> {
|
||||
self.with_store(|store| dedupe::worked_before(&store.all(), call))
|
||||
}
|
||||
|
||||
/// Export as ADIF, honouring the same filters as a read.
|
||||
pub fn export_adi(&self, query: &LogQuery, program_version: &str) -> String {
|
||||
adif::write_adi(&self.query(query), program_version)
|
||||
}
|
||||
|
||||
/// Import an ADIF file, skipping contacts already held.
|
||||
pub fn import_adi(&self, input: &[u8]) -> std::io::Result<ImportOutcome> {
|
||||
let report = adif::parse_adi(input);
|
||||
let mut outcome = ImportOutcome {
|
||||
rejected: report.rejected,
|
||||
..ImportOutcome::default()
|
||||
};
|
||||
self.with_store(|store| {
|
||||
let mut index = DuplicateIndex::build(&store.all());
|
||||
let mut fresh = Vec::new();
|
||||
for qso in report.qsos {
|
||||
if index.contains(&qso) {
|
||||
outcome.duplicate += 1;
|
||||
continue;
|
||||
}
|
||||
index.insert(&qso);
|
||||
fresh.push(qso);
|
||||
}
|
||||
outcome.added = fresh.len();
|
||||
store.put_all(fresh)
|
||||
})?;
|
||||
Ok(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::qso::parse_adif_datetime;
|
||||
|
||||
fn new_book() -> (tempfile::TempDir, Logbook) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let book = Logbook::open(&dir.path().join("logbook.jsonl")).expect("open");
|
||||
(dir, book)
|
||||
}
|
||||
|
||||
fn qso(call: &str, hz: u64, mode: &str, time: &str) -> Qso {
|
||||
Qso::new(
|
||||
new_id(),
|
||||
parse_adif_datetime("20260807", Some(time)).expect("time"),
|
||||
call,
|
||||
hz,
|
||||
mode,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_answers_the_filters_it_is_asked() {
|
||||
let (_dir, book) = new_book();
|
||||
book.put(qso("SP1AA", 14_074_000, "FT8", "120000"))
|
||||
.expect("put");
|
||||
book.put(qso("SP2BB", 7_030_000, "CW", "130000"))
|
||||
.expect("put");
|
||||
book.put(qso("DL3CC", 14_200_000, "SSB", "140000"))
|
||||
.expect("put");
|
||||
|
||||
assert_eq!(book.count(), 3);
|
||||
assert_eq!(book.query(&LogQuery::default()).len(), 3);
|
||||
// Newest first.
|
||||
assert_eq!(book.query(&LogQuery::default())[0].call, "DL3CC");
|
||||
|
||||
let by_band = LogQuery {
|
||||
band: Some("20m".into()),
|
||||
..LogQuery::default()
|
||||
};
|
||||
assert_eq!(book.query(&by_band).len(), 2);
|
||||
|
||||
let by_call = LogQuery {
|
||||
call: Some("sp".into()),
|
||||
..LogQuery::default()
|
||||
};
|
||||
assert_eq!(book.query(&by_call).len(), 2);
|
||||
|
||||
let by_mode = LogQuery {
|
||||
mode: Some("cw".into()),
|
||||
..LogQuery::default()
|
||||
};
|
||||
assert_eq!(book.query(&by_mode)[0].call, "SP2BB");
|
||||
|
||||
let paged = LogQuery {
|
||||
limit: Some(1),
|
||||
offset: Some(1),
|
||||
..LogQuery::default()
|
||||
};
|
||||
assert_eq!(book.query(&paged).len(), 1);
|
||||
// Newest first, so offset 1 is the middle contact.
|
||||
assert_eq!(book.query(&paged)[0].call, "SP2BB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_import_skips_what_is_already_held_and_says_so() {
|
||||
let (_dir, book) = new_book();
|
||||
book.put(qso("SP1AA", 14_074_000, "FT8", "120000"))
|
||||
.expect("put");
|
||||
|
||||
let file = concat!(
|
||||
// the one already held, a minute out, as another logger stamped it
|
||||
"<call:5>SP1AA<qso_date:8>20260807<time_on:6>120100<freq:6>14.074<mode:3>FT8<eor>\n",
|
||||
// a new one
|
||||
"<call:5>DL2XY<qso_date:8>20260807<time_on:6>121500<freq:6>14.074<mode:3>FT8<eor>\n",
|
||||
// and something that is not a contact
|
||||
"<call:5>NODAT<eor>\n",
|
||||
);
|
||||
let outcome = book.import_adi(file.as_bytes()).expect("import");
|
||||
assert_eq!(outcome.added, 1);
|
||||
assert_eq!(outcome.duplicate, 1);
|
||||
assert_eq!(outcome.rejected.len(), 1);
|
||||
assert_eq!(book.count(), 2);
|
||||
|
||||
// And importing the same file again adds nothing.
|
||||
let again = book.import_adi(file.as_bytes()).expect("import again");
|
||||
assert_eq!(again.added, 0);
|
||||
assert_eq!(again.duplicate, 2);
|
||||
assert_eq!(book.count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_can_be_imported_into_an_empty_log() {
|
||||
let (_dir, book) = new_book();
|
||||
book.put(qso("SP1AA", 14_074_000, "FT8", "120000"))
|
||||
.expect("put");
|
||||
book.put(qso("DL3CC", 14_200_000, "SSB", "140000"))
|
||||
.expect("put");
|
||||
let exported = book.export_adi(&LogQuery::default(), "0.1.0");
|
||||
|
||||
let (_other_dir, other) = new_book();
|
||||
let outcome = other.import_adi(exported.as_bytes()).expect("import");
|
||||
assert_eq!(outcome.added, 2);
|
||||
assert_eq!(other.count(), 2);
|
||||
assert_eq!(other.query(&LogQuery::default())[0].call, "DL3CC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worked_before_reads_through_the_log() {
|
||||
let (_dir, book) = new_book();
|
||||
book.put(qso("SP1AA", 14_074_000, "FT8", "120000"))
|
||||
.expect("put");
|
||||
book.put(qso("SP1AA", 7_030_000, "CW", "130000"))
|
||||
.expect("put");
|
||||
assert_eq!(book.worked_before("sp1aa").len(), 2);
|
||||
assert!(book.worked_before("SP9ZZ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_contact_can_be_changed_and_forgotten() {
|
||||
let (_dir, book) = new_book();
|
||||
let mut entry = qso("SP1AA", 14_074_000, "FT8", "120000");
|
||||
let id = entry.id.clone();
|
||||
book.put(entry.clone()).expect("put");
|
||||
entry.rst_rcvd = Some("-11".into());
|
||||
book.put(entry).expect("edit");
|
||||
assert_eq!(book.get(&id).and_then(|q| q.rst_rcvd), Some("-11".into()));
|
||||
assert!(book.delete(&id).expect("delete"));
|
||||
assert!(book.get(&id).is_none());
|
||||
assert_eq!(book.count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! The record a contact is kept as, and the translations between what the radio
|
||||
//! says and what a log file wants.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One contact.
|
||||
///
|
||||
/// The named fields are the ones the application fills, reads or filters on.
|
||||
/// Everything else an imported file carried is kept in [`Qso::extra`] under its
|
||||
/// ADIF field name, so a log that came from another program leaves with what it
|
||||
/// arrived with rather than what this one happens to understand.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Qso {
|
||||
/// Stable identity, ours, not from the file.
|
||||
pub id: String,
|
||||
/// UTC, always: the log is kept in the radio's time, not a browser's.
|
||||
pub started_at: DateTime<Utc>,
|
||||
/// Present once the operator records the end of a contact.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ended_at: Option<DateTime<Utc>>,
|
||||
/// The station worked.
|
||||
pub call: String,
|
||||
/// Dial frequency in Hz. The band is derived from it rather than stored
|
||||
/// twice and allowed to disagree.
|
||||
pub freq_hz: u64,
|
||||
/// ADIF mode, e.g. `SSB`, `CW`, `FT8`.
|
||||
pub mode: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rst_sent: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rst_rcvd: Option<String>,
|
||||
/// The worked station's Maidenhead locator.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gridsquare: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub qth: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub comment: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tx_pwr_w: Option<f64>,
|
||||
/// The callsign this station was on the air with.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub station_callsign: Option<String>,
|
||||
/// The person at the key, which is not always the station.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub operator: Option<String>,
|
||||
/// Our locator *for this contact*: rigs can stand in different places.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub my_gridsquare: Option<String>,
|
||||
/// The rig it was worked on, by display name (ADIF `MY_RIG`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub my_rig: Option<String>,
|
||||
/// Which rig of this client, by id — ours, not ADIF's.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rig_id: Option<String>,
|
||||
/// ADIF fields this application does not model, kept verbatim.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub extra: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Qso {
|
||||
/// A contact with only the fields every log needs.
|
||||
pub fn new(
|
||||
id: String,
|
||||
started_at: DateTime<Utc>,
|
||||
call: &str,
|
||||
freq_hz: u64,
|
||||
mode: &str,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
started_at,
|
||||
ended_at: None,
|
||||
call: normalize_call(call),
|
||||
freq_hz,
|
||||
mode: mode.trim().to_uppercase(),
|
||||
submode: None,
|
||||
rst_sent: None,
|
||||
rst_rcvd: None,
|
||||
gridsquare: None,
|
||||
name: None,
|
||||
qth: None,
|
||||
comment: None,
|
||||
tx_pwr_w: None,
|
||||
station_callsign: None,
|
||||
operator: None,
|
||||
my_gridsquare: None,
|
||||
my_rig: None,
|
||||
rig_id: None,
|
||||
extra: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ADIF band name for the frequency, if it falls in an amateur band.
|
||||
pub fn band(&self) -> Option<&'static str> {
|
||||
band_for_hz(self.freq_hz)
|
||||
}
|
||||
|
||||
/// What two logs have to agree on to be describing the same contact.
|
||||
///
|
||||
/// Not the time: that is compared as a window by [`crate::dedupe`], because
|
||||
/// two programs rarely stamp the same QSO to the same minute.
|
||||
pub fn match_key(&self) -> (String, String, String) {
|
||||
(
|
||||
self.call.clone(),
|
||||
self.band().unwrap_or("").to_string(),
|
||||
self.mode.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Callsigns are compared, sorted and deduplicated, so they are held one way:
|
||||
/// upper case, without the spaces a hurried operator leaves behind.
|
||||
pub fn normalize_call(call: &str) -> String {
|
||||
call.trim().to_uppercase()
|
||||
}
|
||||
|
||||
/// Amateur bands by their ADIF names, as frequency ranges in Hz.
|
||||
///
|
||||
/// Ranges are the widest any region allows, since a log records what was heard
|
||||
/// rather than what a licence permits, and a QSO on a band edge must not fall
|
||||
/// out of the log because one region stops earlier than another.
|
||||
const BANDS: &[(&str, u64, u64)] = &[
|
||||
("2190m", 135_700, 137_800),
|
||||
("630m", 472_000, 479_000),
|
||||
("560m", 501_000, 504_000),
|
||||
("160m", 1_800_000, 2_000_000),
|
||||
("80m", 3_500_000, 4_000_000),
|
||||
("60m", 5_060_000, 5_450_000),
|
||||
("40m", 7_000_000, 7_300_000),
|
||||
("30m", 10_100_000, 10_150_000),
|
||||
("20m", 14_000_000, 14_350_000),
|
||||
("17m", 18_068_000, 18_168_000),
|
||||
("15m", 21_000_000, 21_450_000),
|
||||
("12m", 24_890_000, 24_990_000),
|
||||
("10m", 28_000_000, 29_700_000),
|
||||
("8m", 40_000_000, 45_000_000),
|
||||
("6m", 50_000_000, 54_000_000),
|
||||
("5m", 54_000_001, 69_900_000),
|
||||
("4m", 70_000_000, 71_000_000),
|
||||
("2m", 144_000_000, 148_000_000),
|
||||
("1.25m", 222_000_000, 225_000_000),
|
||||
("70cm", 420_000_000, 450_000_000),
|
||||
("33cm", 902_000_000, 928_000_000),
|
||||
("23cm", 1_240_000_000, 1_300_000_000),
|
||||
("13cm", 2_300_000_000, 2_450_000_000),
|
||||
("9cm", 3_300_000_000, 3_500_000_000),
|
||||
("6cm", 5_650_000_000, 5_925_000_000),
|
||||
("3cm", 10_000_000_000, 10_500_000_000),
|
||||
];
|
||||
|
||||
/// The ADIF band a frequency falls in, or `None` outside the amateur bands —
|
||||
/// a broadcast or utility frequency has no band name, and inventing one would
|
||||
/// put a lie in the log.
|
||||
pub fn band_for_hz(hz: u64) -> Option<&'static str> {
|
||||
BANDS
|
||||
.iter()
|
||||
.find(|(_, low, high)| hz >= *low && hz <= *high)
|
||||
.map(|(name, _, _)| *name)
|
||||
}
|
||||
|
||||
/// The middle of a named band.
|
||||
///
|
||||
/// A log that recorded `20m` and no frequency knows the band and not the dial;
|
||||
/// the midpoint is the honest reading of that, and the band name it exports
|
||||
/// comes back the same.
|
||||
pub fn band_midpoint_for_name(band: &str) -> Option<u64> {
|
||||
let wanted = band.trim().to_lowercase();
|
||||
BANDS
|
||||
.iter()
|
||||
.find(|(name, _, _)| *name == wanted)
|
||||
.map(|(_, low, high)| low + (high - low) / 2)
|
||||
}
|
||||
|
||||
/// The frequency in MHz, as ADIF writes it.
|
||||
pub fn freq_mhz_string(hz: u64) -> String {
|
||||
// Six decimals is 1 Hz, which is finer than any dial reports and is what
|
||||
// other loggers write. The zeros are then dropped from the fraction only:
|
||||
// trimming the whole string would turn 20.000000 into 2, since the run of
|
||||
// trailing zeros reaches back through the point into the integer.
|
||||
let text = format!("{:.6}", hz as f64 / 1e6);
|
||||
match text.split_once('.') {
|
||||
Some((whole, fraction)) => {
|
||||
let fraction = fraction.trim_end_matches('0');
|
||||
if fraction.is_empty() {
|
||||
whole.to_string()
|
||||
} else {
|
||||
format!("{whole}.{fraction}")
|
||||
}
|
||||
}
|
||||
None => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADIF's MHz back to Hz.
|
||||
pub fn hz_from_mhz_string(text: &str) -> Option<u64> {
|
||||
let mhz: f64 = text.trim().parse().ok()?;
|
||||
if !mhz.is_finite() || mhz < 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some((mhz * 1e6).round() as u64)
|
||||
}
|
||||
|
||||
/// What a rig mode becomes in a log.
|
||||
///
|
||||
/// A rig reports what it is demodulating; a log records what the contact was
|
||||
/// made on, and the two are not the same word. `DIG` is deliberately absent:
|
||||
/// a rig in `DIG` is in FT8 or FT4 or something else depending on which decoder
|
||||
/// is running, so the caller has to say which — see [`mode_for_decoder`].
|
||||
pub fn adif_mode_for_rig_mode(rig_mode: &str) -> Option<(&'static str, Option<&'static str>)> {
|
||||
match rig_mode.trim().to_uppercase().as_str() {
|
||||
"USB" => Some(("SSB", Some("USB"))),
|
||||
"LSB" => Some(("SSB", Some("LSB"))),
|
||||
"CW" | "CWR" => Some(("CW", None)),
|
||||
"AM" | "SAM" => Some(("AM", None)),
|
||||
"FM" | "WFM" => Some(("FM", None)),
|
||||
"PKT" => Some(("PKT", None)),
|
||||
// AIS and VDES are maritime, not amateur: nothing on them is a QSO.
|
||||
// DIG says nothing on its own, and Other(..) is whatever a backend
|
||||
// called it, which is the operator's to confirm rather than ours to
|
||||
// guess into the log.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a decoder's contacts are logged as.
|
||||
///
|
||||
/// WSPR is absent on purpose: it is a beacon mode. Hearing a beacon is not a
|
||||
/// contact, so a WSPR spot never opens a log entry.
|
||||
pub fn mode_for_decoder(decoder_id: &str) -> Option<(&'static str, Option<&'static str>)> {
|
||||
match decoder_id.trim().to_lowercase().as_str() {
|
||||
"ft8" => Some(("FT8", None)),
|
||||
// FT4 is a submode of MFSK in ADIF, which is how WSJT-X logs it.
|
||||
"ft4" => Some(("MFSK", Some("FT4"))),
|
||||
"ft2" => Some(("MFSK", Some("FT2"))),
|
||||
"cw" => Some(("CW", None)),
|
||||
"aprs" | "hf-aprs" | "hf_aprs" => Some(("PKT", None)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADIF writes dates as `YYYYMMDD`, always UTC.
|
||||
pub fn adif_date(at: &DateTime<Utc>) -> String {
|
||||
format!("{:04}{:02}{:02}", at.year(), at.month(), at.day())
|
||||
}
|
||||
|
||||
/// ADIF writes times as `HHMMSS`, always UTC.
|
||||
pub fn adif_time(at: &DateTime<Utc>) -> String {
|
||||
format!("{:02}{:02}{:02}", at.hour(), at.minute(), at.second())
|
||||
}
|
||||
|
||||
/// ADIF's date and time back to an instant.
|
||||
///
|
||||
/// Times come as `HHMMSS` or `HHMM`; both are in the wild, and a missing time
|
||||
/// is treated as midnight rather than rejecting the record, because a log with
|
||||
/// a date and no time is still a log.
|
||||
pub fn parse_adif_datetime(date: &str, time: Option<&str>) -> Option<DateTime<Utc>> {
|
||||
let date = date.trim();
|
||||
if date.len() != 8 || !date.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let year: i32 = date[0..4].parse().ok()?;
|
||||
let month: u32 = date[4..6].parse().ok()?;
|
||||
let day: u32 = date[6..8].parse().ok()?;
|
||||
let date = NaiveDate::from_ymd_opt(year, month, day)?;
|
||||
|
||||
let time = time.map(str::trim).filter(|t| !t.is_empty());
|
||||
let time = match time {
|
||||
None => NaiveTime::from_hms_opt(0, 0, 0)?,
|
||||
Some(text) if text.len() == 6 || text.len() == 4 => {
|
||||
if !text.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let hour: u32 = text[0..2].parse().ok()?;
|
||||
let minute: u32 = text[2..4].parse().ok()?;
|
||||
let second: u32 = if text.len() == 6 {
|
||||
text[4..6].parse().ok()?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
NaiveTime::from_hms_opt(hour, minute, second)?
|
||||
}
|
||||
Some(_) => return None,
|
||||
};
|
||||
Some(NaiveDateTime::new(date, time).and_utc())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bands_are_named_from_the_dial() {
|
||||
assert_eq!(band_for_hz(14_074_000), Some("20m"));
|
||||
assert_eq!(band_for_hz(7_030_000), Some("40m"));
|
||||
assert_eq!(band_for_hz(144_300_000), Some("2m"));
|
||||
// A band edge is in the band: a QSO at 14.350 is a 20 m QSO.
|
||||
assert_eq!(band_for_hz(14_350_000), Some("20m"));
|
||||
// Broadcast and utility frequencies have no band, and must not be given
|
||||
// one -- a log that says 49m would be wrong, not merely unhelpful.
|
||||
assert_eq!(band_for_hz(6_075_000), None);
|
||||
assert_eq!(band_for_hz(162_025_000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frequencies_survive_the_trip_through_megahertz() {
|
||||
for hz in [
|
||||
14_074_000_u64,
|
||||
7_030_000,
|
||||
1_840_000,
|
||||
432_100_000,
|
||||
10_138_700,
|
||||
20_000_000,
|
||||
100_000_000,
|
||||
] {
|
||||
let text = freq_mhz_string(hz);
|
||||
assert_eq!(
|
||||
hz_from_mhz_string(&text),
|
||||
Some(hz),
|
||||
"{text} came back wrong"
|
||||
);
|
||||
}
|
||||
assert_eq!(freq_mhz_string(14_074_000), "14.074");
|
||||
assert_eq!(freq_mhz_string(14_000_000), "14");
|
||||
// A whole number of MHz ending in a zero: trimming the string rather
|
||||
// than the fraction turned 20 MHz into 2 MHz, and 100 into 1.
|
||||
assert_eq!(freq_mhz_string(20_000_000), "20");
|
||||
assert_eq!(freq_mhz_string(100_000_000), "100");
|
||||
assert_eq!(freq_mhz_string(10_000_000), "10");
|
||||
assert_eq!(freq_mhz_string(50_313_000), "50.313");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rig_mode_is_not_an_adif_mode() {
|
||||
assert_eq!(adif_mode_for_rig_mode("USB"), Some(("SSB", Some("USB"))));
|
||||
assert_eq!(adif_mode_for_rig_mode("lsb"), Some(("SSB", Some("LSB"))));
|
||||
assert_eq!(adif_mode_for_rig_mode("CWR"), Some(("CW", None)));
|
||||
assert_eq!(adif_mode_for_rig_mode("SAM"), Some(("AM", None)));
|
||||
// The rig cannot answer for DIG, and must not be allowed to guess.
|
||||
assert_eq!(adif_mode_for_rig_mode("DIG"), None);
|
||||
// Nor are the maritime modes contacts at all.
|
||||
assert_eq!(adif_mode_for_rig_mode("AIS"), None);
|
||||
assert_eq!(adif_mode_for_rig_mode("VDES"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_decoder_answers_for_the_digital_modes() {
|
||||
assert_eq!(mode_for_decoder("ft8"), Some(("FT8", None)));
|
||||
assert_eq!(mode_for_decoder("ft4"), Some(("MFSK", Some("FT4"))));
|
||||
assert_eq!(mode_for_decoder("aprs"), Some(("PKT", None)));
|
||||
// Hearing a beacon is not a contact.
|
||||
assert_eq!(mode_for_decoder("wspr"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adif_times_are_read_in_both_widths_and_written_in_one() {
|
||||
let with_seconds = parse_adif_datetime("20260807", Some("121530")).expect("HHMMSS");
|
||||
let without = parse_adif_datetime("20260807", Some("1215")).expect("HHMM");
|
||||
assert_eq!(adif_date(&with_seconds), "20260807");
|
||||
assert_eq!(adif_time(&with_seconds), "121530");
|
||||
assert_eq!(adif_time(&without), "121500");
|
||||
// A date with no time is still a log entry.
|
||||
assert!(parse_adif_datetime("20260807", None).is_some());
|
||||
// Nonsense is not.
|
||||
assert!(parse_adif_datetime("2026080", Some("1215")).is_none());
|
||||
assert!(parse_adif_datetime("20261307", Some("1215")).is_none());
|
||||
assert!(parse_adif_datetime("20260807", Some("12:15")).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Where the log lives.
|
||||
//!
|
||||
//! One JSON Lines file, appended to and never rewritten in place. A log is the
|
||||
//! one thing in this application that cannot be regenerated: a bookmark can be
|
||||
//! typed again and a decode will come round again, but a contact happened once.
|
||||
//! So every write is an append of a single line — atomic as far as a crash is
|
||||
//! concerned, since a torn last line loses only the record being written and is
|
||||
//! skipped on the next load — rather than a dump of the whole file, which at
|
||||
//! forty thousand QSOs would rewrite megabytes to log one contact and lose all
|
||||
//! of them if the power went halfway.
|
||||
//!
|
||||
//! An edit appends a new revision of the same id and a delete appends a
|
||||
//! tombstone; the load keeps the last word on each id. Compaction rewrites the
|
||||
//! file, through a temporary and a rename, once the superseded records outnumber
|
||||
//! the live ones.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{create_dir_all, File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, BufWriter, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::qso::Qso;
|
||||
|
||||
/// One line of the file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
enum Entry {
|
||||
/// A contact, as it stands after this line.
|
||||
Qso(Box<Qso>),
|
||||
/// This id is gone.
|
||||
Delete { id: String },
|
||||
}
|
||||
|
||||
/// Compact once more than this share of the file is superseded.
|
||||
const COMPACT_WHEN_STALE_RATIO: f64 = 0.5;
|
||||
/// ...but not while the file is still trivially small.
|
||||
const COMPACT_MIN_LINES: usize = 256;
|
||||
|
||||
pub struct LogStore {
|
||||
path: PathBuf,
|
||||
/// Live records by id. The log is small enough to hold: forty thousand
|
||||
/// QSOs is a few tens of megabytes, and every read wants it in memory.
|
||||
qsos: HashMap<String, Qso>,
|
||||
/// Lines in the file, live or not, for deciding when to compact.
|
||||
lines_written: usize,
|
||||
}
|
||||
|
||||
impl LogStore {
|
||||
/// Open the log at `path`, creating it and its directory if need be.
|
||||
///
|
||||
/// A line that cannot be read is skipped and counted rather than failing the
|
||||
/// open: a half-written last line from a crash must not cost the operator
|
||||
/// the rest of the log.
|
||||
pub fn open(path: &Path) -> std::io::Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
create_dir_all(parent)?;
|
||||
}
|
||||
let mut store = Self {
|
||||
path: path.to_path_buf(),
|
||||
qsos: HashMap::new(),
|
||||
lines_written: 0,
|
||||
};
|
||||
if path.exists() {
|
||||
let file = File::open(path)?;
|
||||
let mut damaged = 0usize;
|
||||
for line in BufReader::new(file).lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
store.lines_written += 1;
|
||||
match serde_json::from_str::<Entry>(&line) {
|
||||
Ok(Entry::Qso(qso)) => {
|
||||
store.qsos.insert(qso.id.clone(), *qso);
|
||||
}
|
||||
Ok(Entry::Delete { id }) => {
|
||||
store.qsos.remove(&id);
|
||||
}
|
||||
Err(_) => damaged += 1,
|
||||
}
|
||||
}
|
||||
if damaged > 0 {
|
||||
tracing::warn!(
|
||||
"logbook: skipped {damaged} unreadable line(s) in {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.qsos.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.qsos.is_empty()
|
||||
}
|
||||
|
||||
/// Every contact, newest first.
|
||||
pub fn all(&self) -> Vec<Qso> {
|
||||
let mut all: Vec<Qso> = self.qsos.values().cloned().collect();
|
||||
all.sort_by(|a, b| b.started_at.cmp(&a.started_at).then(a.id.cmp(&b.id)));
|
||||
all
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Qso> {
|
||||
self.qsos.get(id)
|
||||
}
|
||||
|
||||
/// Write a contact, new or changed.
|
||||
pub fn put(&mut self, qso: Qso) -> std::io::Result<()> {
|
||||
self.append(&Entry::Qso(Box::new(qso.clone())))?;
|
||||
self.qsos.insert(qso.id.clone(), qso);
|
||||
self.compact_if_needed()
|
||||
}
|
||||
|
||||
/// Write several contacts, appending them in one open of the file.
|
||||
pub fn put_all(&mut self, qsos: Vec<Qso>) -> std::io::Result<()> {
|
||||
if qsos.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file = BufWriter::new(
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?,
|
||||
);
|
||||
for qso in &qsos {
|
||||
let line = serde_json::to_string(&Entry::Qso(Box::new(qso.clone())))
|
||||
.map_err(std::io::Error::other)?;
|
||||
writeln!(file, "{line}")?;
|
||||
self.lines_written += 1;
|
||||
}
|
||||
file.flush()?;
|
||||
for qso in qsos {
|
||||
self.qsos.insert(qso.id.clone(), qso);
|
||||
}
|
||||
self.compact_if_needed()
|
||||
}
|
||||
|
||||
/// Forget a contact. `Ok(false)` if there was nothing to forget.
|
||||
pub fn delete(&mut self, id: &str) -> std::io::Result<bool> {
|
||||
if !self.qsos.contains_key(id) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.append(&Entry::Delete { id: id.to_string() })?;
|
||||
self.qsos.remove(id);
|
||||
self.compact_if_needed()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn append(&mut self, entry: &Entry) -> std::io::Result<()> {
|
||||
let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?;
|
||||
writeln!(file, "{line}")?;
|
||||
file.flush()?;
|
||||
self.lines_written += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compact_if_needed(&mut self) -> std::io::Result<()> {
|
||||
if self.lines_written < COMPACT_MIN_LINES {
|
||||
return Ok(());
|
||||
}
|
||||
let live = self.qsos.len();
|
||||
let stale = self.lines_written.saturating_sub(live);
|
||||
if (stale as f64) < (self.lines_written as f64) * COMPACT_WHEN_STALE_RATIO {
|
||||
return Ok(());
|
||||
}
|
||||
self.compact()
|
||||
}
|
||||
|
||||
/// Rewrite the file with one line per live contact.
|
||||
///
|
||||
/// Through a temporary and a rename, so an interrupted compaction leaves
|
||||
/// the old file whole rather than a half-written new one.
|
||||
pub fn compact(&mut self) -> std::io::Result<()> {
|
||||
let temporary = self.path.with_extension("jsonl.compacting");
|
||||
{
|
||||
let mut file = BufWriter::new(File::create(&temporary)?);
|
||||
for qso in self.all() {
|
||||
let line = serde_json::to_string(&Entry::Qso(Box::new(qso)))
|
||||
.map_err(std::io::Error::other)?;
|
||||
writeln!(file, "{line}")?;
|
||||
}
|
||||
file.flush()?;
|
||||
}
|
||||
std::fs::rename(&temporary, &self.path)?;
|
||||
self.lines_written = self.qsos.len();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::qso::parse_adif_datetime;
|
||||
|
||||
fn qso(id: &str, call: &str, minute: u32) -> Qso {
|
||||
Qso::new(
|
||||
id.to_string(),
|
||||
parse_adif_datetime("20260807", Some(&format!("12{minute:02}00"))).expect("time"),
|
||||
call,
|
||||
14_074_000,
|
||||
"FT8",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_survives_being_closed_and_opened() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("logbook.jsonl");
|
||||
|
||||
let mut store = LogStore::open(&path).expect("open");
|
||||
store.put(qso("a", "SP1AA", 0)).expect("put");
|
||||
store.put(qso("b", "SP2BB", 1)).expect("put");
|
||||
drop(store);
|
||||
|
||||
let store = LogStore::open(&path).expect("reopen");
|
||||
assert_eq!(store.len(), 2);
|
||||
assert_eq!(store.get("a").map(|q| q.call.as_str()), Some("SP1AA"));
|
||||
// Newest first.
|
||||
assert_eq!(store.all()[0].call, "SP2BB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_word_on_an_id_is_the_one_that_counts() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("logbook.jsonl");
|
||||
|
||||
let mut store = LogStore::open(&path).expect("open");
|
||||
store.put(qso("a", "SP1AA", 0)).expect("put");
|
||||
let mut edited = qso("a", "SP1AA", 0);
|
||||
edited.rst_sent = Some("-05".into());
|
||||
store.put(edited).expect("edit");
|
||||
store.put(qso("b", "SP2BB", 1)).expect("put");
|
||||
assert!(store.delete("b").expect("delete"));
|
||||
assert!(!store.delete("b").expect("delete again"));
|
||||
drop(store);
|
||||
|
||||
let store = LogStore::open(&path).expect("reopen");
|
||||
assert_eq!(store.len(), 1);
|
||||
assert_eq!(
|
||||
store.get("a").and_then(|q| q.rst_sent.clone()),
|
||||
Some("-05".into())
|
||||
);
|
||||
}
|
||||
|
||||
/// A crash mid-write leaves a torn line. It costs that record and nothing
|
||||
/// else — the point of appending one line at a time.
|
||||
#[test]
|
||||
fn a_torn_last_line_costs_only_itself() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("logbook.jsonl");
|
||||
|
||||
let mut store = LogStore::open(&path).expect("open");
|
||||
store.put(qso("a", "SP1AA", 0)).expect("put");
|
||||
store.put(qso("b", "SP2BB", 1)).expect("put");
|
||||
drop(store);
|
||||
|
||||
let mut text = std::fs::read_to_string(&path).expect("read");
|
||||
text.push_str("{\"op\":\"qso\",\"id\":\"c\",\"call\":\"SP3");
|
||||
std::fs::write(&path, text).expect("write");
|
||||
|
||||
let store = LogStore::open(&path).expect("reopen past the tear");
|
||||
assert_eq!(store.len(), 2);
|
||||
assert!(store.get("a").is_some() && store.get("b").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_keeps_the_log_and_shortens_the_file() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("logbook.jsonl");
|
||||
|
||||
let mut store = LogStore::open(&path).expect("open");
|
||||
// One live record, rewritten until compaction triggers.
|
||||
for index in 0..COMPACT_MIN_LINES + 10 {
|
||||
let mut entry = qso("a", "SP1AA", 0);
|
||||
entry.comment = Some(format!("revision {index}"));
|
||||
store.put(entry).expect("put");
|
||||
}
|
||||
let lines = std::fs::read_to_string(&path)
|
||||
.expect("read")
|
||||
.lines()
|
||||
.count();
|
||||
assert_eq!(store.len(), 1);
|
||||
// Compacted along the way: the file holds the revisions written since,
|
||||
// not the two hundred and sixty-six that were written.
|
||||
assert!(
|
||||
lines < COMPACT_MIN_LINES,
|
||||
"the file kept {lines} lines for one contact"
|
||||
);
|
||||
assert_eq!(
|
||||
store.get("a").and_then(|q| q.comment.clone()),
|
||||
Some(format!("revision {}", COMPACT_MIN_LINES + 9))
|
||||
);
|
||||
|
||||
// And the compacted file still reads.
|
||||
drop(store);
|
||||
let store = LogStore::open(&path).expect("reopen");
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_import_appends_in_one_pass() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("logbook.jsonl");
|
||||
let mut store = LogStore::open(&path).expect("open");
|
||||
store
|
||||
.put_all(vec![qso("a", "SP1AA", 0), qso("b", "SP2BB", 1)])
|
||||
.expect("put_all");
|
||||
assert_eq!(store.len(), 2);
|
||||
drop(store);
|
||||
assert_eq!(LogStore::open(&path).expect("reopen").len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,29 +0,0 @@
|
||||
// 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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user