Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbab4c5b99 |
@@ -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
-47
@@ -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,9 +3132,7 @@ dependencies = [
|
||||
"dialoguer",
|
||||
"tempfile",
|
||||
"tokio-serial",
|
||||
"toml",
|
||||
"toml_edit 0.22.27",
|
||||
"trx-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3218,7 +3192,6 @@ dependencies = [
|
||||
"base64",
|
||||
"brotli 7.0.0",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
@@ -3227,13 +3200,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 +3243,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,7 +3295,6 @@ dependencies = [
|
||||
"trx-app",
|
||||
"trx-aprs",
|
||||
"trx-backend",
|
||||
"trx-config",
|
||||
"trx-core",
|
||||
"trx-cw",
|
||||
"trx-decode-log",
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
-208
@@ -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]`
|
||||
|
||||
@@ -236,29 +204,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:
|
||||
@@ -309,25 +254,6 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
| 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 +262,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 +269,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 +279,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 |
|
||||
@@ -440,91 +333,13 @@ 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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+1285
-5
File diff suppressed because it is too large
Load Diff
+8
-129
@@ -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 {
|
||||
@@ -296,10 +202,7 @@ async fn async_init() -> DynResult<AppState> {
|
||||
name,
|
||||
url: url.clone(),
|
||||
rig_id,
|
||||
auth: config::RemoteAuthConfig {
|
||||
token,
|
||||
token_file: None,
|
||||
},
|
||||
auth: config::RemoteAuthConfig { token },
|
||||
poll_interval_ms,
|
||||
}]
|
||||
} else {
|
||||
@@ -360,30 +263,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()
|
||||
|
||||
@@ -22,16 +22,30 @@ 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};
|
||||
|
||||
const DEFAULT_REMOTE_PORT: u16 = 4530;
|
||||
const DEFAULT_AUDIO_PORT: u16 = 4531;
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const IO_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
|
||||
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RemoteEndpoint {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl RemoteEndpoint {
|
||||
pub fn connect_addr(&self) -> String {
|
||||
if self.host.contains(':') && !self.host.starts_with('[') {
|
||||
format!("[{}]:{}", self.host, self.port)
|
||||
} else {
|
||||
format!("{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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`.
|
||||
@@ -1308,6 +1322,86 @@ 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)]
|
||||
|
||||
@@ -292,10 +292,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 +305,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");
|
||||
@@ -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",
|
||||
@@ -1766,10 +1756,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 +1782,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);
|
||||
@@ -2911,9 +2899,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 +2944,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 +3552,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 +3566,6 @@ function resetDecoderStateOnRigSwitch() {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = "--";
|
||||
});
|
||||
refreshDecodeBars();
|
||||
window.trxPluginRuntime.rerenderAll();
|
||||
}
|
||||
function resetWfmStereoIndicator() {
|
||||
if (!wfmStFlagEl) return;
|
||||
@@ -4809,7 +4785,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";
|
||||
@@ -5865,7 +5844,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 +5873,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 +5984,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 +6255,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 +6320,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);
|
||||
@@ -7633,35 +7606,11 @@ var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
|
||||
"sstv",
|
||||
"sstv_progress"
|
||||
]);
|
||||
var pendingDecodeStats = [];
|
||||
var PENDING_DECODE_STATS_MAX = 5e4;
|
||||
function recordDecodeStat(kind, rig, tsMs) {
|
||||
const stats = window.trx.modules.map;
|
||||
if (stats) {
|
||||
stats.statsRecordDecode(kind, rig, tsMs);
|
||||
return;
|
||||
}
|
||||
pendingDecodeStats.push({ kind, rig, tsMs });
|
||||
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
|
||||
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
|
||||
}
|
||||
}
|
||||
function flushPendingDecodeStats() {
|
||||
const stats = window.trx.modules.map;
|
||||
if (!stats || pendingDecodeStats.length === 0) return;
|
||||
for (const entry of pendingDecodeStats.splice(0)) {
|
||||
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
|
||||
}
|
||||
stats.scheduleStatsRender();
|
||||
}
|
||||
function scheduleStatsRenderIfLoaded() {
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
function dispatchDecodeMessage(msg, skipStats = false) {
|
||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
|
||||
scheduleStatsRenderIfLoaded();
|
||||
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;
|
||||
@@ -7707,9 +7656,9 @@ function restoreDecodeHistoryGroup(kind, messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||
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,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,7 +1,3 @@
|
||||
import {
|
||||
forActiveRig,
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
@@ -115,7 +111,6 @@ function paintRow(line, rgb) {
|
||||
liveRows = Math.max(liveRows, line + 1);
|
||||
}
|
||||
function onProgress(msg) {
|
||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||
if (msg.state) {
|
||||
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
||||
@@ -136,20 +131,15 @@ function onImage(msg) {
|
||||
sstvHistory.push(image);
|
||||
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||
pruneHistory();
|
||||
if (!isActiveRigDecode(image.rig_id)) return;
|
||||
if (sstvDom.status) {
|
||||
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
||||
}
|
||||
scheduleUi("sstv-latest", renderLatestCard);
|
||||
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
||||
}
|
||||
function sstvRigImages() {
|
||||
return forActiveRig(sstvHistory);
|
||||
}
|
||||
function renderLatestCard() {
|
||||
if (!sstvDom.liveLatest) return;
|
||||
const rigImages = sstvRigImages();
|
||||
const latest = rigImages[rigImages.length - 1];
|
||||
const latest = sstvHistory[sstvHistory.length - 1];
|
||||
if (!latest) {
|
||||
sstvDom.liveLatest.innerHTML = "";
|
||||
return;
|
||||
@@ -170,9 +160,8 @@ function renderLatestCard() {
|
||||
</div>`;
|
||||
}
|
||||
function filteredHistory() {
|
||||
const rigImages = sstvRigImages();
|
||||
const text = filterText.trim().toLowerCase();
|
||||
const matching = text ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : rigImages;
|
||||
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice();
|
||||
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||
return matching;
|
||||
@@ -252,11 +241,7 @@ sstvWindow.trxPluginRuntime.registerDecoder({
|
||||
onMessage: onImage,
|
||||
restore: restoreHistory,
|
||||
prune: renderHistoryTable,
|
||||
reset: resetHistoryView,
|
||||
rerender: () => {
|
||||
renderLatestCard();
|
||||
renderHistoryTable();
|
||||
}
|
||||
reset: resetHistoryView
|
||||
});
|
||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "sstv_progress",
|
||||
|
||||
@@ -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,10 +52,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
||||
<span class="tab-label">Bookmarks</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="logbook">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-logbook"/></svg>
|
||||
<span class="tab-label">Logbook</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="digital-modes" aria-label="Digital modes">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
|
||||
<span class="tab-label">Digital modes</span>
|
||||
@@ -69,10 +63,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
||||
<span class="tab-label">Map</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="satellites">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-satellite"/></svg>
|
||||
<span class="tab-label">Satellites</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="statistics">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
||||
<span class="tab-label">Statistics</span>
|
||||
@@ -485,111 +475,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">
|
||||
@@ -1033,6 +918,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 +960,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 +1117,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 +1462,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">
|
||||
|
||||
@@ -3816,12 +3816,11 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
@media (max-width: 760px) {
|
||||
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
|
||||
}
|
||||
/* One navigation model at every width. Satellites, Statistics, Recorder,
|
||||
* Settings and About are occasional destinations: they live behind More rather
|
||||
* than competing with the operating tabs for the row and then scrolling out of
|
||||
/* One navigation model at every width. Statistics, Recorder, Settings and
|
||||
* About are occasional destinations: they live behind More rather than
|
||||
* competing with the operating tabs for the row and then scrolling out of
|
||||
* reach. The mobile layout already grouped them this way; the desktop strip
|
||||
* now matches it, which is why the tab strip no longer needs to scroll. */
|
||||
.tab-bar-nav .tab[data-tab="satellites"],
|
||||
.tab-bar-nav .tab[data-tab="statistics"],
|
||||
.tab-bar-nav .tab[data-tab="recorder"],
|
||||
.tab-bar-nav .tab[data-tab="settings"],
|
||||
@@ -4031,9 +4030,8 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
|
||||
}
|
||||
.tab-icon, .tab-more-icon { display: block; }
|
||||
.tab[data-tab="satellites"], .tab[data-tab="statistics"],
|
||||
.tab[data-tab="recorder"], .tab[data-tab="settings"],
|
||||
.tab[data-tab="about"] { display: none; }
|
||||
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
|
||||
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
|
||||
.mobile-more-btn { display: flex; }
|
||||
.mobile-more-menu {
|
||||
position: fixed;
|
||||
@@ -5589,6 +5587,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
#sch-sat-form-cancel:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
.bgd-toggle-wrap {
|
||||
min-width: 18rem;
|
||||
flex: 1 1 20rem;
|
||||
}
|
||||
.bgd-toggle-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-height: var(--control-height);
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
.bgd-add-row {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
@@ -5612,6 +5622,48 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.bgd-status-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.bgd-status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 0.55rem;
|
||||
background: color-mix(in srgb, var(--card-bg) 74%, transparent);
|
||||
}
|
||||
.bgd-status-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.bgd-status-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.bgd-status-state {
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.bgd-status-state[data-state="out_of_span"],
|
||||
.bgd-status-state[data-state="waiting_for_spectrum"],
|
||||
.bgd-status-state[data-state="waiting_for_user"],
|
||||
.bgd-status-state[data-state="scheduler_has_control"],
|
||||
.bgd-status-state[data-state="inactive"],
|
||||
.bgd-status-state[data-state="handled_by_scheduler"],
|
||||
.bgd-status-state[data-state="handled_by_virtual_channel"] {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
.bgd-status-state[data-state="missing_bookmark"],
|
||||
.bgd-status-state[data-state="no_supported_decoders"] {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
/* ── "Now Playing" status card (top of scheduler & bgd panels) ──── */
|
||||
.now-playing-card {
|
||||
border-left: 3px solid var(--accent-green);
|
||||
@@ -5827,65 +5879,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.bgd-intro {
|
||||
margin: 0 0 0.9rem;
|
||||
max-width: 62ch;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
/* The switch and what the rig can hear, on one line: the second is the reason
|
||||
a selected bookmark may not be decoding, so it belongs beside the first. */
|
||||
.bgd-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.bgd-enable {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bgd-span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.bgd-span[data-tone="warn"] {
|
||||
color: var(--text-warn, #b7791f);
|
||||
}
|
||||
/* The filter and the bulk buttons sit on the list they act on, and stay on it
|
||||
when the panel narrows. */
|
||||
.bgd-list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bgd-list-toolbar .bgd-checklist-filter {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.bgd-checklist {
|
||||
max-height: 22rem;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 0.4rem;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
/* One row per bookmark: pick it here, and read what it is doing here too. */
|
||||
.bgd-checklist-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(6rem, 1fr) auto auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
@@ -5897,83 +5902,28 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
.bgd-checklist-row:hover {
|
||||
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
|
||||
}
|
||||
.bgd-checklist-row.is-selected {
|
||||
background: color-mix(in srgb, var(--accent-green) 8%, transparent);
|
||||
}
|
||||
.bgd-checklist-row input[type="checkbox"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bgd-checklist-name {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bgd-checklist-meta {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.bgd-checklist-mode {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* A dot and a word. The dot carries the state, so the word can stay a word. */
|
||||
.bgd-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
min-width: 9.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bgd-state-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: currentcolor;
|
||||
opacity: 0.55;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bgd-state[data-state="active"] {
|
||||
color: var(--accent-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
.bgd-state[data-state="active"] .bgd-state-dot {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-green) 22%, transparent);
|
||||
}
|
||||
.bgd-state[data-state="missing_bookmark"],
|
||||
.bgd-state[data-state="no_supported_decoders"] {
|
||||
color: var(--color-error, #c0392b);
|
||||
}
|
||||
.bgd-state[data-state="unselected"] {
|
||||
min-width: 0;
|
||||
}
|
||||
.bgd-checklist-empty {
|
||||
padding: 1rem 0.75rem;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* What the selection adds up to, under the list it describes. */
|
||||
.bgd-summary {
|
||||
margin-top: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
/* ── Select all / none buttons ─────────────────────────────────────── */
|
||||
/* ── Select All / Deselect All buttons ────────────────────────────── */
|
||||
.bgd-select-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.bgd-select-btn {
|
||||
@@ -5991,6 +5941,15 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
/* ── SVG State Dot Badges ─────────────────────────────────────────── */
|
||||
.bgd-state-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 4px;
|
||||
fill: currentColor;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.channel-scheduler-controls {
|
||||
flex-direction: column;
|
||||
@@ -6031,7 +5990,8 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
.bm-page-summary {
|
||||
text-align: center;
|
||||
}
|
||||
.bgd-add-row {
|
||||
.bgd-add-row,
|
||||
.bgd-status-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
@@ -6041,17 +6001,8 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
.interleave-ring-label {
|
||||
max-width: 8rem;
|
||||
}
|
||||
/* Two lines rather than four columns: the name and its state stay legible
|
||||
when there is no room for the frequency beside them. */
|
||||
.bgd-checklist-row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
row-gap: 0.2rem;
|
||||
}
|
||||
.bgd-checklist-meta,
|
||||
.bgd-state {
|
||||
grid-column: 2;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
.bgd-checklist-meta {
|
||||
margin-left: 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -6388,248 +6339,3 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
}
|
||||
|
||||
#tab-statistics > .section-heading { margin-bottom: 0; }
|
||||
|
||||
/* =========================================================================
|
||||
Logbook
|
||||
========================================================================= */
|
||||
.log-panel {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
/* Who the contacts are logged under, and from where: shown once, not typed
|
||||
into every entry. */
|
||||
.log-station {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.log-station-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.log-station-call {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text);
|
||||
}
|
||||
.log-station-operator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.log-station-operator input {
|
||||
width: 7rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.log-station-rig {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
min-width: 0;
|
||||
}
|
||||
.log-station-grid {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.log-clock {
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 0.3rem;
|
||||
background: color-mix(in srgb, var(--card-bg) 70%, transparent);
|
||||
}
|
||||
/* A browser clock that disagrees with the radio's is worth saying out loud:
|
||||
the times in a log are the one thing that cannot be corrected later. */
|
||||
.log-clock.is-adrift {
|
||||
color: var(--accent-yellow, #b7791f);
|
||||
font-weight: 600;
|
||||
}
|
||||
.log-entry {
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.85rem;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.log-entry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
|
||||
gap: 0.6rem 0.75rem;
|
||||
}
|
||||
.log-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.log-field input {
|
||||
min-width: 0;
|
||||
}
|
||||
.log-field-call {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.log-field-call input {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.log-field-narrow {
|
||||
max-width: 10rem;
|
||||
}
|
||||
.log-entry-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.log-entry-buttons {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.log-worked {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.log-worked.is-worked {
|
||||
color: var(--accent-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
.log-primary-btn {
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.4rem 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.log-secondary-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.log-secondary-btn:hover {
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.log-list-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.log-list-toolbar .status-input {
|
||||
max-width: 11rem;
|
||||
}
|
||||
.log-toolbar-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.log-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.log-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
.log-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-table td {
|
||||
padding: 0.4rem 0.65rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-light) 60%, transparent);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.log-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.log-table tbody tr:hover {
|
||||
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
|
||||
}
|
||||
.log-cell-call {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
font-variant-numeric: normal;
|
||||
}
|
||||
.log-row-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 0.3rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.log-row-btn:hover {
|
||||
color: var(--text);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
.log-empty {
|
||||
padding: 1.5rem 0.75rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
white-space: normal;
|
||||
}
|
||||
.log-summary {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
/* The ham layout works the bands: the broadcast furniture goes, and the entry
|
||||
form is the thing the panel opens on. */
|
||||
body[data-operator-layout="ham"] .controls-col-wfm { display: none !important; }
|
||||
body[data-operator-layout="ham"] #rds-panel { display: none !important; }
|
||||
@media (max-width: 700px) {
|
||||
.log-station,
|
||||
.log-entry-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.log-entry-buttons {
|
||||
margin-left: 0;
|
||||
}
|
||||
.log-field-call {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.log-list-toolbar .status-input {
|
||||
max-width: none;
|
||||
flex: 1 1 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,8 @@ await build({
|
||||
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
||||
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
||||
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
|
||||
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
|
||||
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
||||
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
||||
logbook: path.join(sourceDir, "plugins", "logbook.ts"),
|
||||
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
||||
"map-core": path.join(sourceDir, "map-core.ts"),
|
||||
screenshot: path.join(sourceDir, "screenshot.ts"),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && 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 && node tests/mobile-layout.mjs",
|
||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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,12 @@ 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;
|
||||
@@ -1468,13 +1466,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 +1516,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 +2206,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 +2225,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 +3675,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;
|
||||
@@ -4872,7 +4849,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 +4889,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 +5011,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 +5164,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 +5241,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);
|
||||
@@ -6619,49 +6578,11 @@ 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();
|
||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6711,9 +6632,9 @@ function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
|
||||
// Record statistics for restored history messages.
|
||||
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||
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);
|
||||
}
|
||||
|
||||
+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
|
||||
@@ -17,10 +15,8 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
||||
],
|
||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||
satellites: ["/satellite-predictions.js"],
|
||||
statistics: ["/map-core.js"],
|
||||
bookmarks: ["/bookmarks.js"],
|
||||
logbook: ["/logbook.js"],
|
||||
recorder: [],
|
||||
settings: ["/vchan.js", "/scheduler.js"],
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -14,13 +14,11 @@
|
||||
|
||||
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;
|
||||
@@ -34,7 +32,6 @@ interface SstvImage {
|
||||
}
|
||||
|
||||
interface SstvProgress {
|
||||
rig_id?: string | null;
|
||||
state?: string;
|
||||
mode?: string;
|
||||
width?: number;
|
||||
@@ -188,9 +185,6 @@ function paintRow(line: number, rgb: Uint8Array): void {
|
||||
// ── Server messages ─────────────────────────────────────────────────
|
||||
|
||||
function onProgress(msg: SstvProgress): void {
|
||||
// The live canvas draws one picture at a time: a background rig's rows would
|
||||
// paint straight over the one being watched.
|
||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||
if (msg.state) {
|
||||
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||
// A state update carries the geometry; a row update carries only the row.
|
||||
@@ -214,9 +208,6 @@ function onImage(msg: SstvImage): void {
|
||||
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||
pruneHistory();
|
||||
|
||||
// Kept in the history either way, so switching to that rig finds it there.
|
||||
if (!isActiveRigDecode(image.rig_id)) return;
|
||||
|
||||
if (sstvDom.status) {
|
||||
sstvDom.status.textContent = image.complete
|
||||
? `Received ${image.mode ?? "picture"}`
|
||||
@@ -228,15 +219,9 @@ function onImage(msg: SstvImage): void {
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
/** The pictures the selected rig decoded, oldest first, as the history is kept. */
|
||||
function sstvRigImages(): SstvImage[] {
|
||||
return forActiveRig(sstvHistory);
|
||||
}
|
||||
|
||||
function renderLatestCard(): void {
|
||||
if (!sstvDom.liveLatest) return;
|
||||
const rigImages = sstvRigImages();
|
||||
const latest = rigImages[rigImages.length - 1];
|
||||
const latest = sstvHistory[sstvHistory.length - 1];
|
||||
if (!latest) {
|
||||
sstvDom.liveLatest.innerHTML = "";
|
||||
return;
|
||||
@@ -260,12 +245,10 @@ function renderLatestCard(): void {
|
||||
}
|
||||
|
||||
function filteredHistory(): SstvImage[] {
|
||||
// Every rig's pictures are kept; the panel shows the selected rig's.
|
||||
const rigImages = sstvRigImages();
|
||||
const text = filterText.trim().toLowerCase();
|
||||
const matching = text
|
||||
? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
|
||||
: rigImages;
|
||||
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
|
||||
: sstvHistory.slice();
|
||||
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||
return matching;
|
||||
@@ -365,7 +348,6 @@ sstvWindow.trxPluginRuntime.registerDecoder({
|
||||
restore: restoreHistory,
|
||||
prune: renderHistoryTable,
|
||||
reset: resetHistoryView,
|
||||
rerender: () => { renderLatestCard(); renderHistoryTable(); },
|
||||
});
|
||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||
id: "sstv_progress",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
-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();
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -34,14 +34,7 @@ const DECODER_REGISTRY = [
|
||||
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
|
||||
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
||||
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
|
||||
].map((decoder) => ({
|
||||
...decoder,
|
||||
// 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"],
|
||||
@@ -139,12 +132,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,
|
||||
@@ -240,7 +230,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 +269,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,22 +172,7 @@ 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
|
||||
}
|
||||
static_asset_response(&req, content_type, entry)
|
||||
}
|
||||
|
||||
/// Serve a received SSTV picture out of the local cache.
|
||||
@@ -294,12 +215,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 +233,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 +243,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 +349,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!(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -665,8 +639,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)
|
||||
@@ -708,17 +680,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 +839,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() {
|
||||
|
||||
@@ -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
@@ -1,299 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Generating the example configuration from the config structs.
|
||||
//!
|
||||
//! `trx-rs.toml.example` used to be maintained by hand and had fallen years
|
||||
//! behind the code — no `[[rigs]]`, no `[[remotes]]`, no `[timeouts]`, no
|
||||
//! bandplan settings. It is now produced from the structs themselves, so a new
|
||||
//! field appears in the example the moment it exists, and a test fails if the
|
||||
//! checked-in copy drifts.
|
||||
//!
|
||||
//! Section comments come from the table below. A section without an entry is
|
||||
//! still emitted — only its explanatory text is missing — so forgetting to add
|
||||
//! one can never drop a setting from the example.
|
||||
|
||||
use toml_edit::{DocumentMut, Item};
|
||||
|
||||
use crate::{ClientConfig, ServerConfig};
|
||||
|
||||
const HEADER: &str = "\
|
||||
# trx-rs example configuration
|
||||
#
|
||||
# Generated from the config structs; regenerate with:
|
||||
# cargo run -p trx-config --example generate_example
|
||||
#
|
||||
# Both sections are optional: trx-server reads [trx-server], trx-client reads
|
||||
# [trx-client], and either may live in its own file with the section header
|
||||
# omitted. Any string may use ${ENV_VAR}, and credentials may be moved out of
|
||||
# this file with the matching *_file keys.
|
||||
#
|
||||
# Check a config without starting anything:
|
||||
# trx-server --check-config --config trx-rs.toml
|
||||
# trx-client --check-config --config trx-rs.toml
|
||||
";
|
||||
|
||||
/// Explanatory comments for config sections, keyed by dotted path.
|
||||
const SECTION_COMMENTS: &[(&str, &str)] = &[
|
||||
("trx-server", "Server: drives the radio hardware."),
|
||||
(
|
||||
"trx-server.general",
|
||||
"Station identity. Coordinates feed PSKReporter and the map.",
|
||||
),
|
||||
(
|
||||
"trx-server.rig",
|
||||
"Single-rig layout. For several radios, delete this and use [[rigs]].",
|
||||
),
|
||||
(
|
||||
"trx-server.rig.access",
|
||||
"How to reach the radio: serial, tcp, or sdr.",
|
||||
),
|
||||
("trx-server.behavior", "CAT polling and retry behaviour."),
|
||||
(
|
||||
"trx-server.listen",
|
||||
"JSON control listener that trx-client connects to.",
|
||||
),
|
||||
(
|
||||
"trx-server.listen.auth",
|
||||
"Tokens clients must present. Empty means no authentication.\n\
|
||||
Use tokens_file = \"/etc/trx-rs/tokens\" to keep them out of this file.",
|
||||
),
|
||||
("trx-server.audio", "Opus audio stream for trx-client."),
|
||||
(
|
||||
"trx-server.decoders",
|
||||
"Which decoders run. Trimming this list saves real CPU on small boxes.\n\
|
||||
Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr.\n\
|
||||
output_dir sets where sstv/wefax/lrpt write images (default: user cache dir).",
|
||||
),
|
||||
(
|
||||
"trx-server.pskreporter",
|
||||
"Report FT8/FT4/WSPR spots to pskreporter.info.",
|
||||
),
|
||||
(
|
||||
"trx-server.aprsfi",
|
||||
"Forward received APRS frames to APRS-IS.",
|
||||
),
|
||||
(
|
||||
"trx-server.decode_logs",
|
||||
"Write decodes to JSON Lines files.",
|
||||
),
|
||||
(
|
||||
"trx-server.sdr",
|
||||
"SoapySDR pipeline; used when [rig.access] type = \"sdr\".",
|
||||
),
|
||||
(
|
||||
"trx-server.sdr.gain",
|
||||
"\"auto\" for hardware AGC, or \"manual\".",
|
||||
),
|
||||
(
|
||||
"trx-server.sdr.squelch",
|
||||
"Software squelch on demodulated audio.",
|
||||
),
|
||||
(
|
||||
"trx-server.sdr.noise_blanker",
|
||||
"Impulse-noise suppression on the IQ stream.",
|
||||
),
|
||||
(
|
||||
"trx-server.timeouts",
|
||||
"Timeout and buffer tuning. The defaults suit most setups.",
|
||||
),
|
||||
("trx-client", "Client: exposes the radio to users."),
|
||||
("trx-client.general", "Labels shown in the web UI."),
|
||||
(
|
||||
"trx-client.remote",
|
||||
"Legacy single-remote form; prefer [[remotes]] below.",
|
||||
),
|
||||
(
|
||||
"trx-client.frontends.http",
|
||||
"Web UI. default_rig_name and the per-rig maps are keyed by the\n\
|
||||
[[remotes]] name, not the server-side rig id.",
|
||||
),
|
||||
(
|
||||
"trx-client.frontends.http.auth",
|
||||
"Passphrase login for the web UI. rx_passphrase_file and\n\
|
||||
control_passphrase_file keep the secrets out of this file.",
|
||||
),
|
||||
(
|
||||
"trx-client.frontends.rigctl",
|
||||
"Hamlib-compatible TCP interface, one listener per rig.",
|
||||
),
|
||||
(
|
||||
"trx-client.frontends.http_json",
|
||||
"JSON-over-TCP control interface.",
|
||||
),
|
||||
(
|
||||
"trx-client.frontends.audio",
|
||||
"Where to fetch the audio stream from.",
|
||||
),
|
||||
(
|
||||
"trx-client.frontends.audio.bridge",
|
||||
"Play RX audio on a local sound device and capture TX from one.",
|
||||
),
|
||||
(
|
||||
"trx-client.logbook",
|
||||
"Station log of contacts. path defaults to logbook.jsonl in the user's\n data directory; set it to keep the log on a backed-up volume.",
|
||||
),
|
||||
];
|
||||
|
||||
/// Render the combined `trx-rs.toml.example` contents.
|
||||
pub fn combined_example() -> String {
|
||||
let mut doc = DocumentMut::new();
|
||||
doc.decor_mut().set_prefix(HEADER);
|
||||
|
||||
doc.insert("trx-server", section_item(&ServerConfig::example_config()));
|
||||
doc.insert("trx-client", section_item(&ClientConfig::example_config()));
|
||||
|
||||
// Each section was serialized on its own, so both carry table positions
|
||||
// starting at zero and would otherwise render interleaved.
|
||||
renumber_tables(&mut doc);
|
||||
|
||||
for (path, comment) in SECTION_COMMENTS {
|
||||
annotate(&mut doc, path, comment);
|
||||
}
|
||||
|
||||
doc.to_string()
|
||||
}
|
||||
|
||||
/// Renumber every table so the document renders in tree order.
|
||||
fn renumber_tables(doc: &mut DocumentMut) {
|
||||
fn walk(item: &mut Item, next: &mut usize) {
|
||||
match item {
|
||||
Item::Table(table) => {
|
||||
table.set_position(*next);
|
||||
*next += 1;
|
||||
for (_, child) in table.iter_mut() {
|
||||
walk(child, next);
|
||||
}
|
||||
}
|
||||
Item::ArrayOfTables(array) => {
|
||||
for table in array.iter_mut() {
|
||||
table.set_position(*next);
|
||||
*next += 1;
|
||||
for (_, child) in table.iter_mut() {
|
||||
walk(child, next);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut next = 0;
|
||||
for (_, item) in doc.as_table_mut().iter_mut() {
|
||||
walk(item, &mut next);
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize one config into a toml_edit table.
|
||||
fn section_item<T: serde::Serialize>(config: &T) -> Item {
|
||||
let rendered = toml::to_string_pretty(config).unwrap_or_default();
|
||||
let doc: DocumentMut = rendered.parse().expect("serialized config must re-parse");
|
||||
Item::Table(doc.as_table().clone())
|
||||
}
|
||||
|
||||
/// Attach a comment above the table at `path`, if it exists.
|
||||
fn annotate(doc: &mut DocumentMut, path: &str, comment: &str) {
|
||||
let mut item: Option<&mut Item> = None;
|
||||
for segment in path.split('.') {
|
||||
let next = match item {
|
||||
None => doc.get_mut(segment),
|
||||
Some(current) => current.as_table_mut().and_then(|t| t.get_mut(segment)),
|
||||
};
|
||||
match next {
|
||||
Some(found) => item = Some(found),
|
||||
None => return,
|
||||
}
|
||||
}
|
||||
|
||||
let Some(table) = item.and_then(|i| i.as_table_mut()) else {
|
||||
return;
|
||||
};
|
||||
let body: String = comment
|
||||
.lines()
|
||||
.map(|line| format!("# {}\n", line.trim_start()))
|
||||
.collect();
|
||||
table.decor_mut().set_prefix(format!("\n{body}"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The checked-in example must match what the structs produce, so a new
|
||||
/// config field cannot land without showing up in the example.
|
||||
#[test]
|
||||
fn test_checked_in_example_is_up_to_date() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../trx-rs.toml.example")
|
||||
.canonicalize()
|
||||
.expect("example file must exist");
|
||||
let on_disk = std::fs::read_to_string(&path).expect("example file must be readable");
|
||||
assert_eq!(
|
||||
on_disk,
|
||||
combined_example(),
|
||||
"trx-rs.toml.example is out of date; regenerate with \
|
||||
`cargo run -p trx-config --example generate_example`"
|
||||
);
|
||||
}
|
||||
|
||||
/// Nothing in the example may be derived from the machine that generated
|
||||
/// it: [decode_logs].dir defaults to the running user's cache directory,
|
||||
/// which made the generated file differ between a developer's laptop and
|
||||
/// CI, and the up-to-date test fail for everyone but its author.
|
||||
#[test]
|
||||
fn test_example_has_no_machine_specific_paths() {
|
||||
let example = combined_example();
|
||||
for dir in [dirs::home_dir(), dirs::cache_dir(), dirs::config_dir()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let dir = dir.to_string_lossy().into_owned();
|
||||
assert!(
|
||||
!example.contains(&dir),
|
||||
"the example contains this machine's {dir}; pin the value in example_config()"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_example_loads_and_validates() {
|
||||
let mut file = tempfile::NamedTempFile::new().unwrap();
|
||||
std::io::Write::write_all(&mut file, combined_example().as_bytes()).unwrap();
|
||||
|
||||
let server = ServerConfig::load_from_file(file.path()).expect("server section loads");
|
||||
assert!(
|
||||
server.unknown_keys.is_empty(),
|
||||
"the generated example must not contain unknown keys: {:?}",
|
||||
server.unknown_keys
|
||||
);
|
||||
server.config.validate().expect("server section validates");
|
||||
|
||||
let client = ClientConfig::load_from_file(file.path()).expect("client section loads");
|
||||
assert!(
|
||||
client.unknown_keys.is_empty(),
|
||||
"the generated example must not contain unknown keys: {:?}",
|
||||
client.unknown_keys
|
||||
);
|
||||
client.config.validate().expect("client section validates");
|
||||
}
|
||||
|
||||
/// Every section that gained a comment must still exist under that path.
|
||||
#[test]
|
||||
fn test_section_comments_match_real_sections() {
|
||||
let doc: DocumentMut = combined_example().parse().unwrap();
|
||||
for (path, _) in SECTION_COMMENTS {
|
||||
let mut item = None;
|
||||
for segment in path.split('.') {
|
||||
item = match item {
|
||||
None => doc.get(segment),
|
||||
Some(current) => current.as_table().and_then(|t| t.get(segment)),
|
||||
};
|
||||
assert!(
|
||||
item.is_some(),
|
||||
"commented section [{path}] no longer exists"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Loading a config section out of a TOML file.
|
||||
//!
|
||||
//! Two file shapes are accepted:
|
||||
//!
|
||||
//! - **Sectioned** — a combined `trx-rs.toml` with `[trx-server]` and/or
|
||||
//! `[trx-client]` tables. This is what `--print-config` and
|
||||
//! `trx-configurator` emit.
|
||||
//! - **Bare** — a standalone file whose root *is* the section, i.e. `[general]`
|
||||
//! and `[rig]` at the top level. Hand-written per-binary configs use this.
|
||||
//!
|
||||
//! A file that carries some other component's section but not ours is treated
|
||||
//! as "section absent" rather than as a bare file, so loading a client-only
|
||||
//! config with the server reports the missing section instead of silently
|
||||
//! falling back to defaults.
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::unknown::{describe, flatten_paths, UnknownKey};
|
||||
|
||||
/// Every section key that may appear at the root of a combined config file.
|
||||
pub const SECTION_KEYS: &[&str] = &["trx-server", "trx-client"];
|
||||
|
||||
/// A loaded config plus what the loader noticed on the way in.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigLoad<T> {
|
||||
/// The deserialized configuration.
|
||||
pub config: T,
|
||||
/// File the config came from; `None` when nothing was found and defaults
|
||||
/// were used.
|
||||
pub path: Option<PathBuf>,
|
||||
/// Keys present in the file that no config field claimed.
|
||||
pub unknown_keys: Vec<UnknownKey>,
|
||||
/// Every key path the file actually set. Defaults are indistinguishable
|
||||
/// from explicit values once deserialized, so deprecation checks need this.
|
||||
pub present_keys: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl<T: ConfigFile> ConfigLoad<T> {
|
||||
/// Log a warning for every deprecated key the file sets.
|
||||
pub fn report_deprecations(&self) {
|
||||
for message in T::deprecations(&self.present_keys) {
|
||||
tracing::warn!("{}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ConfigLoad<T> {
|
||||
/// Log every unknown key as a warning. With `strict`, also return an error
|
||||
/// so the caller can refuse to start.
|
||||
pub fn report_unknown_keys(&self, strict: bool) -> Result<(), String> {
|
||||
for key in &self.unknown_keys {
|
||||
tracing::warn!("{}", key);
|
||||
}
|
||||
if strict && !self.unknown_keys.is_empty() {
|
||||
return Err(format!(
|
||||
"{} unknown config key(s); refusing to start because --strict-config is set",
|
||||
self.unknown_keys.len()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("Failed to read config file {0}: {1}")]
|
||||
ReadError(PathBuf, String),
|
||||
|
||||
#[error("Failed to parse config file {0}: {1}")]
|
||||
ParseError(PathBuf, String),
|
||||
}
|
||||
|
||||
/// Returns the default search paths for `trx-rs.toml`
|
||||
/// (current directory → XDG config → /etc).
|
||||
fn config_search_paths() -> Vec<PathBuf> {
|
||||
let mut paths = vec![PathBuf::from("trx-rs.toml")];
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
paths.push(config_dir.join("trx-rs").join("trx-rs.toml"));
|
||||
}
|
||||
paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml"));
|
||||
paths
|
||||
}
|
||||
|
||||
/// Pick the table holding `key`'s settings out of a parsed document.
|
||||
///
|
||||
/// Returns the named section when present, the whole document when it carries
|
||||
/// no section headers at all (a bare standalone file), or `None` when the file
|
||||
/// is sectioned but has no section for `key`.
|
||||
fn select_section(table: &toml::Table, key: &str) -> Option<toml::Value> {
|
||||
if let Some(section) = table.get(key) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
let is_sectioned = SECTION_KEYS.iter().any(|k| table.contains_key(*k));
|
||||
if is_sectioned {
|
||||
return None;
|
||||
}
|
||||
Some(toml::Value::Table(table.clone()))
|
||||
}
|
||||
|
||||
/// Extract and deserialize a named section from a TOML file.
|
||||
///
|
||||
/// Returns `Ok(Some((cfg, unknown_keys)))` when the section is present and
|
||||
/// parses cleanly, `Ok(None)` when the section is absent, or `Err` on I/O /
|
||||
/// parse failure.
|
||||
type LoadedSection<T> = (T, Vec<UnknownKey>, BTreeSet<String>);
|
||||
|
||||
fn load_section_from_file<T: ConfigFile>(
|
||||
path: &Path,
|
||||
key: &str,
|
||||
) -> Result<Option<LoadedSection<T>>, ConfigError> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
|
||||
|
||||
let table: toml::Table = toml::from_str(&content)
|
||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
|
||||
|
||||
let Some(mut section) = select_section(&table, key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// ${VAR} references are expanded before deserializing, so any string in the
|
||||
// file can come from the environment.
|
||||
crate::secrets::expand_env_vars(&mut section)
|
||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e))?;
|
||||
|
||||
let present_keys = flatten_paths(§ion);
|
||||
|
||||
// Deserialize straight from the TOML value so serde applies every default,
|
||||
// recording any key no field claimed.
|
||||
let mut ignored: Vec<String> = Vec::new();
|
||||
let cfg: T = serde_ignored::deserialize(section, |path| ignored.push(path.to_string()))
|
||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
|
||||
|
||||
Ok(Some((
|
||||
cfg,
|
||||
describe(&ignored, &T::reference_value()),
|
||||
present_keys,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Trait for loading configuration from a `trx-rs.toml` section.
|
||||
pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize {
|
||||
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
|
||||
fn section_key() -> &'static str;
|
||||
|
||||
/// Warnings for deprecated keys the file sets, given every key path present
|
||||
/// in it. Defaults to none.
|
||||
fn deprecations(_present_keys: &BTreeSet<String>) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// A TOML rendering of a populated config, used to suggest corrections for
|
||||
/// unknown keys. Implementations should fill in list-valued sections such
|
||||
/// as `[[rigs]]` so keys nested inside them can be suggested too.
|
||||
fn reference_value() -> toml::Value {
|
||||
toml::Value::try_from(Self::default())
|
||||
.unwrap_or_else(|_| toml::Value::Table(toml::Table::new()))
|
||||
}
|
||||
|
||||
/// Load the section from a specific file path.
|
||||
///
|
||||
/// Accepts both a sectioned file (`[<section_key>]` at the root) and a bare
|
||||
/// file whose root is the section itself. Returns an error if the file
|
||||
/// cannot be read, is not valid TOML, or is sectioned for some other
|
||||
/// component only.
|
||||
fn load_from_file(path: &Path) -> Result<ConfigLoad<Self>, ConfigError> {
|
||||
let (config, unknown_keys, present_keys) =
|
||||
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
|
||||
ConfigError::ParseError(
|
||||
path.to_path_buf(),
|
||||
format!("missing [{}] section", Self::section_key()),
|
||||
)
|
||||
})?;
|
||||
Ok(ConfigLoad {
|
||||
config,
|
||||
path: Some(path.to_path_buf()),
|
||||
unknown_keys,
|
||||
present_keys,
|
||||
})
|
||||
}
|
||||
|
||||
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
|
||||
/// the first file that contains the expected section.
|
||||
///
|
||||
/// Falls back to `Self::default()` with no path when nothing is found.
|
||||
fn load_from_default_paths() -> Result<ConfigLoad<Self>, ConfigError> {
|
||||
for path in config_search_paths() {
|
||||
if path.exists() {
|
||||
if let Some((config, unknown_keys, present_keys)) =
|
||||
load_section_from_file::<Self>(&path, Self::section_key())?
|
||||
{
|
||||
return Ok(ConfigLoad {
|
||||
config,
|
||||
path: Some(path),
|
||||
unknown_keys,
|
||||
present_keys,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ConfigLoad {
|
||||
config: Self::default(),
|
||||
path: None,
|
||||
unknown_keys: Vec::new(),
|
||||
present_keys: BTreeSet::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn table(s: &str) -> toml::Table {
|
||||
toml::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_section_prefers_named_section() {
|
||||
let t = table("[trx-server]\n[trx-server.general]\ncallsign = \"W1AW\"\n");
|
||||
let section = select_section(&t, "trx-server").unwrap();
|
||||
assert!(section.get("general").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_section_falls_back_to_root_for_bare_file() {
|
||||
let t = table("[general]\ncallsign = \"W1AW\"\n");
|
||||
let section = select_section(&t, "trx-server").unwrap();
|
||||
assert!(section.get("general").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_section_absent_when_other_section_present() {
|
||||
let t = table("[trx-client]\n[trx-client.general]\ncallsign = \"W1AW\"\n");
|
||||
assert!(select_section(&t, "trx-server").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_section_empty_file_is_bare() {
|
||||
let t = table("");
|
||||
assert!(select_section(&t, "trx-server").is_some());
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Configuration types shared by `trx-server`, `trx-client` and
|
||||
//! `trx-configurator`.
|
||||
//!
|
||||
//! Keeping the structs, the loader and the validators in one crate means the
|
||||
//! setup wizard checks a config with exactly the same code the binaries load
|
||||
//! it with, so the two can never drift apart.
|
||||
|
||||
pub mod client;
|
||||
pub mod example;
|
||||
pub mod file;
|
||||
pub mod secrets;
|
||||
pub mod server;
|
||||
pub mod shared;
|
||||
pub mod unknown;
|
||||
pub mod url;
|
||||
|
||||
pub use client::ClientConfig;
|
||||
pub use file::{ConfigError, ConfigFile, ConfigLoad};
|
||||
pub use server::ServerConfig;
|
||||
pub use shared::{validate_log_level, validate_tokens};
|
||||
pub use unknown::UnknownKey;
|
||||
pub use url::{parse_audio_url, parse_remote_url, RemoteEndpoint};
|
||||
@@ -1,297 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Keeping credentials out of the config file.
|
||||
//!
|
||||
//! Tokens and passphrases used to have exactly one representation: written in
|
||||
//! plain text in `trx-rs.toml`, which is awkward when the config is deployed by
|
||||
//! a config-management tool, committed to a private repo, or shared between
|
||||
//! machines. Two alternatives are offered:
|
||||
//!
|
||||
//! - `${VAR}` anywhere in a config string, expanded from the environment.
|
||||
//! - A `*_file` sibling of any secret key, read from disk at startup.
|
||||
//!
|
||||
//! Plus a nudge: a config that holds secrets and is readable by group or others
|
||||
//! gets a warning.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Expand `${VAR}` references in every string in a TOML value.
|
||||
///
|
||||
/// An unset variable is an error rather than an empty string — a silently blank
|
||||
/// passphrase is the kind of thing that disables authentication by accident.
|
||||
pub fn expand_env_vars(value: &mut toml::Value) -> Result<(), String> {
|
||||
match value {
|
||||
toml::Value::String(s) => {
|
||||
if let Some(expanded) = expand_str(s)? {
|
||||
*s = expanded;
|
||||
}
|
||||
}
|
||||
toml::Value::Table(table) => {
|
||||
for (_, child) in table.iter_mut() {
|
||||
expand_env_vars(child)?;
|
||||
}
|
||||
}
|
||||
toml::Value::Array(items) => {
|
||||
for item in items.iter_mut() {
|
||||
expand_env_vars(item)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Expand `${VAR}` in one string; `None` when there was nothing to expand.
|
||||
fn expand_str(input: &str) -> Result<Option<String>, String> {
|
||||
if !input.contains("${") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut rest = input;
|
||||
while let Some(start) = rest.find("${") {
|
||||
out.push_str(&rest[..start]);
|
||||
let after = &rest[start + 2..];
|
||||
let Some(end) = after.find('}') else {
|
||||
// Unterminated: leave the rest exactly as written.
|
||||
out.push_str(&rest[start..]);
|
||||
return Ok(Some(out));
|
||||
};
|
||||
let name = &after[..end];
|
||||
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
|
||||
// Not a variable reference; pass it through untouched.
|
||||
out.push_str(&rest[start..start + 2 + end + 1]);
|
||||
} else {
|
||||
let value = std::env::var(name)
|
||||
.map_err(|_| format!("config references unset environment variable ${{{name}}}"))?;
|
||||
out.push_str(&value);
|
||||
}
|
||||
rest = &after[end + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// Read a single secret from a file: the whole file, trimmed.
|
||||
pub fn read_secret_file(path: &str, what: &str) -> Result<String, String> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?;
|
||||
let secret = content.trim().to_string();
|
||||
if secret.is_empty() {
|
||||
return Err(format!("{what}: secret file {path} is empty"));
|
||||
}
|
||||
warn_if_group_readable(Path::new(path), what);
|
||||
Ok(secret)
|
||||
}
|
||||
|
||||
/// Read a list of secrets, one per line. Blank lines and `#` comments are
|
||||
/// skipped.
|
||||
pub fn read_secret_list_file(path: &str, what: &str) -> Result<Vec<String>, String> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?;
|
||||
let secrets: Vec<String> = content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
if secrets.is_empty() {
|
||||
return Err(format!("{what}: secret file {path} contains no entries"));
|
||||
}
|
||||
warn_if_group_readable(Path::new(path), what);
|
||||
Ok(secrets)
|
||||
}
|
||||
|
||||
/// Fill `inline` from `file` when the config used the `*_file` form.
|
||||
///
|
||||
/// Setting both is an error: which one wins would be a guess.
|
||||
pub fn resolve_secret(
|
||||
inline: &mut Option<String>,
|
||||
file: &Option<String>,
|
||||
what: &str,
|
||||
) -> Result<(), String> {
|
||||
let Some(path) = file else {
|
||||
return Ok(());
|
||||
};
|
||||
if inline.is_some() {
|
||||
return Err(format!(
|
||||
"{what}: set either the value or its _file form, not both"
|
||||
));
|
||||
}
|
||||
*inline = Some(read_secret_file(path, what)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fill a token list from `file` when the config used the `*_file` form.
|
||||
pub fn resolve_secret_list(
|
||||
inline: &mut Vec<String>,
|
||||
file: &Option<String>,
|
||||
what: &str,
|
||||
) -> Result<(), String> {
|
||||
let Some(path) = file else {
|
||||
return Ok(());
|
||||
};
|
||||
if !inline.is_empty() {
|
||||
return Err(format!(
|
||||
"{what}: set either the value or its _file form, not both"
|
||||
));
|
||||
}
|
||||
*inline = read_secret_list_file(path, what)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Warn when a file holding secrets is readable beyond its owner.
|
||||
///
|
||||
/// Advisory only: plenty of valid setups (a dedicated service user, an
|
||||
/// immutable image) are fine, so this never fails the load.
|
||||
pub fn warn_if_group_readable(path: &Path, what: &str) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let Ok(meta) = std::fs::metadata(path) else {
|
||||
return;
|
||||
};
|
||||
let mode = meta.permissions().mode() & 0o077;
|
||||
if mode != 0 {
|
||||
tracing::warn!(
|
||||
"{} is readable by group or others (mode {:o}); it holds secrets ({}). \
|
||||
Consider: chmod 600 {}",
|
||||
path.display(),
|
||||
meta.permissions().mode() & 0o777,
|
||||
what,
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = (path, what);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn test_expand_leaves_plain_strings_alone() {
|
||||
assert_eq!(expand_str("plain").unwrap(), None);
|
||||
assert_eq!(expand_str("TRXRS-%YYYY%.log").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_substitutes_variable() {
|
||||
std::env::set_var("TRX_TEST_TOKEN", "s3cret");
|
||||
assert_eq!(
|
||||
expand_str("Bearer ${TRX_TEST_TOKEN}!").unwrap().as_deref(),
|
||||
Some("Bearer s3cret!")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_errors_on_unset_variable() {
|
||||
let err = expand_str("${TRX_DEFINITELY_NOT_SET_12345}").unwrap_err();
|
||||
assert!(err.contains("unset environment variable"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_passes_through_non_variables() {
|
||||
assert_eq!(
|
||||
expand_str("${not a var}").unwrap().as_deref(),
|
||||
Some("${not a var}")
|
||||
);
|
||||
assert_eq!(
|
||||
expand_str("unterminated ${VAR").unwrap().as_deref(),
|
||||
Some("unterminated ${VAR")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_walks_nested_tables_and_arrays() {
|
||||
std::env::set_var("TRX_TEST_HOST", "radio.example.com");
|
||||
let mut value: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[remote]
|
||||
url = "${TRX_TEST_HOST}:4530"
|
||||
hosts = ["${TRX_TEST_HOST}"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
expand_env_vars(&mut value).unwrap();
|
||||
assert_eq!(
|
||||
value["remote"]["url"].as_str(),
|
||||
Some("radio.example.com:4530")
|
||||
);
|
||||
assert_eq!(
|
||||
value["remote"]["hosts"][0].as_str(),
|
||||
Some("radio.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
fn temp_file(content: &str) -> tempfile::NamedTempFile {
|
||||
let mut f = tempfile::NamedTempFile::new().unwrap();
|
||||
f.write_all(content.as_bytes()).unwrap();
|
||||
f.flush().unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_secret_file_trims() {
|
||||
let f = temp_file(" hunter2\n");
|
||||
assert_eq!(read_secret_file(path_of(&f), "test").unwrap(), "hunter2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_secret_file_rejects_empty() {
|
||||
let f = temp_file(" \n");
|
||||
assert!(read_secret_file(path_of(&f), "test").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_secret_list_skips_blanks_and_comments() {
|
||||
let f = temp_file("# tokens\nalpha\n\n beta \n");
|
||||
assert_eq!(
|
||||
read_secret_list_file(path_of(&f), "test").unwrap(),
|
||||
vec!["alpha".to_string(), "beta".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_secret_fills_from_file() {
|
||||
let f = temp_file("from-file");
|
||||
let mut inline = None;
|
||||
resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap();
|
||||
assert_eq!(inline.as_deref(), Some("from-file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_secret_rejects_both_forms() {
|
||||
let f = temp_file("from-file");
|
||||
let mut inline = Some("inline".to_string());
|
||||
let err = resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err();
|
||||
assert!(err.contains("not both"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_secret_is_a_no_op_without_file() {
|
||||
let mut inline = Some("inline".to_string());
|
||||
resolve_secret(&mut inline, &None, "test").unwrap();
|
||||
assert_eq!(inline.as_deref(), Some("inline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_secret_list_rejects_both_forms() {
|
||||
let f = temp_file("alpha");
|
||||
let mut inline = vec!["inline".to_string()];
|
||||
let err =
|
||||
resolve_secret_list(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err();
|
||||
assert!(err.contains("not both"), "{err}");
|
||||
}
|
||||
|
||||
fn path_of(f: &tempfile::NamedTempFile) -> &str {
|
||||
f.path().to_str().unwrap()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,263 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Reporting for config keys the deserializer ignored.
|
||||
//!
|
||||
//! Every config struct is `#[serde(default)]`, so a misspelled key used to be
|
||||
//! dropped without a word and the setting silently kept its default. The
|
||||
//! loader now collects the ignored key paths and pairs each with the closest
|
||||
//! known key at the same level, so `prot = 9999` reads as a typo instead of
|
||||
//! looking like it worked.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt;
|
||||
|
||||
/// A config key that the deserializer did not recognise.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnknownKey {
|
||||
/// Dotted path of the key, e.g. `listen.prot` or `rigs.0.audio.prot`.
|
||||
pub path: String,
|
||||
/// Closest known key at the same level, when one is near enough to suggest.
|
||||
pub suggestion: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Display for UnknownKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match &self.suggestion {
|
||||
Some(s) => write!(
|
||||
f,
|
||||
"unknown config key '{}' (did you mean '{}'?)",
|
||||
self.path, s
|
||||
),
|
||||
None => write!(f, "unknown config key '{}'", self.path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten a TOML value into the set of dotted key paths it contains.
|
||||
///
|
||||
/// Array indices are normalised to `0` so a path inside `[[rigs]]` matches
|
||||
/// whichever entry it came from.
|
||||
pub fn flatten_paths(value: &toml::Value) -> BTreeSet<String> {
|
||||
let mut paths = BTreeSet::new();
|
||||
walk(value, "", &mut paths);
|
||||
paths
|
||||
}
|
||||
|
||||
fn walk(value: &toml::Value, prefix: &str, paths: &mut BTreeSet<String>) {
|
||||
let join = |seg: &str| {
|
||||
if prefix.is_empty() {
|
||||
seg.to_string()
|
||||
} else {
|
||||
format!("{prefix}.{seg}")
|
||||
}
|
||||
};
|
||||
match value {
|
||||
toml::Value::Table(table) => {
|
||||
for (key, child) in table {
|
||||
let path = join(key);
|
||||
paths.insert(path.clone());
|
||||
walk(child, &path, paths);
|
||||
}
|
||||
}
|
||||
toml::Value::Array(items) => {
|
||||
// Every entry of an array of tables has the same shape, so collapse
|
||||
// them onto index 0 and let one entry stand for all.
|
||||
for item in items {
|
||||
let path = join("0");
|
||||
walk(item, &path, paths);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace numeric path segments with `0` so array entries compare equal.
|
||||
fn normalize(path: &str) -> String {
|
||||
path.split('.')
|
||||
.map(|seg| {
|
||||
if !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit()) {
|
||||
"0"
|
||||
} else {
|
||||
seg
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
/// Suggest the closest known key that sits at the same level as `path`.
|
||||
///
|
||||
/// Returns `None` when nothing is close enough to be worth printing.
|
||||
pub fn suggest(path: &str, known: &BTreeSet<String>) -> Option<String> {
|
||||
let normalized = normalize(path);
|
||||
let (parent, leaf) = match normalized.rsplit_once('.') {
|
||||
Some((parent, leaf)) => (parent, leaf),
|
||||
None => ("", normalized.as_str()),
|
||||
};
|
||||
|
||||
// Anything longer than this is a different word, not a typo.
|
||||
let limit = (leaf.chars().count() / 3).clamp(1, 3);
|
||||
let mut best: Option<(usize, &str)> = None;
|
||||
|
||||
for candidate in known {
|
||||
let (cand_parent, cand_leaf) = match candidate.rsplit_once('.') {
|
||||
Some((p, l)) => (p, l),
|
||||
None => ("", candidate.as_str()),
|
||||
};
|
||||
if cand_parent != parent || cand_leaf == leaf {
|
||||
continue;
|
||||
}
|
||||
let distance = edit_distance(leaf, cand_leaf);
|
||||
if distance <= limit && best.is_none_or(|(best_d, _)| distance < best_d) {
|
||||
best = Some((distance, cand_leaf));
|
||||
}
|
||||
}
|
||||
|
||||
best.map(|(_, leaf)| {
|
||||
if parent.is_empty() {
|
||||
leaf.to_string()
|
||||
} else {
|
||||
format!("{parent}.{leaf}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Pair each ignored path with a suggestion drawn from `reference`.
|
||||
pub fn describe(paths: &[String], reference: &toml::Value) -> Vec<UnknownKey> {
|
||||
let known = flatten_paths(reference);
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| UnknownKey {
|
||||
path: path.clone(),
|
||||
suggestion: suggest(path, &known),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Optimal string alignment distance: Levenshtein plus transpositions, so the
|
||||
/// common `port` → `prot` slip counts as one mistake rather than two.
|
||||
fn edit_distance(a: &str, b: &str) -> usize {
|
||||
let a: Vec<char> = a.chars().collect();
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
if a.is_empty() {
|
||||
return b.len();
|
||||
}
|
||||
if b.is_empty() {
|
||||
return a.len();
|
||||
}
|
||||
|
||||
let mut rows = vec![vec![0usize; b.len() + 1]; a.len() + 1];
|
||||
for (i, row) in rows.iter_mut().enumerate() {
|
||||
row[0] = i;
|
||||
}
|
||||
for (j, cell) in rows[0].iter_mut().enumerate() {
|
||||
*cell = j;
|
||||
}
|
||||
|
||||
for i in 1..=a.len() {
|
||||
for j in 1..=b.len() {
|
||||
let cost = usize::from(a[i - 1] != b[j - 1]);
|
||||
let mut best = (rows[i - 1][j] + 1)
|
||||
.min(rows[i][j - 1] + 1)
|
||||
.min(rows[i - 1][j - 1] + cost);
|
||||
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
|
||||
best = best.min(rows[i - 2][j - 2] + 1);
|
||||
}
|
||||
rows[i][j] = best;
|
||||
}
|
||||
}
|
||||
rows[a.len()][b.len()]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn reference() -> toml::Value {
|
||||
toml::from_str(
|
||||
r#"
|
||||
[general]
|
||||
callsign = "N0CALL"
|
||||
log_level = "info"
|
||||
|
||||
[listen]
|
||||
enabled = true
|
||||
port = 4530
|
||||
|
||||
[[rigs]]
|
||||
id = "hf"
|
||||
[rigs.audio]
|
||||
port = 4531
|
||||
sample_rate = 48000
|
||||
"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_collects_nested_paths() {
|
||||
let paths = flatten_paths(&reference());
|
||||
assert!(paths.contains("general.callsign"));
|
||||
assert!(paths.contains("listen.port"));
|
||||
assert!(paths.contains("rigs.0.audio.sample_rate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_finds_close_sibling() {
|
||||
let known = flatten_paths(&reference());
|
||||
assert_eq!(
|
||||
suggest("listen.prot", &known).as_deref(),
|
||||
Some("listen.port")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_inside_array_entry() {
|
||||
let known = flatten_paths(&reference());
|
||||
assert_eq!(
|
||||
suggest("rigs.1.audio.prot", &known).as_deref(),
|
||||
Some("rigs.0.audio.port")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_ignores_distant_names() {
|
||||
let known = flatten_paths(&reference());
|
||||
assert_eq!(suggest("listen.bananas", &known), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_does_not_cross_levels() {
|
||||
let known = flatten_paths(&reference());
|
||||
// `port` exists under [listen], but not under [general].
|
||||
assert_eq!(suggest("general.port", &known), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_describe_formats_message() {
|
||||
let described = describe(&["listen.prot".to_string()], &reference());
|
||||
assert_eq!(
|
||||
described[0].to_string(),
|
||||
"unknown config key 'listen.prot' (did you mean 'listen.port'?)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_describe_without_suggestion() {
|
||||
let described = describe(&["listen.bananas".to_string()], &reference());
|
||||
assert_eq!(
|
||||
described[0].to_string(),
|
||||
"unknown config key 'listen.bananas'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_distance_counts_transposition_once() {
|
||||
assert_eq!(edit_distance("port", "prot"), 1);
|
||||
assert_eq!(edit_distance("port", "port"), 0);
|
||||
assert_eq!(edit_distance("", "port"), 4);
|
||||
assert_eq!(edit_distance("sample_rat", "sample_rate"), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Parsing for the `host:port` endpoint URLs used by the client's `[[remotes]]`
|
||||
//! and `[frontends.audio]` settings.
|
||||
//!
|
||||
//! These live next to the config structs because validation needs them; the
|
||||
//! client re-exports them for its connection code.
|
||||
|
||||
/// Default port for the server's JSON control listener.
|
||||
pub const DEFAULT_REMOTE_PORT: u16 = 4530;
|
||||
/// Default port for the server's Opus audio listener.
|
||||
pub const DEFAULT_AUDIO_PORT: u16 = 4531;
|
||||
|
||||
/// A resolved `host:port` pair.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RemoteEndpoint {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl RemoteEndpoint {
|
||||
/// Format as a connect string, bracketing bare IPv6 hosts.
|
||||
pub fn connect_addr(&self) -> String {
|
||||
if self.host.contains(':') && !self.host.starts_with('[') {
|
||||
format!("[{}]:{}", self.host, self.port)
|
||||
} else {
|
||||
format!("{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a remote control URL, defaulting to port 4530.
|
||||
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
|
||||
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
|
||||
}
|
||||
|
||||
/// Parse an audio stream URL, defaulting to port 4531.
|
||||
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
|
||||
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
|
||||
}
|
||||
|
||||
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
|
||||
let trimmed = url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("{kind} url is empty"));
|
||||
}
|
||||
|
||||
let addr = trimmed
|
||||
.strip_prefix("tcp://")
|
||||
.or_else(|| trimmed.strip_prefix("http-json://"))
|
||||
.or_else(|| trimmed.strip_prefix("audio://"))
|
||||
.unwrap_or(trimmed);
|
||||
|
||||
parse_host_port(addr, default_port, kind)
|
||||
}
|
||||
|
||||
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
|
||||
if let Some(rest) = input.strip_prefix('[') {
|
||||
let closing = rest
|
||||
.find(']')
|
||||
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
|
||||
let host = &rest[..closing];
|
||||
let remainder = &rest[closing + 1..];
|
||||
if host.is_empty() {
|
||||
return Err(format!("invalid {kind} url: host is empty"));
|
||||
}
|
||||
let port = if remainder.is_empty() {
|
||||
default_port
|
||||
} else if let Some(port_str) = remainder.strip_prefix(':') {
|
||||
parse_port(port_str, kind)?
|
||||
} else {
|
||||
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
|
||||
};
|
||||
return Ok(RemoteEndpoint {
|
||||
host: host.to_string(),
|
||||
port,
|
||||
});
|
||||
}
|
||||
|
||||
if input.contains(':') {
|
||||
if input.matches(':').count() > 1 {
|
||||
return Err(format!(
|
||||
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
|
||||
));
|
||||
}
|
||||
let (host, port_str) = input
|
||||
.rsplit_once(':')
|
||||
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
|
||||
if host.is_empty() {
|
||||
return Err(format!("invalid {kind} url: host is empty"));
|
||||
}
|
||||
return Ok(RemoteEndpoint {
|
||||
host: host.to_string(),
|
||||
port: parse_port(port_str, kind)?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RemoteEndpoint {
|
||||
host: input.to_string(),
|
||||
port: default_port,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
|
||||
let port: u16 = port_str
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
|
||||
if port == 0 {
|
||||
return Err(format!("invalid {kind} port: 0"));
|
||||
}
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_defaults_port() {
|
||||
let ep = parse_remote_url("example.com").unwrap();
|
||||
assert_eq!(ep.host, "example.com");
|
||||
assert_eq!(ep.port, DEFAULT_REMOTE_PORT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_audio_url_defaults_port() {
|
||||
let ep = parse_audio_url("example.com").unwrap();
|
||||
assert_eq!(ep.port, DEFAULT_AUDIO_PORT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_strips_schemes() {
|
||||
for url in &[
|
||||
"tcp://host:9000",
|
||||
"http-json://host:9000",
|
||||
"audio://host:9000",
|
||||
] {
|
||||
let ep = parse_remote_url(url).unwrap();
|
||||
assert_eq!(ep.host, "host");
|
||||
assert_eq!(ep.port, 9000);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_bracketed_ipv6() {
|
||||
let ep = parse_remote_url("[::1]:4532").unwrap();
|
||||
assert_eq!(ep.host, "::1");
|
||||
assert_eq!(ep.port, 4532);
|
||||
assert_eq!(ep.connect_addr(), "[::1]:4532");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rejects_unbracketed_ipv6() {
|
||||
assert!(parse_remote_url("::1:4532").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rejects_empty_host() {
|
||||
assert!(parse_remote_url(":4530").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rejects_zero_port() {
|
||||
assert!(parse_remote_url("host:0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rejects_empty_url() {
|
||||
assert!(parse_remote_url(" ").is_err());
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,7 @@ path = "src/main.rs"
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
dialoguer = "0.11"
|
||||
tokio-serial = { workspace = true }
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
trx-config = { path = "../trx-config" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
+319
-136
@@ -2,37 +2,80 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! `trx-configurator --check`: run a config file through the same loader and
|
||||
//! validators the binaries use.
|
||||
//!
|
||||
//! This used to be a second, hand-maintained implementation — lists of known
|
||||
//! keys and a handful of re-implemented range checks — which drifted out of
|
||||
//! date as soon as a field was added. It now defers entirely to `trx-config`,
|
||||
//! so a config that checks clean here is one the binaries will accept.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path;
|
||||
|
||||
use trx_config::{ClientConfig, ServerConfig};
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
/// Top-level keys that only appear in a server config. Used solely to guess
|
||||
/// what a section-less file is meant to be; the real key checking is done by
|
||||
/// the loader.
|
||||
const SERVER_MARKERS: &[&str] = &[
|
||||
/// Known top-level keys for a standalone server config.
|
||||
const SERVER_KEYS: &[&str] = &[
|
||||
"general",
|
||||
"rig",
|
||||
"rigs",
|
||||
"listen",
|
||||
"behavior",
|
||||
"listen",
|
||||
"audio",
|
||||
"sdr",
|
||||
"pskreporter",
|
||||
"aprsfi",
|
||||
"decode_logs",
|
||||
"timeouts",
|
||||
"audio",
|
||||
];
|
||||
|
||||
/// Top-level keys that only appear in a client config.
|
||||
const CLIENT_MARKERS: &[&str] = &["remote", "remotes", "frontends"];
|
||||
/// Known top-level keys for a standalone client config.
|
||||
const CLIENT_KEYS: &[&str] = &["general", "remote", "remotes", "frontends"];
|
||||
|
||||
/// Known top-level keys for a combined trx-rs.toml.
|
||||
const COMBINED_KEYS: &[&str] = &["trx-server", "trx-client"];
|
||||
|
||||
/// Known sub-keys within [general] (server).
|
||||
const SERVER_GENERAL_KEYS: &[&str] = &["callsign", "log_level", "latitude", "longitude"];
|
||||
|
||||
/// Known sub-keys within [general] (client).
|
||||
const CLIENT_GENERAL_KEYS: &[&str] = &[
|
||||
"callsign",
|
||||
"log_level",
|
||||
"website_url",
|
||||
"website_name",
|
||||
"ais_vessel_url_base",
|
||||
];
|
||||
|
||||
/// Known sub-keys within [rig].
|
||||
const RIG_KEYS: &[&str] = &["model", "initial_freq_hz", "initial_mode", "access"];
|
||||
|
||||
/// Known sub-keys within [rig.access].
|
||||
const ACCESS_KEYS: &[&str] = &["type", "port", "baud", "host", "tcp_port", "args"];
|
||||
|
||||
/// Known sub-keys within [listen].
|
||||
const LISTEN_KEYS: &[&str] = &["enabled", "listen", "port", "auth"];
|
||||
|
||||
/// Known sub-keys within [audio] (server).
|
||||
const AUDIO_KEYS: &[&str] = &[
|
||||
"enabled",
|
||||
"listen",
|
||||
"port",
|
||||
"rx_enabled",
|
||||
"tx_enabled",
|
||||
"device",
|
||||
"sample_rate",
|
||||
"channels",
|
||||
"frame_duration_ms",
|
||||
"bitrate_bps",
|
||||
];
|
||||
|
||||
/// Known sub-keys within [behavior].
|
||||
const BEHAVIOR_KEYS: &[&str] = &[
|
||||
"poll_interval_ms",
|
||||
"poll_interval_tx_ms",
|
||||
"max_retries",
|
||||
"retry_base_delay_ms",
|
||||
"vfo_prime",
|
||||
];
|
||||
|
||||
/// Known sub-keys within [remote].
|
||||
const REMOTE_KEYS: &[&str] = &["url", "rig_id", "auth", "poll_interval_ms"];
|
||||
|
||||
/// Known sub-keys within [frontends].
|
||||
const FRONTENDS_KEYS: &[&str] = &["http", "rigctl", "http_json", "audio"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DetectedType {
|
||||
@@ -57,38 +100,49 @@ pub fn check_file(path: &Path) -> Result<String, String> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
||||
|
||||
let table: toml::Table = toml::from_str(&content)
|
||||
// Step 1: TOML syntax check
|
||||
let doc: DocumentMut = content
|
||||
.parse()
|
||||
.map_err(|e| format!("{}: TOML syntax error: {}", path.display(), e))?;
|
||||
|
||||
let detected = detect_type(&table);
|
||||
|
||||
let mut report = String::new();
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
let table = doc.as_table();
|
||||
|
||||
// Step 2: Detect config type
|
||||
let detected = detect_type(table);
|
||||
writeln!(report, "{}: valid TOML", path.display()).unwrap();
|
||||
writeln!(report, " Detected type: {}", detected).unwrap();
|
||||
|
||||
// Step 3: Structural validation
|
||||
match detected {
|
||||
DetectedType::Server => check_server(path, &mut warnings, &mut errors),
|
||||
DetectedType::Client => check_client(path, &mut warnings, &mut errors),
|
||||
DetectedType::Server => {
|
||||
check_unknown_keys(table, SERVER_KEYS, "", &mut warnings);
|
||||
check_server_sections(table, "", &mut warnings, &mut errors);
|
||||
}
|
||||
DetectedType::Client => {
|
||||
check_unknown_keys(table, CLIENT_KEYS, "", &mut warnings);
|
||||
check_client_sections(table, "", &mut warnings, &mut errors);
|
||||
}
|
||||
DetectedType::Combined => {
|
||||
if table.contains_key("trx-server") {
|
||||
check_server(path, &mut warnings, &mut errors);
|
||||
check_unknown_keys(table, COMBINED_KEYS, "", &mut warnings);
|
||||
if let Some(server) = table.get("trx-server").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(server, SERVER_KEYS, "[trx-server].", &mut warnings);
|
||||
check_server_sections(server, "[trx-server].", &mut warnings, &mut errors);
|
||||
}
|
||||
if table.contains_key("trx-client") {
|
||||
check_client(path, &mut warnings, &mut errors);
|
||||
if let Some(client) = table.get("trx-client").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(client, CLIENT_KEYS, "[trx-client].", &mut warnings);
|
||||
check_client_sections(client, "[trx-client].", &mut warnings, &mut errors);
|
||||
}
|
||||
}
|
||||
DetectedType::Unknown => {
|
||||
warnings.push(
|
||||
"Could not detect config type. Expected server, client, or combined \
|
||||
(trx-rs.toml) layout."
|
||||
.to_string(),
|
||||
);
|
||||
warnings.push("Could not detect config type. Expected server, client, or combined (trx-rs.toml) layout.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Format report
|
||||
for w in &warnings {
|
||||
writeln!(report, " warning: {}", w).unwrap();
|
||||
}
|
||||
@@ -115,54 +169,23 @@ pub fn check_file(path: &Path) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_server(path: &Path, warnings: &mut Vec<String>, errors: &mut Vec<String>) {
|
||||
match ServerConfig::load_from_file(path) {
|
||||
Ok(loaded) => {
|
||||
warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string()));
|
||||
if let Err(e) = loaded.config.validate() {
|
||||
errors.push(format!("[trx-server] {}", e));
|
||||
}
|
||||
errors.extend(
|
||||
loaded
|
||||
.config
|
||||
.validate_sdr()
|
||||
.into_iter()
|
||||
.map(|e| format!("[trx-server] {}", e)),
|
||||
);
|
||||
}
|
||||
Err(e) => errors.push(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_client(path: &Path, warnings: &mut Vec<String>, errors: &mut Vec<String>) {
|
||||
match ClientConfig::load_from_file(path) {
|
||||
Ok(loaded) => {
|
||||
warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string()));
|
||||
if let Err(e) = loaded.config.validate() {
|
||||
errors.push(format!("[trx-client] {}", e));
|
||||
}
|
||||
}
|
||||
Err(e) => errors.push(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_type(table: &toml::Table) -> DetectedType {
|
||||
fn detect_type(table: &toml_edit::Table) -> DetectedType {
|
||||
if table.contains_key("trx-server") || table.contains_key("trx-client") {
|
||||
return DetectedType::Combined;
|
||||
}
|
||||
let keys: Vec<&str> = table.keys().map(|k| k.as_str()).collect();
|
||||
let keys: Vec<&str> = table.iter().map(|(k, _)| k).collect();
|
||||
|
||||
// Distinguishing keys first, then a simple majority.
|
||||
if keys.iter().any(|k| ["rig", "rigs", "listen"].contains(k)) {
|
||||
let server_score = keys.iter().filter(|k| SERVER_KEYS.contains(k)).count();
|
||||
let client_score = keys.iter().filter(|k| CLIENT_KEYS.contains(k)).count();
|
||||
|
||||
// Use distinguishing keys to break ties
|
||||
if keys.contains(&"rig") || keys.contains(&"rigs") || keys.contains(&"listen") {
|
||||
return DetectedType::Server;
|
||||
}
|
||||
if keys.iter().any(|k| CLIENT_MARKERS.contains(k)) {
|
||||
if keys.contains(&"remote") || keys.contains(&"remotes") || keys.contains(&"frontends") {
|
||||
return DetectedType::Client;
|
||||
}
|
||||
|
||||
let server_score = keys.iter().filter(|k| SERVER_MARKERS.contains(k)).count();
|
||||
let client_score = keys.iter().filter(|k| CLIENT_MARKERS.contains(k)).count();
|
||||
|
||||
if server_score > client_score {
|
||||
DetectedType::Server
|
||||
} else if client_score > server_score {
|
||||
@@ -174,6 +197,194 @@ fn detect_type(table: &toml::Table) -> DetectedType {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_unknown_keys(
|
||||
table: &toml_edit::Table,
|
||||
known: &[&str],
|
||||
prefix: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
for (key, _) in table.iter() {
|
||||
if !known.contains(&key) {
|
||||
warnings.push(format!("{}unknown key '{}'", prefix, key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_server_sections(
|
||||
table: &toml_edit::Table,
|
||||
prefix: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
general,
|
||||
SERVER_GENERAL_KEYS,
|
||||
&format!("{}[general].", prefix),
|
||||
warnings,
|
||||
);
|
||||
validate_log_level(general, &format!("{}[general]", prefix), errors);
|
||||
validate_coordinates(general, &format!("{}[general]", prefix), errors);
|
||||
}
|
||||
|
||||
if let Some(rig) = table.get("rig").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(rig, RIG_KEYS, &format!("{}[rig].", prefix), warnings);
|
||||
if let Some(access) = rig.get("access").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
access,
|
||||
ACCESS_KEYS,
|
||||
&format!("{}[rig.access].", prefix),
|
||||
warnings,
|
||||
);
|
||||
validate_access(access, &format!("{}[rig.access]", prefix), errors);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(listen) = table.get("listen").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
listen,
|
||||
LISTEN_KEYS,
|
||||
&format!("{}[listen].", prefix),
|
||||
warnings,
|
||||
);
|
||||
validate_port(listen, "port", &format!("{}[listen]", prefix), errors);
|
||||
}
|
||||
|
||||
if let Some(audio) = table.get("audio").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(audio, AUDIO_KEYS, &format!("{}[audio].", prefix), warnings);
|
||||
validate_port(audio, "port", &format!("{}[audio]", prefix), errors);
|
||||
}
|
||||
|
||||
if let Some(behavior) = table.get("behavior").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
behavior,
|
||||
BEHAVIOR_KEYS,
|
||||
&format!("{}[behavior].", prefix),
|
||||
warnings,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_client_sections(
|
||||
table: &toml_edit::Table,
|
||||
prefix: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
general,
|
||||
CLIENT_GENERAL_KEYS,
|
||||
&format!("{}[general].", prefix),
|
||||
warnings,
|
||||
);
|
||||
validate_log_level(general, &format!("{}[general]", prefix), errors);
|
||||
}
|
||||
|
||||
if let Some(remote) = table.get("remote").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
remote,
|
||||
REMOTE_KEYS,
|
||||
&format!("{}[remote].", prefix),
|
||||
warnings,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(frontends) = table.get("frontends").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
frontends,
|
||||
FRONTENDS_KEYS,
|
||||
&format!("{}[frontends].", prefix),
|
||||
warnings,
|
||||
);
|
||||
if let Some(http) = frontends.get("http").and_then(|v| v.as_table()) {
|
||||
validate_port(http, "port", &format!("{}[frontends.http]", prefix), errors);
|
||||
}
|
||||
if let Some(rigctl) = frontends.get("rigctl").and_then(|v| v.as_table()) {
|
||||
validate_port(
|
||||
rigctl,
|
||||
"port",
|
||||
&format!("{}[frontends.rigctl]", prefix),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Value validators ────────────────────────────────────────────────────
|
||||
|
||||
fn validate_log_level(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
|
||||
if let Some(level) = table.get("log_level").and_then(|v| v.as_str()) {
|
||||
if !["trace", "debug", "info", "warn", "error"].contains(&level) {
|
||||
errors.push(format!(
|
||||
"{}.log_level '{}' is invalid (expected: trace, debug, info, warn, error)",
|
||||
context, level
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_coordinates(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
|
||||
if let Some(lat) = table
|
||||
.get("latitude")
|
||||
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
|
||||
{
|
||||
if !(-90.0..=90.0).contains(&lat) {
|
||||
errors.push(format!(
|
||||
"{}.latitude {} is out of range (-90..90)",
|
||||
context, lat
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(lon) = table
|
||||
.get("longitude")
|
||||
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
|
||||
{
|
||||
if !(-180.0..=180.0).contains(&lon) {
|
||||
errors.push(format!(
|
||||
"{}.longitude {} is out of range (-180..180)",
|
||||
context, lon
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let has_lat = table.contains_key("latitude");
|
||||
let has_lon = table.contains_key("longitude");
|
||||
if has_lat != has_lon {
|
||||
errors.push(format!(
|
||||
"{}: latitude and longitude must be set together or both omitted",
|
||||
context
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_port(table: &toml_edit::Table, key: &str, context: &str, errors: &mut Vec<String>) {
|
||||
if let Some(port) = table.get(key).and_then(|v| v.as_integer()) {
|
||||
if let Some(enabled) = table.get("enabled").and_then(|v| v.as_bool()) {
|
||||
if enabled && port <= 0 {
|
||||
errors.push(format!("{}.{} must be > 0 when enabled", context, key));
|
||||
}
|
||||
}
|
||||
if !(0..=65535).contains(&port) {
|
||||
errors.push(format!(
|
||||
"{}.{} {} is out of range (0..65535)",
|
||||
context, key, port
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_access(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
|
||||
if let Some(access_type) = table.get("type").and_then(|v| v.as_str()) {
|
||||
if !["serial", "tcp", "sdr"].contains(&access_type) {
|
||||
errors.push(format!(
|
||||
"{}.type '{}' is invalid (expected: serial, tcp, sdr)",
|
||||
context, access_type
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -207,7 +418,8 @@ enabled = true
|
||||
port = 4530
|
||||
"#,
|
||||
);
|
||||
let report = result.expect("expected a clean report");
|
||||
assert!(result.is_ok());
|
||||
let report = result.unwrap();
|
||||
assert!(report.contains("Detected type: server"));
|
||||
assert!(report.contains("No issues found"));
|
||||
}
|
||||
@@ -220,40 +432,41 @@ port = 4530
|
||||
callsign = "W1AW"
|
||||
|
||||
[remote]
|
||||
url = "192.168.1.10:4530"
|
||||
url = "localhost:4530"
|
||||
|
||||
[frontends.http]
|
||||
enabled = true
|
||||
port = 8080
|
||||
"#,
|
||||
);
|
||||
let report = result.expect("expected a clean report");
|
||||
assert!(result.is_ok());
|
||||
let report = result.unwrap();
|
||||
assert!(report.contains("Detected type: client"));
|
||||
assert!(report.contains("No issues found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_combined_config() {
|
||||
let result = check_toml(
|
||||
r#"
|
||||
[trx-server.rig]
|
||||
model = "ft817"
|
||||
[trx-server.rig.access]
|
||||
type = "serial"
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
[trx-server.general]
|
||||
callsign = "W1AW"
|
||||
|
||||
[trx-client.general]
|
||||
callsign = "W1AW"
|
||||
|
||||
[trx-client.remote]
|
||||
url = "127.0.0.1:4530"
|
||||
url = "localhost:4530"
|
||||
"#,
|
||||
);
|
||||
let report = result.expect("expected a clean report");
|
||||
assert!(result.is_ok());
|
||||
let report = result.unwrap();
|
||||
assert!(report.contains("Detected type: combined"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_toml_syntax() {
|
||||
let result = check_toml("[general\ncallsign = \"W1AW\"\n");
|
||||
let result = check_toml("this is not [valid toml");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("TOML syntax error"));
|
||||
}
|
||||
|
||||
@@ -261,22 +474,19 @@ url = "127.0.0.1:4530"
|
||||
fn test_unknown_key_warning() {
|
||||
let result = check_toml(
|
||||
r#"
|
||||
[general]
|
||||
callsign = "W1AW"
|
||||
|
||||
[rig]
|
||||
model = "ft817"
|
||||
[rig.access]
|
||||
type = "serial"
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
|
||||
[listen]
|
||||
prot = 4530
|
||||
[bogus_section]
|
||||
foo = "bar"
|
||||
"#,
|
||||
);
|
||||
let report = result.expect("unknown keys are warnings, not errors");
|
||||
assert!(
|
||||
report.contains("unknown config key 'listen.prot' (did you mean 'listen.port'?)"),
|
||||
"unexpected report: {report}"
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
let report = result.unwrap();
|
||||
assert!(report.contains("unknown key 'bogus_section'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -288,13 +498,11 @@ log_level = "verbose"
|
||||
|
||||
[rig]
|
||||
model = "ft817"
|
||||
[rig.access]
|
||||
type = "serial"
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
"#,
|
||||
);
|
||||
assert!(result.unwrap_err().contains("log_level"));
|
||||
assert!(result.is_err());
|
||||
let report = result.unwrap_err();
|
||||
assert!(report.contains("log_level 'verbose' is invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -302,17 +510,15 @@ baud = 9600
|
||||
let result = check_toml(
|
||||
r#"
|
||||
[general]
|
||||
latitude = 52.0
|
||||
latitude = 45.0
|
||||
|
||||
[rig]
|
||||
model = "ft817"
|
||||
[rig.access]
|
||||
type = "serial"
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
"#,
|
||||
);
|
||||
assert!(result.unwrap_err().contains("longitude"));
|
||||
assert!(result.is_err());
|
||||
let report = result.unwrap_err();
|
||||
assert!(report.contains("latitude and longitude must be set together"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -320,18 +526,16 @@ baud = 9600
|
||||
let result = check_toml(
|
||||
r#"
|
||||
[general]
|
||||
latitude = 120.0
|
||||
latitude = 95.0
|
||||
longitude = 10.0
|
||||
|
||||
[rig]
|
||||
model = "ft817"
|
||||
[rig.access]
|
||||
type = "serial"
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
"#,
|
||||
);
|
||||
assert!(result.unwrap_err().contains("latitude"));
|
||||
assert!(result.is_err());
|
||||
let report = result.unwrap_err();
|
||||
assert!(report.contains("latitude 95 is out of range"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -343,31 +547,10 @@ model = "ft817"
|
||||
|
||||
[rig.access]
|
||||
type = "usb"
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
"#,
|
||||
);
|
||||
assert!(result.unwrap_err().contains("access"));
|
||||
}
|
||||
|
||||
/// The old checker only knew a fixed list of top-level keys and a few range
|
||||
/// rules, so it passed configs the server rejects at startup.
|
||||
#[test]
|
||||
fn test_catches_errors_the_key_list_checker_missed() {
|
||||
let result = check_toml(
|
||||
r#"
|
||||
[rig]
|
||||
model = "ft817"
|
||||
[rig.access]
|
||||
type = "serial"
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
|
||||
[audio]
|
||||
enabled = true
|
||||
frame_duration_ms = 7
|
||||
"#,
|
||||
);
|
||||
assert!(result.unwrap_err().contains("frame_duration_ms"));
|
||||
assert!(result.is_err());
|
||||
let report = result.unwrap_err();
|
||||
assert!(report.contains("type 'usb' is invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,13 +211,10 @@ pub fn build_server(general: ServerGeneral, rig: RigSetup, listen: ListenSetup)
|
||||
let mut doc = DocumentMut::new();
|
||||
doc.decor_mut()
|
||||
.set_prefix("# trx-server configuration\n# Generated by trx-configurator\n");
|
||||
// Emit the sectioned shape (`[trx-server]`) that trx-server writes with
|
||||
// --print-config, so a generated file can be dropped into a combined
|
||||
// trx-rs.toml unchanged.
|
||||
doc.insert(
|
||||
"trx-server",
|
||||
Item::Table(build_server_tables(general, rig, listen)),
|
||||
);
|
||||
let tables = build_server_tables(general, rig, listen);
|
||||
for (key, item) in tables.iter() {
|
||||
doc.insert(key, item.clone());
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
@@ -353,10 +350,10 @@ pub fn build_client(
|
||||
let mut doc = DocumentMut::new();
|
||||
doc.decor_mut()
|
||||
.set_prefix("# trx-client configuration\n# Generated by trx-configurator\n");
|
||||
doc.insert(
|
||||
"trx-client",
|
||||
Item::Table(build_client_tables(general, remote, frontends)),
|
||||
);
|
||||
let tables = build_client_tables(general, remote, frontends);
|
||||
for (key, item) in tables.iter() {
|
||||
doc.insert(key, item.clone());
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
@@ -456,60 +453,3 @@ pub fn write_file(doc: &DocumentMut, path: &Path) -> Result<(), String> {
|
||||
println!("Wrote {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use trx_config::{ClientConfig, ServerConfig};
|
||||
|
||||
fn write_temp(doc: &DocumentMut) -> tempfile::NamedTempFile {
|
||||
let file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
|
||||
std::fs::write(file.path(), doc.to_string()).unwrap();
|
||||
file
|
||||
}
|
||||
|
||||
/// The wizard used to emit root-level `[general]` / `[rig]` tables while the
|
||||
/// loader demanded a `[trx-server]` section, so every generated standalone
|
||||
/// config was rejected by the binary it was generated for.
|
||||
#[test]
|
||||
fn test_generated_server_config_loads_and_validates() {
|
||||
let file = write_temp(&build_default(ConfigType::Server));
|
||||
let cfg = ServerConfig::load_from_file(file.path())
|
||||
.expect("generated config must load")
|
||||
.config;
|
||||
cfg.validate().expect("generated config must validate");
|
||||
assert_eq!(cfg.rig.model.as_deref(), Some("ft817"));
|
||||
assert_eq!(cfg.listen.port, 4530);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generated_client_config_loads_and_validates() {
|
||||
let file = write_temp(&build_default(ConfigType::Client));
|
||||
let cfg = ClientConfig::load_from_file(file.path())
|
||||
.expect("generated config must load")
|
||||
.config;
|
||||
cfg.validate().expect("generated config must validate");
|
||||
assert_eq!(cfg.remote.url.as_deref(), Some("localhost:4530"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generated_combined_config_loads_both_sections() {
|
||||
let file = write_temp(&build_default(ConfigType::Combined));
|
||||
let server = ServerConfig::load_from_file(file.path())
|
||||
.expect("server section must load")
|
||||
.config;
|
||||
server.validate().expect("server section must validate");
|
||||
let client = ClientConfig::load_from_file(file.path())
|
||||
.expect("client section must load")
|
||||
.config;
|
||||
client.validate().expect("client section must validate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generated_docs_are_sectioned() {
|
||||
let doc = build_default(ConfigType::Server);
|
||||
assert!(doc.as_table().contains_key("trx-server"));
|
||||
let doc = build_default(ConfigType::Client);
|
||||
assert!(doc.as_table().contains_key("trx-client"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ cpal = "0.15"
|
||||
num-complex = "0.4"
|
||||
opus = "0.3"
|
||||
trx-app = { path = "../trx-app" }
|
||||
trx-config = { path = "../trx-config" }
|
||||
trx-backend = { path = "trx-backend", features = ["soapysdr"] }
|
||||
trx-ais = { path = "../decoders/trx-ais" }
|
||||
trx-vdes = { path = "../decoders/trx-vdes" }
|
||||
|
||||
@@ -2291,12 +2291,15 @@ pub async fn run_wefax_decoder(
|
||||
mut state_rx: watch::Receiver<RigState>,
|
||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||
histories: Arc<DecoderHistories>,
|
||||
wefax_output_dir: std::path::PathBuf,
|
||||
) {
|
||||
use trx_wefax::{WefaxConfig, WefaxDecoder, WefaxEvent};
|
||||
|
||||
info!("WEFAX decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||
|
||||
let wefax_output_dir = dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||
.join("trx-rs")
|
||||
.join("wefax");
|
||||
let config = WefaxConfig {
|
||||
output_dir: Some(wefax_output_dir.to_string_lossy().into_owned()),
|
||||
..WefaxConfig::default()
|
||||
|
||||
+1619
-5
File diff suppressed because it is too large
Load Diff
+142
-259
@@ -30,8 +30,6 @@ use trx_core::audio::AudioStreamInfo;
|
||||
|
||||
use trx_app::{init_logging, normalize_name};
|
||||
use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess};
|
||||
use trx_config::shared::BoundSocket;
|
||||
use trx_config::ConfigFile;
|
||||
use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff};
|
||||
use trx_core::rig::request::RigRequest;
|
||||
use trx_core::rig::state::RigState;
|
||||
@@ -58,12 +56,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,
|
||||
/// Rig backend to use (e.g. ft817, ft450d)
|
||||
#[arg(short = 'r', long = "rig")]
|
||||
rig: Option<String>,
|
||||
@@ -663,44 +655,38 @@ fn spawn_rig_audio_stack(
|
||||
}
|
||||
|
||||
// Spawn APRS decoder task
|
||||
if rig_cfg.decoders.is_enabled("aprs") {
|
||||
let aprs_pcm_rx = pcm_tx.subscribe();
|
||||
let aprs_state_rx = state_rx.clone();
|
||||
let aprs_decode_tx = decode_tx.clone();
|
||||
let aprs_sr = rig_cfg.audio.sample_rate;
|
||||
let aprs_ch = rig_cfg.audio.channels;
|
||||
let aprs_shutdown_rx = shutdown_rx.clone();
|
||||
let aprs_logs = decoder_logs.clone();
|
||||
let aprs_histories = histories.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs, aprs_histories) => {}
|
||||
_ = wait_for_shutdown(aprs_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
let aprs_pcm_rx = pcm_tx.subscribe();
|
||||
let aprs_state_rx = state_rx.clone();
|
||||
let aprs_decode_tx = decode_tx.clone();
|
||||
let aprs_sr = rig_cfg.audio.sample_rate;
|
||||
let aprs_ch = rig_cfg.audio.channels;
|
||||
let aprs_shutdown_rx = shutdown_rx.clone();
|
||||
let aprs_logs = decoder_logs.clone();
|
||||
let aprs_histories = histories.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs, aprs_histories) => {}
|
||||
_ = wait_for_shutdown(aprs_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
|
||||
// Spawn HF APRS decoder task
|
||||
if rig_cfg.decoders.is_enabled("aprs_hf") {
|
||||
let hf_aprs_pcm_rx = pcm_tx.subscribe();
|
||||
let hf_aprs_state_rx = state_rx.clone();
|
||||
let hf_aprs_decode_tx = decode_tx.clone();
|
||||
let hf_aprs_sr = rig_cfg.audio.sample_rate;
|
||||
let hf_aprs_ch = rig_cfg.audio.channels;
|
||||
let hf_aprs_shutdown_rx = shutdown_rx.clone();
|
||||
let hf_aprs_logs = decoder_logs.clone();
|
||||
let hf_aprs_histories = histories.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_hf_aprs_decoder(hf_aprs_sr, hf_aprs_ch as u16, hf_aprs_pcm_rx, hf_aprs_state_rx, hf_aprs_decode_tx, hf_aprs_logs, hf_aprs_histories) => {}
|
||||
_ = wait_for_shutdown(hf_aprs_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
let hf_aprs_pcm_rx = pcm_tx.subscribe();
|
||||
let hf_aprs_state_rx = state_rx.clone();
|
||||
let hf_aprs_decode_tx = decode_tx.clone();
|
||||
let hf_aprs_sr = rig_cfg.audio.sample_rate;
|
||||
let hf_aprs_ch = rig_cfg.audio.channels;
|
||||
let hf_aprs_shutdown_rx = shutdown_rx.clone();
|
||||
let hf_aprs_logs = decoder_logs.clone();
|
||||
let hf_aprs_histories = histories.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_hf_aprs_decoder(hf_aprs_sr, hf_aprs_ch as u16, hf_aprs_pcm_rx, hf_aprs_state_rx, hf_aprs_decode_tx, hf_aprs_logs, hf_aprs_histories) => {}
|
||||
_ = wait_for_shutdown(hf_aprs_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
|
||||
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) =
|
||||
sdr_ais_pcm_rx.filter(|_| rig_cfg.decoders.is_enabled("ais"))
|
||||
{
|
||||
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = sdr_ais_pcm_rx {
|
||||
let ais_state_rx = state_rx.clone();
|
||||
let ais_decode_tx = decode_tx.clone();
|
||||
let ais_shutdown_rx = shutdown_rx.clone();
|
||||
@@ -715,7 +701,7 @@ fn spawn_rig_audio_stack(
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(vdes_iq_rx) = sdr_vdes_iq_rx.filter(|_| rig_cfg.decoders.is_enabled("vdes")) {
|
||||
if let Some(vdes_iq_rx) = sdr_vdes_iq_rx {
|
||||
let vdes_state_rx = state_rx.clone();
|
||||
let vdes_decode_tx = decode_tx.clone();
|
||||
let vdes_shutdown_rx = shutdown_rx.clone();
|
||||
@@ -737,61 +723,55 @@ fn spawn_rig_audio_stack(
|
||||
}
|
||||
|
||||
// Spawn CW decoder task
|
||||
if rig_cfg.decoders.is_enabled("cw") {
|
||||
let cw_pcm_rx = pcm_tx.subscribe();
|
||||
let cw_state_rx = state_rx.clone();
|
||||
let cw_decode_tx = decode_tx.clone();
|
||||
let cw_sr = rig_cfg.audio.sample_rate;
|
||||
let cw_ch = rig_cfg.audio.channels;
|
||||
let cw_shutdown_rx = shutdown_rx.clone();
|
||||
let cw_logs = decoder_logs.clone();
|
||||
let cw_histories = histories.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs, cw_histories) => {}
|
||||
_ = wait_for_shutdown(cw_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
let cw_pcm_rx = pcm_tx.subscribe();
|
||||
let cw_state_rx = state_rx.clone();
|
||||
let cw_decode_tx = decode_tx.clone();
|
||||
let cw_sr = rig_cfg.audio.sample_rate;
|
||||
let cw_ch = rig_cfg.audio.channels;
|
||||
let cw_shutdown_rx = shutdown_rx.clone();
|
||||
let cw_logs = decoder_logs.clone();
|
||||
let cw_histories = histories.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs, cw_histories) => {}
|
||||
_ = wait_for_shutdown(cw_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
|
||||
// Spawn FT8 decoder task
|
||||
if rig_cfg.decoders.is_enabled("ft8") {
|
||||
let ft8_pcm_rx = pcm_tx.subscribe();
|
||||
let ft8_state_rx = state_rx.clone();
|
||||
let ft8_decode_tx = decode_tx.clone();
|
||||
let ft8_sr = rig_cfg.audio.sample_rate;
|
||||
let ft8_ch = rig_cfg.audio.channels;
|
||||
let ft8_shutdown_rx = shutdown_rx.clone();
|
||||
let ft8_logs = decoder_logs.clone();
|
||||
let ft8_histories = histories.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs, ft8_histories) => {}
|
||||
_ = wait_for_shutdown(ft8_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
let ft8_pcm_rx = pcm_tx.subscribe();
|
||||
let ft8_state_rx = state_rx.clone();
|
||||
let ft8_decode_tx = decode_tx.clone();
|
||||
let ft8_sr = rig_cfg.audio.sample_rate;
|
||||
let ft8_ch = rig_cfg.audio.channels;
|
||||
let ft8_shutdown_rx = shutdown_rx.clone();
|
||||
let ft8_logs = decoder_logs.clone();
|
||||
let ft8_histories = histories.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs, ft8_histories) => {}
|
||||
_ = wait_for_shutdown(ft8_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
|
||||
// Spawn FT4 decoder task
|
||||
if rig_cfg.decoders.is_enabled("ft4") {
|
||||
let ft4_pcm_rx = pcm_tx.subscribe();
|
||||
let ft4_state_rx = state_rx.clone();
|
||||
let ft4_decode_tx = decode_tx.clone();
|
||||
let ft4_sr = rig_cfg.audio.sample_rate;
|
||||
let ft4_ch = rig_cfg.audio.channels;
|
||||
let ft4_shutdown_rx = shutdown_rx.clone();
|
||||
let ft4_histories = histories.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {}
|
||||
_ = wait_for_shutdown(ft4_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
let ft4_pcm_rx = pcm_tx.subscribe();
|
||||
let ft4_state_rx = state_rx.clone();
|
||||
let ft4_decode_tx = decode_tx.clone();
|
||||
let ft4_sr = rig_cfg.audio.sample_rate;
|
||||
let ft4_ch = rig_cfg.audio.channels;
|
||||
let ft4_shutdown_rx = shutdown_rx.clone();
|
||||
let ft4_histories = histories.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {}
|
||||
_ = wait_for_shutdown(ft4_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
|
||||
// Spawn FT2 decoder task
|
||||
#[cfg(feature = "ft2")]
|
||||
if rig_cfg.decoders.is_enabled("ft2") {
|
||||
{
|
||||
let ft2_pcm_rx = pcm_tx.subscribe();
|
||||
let ft2_state_rx = state_rx.clone();
|
||||
let ft2_decode_tx = decode_tx.clone();
|
||||
@@ -808,76 +788,73 @@ fn spawn_rig_audio_stack(
|
||||
}
|
||||
|
||||
// Spawn WSPR decoder task
|
||||
if rig_cfg.decoders.is_enabled("wspr") {
|
||||
let wspr_pcm_rx = pcm_tx.subscribe();
|
||||
let wspr_state_rx = state_rx.clone();
|
||||
let wspr_decode_tx = decode_tx.clone();
|
||||
let wspr_sr = rig_cfg.audio.sample_rate;
|
||||
let wspr_ch = rig_cfg.audio.channels;
|
||||
let wspr_shutdown_rx = shutdown_rx.clone();
|
||||
let wspr_logs = decoder_logs.clone();
|
||||
let wspr_histories = histories.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs, wspr_histories) => {}
|
||||
_ = wait_for_shutdown(wspr_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
let wspr_pcm_rx = pcm_tx.subscribe();
|
||||
let wspr_state_rx = state_rx.clone();
|
||||
let wspr_decode_tx = decode_tx.clone();
|
||||
let wspr_sr = rig_cfg.audio.sample_rate;
|
||||
let wspr_ch = rig_cfg.audio.channels;
|
||||
let wspr_shutdown_rx = shutdown_rx.clone();
|
||||
let wspr_logs = decoder_logs.clone();
|
||||
let wspr_histories = histories.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs, wspr_histories) => {}
|
||||
_ = wait_for_shutdown(wspr_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
|
||||
// Spawn Meteor-M LRPT decoder task
|
||||
if rig_cfg.decoders.is_enabled("lrpt") {
|
||||
let lrpt_pcm_rx = pcm_tx.subscribe();
|
||||
let lrpt_state_rx = state_rx.clone();
|
||||
let lrpt_decode_tx = decode_tx.clone();
|
||||
let lrpt_sr = rig_cfg.audio.sample_rate;
|
||||
let lrpt_ch = rig_cfg.audio.channels;
|
||||
let lrpt_shutdown_rx = shutdown_rx.clone();
|
||||
let lrpt_histories = histories.clone();
|
||||
let lrpt_output_dir = rig_cfg.decoders.output_dir_for("lrpt");
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {}
|
||||
_ = wait_for_shutdown(lrpt_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
let lrpt_pcm_rx = pcm_tx.subscribe();
|
||||
let lrpt_state_rx = state_rx.clone();
|
||||
let lrpt_decode_tx = decode_tx.clone();
|
||||
let lrpt_sr = rig_cfg.audio.sample_rate;
|
||||
let lrpt_ch = rig_cfg.audio.channels;
|
||||
let lrpt_shutdown_rx = shutdown_rx.clone();
|
||||
let lrpt_histories = histories.clone();
|
||||
let lrpt_output_dir = dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||
.join("trx-rs")
|
||||
.join("lrpt");
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {}
|
||||
_ = wait_for_shutdown(lrpt_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
|
||||
// Spawn WEFAX decoder task
|
||||
if rig_cfg.decoders.is_enabled("wefax") {
|
||||
let wefax_pcm_rx = pcm_tx.subscribe();
|
||||
let wefax_state_rx = state_rx.clone();
|
||||
let wefax_decode_tx = decode_tx.clone();
|
||||
let wefax_sr = rig_cfg.audio.sample_rate;
|
||||
let wefax_ch = rig_cfg.audio.channels;
|
||||
let wefax_shutdown_rx = shutdown_rx.clone();
|
||||
let wefax_histories = histories.clone();
|
||||
let wefax_output_dir = rig_cfg.decoders.output_dir_for("wefax");
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories, wefax_output_dir) => {}
|
||||
_ = wait_for_shutdown(wefax_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
let wefax_pcm_rx = pcm_tx.subscribe();
|
||||
let wefax_state_rx = state_rx.clone();
|
||||
let wefax_decode_tx = decode_tx.clone();
|
||||
let wefax_sr = rig_cfg.audio.sample_rate;
|
||||
let wefax_ch = rig_cfg.audio.channels;
|
||||
let wefax_shutdown_rx = shutdown_rx.clone();
|
||||
let wefax_histories = histories.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories) => {}
|
||||
_ = wait_for_shutdown(wefax_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
|
||||
// Spawn SSTV decoder task
|
||||
if rig_cfg.decoders.is_enabled("sstv") {
|
||||
let sstv_pcm_rx = pcm_tx.subscribe();
|
||||
let sstv_state_rx = state_rx.clone();
|
||||
let sstv_decode_tx = decode_tx.clone();
|
||||
let sstv_sr = rig_cfg.audio.sample_rate;
|
||||
let sstv_ch = rig_cfg.audio.channels;
|
||||
let sstv_shutdown_rx = shutdown_rx.clone();
|
||||
let sstv_histories = histories.clone();
|
||||
let sstv_output_dir = rig_cfg.decoders.output_dir_for("sstv");
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {}
|
||||
_ = wait_for_shutdown(sstv_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
let sstv_pcm_rx = pcm_tx.subscribe();
|
||||
let sstv_state_rx = state_rx.clone();
|
||||
let sstv_decode_tx = decode_tx.clone();
|
||||
let sstv_sr = rig_cfg.audio.sample_rate;
|
||||
let sstv_ch = rig_cfg.audio.channels;
|
||||
let sstv_shutdown_rx = shutdown_rx.clone();
|
||||
let sstv_histories = histories.clone();
|
||||
let sstv_output_dir = dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||
.join("trx-rs")
|
||||
.join("sstv");
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {}
|
||||
_ = wait_for_shutdown(sstv_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if rig_cfg.audio.tx_enabled {
|
||||
@@ -907,82 +884,6 @@ fn spawn_rig_audio_stack(
|
||||
handles
|
||||
}
|
||||
|
||||
/// Sockets this process will bind, given the config and the CLI overrides.
|
||||
///
|
||||
/// `--listen` overrides the bind address of both the control listener and every
|
||||
/// rig's audio listener, so the two callers of this must agree on the rules.
|
||||
fn bound_sockets(cli: &Cli, cfg: &ServerConfig, rigs: &[RigInstanceConfig]) -> Vec<BoundSocket> {
|
||||
let mut sockets = Vec::new();
|
||||
if cfg.listen.enabled {
|
||||
sockets.push(BoundSocket::new(
|
||||
cli.listen.unwrap_or(cfg.listen.listen),
|
||||
cli.port.unwrap_or(cfg.listen.port),
|
||||
"[listen]",
|
||||
));
|
||||
}
|
||||
let audio_ip = cli.listen.unwrap_or(cfg.audio.listen);
|
||||
for rig in rigs {
|
||||
if rig.audio.enabled {
|
||||
sockets.push(BoundSocket::new(
|
||||
audio_ip,
|
||||
rig.audio.port,
|
||||
format!("rig \"{}\" [audio]", rig.id),
|
||||
));
|
||||
}
|
||||
}
|
||||
sockets
|
||||
}
|
||||
|
||||
/// `--check-config`: report everything wrong with the configuration and exit.
|
||||
///
|
||||
/// Unlike startup, this reports every problem it finds rather than stopping at
|
||||
/// the first, so a config can be fixed in one pass.
|
||||
fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad<ServerConfig>) -> DynResult<()> {
|
||||
match &loaded.path {
|
||||
Some(path) => println!("{}", path.display()),
|
||||
None => println!("(no config file found; checking built-in defaults)"),
|
||||
}
|
||||
|
||||
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
|
||||
warnings.extend(ServerConfig::deprecations(&loaded.present_keys));
|
||||
for warning in &warnings {
|
||||
println!(" warning: {}", warning);
|
||||
}
|
||||
|
||||
let mut cfg = loaded.config.clone();
|
||||
let mut errors = Vec::new();
|
||||
if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) {
|
||||
errors.push(e);
|
||||
}
|
||||
let cfg = &cfg;
|
||||
let rigs = cfg.resolved_rigs();
|
||||
|
||||
errors.extend(cfg.validate_all());
|
||||
errors.extend(cfg.validate_sdr());
|
||||
errors.extend(cfg.validate_resolved_all(&rigs, &bound_sockets(cli, cfg, &rigs)));
|
||||
|
||||
for e in &errors {
|
||||
println!(" error: {}", e);
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
println!(
|
||||
" OK: {} rig(s) configured: {}",
|
||||
rigs.len(),
|
||||
rigs.iter()
|
||||
.map(|r| r.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
if !warnings.is_empty() {
|
||||
println!(" {} warning(s)", warnings.len());
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> DynResult<()> {
|
||||
let mut bootstrap_ctx = RegistrationContext::new();
|
||||
@@ -995,30 +896,12 @@ async fn main() -> DynResult<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let loaded = if let Some(ref path) = cli.config {
|
||||
ServerConfig::load_from_file(path)?
|
||||
let (cfg, config_path) = if let Some(ref path) = cli.config {
|
||||
let cfg = ServerConfig::load_from_file(path)?;
|
||||
(cfg, Some(path.clone()))
|
||||
} else {
|
||||
ServerConfig::load_from_default_paths()?
|
||||
};
|
||||
let config_path = loaded.path.clone();
|
||||
|
||||
if cli.check_config {
|
||||
return check_config(&cli, &loaded);
|
||||
}
|
||||
|
||||
// Logging comes up before any config complaint so the warnings are visible.
|
||||
init_logging(loaded.config.general.log_level.as_deref());
|
||||
|
||||
if let Some(ref path) = config_path {
|
||||
info!("Loaded configuration from {}", path.display());
|
||||
}
|
||||
loaded.report_unknown_keys(cli.strict_config)?;
|
||||
loaded.report_deprecations();
|
||||
|
||||
let mut cfg = loaded.config;
|
||||
// Secrets configured as *_file are read before validation, so everything
|
||||
// downstream sees resolved values.
|
||||
cfg.resolve_secrets(config_path.as_deref())?;
|
||||
cfg.validate()
|
||||
.map_err(|e| format!("Invalid server configuration: {}", e))?;
|
||||
|
||||
@@ -1031,6 +914,12 @@ async fn main() -> DynResult<()> {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
init_logging(cfg.general.log_level.as_deref());
|
||||
|
||||
if let Some(ref path) = config_path {
|
||||
info!("Loaded configuration from {}", path.display());
|
||||
}
|
||||
|
||||
let registry = Arc::new(bootstrap_ctx);
|
||||
|
||||
// --- Resolve the effective rig list ---
|
||||
@@ -1092,12 +981,6 @@ async fn main() -> DynResult<()> {
|
||||
(callsign, cfg.general.latitude, cfg.general.longitude)
|
||||
};
|
||||
|
||||
// Second validation phase: now that CLI overrides have been folded in, check
|
||||
// the things that need the final rig list — chiefly that no two listeners
|
||||
// claim the same socket.
|
||||
cfg.validate_resolved(&resolved_rigs, &bound_sockets(&cli, &cfg, &resolved_rigs))
|
||||
.map_err(|e| format!("Invalid server configuration: {}", e))?;
|
||||
|
||||
info!(
|
||||
"Starting trx-server with {} rig(s): {}",
|
||||
resolved_rigs.len(),
|
||||
|
||||
+6
-114
@@ -1,60 +1,36 @@
|
||||
# trx-rs example configuration
|
||||
#
|
||||
# Generated from the config structs; regenerate with:
|
||||
# cargo run -p trx-config --example generate_example
|
||||
#
|
||||
# Both sections are optional: trx-server reads [trx-server], trx-client reads
|
||||
# [trx-client], and either may live in its own file with the section header
|
||||
# omitted. Any string may use ${ENV_VAR}, and credentials may be moved out of
|
||||
# this file with the matching *_file keys.
|
||||
#
|
||||
# Check a config without starting anything:
|
||||
# trx-server --check-config --config trx-rs.toml
|
||||
# trx-client --check-config --config trx-rs.toml
|
||||
|
||||
# Server: drives the radio hardware.
|
||||
[trx-server]
|
||||
rigs = []
|
||||
|
||||
# Station identity. Coordinates feed PSKReporter and the map.
|
||||
[trx-server.general]
|
||||
callsign = "N0CALL"
|
||||
log_level = "info"
|
||||
latitude = 52.2297
|
||||
longitude = 21.0122
|
||||
|
||||
# Single-rig layout. For several radios, delete this and use [[rigs]].
|
||||
[trx-server.rig]
|
||||
model = "ft817"
|
||||
initial_freq_hz = 144300000
|
||||
initial_mode = "USB"
|
||||
|
||||
# How to reach the radio: serial, tcp, or sdr.
|
||||
[trx-server.rig.access]
|
||||
type = "serial"
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
|
||||
# CAT polling and retry behaviour.
|
||||
[trx-server.behavior]
|
||||
poll_interval_ms = 500
|
||||
poll_interval_tx_ms = 100
|
||||
max_retries = 3
|
||||
retry_base_delay_ms = 100
|
||||
vfo_prime = true
|
||||
|
||||
# JSON control listener that trx-client connects to.
|
||||
[trx-server.listen]
|
||||
enabled = true
|
||||
listen = "127.0.0.1"
|
||||
port = 4530
|
||||
|
||||
# Tokens clients must present. Empty means no authentication.
|
||||
# Use tokens_file = "/etc/trx-rs/tokens" to keep them out of this file.
|
||||
[trx-server.listen.auth]
|
||||
tokens = []
|
||||
|
||||
# Opus audio stream for trx-client.
|
||||
[trx-server.audio]
|
||||
enabled = true
|
||||
listen = "127.0.0.1"
|
||||
@@ -66,92 +42,45 @@ channels = 2
|
||||
frame_duration_ms = 20
|
||||
bitrate_bps = 256000
|
||||
|
||||
# Report FT8/FT4/WSPR spots to pskreporter.info.
|
||||
[trx-server.pskreporter]
|
||||
enabled = false
|
||||
host = "report.pskreporter.info"
|
||||
port = 4739
|
||||
|
||||
# Forward received APRS frames to APRS-IS.
|
||||
[trx-server.aprsfi]
|
||||
enabled = false
|
||||
host = "rotate.aprs.net"
|
||||
port = 14580
|
||||
passcode = -1
|
||||
beacon = false
|
||||
beacon_interval_secs = 1200
|
||||
beacon_symbol_table = "/"
|
||||
beacon_symbol_code = "-"
|
||||
|
||||
# Write decodes to JSON Lines files.
|
||||
[trx-server.decode_logs]
|
||||
enabled = false
|
||||
dir = "/var/lib/trx-rs/decoders"
|
||||
dir = "/path/to/log/dir"
|
||||
aprs_file = "TRXRS-APRS-%YYYY%-%MM%-%DD%.log"
|
||||
cw_file = "TRXRS-CW-%YYYY%-%MM%-%DD%.log"
|
||||
ft8_file = "TRXRS-FT8-%YYYY%-%MM%-%DD%.log"
|
||||
wspr_file = "TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"
|
||||
wefax_file = "TRXRS-WEFAX-%YYYY%-%MM%-%DD%.log"
|
||||
|
||||
# Which decoders run. Trimming this list saves real CPU on small boxes.
|
||||
# Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr.
|
||||
# output_dir sets where sstv/wefax/lrpt write images (default: user cache dir).
|
||||
[trx-server.decoders]
|
||||
enabled = [
|
||||
"aprs",
|
||||
"aprs_hf",
|
||||
"ais",
|
||||
"cw",
|
||||
"ft2",
|
||||
"ft4",
|
||||
"ft8",
|
||||
"lrpt",
|
||||
"sstv",
|
||||
"vdes",
|
||||
"wefax",
|
||||
"wspr",
|
||||
]
|
||||
|
||||
# SoapySDR pipeline; used when [rig.access] type = "sdr".
|
||||
[trx-server.sdr]
|
||||
sample_rate = 1920000
|
||||
spectrum_fft_size = 1024
|
||||
spectrum_interval_ms = 50
|
||||
bandwidth = 1500000
|
||||
wfm_deemphasis_us = 50
|
||||
center_offset_hz = 100000
|
||||
channels = []
|
||||
max_virtual_channels = 4
|
||||
spectrum_fft_size = 1024
|
||||
spectrum_interval_ms = 50
|
||||
|
||||
# "auto" for hardware AGC, or "manual".
|
||||
[trx-server.sdr.gain]
|
||||
mode = "auto"
|
||||
value = 30.0
|
||||
|
||||
# Software squelch on demodulated audio.
|
||||
[trx-server.sdr.squelch]
|
||||
enabled = false
|
||||
threshold_db = -65.0
|
||||
hysteresis_db = 3.0
|
||||
tail_ms = 180
|
||||
|
||||
# Impulse-noise suppression on the IQ stream.
|
||||
[trx-server.sdr.noise_blanker]
|
||||
enabled = false
|
||||
threshold = 10.0
|
||||
|
||||
# Timeout and buffer tuning. The defaults suit most setups.
|
||||
[trx-server.timeouts]
|
||||
command_exec_timeout_ms = 10000
|
||||
poll_refresh_timeout_ms = 8000
|
||||
io_timeout_ms = 10000
|
||||
request_timeout_ms = 12000
|
||||
rig_task_channel_buffer = 32
|
||||
|
||||
# Client: exposes the radio to users.
|
||||
[trx-client]
|
||||
|
||||
# Labels shown in the web UI.
|
||||
[trx-client.general]
|
||||
callsign = "N0CALL"
|
||||
website_url = "https://haxx.space"
|
||||
@@ -159,52 +88,25 @@ website_name = "haxx.space"
|
||||
ais_vessel_url_base = "https://www.vesselfinder.com/?mmsi="
|
||||
log_level = "info"
|
||||
|
||||
# Legacy single-remote form; prefer [[remotes]] below.
|
||||
[trx-client.remote]
|
||||
poll_interval_ms = 750
|
||||
spectrum_interval_ms = 50
|
||||
|
||||
[trx-client.remote.auth]
|
||||
|
||||
[[trx-client.remotes]]
|
||||
name = "home-hf"
|
||||
url = "192.168.1.100:4530"
|
||||
url = "192.168.1.100:9000"
|
||||
rig_id = "hf"
|
||||
poll_interval_ms = 750
|
||||
spectrum_interval_ms = 50
|
||||
|
||||
[trx-client.remotes.auth]
|
||||
[trx-client.remote.auth]
|
||||
token = "my-token"
|
||||
|
||||
[[trx-client.remotes]]
|
||||
name = "home-vhf"
|
||||
url = "192.168.1.100:4530"
|
||||
rig_id = "vhf"
|
||||
poll_interval_ms = 750
|
||||
spectrum_interval_ms = 50
|
||||
|
||||
[trx-client.remotes.auth]
|
||||
token = "my-token"
|
||||
|
||||
# Web UI. default_rig_name and the per-rig maps are keyed by the
|
||||
# [[remotes]] name, not the server-side rig id.
|
||||
[trx-client.frontends.http]
|
||||
enabled = true
|
||||
listen = "127.0.0.1"
|
||||
port = 8080
|
||||
default_rig_name = "home-hf"
|
||||
default_rig_id = "hf"
|
||||
initial_map_zoom = 10
|
||||
spectrum_coverage_margin_hz = 50000
|
||||
spectrum_usable_span_ratio = 0.9200000166893005
|
||||
show_sdr_gain_control = true
|
||||
bandplan_enabled = true
|
||||
bandplan_region = "iaru_r1"
|
||||
decode_history_retention_min = 1440
|
||||
|
||||
[trx-client.frontends.http.decode_history_retention_min_by_rig]
|
||||
|
||||
# Passphrase login for the web UI. rx_passphrase_file and
|
||||
# control_passphrase_file keep the secrets out of this file.
|
||||
[trx-client.frontends.http.auth]
|
||||
enabled = false
|
||||
rx_passphrase = "rx-passphrase-example"
|
||||
@@ -214,7 +116,6 @@ session_ttl_min = 480
|
||||
cookie_secure = false
|
||||
cookie_same_site = "Lax"
|
||||
|
||||
# Hamlib-compatible TCP interface, one listener per rig.
|
||||
[trx-client.frontends.rigctl]
|
||||
enabled = false
|
||||
listen = "127.0.0.1"
|
||||
@@ -222,7 +123,6 @@ port = 4532
|
||||
|
||||
[trx-client.frontends.rigctl.rig_ports]
|
||||
|
||||
# JSON-over-TCP control interface.
|
||||
[trx-client.frontends.http_json]
|
||||
enabled = true
|
||||
listen = "127.0.0.1"
|
||||
@@ -231,22 +131,14 @@ port = 0
|
||||
[trx-client.frontends.http_json.auth]
|
||||
tokens = []
|
||||
|
||||
# Where to fetch the audio stream from.
|
||||
[trx-client.frontends.audio]
|
||||
enabled = true
|
||||
server_port = 4531
|
||||
|
||||
[trx-client.frontends.audio.rig_urls]
|
||||
|
||||
[trx-client.frontends.audio.rig_ports]
|
||||
|
||||
# Play RX audio on a local sound device and capture TX from one.
|
||||
[trx-client.frontends.audio.bridge]
|
||||
enabled = false
|
||||
bitrate_bps = 192000
|
||||
rx_gain = 1.0
|
||||
tx_gain = 1.0
|
||||
|
||||
# Station log of contacts. path defaults to logbook.jsonl in the user's
|
||||
# data directory; set it to keep the log on a backed-up volume.
|
||||
[trx-client.logbook]
|
||||
|
||||
Reference in New Issue
Block a user