Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93b4964b3b | ||
|
|
14c57035e1 | ||
|
|
7a7fa62af7 | ||
|
|
6ee4c430c7 | ||
|
|
0df6370c59 | ||
|
|
fa94657619 | ||
|
|
f5b74856c6 | ||
|
|
d42fdab85c | ||
|
|
83a8708d60 | ||
|
|
c0671bfe2e | ||
|
|
ec0cb95de9 | ||
|
|
9d691343cf | ||
|
|
c6088bb2de | ||
|
|
069728ac68 | ||
|
|
f221c0ee1e | ||
|
|
013492adb0 | ||
|
|
3a87b58ba3 | ||
|
|
e6b8a17772 | ||
|
|
802c6e8c43 | ||
|
|
f82631690a | ||
|
|
3b58e39eec | ||
|
|
45e3f3edb8 | ||
|
|
c931d8a762 | ||
|
|
eb271e8f20 | ||
|
|
e2d2485703 | ||
|
|
712a124081 | ||
|
|
08a932c9c1 | ||
|
|
fee2303625 | ||
|
|
4d5c5c3eb3 | ||
|
|
d322ea9a07 | ||
|
|
75c02ea2a0 | ||
|
|
ef84b04c99 | ||
|
|
fbd6cf400f | ||
|
|
b16cb973f2 | ||
|
|
da886de189 | ||
|
|
7759e09b4e | ||
|
|
a8990ff29d | ||
|
|
e3b810b4ff | ||
|
|
337392bb6d | ||
|
|
d98bb2e929 | ||
|
|
3ab6b95ff8 | ||
|
|
b0da6a3d14 | ||
|
|
2d7469a7f7 | ||
|
|
775f411606 | ||
|
|
6501e97895 | ||
|
|
f3807c21ba | ||
|
|
d536c39ef5 | ||
|
|
284a7a18c7 | ||
|
|
482f81fe45 | ||
|
|
5d7a9a9562 | ||
|
|
a036120871 | ||
|
|
ccceab9d02 | ||
|
|
35db1440e4 | ||
|
|
e3aabc06a4 | ||
|
|
ab9d0bfe0f | ||
|
|
a3fd32ec91 | ||
|
|
c297f91ca8 | ||
|
|
ee1f9ccf35 | ||
|
|
6fb7f06375 | ||
|
|
54caee37c4 | ||
|
|
15fdbce22a | ||
|
|
c7737aabd6 | ||
|
|
6f5acc2eef | ||
|
|
67a03c3ddc | ||
|
|
8da3a27803 | ||
|
|
e0cefb5c41 | ||
|
|
113faede14 | ||
|
|
8e491544dd | ||
|
|
cbe571a742 | ||
|
|
bdda7ad07a | ||
|
|
3fd6763ff0 | ||
|
|
a89c6e0df8 | ||
|
|
48d92ff547 | ||
|
|
adadb632fa | ||
|
|
b0b25c7f1f | ||
|
|
e120bd4f04 | ||
|
|
40df4fe22f | ||
|
|
d114a73729 | ||
|
|
d0666581c3 | ||
|
|
37dc2a07e5 | ||
|
|
7424bc6e81 | ||
|
|
01c93d979f | ||
|
|
0190255762 | ||
|
|
0cc806722e | ||
|
|
08f3e54afb | ||
|
|
520d17810c | ||
|
|
c9218310ae | ||
|
|
8cc6997eec | ||
|
|
6d309cada1 | ||
|
|
7153768579 | ||
|
|
df4155108f | ||
|
|
9bc5a14c69 | ||
|
|
842d4708a7 | ||
|
|
e2aba828a9 | ||
|
|
753d8d2ffa | ||
|
|
38b480a985 | ||
|
|
67005a8d50 | ||
|
|
9cfa7a4dd1 | ||
|
|
a1c4305f20 | ||
|
|
3b296b19ab | ||
|
|
8a0d76fa0c | ||
|
|
bd5f9c0746 | ||
|
|
29591c5f0c | ||
|
|
01bcf256e2 | ||
|
|
c9d1fc1cc0 | ||
|
|
f43d41892e | ||
|
|
1f2e86c04d | ||
|
|
305da583e1 | ||
|
|
71bde30e24 | ||
|
|
31b13cfbc0 | ||
|
|
30d88276f3 | ||
|
|
b18db7acfc | ||
|
|
d303dee768 | ||
|
|
ad02583ef7 | ||
|
|
add4b175fc | ||
|
|
11940ae843 | ||
|
|
91f046444a | ||
|
|
386fb7f030 | ||
|
|
5a6db2b656 | ||
|
|
c3958be0b2 | ||
|
|
a42a3d5713 | ||
|
|
0a1569dbbd | ||
|
|
843dccf0c4 | ||
|
|
d49126d45c | ||
|
|
cc1ad06a9d | ||
|
|
4dd15b09e9 | ||
|
|
4eec272c0b | ||
|
|
816a727e88 | ||
|
|
2b5c195ab4 | ||
|
|
139b4675e3 | ||
|
|
2ad72b19fb | ||
|
|
678c8821c2 | ||
|
|
5394b55bb7 | ||
|
|
05d64024ef | ||
|
|
cb27aa5ebf | ||
|
|
aefb984974 | ||
|
|
91b5af1c7b | ||
|
|
4fd70f6a9d | ||
|
|
51e279887d | ||
|
|
7e08553e6e | ||
|
|
3564eecc36 | ||
|
|
3e7f3832e0 | ||
|
|
34b60f9f20 | ||
|
|
b7bfd39652 | ||
|
|
6a1103bf5f | ||
|
|
91653bca57 | ||
|
|
6be0f43dd0 | ||
|
|
370fde42f7 | ||
|
|
e5ff30823d | ||
|
|
9e86d57dac | ||
|
|
00bfee4ed2 | ||
|
|
f4bc55cd41 | ||
|
|
3a9dda13c4 | ||
|
|
9b677c6b35 | ||
|
|
933d601c03 | ||
|
|
b4f0e0bc29 | ||
|
|
9f384402fa | ||
|
|
18b69ee8b4 | ||
|
|
8c1b7af5b3 | ||
|
|
6e953ab1f4 | ||
|
|
88202efddb | ||
|
|
3f15608c59 | ||
|
|
e6a6f04ccf | ||
|
|
3b1a8ef01a | ||
|
|
fd097a647f | ||
|
|
b2bd818ac4 | ||
|
|
d71d09cbb6 | ||
|
|
557fb162c3 | ||
|
|
77a2350240 | ||
|
|
2b3d118d84 | ||
|
|
638ffcb326 | ||
|
|
bf4fba484a | ||
|
|
3ea7f44fd1 | ||
|
|
384925f24a | ||
|
|
898323c49f | ||
|
|
48345a22fc | ||
|
|
b83d55cc32 | ||
|
|
76e4288b9e | ||
|
|
246ecf23ba | ||
|
|
1717677056 | ||
|
|
15159f38a5 | ||
|
|
6a28344e93 | ||
|
|
a14ade9277 | ||
|
|
513ab178ec | ||
|
|
e3aeeae616 | ||
|
|
a5cfecbf76 | ||
|
|
7d644d05cc | ||
|
|
75510a1161 | ||
|
|
1a0a349fb3 | ||
|
|
5e09b6a796 | ||
|
|
d6a2e84eed | ||
|
|
7b0ef0ba97 | ||
|
|
aa871a07b7 | ||
|
|
89584f173d | ||
|
|
a71e48f811 | ||
|
|
5c4f101402 | ||
|
|
ede9461010 | ||
|
|
e6ca7a8bdd | ||
|
|
9be3f9147b | ||
|
|
f7f801cdb7 | ||
|
|
43f15f1a2c | ||
|
|
2c5416500f | ||
|
|
fcdc5809e6 | ||
|
|
7254950162 | ||
|
|
1668455ff4 | ||
|
|
c07b746d4b | ||
|
|
d128dabc19 | ||
|
|
2823f3e401 | ||
|
|
1070637c40 | ||
|
|
13de53772b | ||
|
|
6b04072e52 | ||
|
|
64b8ba7fdc | ||
|
|
a9ca50b2dd | ||
|
|
512b5fd35b | ||
|
|
77e3d011a0 | ||
|
|
b7dd8d4852 | ||
|
|
4c75680689 | ||
|
|
5aac28f564 | ||
|
|
c91c8c3b47 | ||
|
|
968da5488c | ||
|
|
1b5b0c2e90 | ||
|
|
0385aed760 | ||
|
|
24a5a0480d | ||
|
|
828f99b8ac | ||
|
|
9e5868b839 | ||
|
|
40d0ca57c3 | ||
|
|
aa537f9eea | ||
|
|
ea1bd2a66d | ||
|
|
1f54656256 | ||
|
|
54dd109288 | ||
|
|
2c1d7235f0 | ||
|
|
adf9844d1b | ||
|
|
7afe40a48e | ||
|
|
311ea8341f | ||
|
|
a2958d458e | ||
|
|
d9b7e48e83 | ||
|
|
3e9ebdb89a | ||
|
|
be66ac1e19 | ||
|
|
6aab4a6989 | ||
|
|
9c62bf0152 | ||
|
|
1630c8fdf4 | ||
|
|
408791ddf5 | ||
|
|
3de47a8825 | ||
|
|
880ecdbbb5 | ||
|
|
615df0dc10 | ||
|
|
2194279602 | ||
|
|
51c4bda71a | ||
|
|
7d7d175ede | ||
|
|
be1ae76eb3 | ||
|
|
50157a25d3 | ||
|
|
fe1a77a54d | ||
|
|
b818a2d947 | ||
|
|
5c4ae0cfd7 | ||
|
|
69635a15bc | ||
|
|
78220e700f | ||
|
|
42a6b9c76a | ||
|
|
56affa4bed | ||
|
|
aa5af4fc75 | ||
|
|
46772e54fe | ||
|
|
a0cea352ff | ||
|
|
190b86eb1c | ||
|
|
cfc3d00ea1 | ||
|
|
9033e8518c | ||
|
|
bfbd9fa61a | ||
|
|
f0c4f22942 | ||
|
|
19c91f32a0 | ||
|
|
3ec23bc613 | ||
|
|
2fbb922bd2 | ||
|
|
f0afdcc498 | ||
|
|
22352c5748 | ||
|
|
6356d60a66 | ||
|
|
cafade0dbb | ||
|
|
2e39615554 | ||
|
|
666b933114 | ||
|
|
def59da748 | ||
|
|
fe69bc308c | ||
|
|
c07a17dc47 | ||
|
|
3cef885934 | ||
|
|
b8db653981 | ||
|
|
9729ef62ba | ||
|
|
cc6411a618 | ||
|
|
991831bdec | ||
|
|
1b2da95ad4 | ||
|
|
10d86db50a | ||
|
|
8538f48259 | ||
|
|
0fa91c3d5f | ||
|
|
c86d331bd9 | ||
|
|
77a752efe3 | ||
|
|
781fbfaa30 | ||
|
|
61c11c0fe3 | ||
|
|
64b746f007 | ||
|
|
9cc72c7575 | ||
|
|
9e4f43f648 | ||
|
|
5f044b959e | ||
|
|
68a49be8c1 | ||
|
|
8eb82d6cdb | ||
|
|
d327db3f57 | ||
|
|
59e6570f17 | ||
|
|
82a2c6cb7f | ||
|
|
24eaf597fd | ||
|
|
14a22ddb66 | ||
|
|
9156acea5f | ||
|
|
5d0906f00e | ||
|
|
901e967b53 | ||
|
|
4ab4f70349 | ||
|
|
64e80986ea | ||
|
|
816c6ffcf1 | ||
|
|
2166d1aa4b | ||
|
|
0a9a09bec2 | ||
|
|
34ec91684e | ||
|
|
11f1e332f7 | ||
|
|
bd9e091e65 | ||
|
|
d38c783dcc | ||
|
|
c825caa7a8 | ||
|
|
215652570c | ||
|
|
79552bfae1 | ||
|
|
8fc04563e1 | ||
|
|
19993bafc1 | ||
|
|
da1793a902 | ||
|
|
14c87f7fa9 | ||
|
|
9d4ccb9254 |
@@ -46,3 +46,4 @@ cat.log
|
|||||||
.env.local
|
.env.local
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
*.syso
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
OpsLog is a Windows ham-radio logger built with **Wails v2** — Go backend, React/TypeScript frontend, compiled into a single `.exe`. Author F4BPO. See `README.md` for the user-facing feature list and `wiki/` for end-user documentation.
|
||||||
|
|
||||||
|
**Pure Go, no CGO** — SQLite is `modernc.org/sqlite`, serial is `go.bug.st/serial`. Any dependency requiring cgo breaks the build; check before adding one.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wails dev # hot-reload dev (Go methods also reachable at http://localhost:34115)
|
||||||
|
wails build # full build → build/bin/OpsLog.exe (~25 s)
|
||||||
|
wails generate module # REQUIRED after changing exported App methods (regenerates TS bindings)
|
||||||
|
|
||||||
|
go build ./... # fast Go-only check
|
||||||
|
go test ./... # all Go tests
|
||||||
|
go test ./internal/steppir/ -run TestApplyPendingDirHold -v # one test
|
||||||
|
gofmt -w <file>
|
||||||
|
|
||||||
|
cd frontend && npx tsc --noEmit # frontend typecheck alone (wails build does this too)
|
||||||
|
```
|
||||||
|
|
||||||
|
Release: `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task* → *Release OpsLog*) — bumps the version, pushes to Gitea, builds and publishes to Gitea + GitHub.
|
||||||
|
|
||||||
|
Prefer `wails build` for final validation: it is the only command that exercises Go, the TS typecheck, the bindings and the asset embed together.
|
||||||
|
|
||||||
|
## Navigating app.go
|
||||||
|
|
||||||
|
`app.go` is ~14 000 lines and holds nearly every Wails binding. **Do not read it linearly** — it is organised into banner-delimited sections:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "^// ──\|^// ---" app.go # table of contents
|
||||||
|
```
|
||||||
|
|
||||||
|
Sections map to features (`── Motorized antenna (Ultrabeam / SteppIR) ──`, `── NET Control ──`, `── DX-cluster spot alerts ──`, …). Find the banner, then read that range. The `App` struct (~line 448) is the other useful landmark: its fields document every subsystem and carry substantial explanatory comments.
|
||||||
|
|
||||||
|
Smaller root files split off self-contained areas: `app_cw.go`, `app_qsl_designer.go`, `app_secret.go`, `chat.go`, `offline.go`, `relayauto.go`, `adifwatch.go`, `livestatus.go`, `update.go`, `telemetry.go`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Wails binding boundary
|
||||||
|
|
||||||
|
Exported methods on `*App` are the entire frontend API. Adding or changing a signature requires `wails generate module`, which writes `frontend/wailsjs/go/main/App.d.ts` and `models.ts`. Parameters and return types must be Wails-serializable — the generator prints `Not found: time.Time` noise for unsupported types (that particular message is long-standing and harmless).
|
||||||
|
|
||||||
|
Backend→frontend push uses Wails events (`runtime.EventsEmit` / `EventsOn`), e.g. `qso:logged`, `update:progress`. Most hardware status is **polled** by the frontend on an interval rather than pushed.
|
||||||
|
|
||||||
|
### Two separate databases
|
||||||
|
|
||||||
|
This split is easy to get wrong and matters:
|
||||||
|
|
||||||
|
- **`a.db`** — settings database (`settings.db` / `opslog.db`). Always SQLite. Holds settings and profiles. Its location is chosen by the user and recorded in `data/config.json`.
|
||||||
|
- **`a.logDb`** — the logbook (QSOs). Either a per-profile SQLite file, the default `logbook.db`, or a **shared MySQL** so several operators log into one database. `a.dbBackend` says which.
|
||||||
|
|
||||||
|
QSOs never go to `a.db`. `internal/db` holds the connection helpers and the MySQL specifics; `internal/qso` is the repository.
|
||||||
|
|
||||||
|
Remote MySQL is the slow path: startup deliberately brings CAT/rig links up **before** connecting the logbook, and hot paths (cluster spot enrichment, alert matching) avoid per-row queries — hence the in-memory `wcbm` worked-index and the `clusterEvents` queue that keeps the telnet socket draining.
|
||||||
|
|
||||||
|
### Per-profile settings scoping
|
||||||
|
|
||||||
|
`internal/settings.Store` is a key/value store over the settings DB. Every key is transparently prefixed with the active profile (`p3.`), so each station profile has a complete independent set. Two consequences:
|
||||||
|
|
||||||
|
- `App.settingsScoped` (atomic) gates reads until the active profile is known. Anything reading settings early must respect it or it reads the wrong scope.
|
||||||
|
- Settings keys are declared as `key<Thing>` string constants near the top of `app.go` (~180 of them) — grep `key[A-Z].*= "` to find one.
|
||||||
|
|
||||||
|
Password-type keys are encrypted at rest when the secret vault is unlocked (`internal/secret`); a locked vault returns `""` rather than ciphertext, and callers treat that as "not configured".
|
||||||
|
|
||||||
|
### Hardware device pattern
|
||||||
|
|
||||||
|
The codebase talks to ~20 devices (rigs, amplifiers, antenna controllers, switches, keyers, rotators). They all follow the same shape — match it when adding one:
|
||||||
|
|
||||||
|
1. **`internal/<device>/`** — a self-contained package exposing a `Client` with `New(...)`, `Start()`, `Stop()`, `GetStatus()`. The client owns its own goroutine: a reconnecting poll loop, a mutex serialising the shared connection, and a cached last-known `Status`. Transports are usually TCP *or* serial behind one `io.ReadWriteCloser`.
|
||||||
|
2. **Settings** — `key<Thing>*` constants plus `Get<Thing>Settings()` / `Save<Thing>Settings()` bindings in `app.go`.
|
||||||
|
3. **Lifecycle** — a `start<Thing>()` method that tears down any existing client and rebuilds it from settings; called at startup and again whenever settings are saved.
|
||||||
|
4. **Status binding** — `Get<Thing>Status()`, polled by the frontend.
|
||||||
|
5. **UI** — a settings panel in `frontend/src/components/SettingsModal.tsx` and a live widget in `frontend/src/components/`.
|
||||||
|
|
||||||
|
`internal/steppir` and `internal/antgenius` are compact, well-commented references. Wire-protocol packages carry the byte layout in the package doc comment and pin it with table tests against **real captured frames** — keep that up, since a wrong byte silently mistunes an antenna.
|
||||||
|
|
||||||
|
Where two devices are interchangeable (Ultrabeam / SteppIR), `app.go` defines a small interface (`motorAntenna`) plus thin per-device adapters rather than branching everywhere.
|
||||||
|
|
||||||
|
### CAT
|
||||||
|
|
||||||
|
`internal/cat` (~7 000 lines, the largest package) is the rig abstraction: OmniRig, native FlexRadio/SmartSDR, native Icom CI-V (USB **and** remote-over-internet), and TCI (SunSDR / Expert Electronics). `cat.Manager` exposes a backend-agnostic `State()`; backend-specific features are reached through typed escapes such as `FlexDo(func(cat.FlexController) error)`. Check `State().Backend` before using one.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
`internal/applog` — Wails builds with the Windows GUI subsystem, so `fmt.Println` is discarded. Use `applog.Printf` (or plain `log.Printf` inside `internal/` packages) so output reaches the rotating log file in the user data dir. That file is usually the only evidence available when diagnosing a user's hardware problem.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
**Changelog is mandatory.** Every user-visible change gets an entry in `changelog.json`, **in both `en` and `fr`**, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants.
|
||||||
|
|
||||||
|
**Append new entries at the BOTTOM of a version block**, never at the top. A block is read top to bottom, so a feature must appear before the fixes made to it — prepending meant an operator read "the Yaesu meters are corrected" several entries before learning a Yaesu console existed at all.
|
||||||
|
|
||||||
|
**Version lives in two places** and must stay in lockstep: `appVersion` in `telemetry.go` and `APP_VERSION` in `frontend/src/version.ts`.
|
||||||
|
|
||||||
|
**Bilingual UI.** Every user-visible string goes through `t()` from `frontend/src/lib/i18n.tsx` (~700 keys), with both English and French provided. No hardcoded display strings.
|
||||||
|
|
||||||
|
**Commit messages must not include a `Co-Authored-By` line or any mention of the model.**
|
||||||
|
|
||||||
|
**Comments explain *why*, not *what*.** The existing code documents the reasoning behind non-obvious decisions — which protocol source was trusted, what field failure a workaround addresses, why an ordering matters. Match that: a comment that restates the code is noise, a comment recording the hard-won reason is why this codebase is navigable.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Adding a promoted ADIF field touches five places in lockstep.** `internal/adif` promotes ~30 ADIF fields to real QSO columns; miss one and imports silently drop data. All of: the struct field, column list, insert args and scan in `internal/qso/qso.go`; the dictionary entry (`Promoted: true`) in `internal/adif/fields.go`; the `adifPromoted` list *and* the assignment in `internal/adif/import.go`; the writer in `internal/adif/export.go`; then the frontend column in `RecentQSOsGrid.tsx` with its EN+FR i18n keys. Trace an existing field (e.g. `ant_path`) across the repo as a template.
|
||||||
|
- **Generated files — don't hand-edit.** `internal/dxcc/dxcc_names_gen.go` (cty.dat joined to the ARRL/ADIF entity list) and `internal/awardref/uscounties_gen.go` (emitted by `cmd/cntygen` from the FIPS county CSV; a one-shot generator, not part of the build).
|
||||||
|
- **Single-instance guard** (`main.go`): a second process would open its own CAT and antenna-follow loops and the two would fight over the rig frequency.
|
||||||
|
- **Frontend controlled inputs**: several editors normalise a value on every keystroke (trim, split, filter). Binding an input directly to the normalised form makes Enter/Space appear dead — keep raw text in local state and derive the stored value from it.
|
||||||
+80
-20
@@ -1,7 +1,11 @@
|
|||||||
# OpsLog
|
# OpsLog
|
||||||
|
|
||||||
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
|
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||||
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
<img src="https://img.shields.io/badge/Discord-Rejoindre%20le%20serveur-5865F2?logo=discord&logoColor=white" alt="Rejoindre notre Discord" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie en
|
||||||
|
bandeau unique, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
||||||
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
|
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
|
||||||
RS-BA1) et **TCI** (SunSDR / Expert Electronics), cluster DX avec alertes de
|
RS-BA1) et **TCI** (SunSDR / Expert Electronics), cluster DX avec alertes de
|
||||||
spots, suivi des diplômes, cartes, log de concours, gestion des QSL et un
|
spots, suivi des diplômes, cartes, log de concours, gestion des QSL et un
|
||||||
@@ -28,7 +32,7 @@ Développé par **F4BPO**.
|
|||||||
|
|
||||||
## Journalisation
|
## Journalisation
|
||||||
|
|
||||||
- **Bandeau de saisie façon Log4OM :** indicatif, RST émis/reçu, nom/QTH/locator,
|
- **Bandeau de saisie unique :** indicatif, RST émis/reçu, nom/QTH/locator,
|
||||||
bande/mode, fréquence TX/RX (split), heure de début/fin, commentaire/note. Le
|
bande/mode, fréquence TX/RX (split), heure de début/fin, commentaire/note. Le
|
||||||
**drapeau** de l'entité contactée est affiché en grand à côté des champs RST.
|
**drapeau** de l'entité contactée est affiché en grand à côté des champs RST.
|
||||||
- **Recherche d'indicatif** (QRZ.com / HamQTH) avec photo, pré-remplissage du
|
- **Recherche d'indicatif** (QRZ.com / HamQTH) avec photo, pré-remplissage du
|
||||||
@@ -38,7 +42,10 @@ Développé par **F4BPO**.
|
|||||||
district (`/8`, `/W6`), plus les dérogations DXpédition de ClubLog par dates.
|
district (`/8`, `/W6`), plus les dérogations DXpédition de ClubLog par dates.
|
||||||
- **QSO récents**, matrice **déjà contactés** (par créneau bande/mode),
|
- **QSO récents**, matrice **déjà contactés** (par créneau bande/mode),
|
||||||
re-résolution en masse depuis cty/QRZ/ClubLog, envoi en masse vers les services
|
re-résolution en masse depuis cty/QRZ/ClubLog, envoi en masse vers les services
|
||||||
QSL.
|
QSL. Un **compteur de sélection** en direct, une bascule **Tout sélectionner /
|
||||||
|
Tout désélectionner**, et une limite d'affichage qui garde le journal complet
|
||||||
|
rapide **mais qui saute dès qu'un filtre est actif** (toutes les correspondances
|
||||||
|
sont montrées, pas seulement la première page).
|
||||||
- **Constructeur de filtres QSO avancé** (champ / opérateur / valeur, ET / OU,
|
- **Constructeur de filtres QSO avancé** (champ / opérateur / valeur, ET / OU,
|
||||||
préréglages enregistrés) avec **export ADIF** des lignes filtrées ou
|
préréglages enregistrés) avec **export ADIF** des lignes filtrées ou
|
||||||
sélectionnées.
|
sélectionnées.
|
||||||
@@ -63,7 +70,9 @@ Développé par **F4BPO**.
|
|||||||
légendés) et le(s) **lobe(s) du faisceau d'antenne** tracés depuis l'azimut du
|
légendés) et le(s) **lobe(s) du faisceau d'antenne** tracés depuis l'azimut du
|
||||||
rotor.
|
rotor.
|
||||||
- **Compas de rotor** (azimutal équidistant, clic pour tourner) piloté par
|
- **Compas de rotor** (azimutal équidistant, clic pour tourner) piloté par
|
||||||
PstRotator.
|
**PstRotator** (UDP), un **Rotator Genius 4O3A** (TCP natif) ou un **microHAM
|
||||||
|
ARCO** contrôlé nativement — sans PstRotator — via le **réseau** ou l'**USB**
|
||||||
|
avec son protocole Yaesu GS-232A.
|
||||||
- **Support Ultrabeam** (Normal / inversé 180° / bidirectionnel) : la direction
|
- **Support Ultrabeam** (Normal / inversé 180° / bidirectionnel) : la direction
|
||||||
rayonnée est en vert et le **boom mécanique** en gris, à la fois sur le compas
|
rayonnée est en vert et le **boom mécanique** en gris, à la fois sur le compas
|
||||||
et sur la carte, pour toujours savoir où pointe l'antenne.
|
et sur la carte, pour toujours savoir où pointe l'antenne.
|
||||||
@@ -77,10 +86,12 @@ Développé par **F4BPO**.
|
|||||||
Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage.
|
Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage.
|
||||||
- **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté),
|
- **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté),
|
||||||
clic pour accorder la radio, et une **bandmap** multi-bandes (bandes façon
|
clic pour accorder la radio, et une **bandmap** multi-bandes (bandes façon
|
||||||
panadapter).
|
panadapter). En option, tous les **modes numériques comptent comme un seul**
|
||||||
|
(style DXCC) pour le coloriage nouveau/nouveau-créneau et les badges de la
|
||||||
|
matrice des déjà-contactés (Réglages → Général).
|
||||||
- Les spots **POTA** sont étiquetés avec leur référence de parc (via
|
- Les spots **POTA** sont étiquetés avec leur référence de parc (via
|
||||||
`api.pota.app`).
|
`api.pota.app`).
|
||||||
- **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande /
|
- **Alertes de spots :** règles sur indicatif / pays / bande /
|
||||||
mode / spotter, avec notification sonore, visuelle et e-mail (Outils →
|
mode / spotter, avec notification sonore, visuelle et e-mail (Outils →
|
||||||
*Gestion des alertes*).
|
*Gestion des alertes*).
|
||||||
|
|
||||||
@@ -93,7 +104,8 @@ connexion rapide non bloquante — une radio éteinte ne fige jamais l'applicati
|
|||||||
supportée par OmniRig.
|
supportée par OmniRig.
|
||||||
- **FlexRadio (SmartSDR)** via l'API TCP de la radio — fréquence / mode / split
|
- **FlexRadio (SmartSDR)** via l'API TCP de la radio — fréquence / mode / split
|
||||||
de la slice en temps réel, découverte UDP, et **spots panadapter** (les spots
|
de la slice en temps réel, découverte UDP, et **spots panadapter** (les spots
|
||||||
du cluster poussés sur l'écran Flex, clic → remplir l'indicatif).
|
du cluster poussés sur l'écran Flex ; un clic remplit l'indicatif et **accorde
|
||||||
|
la radio sur la bonne fréquence ET le bon mode**).
|
||||||
- **Icom CI-V** — natif, via le port **USB** de la radio *ou* par internet via le
|
- **Icom CI-V** — natif, via le port **USB** de la radio *ou* par internet via le
|
||||||
**serveur LAN intégré** de la radio (voir *Icom à distance* ci-dessous). Ni
|
**serveur LAN intégré** de la radio (voir *Icom à distance* ci-dessous). Ni
|
||||||
RS-BA1 ni Remote Utility nécessaires.
|
RS-BA1 ni Remote Utility nécessaires.
|
||||||
@@ -110,9 +122,17 @@ Affiché uniquement quand le backend CAT est une FlexRadio :
|
|||||||
|
|
||||||
- **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de
|
- **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de
|
||||||
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
|
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
|
||||||
- **Réception (slice active) :** mode/seuil AGC, niveau audio, NB / NR / ANF.
|
- **Réception (slice active) :** sélecteurs d'**antenne** RX/TX et une bascule
|
||||||
|
**DAX** (audio d'émission via DAX, pour WSJT-X & co), mode/seuil AGC, niveau
|
||||||
|
audio, NB / WNB / NR / ANF, et — sur les radios **SmartSDR v4** (séries 8000 /
|
||||||
|
Aurora) — les filtres DSP supplémentaires **NRL, NRS, NRF** (avec niveau) plus
|
||||||
|
**RNN** (réduction de bruit par IA) et **ANFT** (notch automatique FFT),
|
||||||
|
affichés automatiquement si la radio les supporte. **RIT / XIT** avec accord
|
||||||
|
molette / ±.
|
||||||
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
||||||
- **Amplificateur :** PowerGenius XL operate/standby + défaut.
|
- **Amplificateur :** la carte de commande suit l'ampli configuré, avec une liste
|
||||||
|
déroulante pour le choisir quand **plusieurs amplificateurs** sont configurés
|
||||||
|
(p. ex. deux SPE en parallèle). Voir *Amplis & commutateurs* ci-dessous.
|
||||||
- **Mesures en direct** via le flux UDP VITA-49 : S-mètre (unités S), puissance
|
- **Mesures en direct** via le flux UDP VITA-49 : S-mètre (unités S), puissance
|
||||||
directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli.
|
directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli.
|
||||||
|
|
||||||
@@ -159,9 +179,10 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
|||||||
## Keyers & audio
|
## Keyers & audio
|
||||||
|
|
||||||
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
|
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
|
||||||
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **Icom** (le keyer
|
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **FlexRadio CWX**
|
||||||
intégré de la radio via CI-V — sans matériel supplémentaire, fonctionne aussi à
|
(le keyer intégré de la radio via l'API SmartSDR — type-ahead et retour arrière,
|
||||||
distance) ou **TCI**.
|
sans WinKeyer ni SmartCAT), **Icom** (le keyer intégré de la radio via CI-V —
|
||||||
|
sans matériel supplémentaire, fonctionne aussi à distance) ou **TCI**.
|
||||||
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1–F6 et les
|
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1–F6 et les
|
||||||
émettre.
|
émettre.
|
||||||
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
|
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
|
||||||
@@ -170,10 +191,33 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
|||||||
|
|
||||||
## Amplis & commutateurs
|
## Amplis & commutateurs
|
||||||
|
|
||||||
- Amplificateur **PowerGenius XL** (4O3A) — TCP direct : operate/standby,
|
- **Amplificateurs** — configurez **un ou plusieurs** amplis (Réglages →
|
||||||
sélecteur de mode ventilateur et affichage des défauts.
|
Amplificateur est une liste ; p. ex. deux SPE en parallèle pour plus de
|
||||||
|
puissance). La carte de chaque ampli apparaît sur l'onglet FlexRadio et dans
|
||||||
|
**Station Control**, avec une liste déroulante pour choisir lequel afficher ; la
|
||||||
|
barre de statut du bas porte **une pastille cliquable par ampli** (vert =
|
||||||
|
OPERATE, orange = STANDBY, rouge = hors ligne). Supportés :
|
||||||
|
- **PowerGenius XL** (4O3A) en TCP direct — operate/standby, sélecteur de mode
|
||||||
|
ventilateur et affichage des défauts.
|
||||||
|
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) via **USB** (COM virtuel) ou le
|
||||||
|
**réseau** (pont RS232-Ethernet) — operate/standby, Marche/Arrêt, niveau de
|
||||||
|
sortie Low / Mid / High, une barre de puissance et le statut en direct (bande,
|
||||||
|
ROS, courant PA, température, avertissements/alarmes).
|
||||||
|
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) via **USB** ou le **réseau**
|
||||||
|
(pont RS232-Ethernet) — operate/standby/arrêt et télémétrie en direct
|
||||||
|
(puissance directe & réfléchie, ROS, température PA, bande, ventilateur,
|
||||||
|
défauts). La mise en marche fonctionne via un câble série reliant les lignes
|
||||||
|
DTR/RTS.
|
||||||
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
|
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
|
||||||
commutation A/B ancré.
|
commutation A/B ancré.
|
||||||
|
- Panneau **Station Control** (ancrable, widgets réordonnables par glisser) : le
|
||||||
|
**rotor**, la commande d'éléments **Ultrabeam** et des **cartes relais** —
|
||||||
|
WebSwitch 1216H, KMTronic, **Denkovi** USB (bit-bang FT245 D2XX, 4 ou 8 relais)
|
||||||
|
et USB-série générique (CH340 / LCUS, protocole A0) — pour l'alimentation, les
|
||||||
|
antennes et accessoires de la station.
|
||||||
|
- **Relais automatiques** (Réglages) : bascule les relais de Station Control
|
||||||
|
automatiquement selon la fréquence / bande de la radio (comme PstRotator) — par
|
||||||
|
relais, une fenêtre de fréquence ou un ensemble de bandes.
|
||||||
|
|
||||||
## QSL & diplômes
|
## QSL & diplômes
|
||||||
|
|
||||||
@@ -201,13 +245,23 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
|||||||
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
|
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
|
||||||
direct.
|
direct.
|
||||||
|
|
||||||
|
## Statistiques
|
||||||
|
|
||||||
|
- **Tableau de bord des statistiques :** tuiles de synthèse (QSO, indicatifs
|
||||||
|
uniques, entités DXCC, continents, % confirmés) plus des graphiques — QSO **par
|
||||||
|
mode**, un split **CW / phonie / data** par bande, l'**activité dans le temps**
|
||||||
|
(vues glissantes jour / 7 jours / 30 jours / 12 mois), par opérateur et par
|
||||||
|
continent. Filtres par plage de dates, par opérateur et par concours, et une vue
|
||||||
|
**Table** qui reprend chaque graphique.
|
||||||
|
|
||||||
## Statut opérateur en direct (événements spéciaux)
|
## Statut opérateur en direct (événements spéciaux)
|
||||||
|
|
||||||
Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex.
|
Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex.
|
||||||
**TM74TFR**) : Réglages → Général → *Publier le statut opérateur en direct*.
|
**TM74TFR**), la publication est **automatique** — aucun réglage à activer.
|
||||||
Chaque instance OpsLog envoie un battement de cœur de son activité (indicatif de
|
Chaque instance OpsLog envoie un battement de cœur de son activité (indicatif de
|
||||||
l'opérateur, bande, fréquence, mode) dans une table `live_status` toutes les
|
l'opérateur, bande, fréquence, mode) dans une table `live_status` toutes les
|
||||||
~15 s. Un petit rendu PHP
|
~15 s, et repasse *hors antenne* automatiquement 5 min après le dernier QSO
|
||||||
|
logué. Un petit rendu PHP
|
||||||
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) sur votre
|
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) sur votre
|
||||||
propre serveur web lit cette table et produit une page/image en direct que vous
|
propre serveur web lit cette table et produit une page/image en direct que vous
|
||||||
pouvez intégrer sur la bio **QRZ.com** de la station
|
pouvez intégrer sur la bio **QRZ.com** de la station
|
||||||
@@ -217,8 +271,11 @@ ce n'est pas un serveur web.
|
|||||||
## Net control
|
## Net control
|
||||||
|
|
||||||
- **Log de net dirigé** (Outils → Net) : un roster global (`nets.json`) plus une
|
- **Log de net dirigé** (Outils → Net) : un roster global (`nets.json`) plus une
|
||||||
session active en mémoire — pointez les stations présentes, puis loguez tout le
|
session active en mémoire — pointez les stations présentes, puis loguez-les une
|
||||||
net d'un coup en utilisant la fréquence CAT.
|
par une ou tout le net d'un coup (**Logger tout le monde**) en utilisant la
|
||||||
|
fréquence CAT. **Glisser-déposer** entre les deux listes (roster → on air démarre
|
||||||
|
un QSO, on air → roster l'enregistre), et après chaque log la station on air
|
||||||
|
suivante est sélectionnée automatiquement pour enchaîner les contacts.
|
||||||
|
|
||||||
## Apparence & langue
|
## Apparence & langue
|
||||||
|
|
||||||
@@ -247,7 +304,10 @@ ce n'est pas un serveur web.
|
|||||||
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert,
|
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert,
|
||||||
contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
|
contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
|
||||||
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
|
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
|
||||||
- **Vérification de mise à jour** au démarrage avec un toast (désactivable).
|
- **Vérification de mise à jour** au démarrage et toutes les 5 minutes (et à
|
||||||
|
l'ouverture d'Aide → À propos), avec un toast (désactivable), plus une fenêtre
|
||||||
|
**Nouveautés** qui affiche le changelog (anglais / français) au premier
|
||||||
|
lancement après une mise à jour — réouvrable à tout moment depuis le menu Aide.
|
||||||
- **Télémétrie d'usage anonyme** (un battement de cœur quotidien : ID
|
- **Télémétrie d'usage anonyme** (un battement de cœur quotidien : ID
|
||||||
d'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
|
d'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
|
||||||
désactivable dans les Préférences).
|
désactivable dans les Préférences).
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# OpsLog
|
# OpsLog
|
||||||
|
|
||||||
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
A modern, fast ham-radio logger for Windows — single-strip entry, real-time CAT
|
||||||
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
||||||
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
||||||
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
|
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
|
||||||
@@ -27,7 +31,7 @@ Developed by **F4BPO**.
|
|||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode,
|
- **Single-strip entry:** callsign, RST tx/rx, name/QTH/grid, band/mode,
|
||||||
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
|
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
|
||||||
**flag** is shown large next to the RST fields.
|
**flag** is shown large next to the RST fields.
|
||||||
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
||||||
@@ -36,7 +40,10 @@ Developed by **F4BPO**.
|
|||||||
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
||||||
ClubLog DXpedition date overrides.
|
ClubLog DXpedition date overrides.
|
||||||
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
||||||
from cty/QRZ/ClubLog, bulk send to QSL services.
|
from cty/QRZ/ClubLog, bulk send to QSL services. A live **selection count**, a
|
||||||
|
**Select all / Unselect all** toggle, and a row limit that keeps the full log
|
||||||
|
fast **but is lifted while a filter is active** (every match is shown, not just
|
||||||
|
the first page).
|
||||||
- **Advanced QSO filter builder** (field / operator / value, AND / OR, saved
|
- **Advanced QSO filter builder** (field / operator / value, AND / OR, saved
|
||||||
presets) with filtered- and selected-row **ADIF export**.
|
presets) with filtered- and selected-row **ADIF export**.
|
||||||
- **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally
|
- **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally
|
||||||
@@ -55,7 +62,10 @@ Developed by **F4BPO**.
|
|||||||
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||||
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
||||||
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||||
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by PstRotator.
|
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by
|
||||||
|
**PstRotator** (UDP), a **4O3A Rotator Genius** (native TCP) or a **microHAM
|
||||||
|
ARCO** controlled natively — no PstRotator needed — over the **LAN** or **USB**
|
||||||
|
using its Yaesu GS-232A protocol.
|
||||||
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
|
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
|
||||||
direction is shown in green and the **mechanical boom** in grey, on both the
|
direction is shown in green and the **mechanical boom** in grey, on both the
|
||||||
compass and the map, so you never lose track of where the antenna points.
|
compass and the map, so you never lose track of where the antenna points.
|
||||||
@@ -67,9 +77,11 @@ Developed by **F4BPO**.
|
|||||||
mode / status / source) shared by the Cluster tab and the Main-view cluster
|
mode / status / source) shared by the Cluster tab and the Main-view cluster
|
||||||
pane, with a show/hide toggle.
|
pane, with a show/hide toggle.
|
||||||
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
||||||
rig, and a multi-band **Band Map** (panadapter-style strips).
|
rig, and a multi-band **Band Map** (panadapter-style strips). Optionally, all
|
||||||
|
**digital modes count as one** (DXCC-style) for the new/new-slot colouring and
|
||||||
|
the worked-before matrix badges (Settings → General).
|
||||||
- **POTA** spots are tagged with their park reference (via `api.pota.app`).
|
- **POTA** spots are tagged with their park reference (via `api.pota.app`).
|
||||||
- **Spot alerts** (Log4OM-style): rules on call / country / band / mode /
|
- **Spot alerts:** rules on call / country / band / mode /
|
||||||
spotter, with sound, visual and e-mail notification (Tools → *Alert
|
spotter, with sound, visual and e-mail notification (Tools → *Alert
|
||||||
management*).
|
management*).
|
||||||
|
|
||||||
@@ -81,7 +93,8 @@ non-blocking connect so a powered-off radio never freezes the app:
|
|||||||
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||||
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
||||||
mode / split, UDP discovery, and **panadapter spots** (cluster spots pushed to
|
mode / split, UDP discovery, and **panadapter spots** (cluster spots pushed to
|
||||||
the Flex display, click → fill the call).
|
the Flex display; a click fills the call and **tunes the rig to the right
|
||||||
|
frequency AND mode**).
|
||||||
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
||||||
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
||||||
Remote Utility needed.
|
Remote Utility needed.
|
||||||
@@ -98,9 +111,16 @@ Shown only when the CAT backend is a FlexRadio:
|
|||||||
|
|
||||||
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
|
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
|
||||||
VOX (+ level + delay), monitor (+ level), mic gain.
|
VOX (+ level + delay), monitor (+ level), mic gain.
|
||||||
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF.
|
- **Receive (active slice):** RX/TX **antenna** selectors and a **DAX** toggle
|
||||||
|
(TX audio through DAX, for WSJT-X & co), AGC mode/threshold, audio level,
|
||||||
|
NB / WNB / NR / ANF, and — on **SmartSDR v4** radios (8000 / Aurora series) —
|
||||||
|
the extra DSP tools **NRL, NRS, NRF** (with level) plus **RNN** (AI noise
|
||||||
|
reduction) and **ANFT** (FFT auto-notch), shown automatically when the radio
|
||||||
|
supports them. **RIT / XIT** with wheel / ± tuning.
|
||||||
- **Antenna tuner (ATU):** tune / bypass / memories.
|
- **Antenna tuner (ATU):** tune / bypass / memories.
|
||||||
- **Amplifier:** PowerGenius XL operate/standby + fault.
|
- **Amplifier:** the amp card follows whichever amplifier is configured, with a
|
||||||
|
dropdown to pick it when **several amplifiers** are set up (e.g. two SPEs run
|
||||||
|
in parallel). See *Amplifiers & switches* below.
|
||||||
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
||||||
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
||||||
|
|
||||||
@@ -141,18 +161,40 @@ as Mumble.)
|
|||||||
## Keyers & audio
|
## Keyers & audio
|
||||||
|
|
||||||
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
||||||
**WinKeyer** (K1EL WK1/2/3 over a COM port), **Icom** (the radio's own keyer
|
**WinKeyer** (K1EL WK1/2/3 over a COM port), **FlexRadio CWX** (the radio's
|
||||||
over CI-V — no extra hardware, works over the remote link too) or **TCI**.
|
built-in keyer over the SmartSDR API — type-ahead and backspace, no WinKeyer or
|
||||||
|
SmartCAT needed), **Icom** (the radio's own keyer over CI-V — no extra hardware,
|
||||||
|
works over the remote link too) or **TCI**.
|
||||||
- **Digital Voice Keyer** (DVK): record F1–F6 voice messages and transmit them.
|
- **Digital Voice Keyer** (DVK): record F1–F6 voice messages and transmit them.
|
||||||
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
||||||
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
||||||
|
|
||||||
## Amplifiers & switches
|
## Amplifiers & switches
|
||||||
|
|
||||||
- **PowerGenius XL** (4O3A) amplifier — direct TCP: operate/standby, fan-mode
|
- **Amplifiers** — configure **one or several** amps (Settings → Amplifier is a
|
||||||
|
list; e.g. two SPEs run in parallel for more power). Each amp's control card
|
||||||
|
appears on the FlexRadio tab and in **Station Control**, with a dropdown to
|
||||||
|
choose which one it shows; the bottom status bar carries **one clickable chip
|
||||||
|
per amp** (green = OPERATE, orange = STANDBY, red = offline). Supported:
|
||||||
|
- **PowerGenius XL** (4O3A) over direct TCP — operate/standby, fan-mode
|
||||||
selector and fault display.
|
selector and fault display.
|
||||||
|
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) over **USB** (virtual COM) or the
|
||||||
|
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
||||||
|
Low / Mid / High output level, an output-power bar and live status (band,
|
||||||
|
SWR, PA current, temperature, warnings/alarms).
|
||||||
|
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) over **USB** or the **network**
|
||||||
|
(RS232-to-Ethernet bridge) — operate/standby/off and live telemetry (forward
|
||||||
|
& reflected power, SWR, PA temperature, band, fan, faults). Power-ON works
|
||||||
|
over a serial cable that wires the DTR/RTS lines.
|
||||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||||
antenna-switch widget.
|
antenna-switch widget.
|
||||||
|
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
||||||
|
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
||||||
|
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
|
||||||
|
(CH340 / LCUS, A0 protocol) — for station power, antennas and accessories.
|
||||||
|
- **Relay auto-control** (Settings): switch Station-Control relays automatically
|
||||||
|
from the rig frequency / band (like PstRotator) — per relay, a frequency window
|
||||||
|
or a set of bands.
|
||||||
|
|
||||||
## QSL & awards
|
## QSL & awards
|
||||||
|
|
||||||
@@ -176,12 +218,21 @@ as Mumble.)
|
|||||||
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
|
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
|
||||||
flags dupes and keeps a live scoreboard.
|
flags dupes and keeps a live scoreboard.
|
||||||
|
|
||||||
|
## Statistics
|
||||||
|
|
||||||
|
- **Logbook statistics dashboard:** headline tiles (QSOs, unique callsigns, DXCC
|
||||||
|
entities, continents, % confirmed) plus charts — QSOs **by mode**, a per-band
|
||||||
|
**CW / phone / data** split, **activity over time** (rolling day / 7-day /
|
||||||
|
30-day / 12-month views), by operator and by continent. Date-range, per-operator
|
||||||
|
and per-contest filters, and a **Table** view that mirrors every chart.
|
||||||
|
|
||||||
## Multi-operator live status (special events)
|
## Multi-operator live status (special events)
|
||||||
|
|
||||||
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
|
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**),
|
||||||
Settings → General → *Publish live operator status*. Each OpsLog instance
|
publishing is **automatic** — no setting to turn on. Each OpsLog instance
|
||||||
heartbeats its current activity (operator call, band, frequency, mode) into a
|
heartbeats its current activity (operator call, band, frequency, mode) into a
|
||||||
`live_status` table every ~15 s. A small PHP renderer
|
`live_status` table every ~15 s, and drops back to *off air* automatically 5 min
|
||||||
|
after the last logged QSO. A small PHP renderer
|
||||||
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
|
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
|
||||||
own web server reads that table and produces a live page/image you can embed on
|
own web server reads that table and produces a live page/image you can embed on
|
||||||
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
|
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
|
||||||
@@ -190,8 +241,11 @@ only writes to the DB — it is not a web server.
|
|||||||
## Net control
|
## Net control
|
||||||
|
|
||||||
- **Directed-net logging** (Tools → Net): a global roster (`nets.json`) plus an
|
- **Directed-net logging** (Tools → Net): a global roster (`nets.json`) plus an
|
||||||
in-memory active session — check stations in, then log the whole net at once
|
in-memory active session — check stations in, then log them individually or the
|
||||||
using the CAT frequency.
|
whole net at once (**Log everyone**) using the CAT frequency. **Drag & drop**
|
||||||
|
between the two lists (roster → on-air starts a QSO, on-air → roster logs it),
|
||||||
|
and after each log the next on-air station is selected automatically so you can
|
||||||
|
chain contacts.
|
||||||
|
|
||||||
## Appearance & language
|
## Appearance & language
|
||||||
|
|
||||||
@@ -218,7 +272,10 @@ only writes to the DB — it is not a web server.
|
|||||||
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
||||||
OpsLog startup, skipping any already running.
|
OpsLog startup, skipping any already running.
|
||||||
- **Backup:** optional database + ADIF backup at shutdown.
|
- **Backup:** optional database + ADIF backup at shutdown.
|
||||||
- **Update check** at startup with a toast (toggleable).
|
- **Update check** at startup and every 5 minutes (and on opening Help → About),
|
||||||
|
with a toast (toggleable), plus a **What's new** dialog that shows the changelog
|
||||||
|
(English / French) on the first launch after an update — reopenable any time
|
||||||
|
from the Help menu.
|
||||||
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||||
version + OS — no callsign or QSO data; opt-out in Preferences).
|
version + OS — no callsign or QSO data; opt-out in Preferences).
|
||||||
|
|
||||||
|
|||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// ADIF monitor: watches a configurable list of external ADIF files (fldigi's
|
||||||
|
// RTTY logbook, N1MM, VarAC…) and imports newly appended QSOs into OpsLog as if
|
||||||
|
// they had been logged here — same enrichment, dedup and automatic upload to
|
||||||
|
// external services (QRZ, Club Log…). Deliberately simpler than Log4OM's monitor:
|
||||||
|
// no per-file "upload" / "delete after load" toggles — importing + auto-upload is
|
||||||
|
// just what happens.
|
||||||
|
//
|
||||||
|
// Design notes:
|
||||||
|
// - A newly added file starts at its CURRENT size (Offset = -1 sentinel → set to
|
||||||
|
// size on first scan) so the QSOs already in it are NOT bulk-imported; only
|
||||||
|
// contacts appended AFTER you add the file come in.
|
||||||
|
// - Reads only up to the last complete <eor>, so a half-written record waits for
|
||||||
|
// the next poll instead of importing a truncated QSO.
|
||||||
|
// - Each record is fed through LogUDPLoggedADIF, which already does the full
|
||||||
|
// enrichment + ±2-minute dedup (shared udpLogMu, so it can't race the UDP
|
||||||
|
// auto-log) + automatic external-service upload.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyADIFMonitor = "adifmon.config"
|
||||||
|
|
||||||
|
// ADIFWatchFile is one monitored ADIF file.
|
||||||
|
type ADIFWatchFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Offset is the number of bytes already consumed. -1 means "not yet
|
||||||
|
// initialised": the first scan sets it to the file's current size so existing
|
||||||
|
// history is skipped.
|
||||||
|
Offset int64 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADIFMonitorConfig is the whole monitor setup: a master switch + the file list.
|
||||||
|
type ADIFMonitorConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Files []ADIFWatchFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const eorTag = "<eor>"
|
||||||
|
|
||||||
|
func (a *App) loadADIFMonitorLocked() ADIFMonitorConfig {
|
||||||
|
var cfg ADIFMonitorConfig
|
||||||
|
if a.settings == nil {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
s, _ := a.settings.GetGlobal(a.ctx, keyADIFMonitor)
|
||||||
|
if strings.TrimSpace(s) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(s), &cfg)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) saveADIFMonitorLocked(cfg ADIFMonitorConfig) {
|
||||||
|
b, _ := json.Marshal(cfg)
|
||||||
|
a.setSettingGlobal(keyADIFMonitor, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetADIFMonitor returns the monitor configuration for the settings UI.
|
||||||
|
func (a *App) GetADIFMonitor() ADIFMonitorConfig {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
defer a.adifMonMu.Unlock()
|
||||||
|
return a.loadADIFMonitorLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveADIFMonitor persists the monitor configuration. A file the user just added
|
||||||
|
// gets Offset = -1 so its existing content is skipped (only QSOs logged AFTER it
|
||||||
|
// was added import); a file already present keeps its current read position.
|
||||||
|
func (a *App) SaveADIFMonitor(cfg ADIFMonitorConfig) error {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
defer a.adifMonMu.Unlock()
|
||||||
|
old := a.loadADIFMonitorLocked()
|
||||||
|
oldOff := make(map[string]int64, len(old.Files))
|
||||||
|
for _, f := range old.Files {
|
||||||
|
oldOff[strings.TrimSpace(f.Path)] = f.Offset
|
||||||
|
}
|
||||||
|
for i := range cfg.Files {
|
||||||
|
cfg.Files[i].Path = strings.TrimSpace(cfg.Files[i].Path)
|
||||||
|
if off, ok := oldOff[cfg.Files[i].Path]; ok {
|
||||||
|
cfg.Files[i].Offset = off // keep the read position of an existing file
|
||||||
|
} else {
|
||||||
|
cfg.Files[i].Offset = -1 // new file → skip its existing history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.saveADIFMonitorLocked(cfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickADIFMonitorFile opens a file dialog to choose an ADIF file to monitor.
|
||||||
|
func (a *App) PickADIFMonitorFile() (string, error) {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return "", fmt.Errorf("no app context")
|
||||||
|
}
|
||||||
|
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
|
Title: "Choose an ADIF file to monitor",
|
||||||
|
Filters: []wruntime.FileFilter{
|
||||||
|
{DisplayName: "ADIF (*.adi;*.adif)", Pattern: "*.adi;*.adif"},
|
||||||
|
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adifMonitorLoop polls the enabled ADIF files every few seconds and imports any
|
||||||
|
// newly appended QSOs. Runs for the app's lifetime on its own goroutine.
|
||||||
|
func (a *App) adifMonitorLoop() {
|
||||||
|
tick := time.NewTicker(5 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
for range tick.C {
|
||||||
|
if a.ctx == nil || a.qso == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.scanADIFMonitors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanADIFMonitors walks the enabled files once, importing new records and
|
||||||
|
// persisting advanced offsets.
|
||||||
|
func (a *App) scanADIFMonitors() {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
cfg := a.loadADIFMonitorLocked()
|
||||||
|
a.adifMonMu.Unlock()
|
||||||
|
if !cfg.Enabled || len(cfg.Files) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// path → new offset, for the files we advanced this pass.
|
||||||
|
advanced := map[string]int64{}
|
||||||
|
for i := range cfg.Files {
|
||||||
|
f := &cfg.Files[i]
|
||||||
|
if !f.Enabled || strings.TrimSpace(f.Path) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(f.Path)
|
||||||
|
if err != nil {
|
||||||
|
continue // not present (yet) — try again next tick
|
||||||
|
}
|
||||||
|
size := fi.Size()
|
||||||
|
if f.Offset < 0 {
|
||||||
|
// First sight of this file → skip whatever history it already holds.
|
||||||
|
advanced[f.Path] = size
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if size < f.Offset {
|
||||||
|
f.Offset = 0 // truncated / rotated → re-read from the start (dedup protects us)
|
||||||
|
}
|
||||||
|
if size == f.Offset {
|
||||||
|
continue // nothing new
|
||||||
|
}
|
||||||
|
newOff, n := a.importADIFAppend(f.Path, f.Offset, size)
|
||||||
|
if newOff != f.Offset {
|
||||||
|
advanced[f.Path] = newOff
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
applog.Printf("adif monitor: imported %d QSO(s) from %s", n, f.Path)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "adifmon:imported", map[string]any{"file": f.Path, "count": n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(advanced) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Persist the advanced offsets WITHOUT clobbering a concurrent UI save of the
|
||||||
|
// file list: re-read the stored config and only patch offsets for paths that
|
||||||
|
// still exist there.
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
cur := a.loadADIFMonitorLocked()
|
||||||
|
for i := range cur.Files {
|
||||||
|
if off, ok := advanced[strings.TrimSpace(cur.Files[i].Path)]; ok {
|
||||||
|
cur.Files[i].Offset = off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.saveADIFMonitorLocked(cur)
|
||||||
|
a.adifMonMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// importADIFAppend reads bytes [from,to) of an ADIF file, imports every COMPLETE
|
||||||
|
// record found (up to the last <eor>) and returns the new offset (just past that
|
||||||
|
// last <eor>) plus how many QSOs were actually imported (duplicates excluded).
|
||||||
|
func (a *App) importADIFAppend(path string, from, to int64) (int64, int) {
|
||||||
|
fh, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
if _, err := fh.Seek(from, io.SeekStart); err != nil {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
buf := make([]byte, to-from)
|
||||||
|
nRead, err := io.ReadFull(fh, buf)
|
||||||
|
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
buf = buf[:nRead]
|
||||||
|
|
||||||
|
// Only consume up to the last complete <eor>; a half-written trailing record
|
||||||
|
// waits for the next poll.
|
||||||
|
lower := bytes.ToLower(buf)
|
||||||
|
last := bytes.LastIndex(lower, []byte(eorTag))
|
||||||
|
if last < 0 {
|
||||||
|
return from, 0 // no complete record yet
|
||||||
|
}
|
||||||
|
end := last + len(eorTag)
|
||||||
|
chunk := buf[:end]
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, rec := range splitADIFRecords(chunk) {
|
||||||
|
if strings.TrimSpace(rec) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := a.LogUDPLoggedADIF(rec); err == nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
// A duplicate (already in the log within ±2 min) returns an error and is
|
||||||
|
// simply not counted — expected when the same QSO is also logged in OpsLog.
|
||||||
|
}
|
||||||
|
return from + int64(end), count
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitADIFRecords cuts an ADIF byte slice into individual record texts, each
|
||||||
|
// ending at its <eor> (case-insensitive). Any leading file header (up to the
|
||||||
|
// first record's <eor>) rides along with the first record — LogUDPLoggedADIF
|
||||||
|
// parses past an <EOH> header fine, and prepends one when there is none.
|
||||||
|
func splitADIFRecords(b []byte) []string {
|
||||||
|
lower := bytes.ToLower(b)
|
||||||
|
var out []string
|
||||||
|
start := 0
|
||||||
|
for {
|
||||||
|
rel := bytes.Index(lower[start:], []byte(eorTag))
|
||||||
|
if rel < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := start + rel + len(eorTag)
|
||||||
|
out = append(out, string(b[start:end]))
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+10
-11
@@ -437,17 +437,16 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
|||||||
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
|
||||||
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
|
||||||
// independent. q came straight from GetByID, so a full Update rewrites the
|
//
|
||||||
// row unchanged apart from this field.
|
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
|
||||||
if q.Extras == nil {
|
// read up top is now stale after the slow e-mail send, and rewriting the whole
|
||||||
q.Extras = map[string]string{}
|
// row would revert any column an auto-upload changed meanwhile — that's how
|
||||||
}
|
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
|
||||||
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
|
||||||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
return fmt.Errorf("QSL card sent but status not saved: %w", err)
|
||||||
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
|
||||||
}
|
}
|
||||||
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
||||||
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ var sensitiveSettingKeys = map[string]bool{
|
|||||||
keyExtLoTWWebPassword: true,
|
keyExtLoTWWebPassword: true,
|
||||||
keyExtHRDLogCode: true,
|
keyExtHRDLogCode: true,
|
||||||
keyExtEQSLPassword: true,
|
keyExtEQSLPassword: true,
|
||||||
|
keyExtCloudlogAPIKey: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Three band tables exist in Go — cat.BandFromHz (the rig/CAT one), bandForHz
|
||||||
|
// (awards) and the cluster's own — plus one in App.tsx. They must agree, and
|
||||||
|
// nothing checked it: the CAT one stopped at 23 cm, so a 10 GHz QSO came back
|
||||||
|
// with an EMPTY band. An empty band is not a cosmetic problem — it is not
|
||||||
|
// logged, not counted in any award slot, and exports without a BAND field.
|
||||||
|
//
|
||||||
|
// One frequency per band, taken inside the range, plus the edges that used to be
|
||||||
|
// missing entirely.
|
||||||
|
func TestBandPlansAgree(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
hz int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{137000, "2190m"},
|
||||||
|
{475000, "630m"},
|
||||||
|
{1840000, "160m"},
|
||||||
|
{3650000, "80m"},
|
||||||
|
{7100000, "40m"},
|
||||||
|
{10120000, "30m"},
|
||||||
|
{14200000, "20m"},
|
||||||
|
{18100000, "17m"},
|
||||||
|
{21200000, "15m"},
|
||||||
|
{24940000, "12m"},
|
||||||
|
{28400000, "10m"},
|
||||||
|
{50150000, "6m"},
|
||||||
|
{70200000, "4m"},
|
||||||
|
{144300000, "2m"},
|
||||||
|
{223500000, "1.25m"},
|
||||||
|
{432000000, "70cm"},
|
||||||
|
{1296000000, "23cm"},
|
||||||
|
// The microwave bands that were missing. 3 cm is the one an operator
|
||||||
|
// reported: 10 GHz is worked and spotted, and resolved to nothing.
|
||||||
|
{2320000000, "13cm"},
|
||||||
|
{3400000000, "9cm"},
|
||||||
|
{5760000000, "6cm"},
|
||||||
|
{10368000000, "3cm"},
|
||||||
|
{24048000000, "1.25cm"},
|
||||||
|
{47088000000, "6mm"},
|
||||||
|
{76032000000, "4mm"},
|
||||||
|
{122250000000, "2.5mm"},
|
||||||
|
{134928000000, "2mm"},
|
||||||
|
{241920000000, "1mm"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := cat.BandFromHz(c.hz); got != c.want {
|
||||||
|
t.Errorf("cat.BandFromHz(%d) = %q, want %q", c.hz, got, c.want)
|
||||||
|
}
|
||||||
|
if got := bandForHz(c.hz); got != c.want {
|
||||||
|
t.Errorf("bandForHz(%d) = %q, want %q — the award band plan disagrees with the CAT one", c.hz, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A frequency in no amateur band must resolve to "" everywhere, not to the
|
||||||
|
// nearest band: guessing here would put a QSO in a band the operator never used.
|
||||||
|
func TestBandPlanRejectsOutOfBand(t *testing.T) {
|
||||||
|
for _, hz := range []int64{100_000_000, 1_000_000_000, 15_000_000_000} {
|
||||||
|
if got := cat.BandFromHz(hz); got != "" {
|
||||||
|
t.Errorf("cat.BandFromHz(%d) = %q, want empty", hz, got)
|
||||||
|
}
|
||||||
|
if got := bandForHz(hz); got != "" {
|
||||||
|
t.Errorf("bandForHz(%d) = %q, want empty", hz, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every field the bulk-edit dialog OFFERS must be one the repository accepts.
|
||||||
|
//
|
||||||
|
// These two lists live in different packages — a TypeScript field table on one
|
||||||
|
// side, a Go whitelist on the other — and they drifted apart the day the
|
||||||
|
// confirmation dates were added: the new columns went into the QSL Manager's
|
||||||
|
// filter whitelist instead of the bulk-edit one, so the dialog listed fields it
|
||||||
|
// could not write and the operator got "field … is not bulk-editable" only after
|
||||||
|
// selecting 54 QSOs and clicking Apply. Nothing in the build caught it.
|
||||||
|
//
|
||||||
|
// The test reads the ACTUAL field ids out of the .tsx rather than a copy, so a
|
||||||
|
// field added to the UI alone fails here immediately.
|
||||||
|
func TestBulkEditFieldsAreWritable(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("frontend/src/components/BulkEditModal.tsx")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("frontend source not available: %v", err)
|
||||||
|
}
|
||||||
|
ids := regexp.MustCompile(`\{ id: '([a-z0-9_]+)'`).FindAllStringSubmatch(string(src), -1)
|
||||||
|
if len(ids) < 10 {
|
||||||
|
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||||
|
}
|
||||||
|
for _, m := range ids {
|
||||||
|
id := m[1]
|
||||||
|
// Handled before the column map: freq takes a numeric path (freq_hz +
|
||||||
|
// band together) and the extras live in extras_json.
|
||||||
|
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
col, ok := bulkFieldColumns[id]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("bulk-edit UI offers %q, which maps to no column (bulkFieldColumns)", id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Extras live outside the qso table (owner_callsign and friends): they are
|
||||||
|
// written into extras_json, not a column, so the column whitelist does not
|
||||||
|
// apply to them.
|
||||||
|
if strings.HasPrefix(col, "extras.") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !qso.IsBulkEditable(col) && !qso.IsBulkEditableExtra(col) {
|
||||||
|
t.Errorf("bulk-edit UI offers %q → column %q, which the repository refuses", id, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same contract for the filter builder: every field it offers must be one the
|
||||||
|
// query layer will accept.
|
||||||
|
func TestFilterFieldsAreQueryable(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("frontend/src/components/FilterBuilder.tsx")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("frontend source not available: %v", err)
|
||||||
|
}
|
||||||
|
// Anchored on `type:` — the FIELDS entries carry one, the OPERATOR list does
|
||||||
|
// not, and without it every operator (eq, contains, …) was read as a field.
|
||||||
|
ids := regexp.MustCompile(`\{ value: '([a-z0-9_]+)', label: '[^']*', type:`).FindAllStringSubmatch(string(src), -1)
|
||||||
|
if len(ids) < 10 {
|
||||||
|
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||||
|
}
|
||||||
|
for _, m := range ids {
|
||||||
|
col := m[1]
|
||||||
|
if !qso.IsFilterable(col) && !qso.IsFilterableExtra(col) {
|
||||||
|
t.Errorf("filter builder offers %q, which the query layer refuses", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed changelog.json
|
||||||
|
var changelogJSON []byte
|
||||||
|
|
||||||
|
// ChangelogEntry is one release's user-facing notes, in English and French. It's
|
||||||
|
// shown once on the first launch after an update (the "What's new" dialog),
|
||||||
|
// derived from the release's commit history.
|
||||||
|
type ChangelogEntry struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
EN []string `json:"en"`
|
||||||
|
FR []string `json:"fr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyChangelogSeen = "changelog.last_seen_version"
|
||||||
|
|
||||||
|
func loadChangelog() []ChangelogEntry {
|
||||||
|
var out []ChangelogEntry
|
||||||
|
if err := json.Unmarshal(changelogJSON, &out); err != nil {
|
||||||
|
applog.Printf("changelog: parse failed: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChangelog returns every changelog entry up to the running version (newest
|
||||||
|
// first), without touching the "seen" marker — for a "What's new" button that
|
||||||
|
// reopens the notes on demand.
|
||||||
|
func (a *App) GetChangelog() []ChangelogEntry {
|
||||||
|
out := []ChangelogEntry{}
|
||||||
|
for _, e := range loadChangelog() {
|
||||||
|
if strings.TrimSpace(e.Version) == "" || versionLess(appVersion, e.Version) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWhatsNew returns the changelog entries the operator hasn't seen yet — the
|
||||||
|
// notes for every version newer than the last one they ran, up to the current
|
||||||
|
// build — and records the current version as seen so it pops exactly once per
|
||||||
|
// update. Empty when there's nothing new.
|
||||||
|
func (a *App) GetWhatsNew() []ChangelogEntry {
|
||||||
|
if a.settings == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastSeen, _ := a.settings.GetGlobal(a.ctx, keyChangelogSeen)
|
||||||
|
cur := appVersion
|
||||||
|
a.setSettingGlobal(keyChangelogSeen, cur) // advance the marker now
|
||||||
|
|
||||||
|
out := []ChangelogEntry{}
|
||||||
|
for _, e := range loadChangelog() {
|
||||||
|
if strings.TrimSpace(e.Version) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if versionLess(cur, e.Version) {
|
||||||
|
continue // don't preview notes for a version newer than we run
|
||||||
|
}
|
||||||
|
if lastSeen == "" {
|
||||||
|
// First run of the feature (fresh install, or upgrade from a build that
|
||||||
|
// predates it): show only the CURRENT version's notes, once.
|
||||||
|
if e.Version == cur {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if versionLess(lastSeen, e.Version) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+592
@@ -0,0 +1,592 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"version": "0.22.3",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"The Kenwood backend is now exercised against a rig that answers: OpsLog talks to the TS-2000 emulator it already carries for ACOM amplifiers, so frequency, mode, VFO, split and PTT are checked end to end. Testing on a real Kenwood is still needed, but the dialogue itself is no longer untried.",
|
||||||
|
"Kenwood: split is now detected from the transmit and receive VFO (FR/FT) when the radio leaves it out of its status frame — the case seen on a Flex in Kenwood CAT mode, where the frequency read correctly but split never appeared. Radios that report it in the status frame are unaffected.",
|
||||||
|
"The CAT protocol trace covers the Kenwood backend as well, not just CI-V. Settings → CAT.",
|
||||||
|
"Updating selected QSOs from the callsign databases now shows a progress bar with the callsign being queried, in a corner rather than a dialog so the rest of OpsLog stays usable — a contest log is thousands of contacts and one network round trip each. The menu entry is renamed \"Update from the callsign databases\": it has always queried every configured provider, QRZ.com then HamQTH.",
|
||||||
|
"The world map can show the grey line: the day/night terminator with its twilight band, redrawn every minute. Button at the top right of the map, off by default, and the choice is remembered.",
|
||||||
|
"The protocol trace checkboxes (CAT and WinKeyer) now show whether the trace is really running. They came back unticked on a trace that was still on, so ticking the box to enable it actually switched it off and the log sent afterwards contained no trace.",
|
||||||
|
"A radio reporting LSB or USB now selects SSB in the entry form. Radios report the sideband, the mode list holds SSB, so nothing matched and the mode stayed empty while the frequency tracked correctly. It also keeps the logged mode ADIF-valid: LSB and USB are submodes there, not modes.",
|
||||||
|
"Kenwood CAT can go over the network: give a host:port instead of a COM port and OpsLog talks to a serial bridge (ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio). This is not the radio's own RJ45, which speaks Kenwood's KNS protocol.",
|
||||||
|
"The CAT backend list is renamed by how the radio is reached: OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, network), Xiegu (USB), Icom (CI-V USB), Icom (CI-V network), TCI.",
|
||||||
|
"With automatic recording off but the audio devices configured, a red dot appears where the recording counter goes. Click it to record the contact by hand: the counter starts, the dot disappears, and logging the QSO saves the file as usual. The sound devices are released again afterwards.",
|
||||||
|
"A recording in progress can be stopped and TRANSMITTED to the station being worked, keyed and sent like a voice-keyer message — for when they ask to hear their own signal. The audio is kept and still saved with the QSO, and recording can be resumed.",
|
||||||
|
"The award reference table can be sorted by reference (the default) or by description — click either heading, click again to reverse. Both the grid and list views follow.",
|
||||||
|
"Deleting a QSO can now withdraw it from QRZ.com and Club Log as well — Settings → External services, off by default. Club Log matches on callsign, time and band so it works for any QSO; QRZ.com can only remove records OpsLog uploaded itself, since its API deletes by record number only.",
|
||||||
|
"QSL and upload status columns are coloured: Y green, N red, R blue. Colour only, no badges. Applies everywhere the QSO table is used — recent QSOs, worked before, NET Control and the QSL manager.",
|
||||||
|
"Settings → General chooses how dates are displayed: Standard (2026-07-30), French (30-07-2026) or US (07-30-2026). Display only — the log is always stored in the ADIF standard format, and exports are unaffected.",
|
||||||
|
"New award: DARC DOK Award (DLD), the German Deutschland-Diplom, matched on the DOK reference in the QSO."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Le backend Kenwood est désormais éprouvé face à une radio qui répond : OpsLog dialogue avec l'émulateur TS-2000 qu'il embarque déjà pour les amplis ACOM, ce qui vérifie fréquence, mode, VFO, split et PTT de bout en bout. Un essai sur un vrai Kenwood reste nécessaire, mais le dialogue n'est plus non testé.",
|
||||||
|
"Kenwood : le split est désormais détecté à partir des VFO d'émission et de réception (FR/FT) quand la radio ne le renseigne pas dans sa trame d'état — le cas observé sur un Flex en mode CAT Kenwood, où la fréquence était juste mais le split n'apparaissait jamais. Les radios qui le signalent normalement ne changent pas.",
|
||||||
|
"La trace du protocole CAT couvre aussi le backend Kenwood, plus seulement le CI-V. Réglages → CAT.",
|
||||||
|
"La mise à jour des QSO sélectionnés depuis les annuaires affiche désormais une barre de progression avec l'indicatif en cours d'interrogation, dans un coin plutôt qu'en fenêtre : le reste d'OpsLog reste utilisable — un log de concours, c'est des milliers de contacts et un aller-retour réseau pour chacun. L'entrée de menu devient « Mettre à jour depuis les annuaires » : elle a toujours interrogé tous les annuaires configurés, QRZ.com puis HamQTH.",
|
||||||
|
"La carte du monde peut afficher la ligne grise : le terminateur jour/nuit et sa bande de crépuscule, redessinés chaque minute. Bouton en haut à droite de la carte, désactivé par défaut, et le choix est mémorisé.",
|
||||||
|
"Les cases de trace du protocole (CAT et WinKeyer) reflètent désormais l'état réel de la trace. Elles réapparaissaient décochées alors que la trace tournait : cocher la case pour l'activer la désactivait en fait, et le journal envoyé ensuite ne contenait aucune trace.",
|
||||||
|
"Une radio qui annonce LSB ou USB sélectionne désormais SSB dans la saisie. Les radios annoncent la bande latérale, la liste des modes contient SSB : rien ne correspondait et le mode restait vide alors que la fréquence suivait. Cela garde aussi le mode journalisé conforme à l'ADIF, où LSB et USB sont des sous-modes et non des modes.",
|
||||||
|
"Le CAT Kenwood peut passer par le réseau : indiquez un hôte:port au lieu d'un port COM et OpsLog dialogue avec un pont série (ser2net, boîtier Ethernet-série, Raspberry Pi près de la radio). Il ne s'agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood.",
|
||||||
|
"La liste des backends CAT est renommée selon la façon dont la radio est jointe : OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, réseau), Xiegu (USB), Icom (CI-V USB), Icom (CI-V réseau), TCI.",
|
||||||
|
"Quand l'enregistrement automatique est désactivé mais que les périphériques audio sont configurés, un rond rouge apparaît à l'emplacement du compteur. Un clic enregistre le contact à la main : le compteur démarre, le rond disparaît, et journaliser le QSO enregistre le fichier comme d'habitude. Les périphériques audio sont ensuite relâchés.",
|
||||||
|
"Un enregistrement en cours peut être arrêté puis ÉMIS vers la station travaillée, radio passée en émission comme un message du manipulateur vocal — pour quand elle demande à entendre son propre signal. L'audio est conservé et toujours enregistré avec le QSO, et l'enregistrement peut reprendre.",
|
||||||
|
"Le tableau des références d'un diplôme peut être trié par référence (par défaut) ou par description — cliquez sur l'en-tête, un second clic inverse l'ordre. Les vues grille et liste suivent toutes deux.",
|
||||||
|
"Supprimer un QSO peut désormais le retirer aussi de QRZ.com et Club Log — Réglages → Services externes, désactivé par défaut. Club Log retrouve le contact par indicatif, heure et bande, donc cela vaut pour n'importe quel QSO ; QRZ.com ne peut retirer que les enregistrements envoyés par OpsLog, son API ne supprimant que par numéro d'enregistrement.",
|
||||||
|
"Les colonnes de statut QSL et d'envoi sont colorées : Y en vert, N en rouge, R en bleu. Couleur seule, sans pastille. Partout où le tableau des QSO est utilisé — QSO récents, déjà contacté, NET Control et le gestionnaire de QSL.",
|
||||||
|
"Réglages → Général permet de choisir l'affichage des dates : Standard (2026-07-30), Français (30-07-2026) ou US (07-30-2026). Affichage seulement — le journal reste toujours enregistré au format standard ADIF, et les exports ne changent pas.",
|
||||||
|
"Nouveau diplôme : DARC DOK Award (DLD), le Deutschland-Diplom allemand, reconnu d'après la référence DOK du QSO."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.22.2",
|
||||||
|
"date": "2026-07-30",
|
||||||
|
"en": [
|
||||||
|
"The Antenna Genius and Tuner Genius settings entries carry a small 4O3A mark, so the panels driving that hardware are recognisable at a glance in the sidebar.",
|
||||||
|
"The database shown in the status bar is the LOGBOOK file. On a local SQLite setup it named the settings database instead, so anyone checking where their QSOs live — or which file to back up — was pointed at the wrong one.",
|
||||||
|
"Yaesu: the mode follows the VFO you are on. A spot clicked while working the SUB receiver tuned the sub VFO but set the mode on MAIN, leaving the VFO in use on its old mode; the console read main mode too.",
|
||||||
|
"Rotator control now covers any GS-232A controller, ERC (Easy Rotor Control) included: the serial speed is selectable and the entry is named after the protocol rather than one device. Set the ERC to GS-232 emulation, not Hy-Gain DCU-1.",
|
||||||
|
"Native Kenwood CAT: a sixth backend talks straight to a TS-590, TS-890, TS-990 or TS-2000 over its serial port — frequency, mode, VFO, split and PTT — with no OmniRig in between. Elecraft K3/K4 speak the same dialect. Pick \"Kenwood (native)\" in Settings → CAT.",
|
||||||
|
"Icom and Xiegu: a CI-V frame read at the wrong offset no longer turns into a frequency. Values such as 16445550000 Hz appeared in the status bar while the rig sat on 145.550 MHz — the decoder treated non-decimal bytes as digits. Such frames are now refused and the last good reading stands.",
|
||||||
|
"Settings → CAT can log the CI-V protocol: every frame to and from an Icom or Xiegu, as hex. For reporting a radio that answers oddly — a button that does the wrong thing, a frequency that jumps — where a description alone leaves the cause to guesswork.",
|
||||||
|
"A crash in the window no longer leaves a blank screen. The error is written to the app log and shown on screen, selectable, with a button to copy it and one to reload — so a fault can be reported with its cause instead of a description of an empty window.",
|
||||||
|
"ADIF import can move fields as it reads them. Contest software stores the exchange where its own module keeps it — the RSGB IOTA contest exports the island reference in STATE, which imports as a US state and leaves the IOTA award empty. Add a STATE → IOTA row in the import dialog and it lands where the award looks.",
|
||||||
|
"The recent-QSO grid no longer offers per-column filters. They only ever searched the rows on screen, so an empty result looked like a missing QSO when the log simply held it further back — use the advanced filter, which queries the whole logbook. Column headers gain the width back."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les entrées de réglages Antenna Genius et Tuner Genius portent une petite marque 4O3A : les panneaux pilotant ce matériel se repèrent d'un coup d'œil dans le menu.",
|
||||||
|
"La base affichée dans la barre d'état est bien le fichier du CARNET. En SQLite local, elle désignait la base des réglages : qui vérifiait où résident ses QSO — ou quel fichier sauvegarder — était orienté vers le mauvais.",
|
||||||
|
"Yaesu : le mode suit le VFO utilisé. Un spot cliqué en travaillant sur le récepteur SUB accordait bien le VFO secondaire mais réglait le mode sur le MAIN, laissant le VFO en service sur son ancien mode ; la console lisait également le mode du principal.",
|
||||||
|
"Le contrôle de rotator couvre désormais tout contrôleur GS-232A, ERC (Easy Rotor Control) compris : la vitesse du port série est sélectionnable et l'entrée porte le nom du protocole plutôt que celui d'un seul appareil. Réglez l'ERC sur l'émulation GS-232, pas Hy-Gain DCU-1.",
|
||||||
|
"CAT Kenwood natif : un sixième backend parle directement à un TS-590, TS-890, TS-990 ou TS-2000 par son port série — fréquence, mode, VFO, split et PTT — sans OmniRig. Les Elecraft K3/K4 parlent le même dialecte. À choisir sous « Kenwood (natif) » dans Réglages → CAT.",
|
||||||
|
"Icom et Xiegu : une trame CI-V lue au mauvais décalage ne se transforme plus en fréquence. Des valeurs comme 16445550000 Hz apparaissaient dans la barre d'état alors que la radio était sur 145,550 MHz — le décodeur prenait des octets non décimaux pour des chiffres. Ces trames sont désormais refusées et la dernière lecture valable est conservée.",
|
||||||
|
"Réglages → CAT permet de journaliser le protocole CI-V : chaque trame échangée avec un Icom ou un Xiegu, en hexadécimal. Pour signaler une radio qui répond de travers — un bouton qui fait autre chose, une fréquence qui saute — là où une description seule laisse la cause à la devinette.",
|
||||||
|
"Un plantage de la fenêtre ne laisse plus un écran vide. L'erreur est écrite dans le journal et affichée à l'écran, sélectionnable, avec un bouton pour la copier et un pour recharger — de quoi signaler un défaut avec sa cause plutôt qu'une description de fenêtre vide.",
|
||||||
|
"L'import ADIF peut déplacer des champs à la lecture. Les logiciels de concours rangent l'échange là où leur module le garde — le contest IOTA de la RSGB exporte la référence d'île dans STATE, importée comme un état américain, et le diplôme IOTA reste vide. Une ligne STATE → IOTA dans la fenêtre d'import et la référence arrive là où le diplôme la cherche.",
|
||||||
|
"La grille des QSO récents ne propose plus de filtres par colonne. Ils ne cherchaient que dans les lignes affichées : un résultat vide ressemblait à un QSO manquant alors que le journal le contenait plus loin — le filtre avancé, lui, interroge tout le journal. Les en-têtes de colonne récupèrent la place."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.22.1",
|
||||||
|
"date": "2026-07-29",
|
||||||
|
"en": [
|
||||||
|
"Clicking a cluster spot while listening on the SUB VFO no longer pulls the radio back to MAIN. The command used to tune sets the main VFO by definition; on SUB the sub VFO is now written directly and the VFO selection is left alone.",
|
||||||
|
"Split showed the opposite of the radio on some Yaesus. Where the rig reports which VFO transmits rather than a split flag, that has to be compared with the VFO you are listening to — so it read backwards for an operator working on SUB and correctly for one on MAIN. Setting split had the same fault.",
|
||||||
|
"A shared MySQL logbook could fail to build its schema, leaving OpsLog on the local SQLite database with a message that only said the connection failed. Column declarations that are quoted, or share a line with others, escaped the SQLite-to-MySQL type conversion — so MySQL refused an index and a default value. A database already left in that state is now repaired at startup instead of failing at every launch.",
|
||||||
|
"Yaesu: OpsLog now follows the VFO you are actually on — the displayed frequency, and the VFO a cluster spot tunes. An FTDX101 answers the receive-VFO query with two digits where an FTDX10 uses one, and the wrong one was read.",
|
||||||
|
"Clicking a spot in DXHunter can now tune the radio: a remote-call UDP packet carrying <FREQ> and <MODE> sets the frequency and mode on whichever CAT backend is active, as well as filling the callsign. A packet without them still just fills the callsign.",
|
||||||
|
"The ADIF import option that fills your station fields from your profile also stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com) on the ones the file leaves empty — it always did, but nothing said so, and a WSJT-X log carries almost none. Existing confirmations are never overwritten.",
|
||||||
|
"QRZ.com confirmation download marked far too many QSOs as confirmed: it accepted the paper-QSL received flag, which you uploaded to QRZ yourself, as if it were a QRZ confirmation. Only QRZ own confirmation now counts — check the QRZ received column after your next download, as earlier ones may have set it wrongly.",
|
||||||
|
"The Recent QSOs counter labels each of its three numbers — shown, matching the filter, and in the log — with thousands separators. \"Showing 10000 of 23683 matches · 28648 total\" read as if the filter had found more QSOs than the log holds.",
|
||||||
|
"Switching the CAT backend back to a Yaesu no longer risks leaving the serial port held. Each reconnect opened a new handle without closing the previous one, and Windows opens a serial port exclusively, so the next attempt could fail with \"port busy\".",
|
||||||
|
"A Yaesu that is switched off is reported as such. Opening the serial port used to be logged as \"connected\", with the model name left over from the previous session, so a radio that was simply powered down looked like a software fault.",
|
||||||
|
"Yaesu: moving RX and TX together to the SUB receiver is no longer shown as split. That is simplex on the sub VFO, but the rig reports its split flag anyway, so OpsLog now works split out from which VFO transmits and which receives.",
|
||||||
|
"Auto-call CW now waits the gap you set AFTER the message, not from the moment it starts. With a WinKeyer or serial keyer the wait could end before the call had finished, so a 6 s gap gave about 2 s of silence after a 4 s CQ."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Cliquer sur un spot du cluster en écoutant sur le VFO SUB ne ramène plus la radio sur le VFO principal. La commande utilisée pour s'accorder agit par définition sur le VFO principal ; sur SUB, c'est désormais le VFO secondaire qui est écrit, sans toucher à la sélection.",
|
||||||
|
"Le split affichait l'inverse de la radio sur certains Yaesu. Quand la radio indique quel VFO émet plutôt qu'un indicateur de split, il faut le comparer au VFO écouté — l'affichage était donc inversé pour un opérateur travaillant sur SUB et correct pour un autre sur MAIN. Le réglage du split souffrait du même défaut.",
|
||||||
|
"Un carnet MySQL partagé pouvait échouer à créer son schéma, laissant OpsLog sur la base SQLite locale avec un message indiquant seulement l'échec de la connexion. Les déclarations de colonnes entre guillemets, ou partageant une ligne avec d'autres, échappaient à la conversion de types SQLite vers MySQL — d'où le refus d'un index et d'une valeur par défaut. Une base déjà dans cet état est désormais réparée au démarrage au lieu d'échouer à chaque lancement.",
|
||||||
|
"Yaesu : OpsLog suit désormais le VFO réellement utilisé — la fréquence affichée, et le VFO qu'un spot du cluster accorde. Un FTDX101 répond à la question du VFO de réception avec deux chiffres là où un FTDX10 n'en donne qu'un, et c'est le mauvais qui était lu.",
|
||||||
|
"Cliquer un spot dans DXHunter peut désormais accorder la radio : un paquet UDP remote_call contenant <FREQ> et <MODE> règle la fréquence et le mode sur le backend CAT actif, en plus de renseigner l'indicatif. Un paquet sans ces balises se contente, comme avant, de renseigner l'indicatif.",
|
||||||
|
"L'option d'import ADIF qui remplit vos champs station depuis votre profil applique aussi vos statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com) sur ceux que le fichier laisse vides — c'était déjà le cas, mais rien ne le disait, et un log WSJT-X n'en contient pratiquement aucun. Les confirmations existantes ne sont jamais écrasées.",
|
||||||
|
"Le téléchargement des confirmations QRZ.com marquait beaucoup trop de QSO comme confirmés : il acceptait l'indicateur de QSL papier reçue — que vous avez vous-même envoyé à QRZ — comme une confirmation QRZ. Seule la confirmation propre à QRZ compte désormais ; vérifiez la colonne QRZ.com reçu après votre prochain téléchargement, les précédents ayant pu la remplir à tort.",
|
||||||
|
"Le compteur des QSO récents nomme chacun de ses trois nombres — affichés, correspondant au filtre, et présents dans le log — avec des séparateurs de milliers. « Showing 10000 of 23683 matches · 28648 total » se lisait comme si le filtre trouvait plus de QSO que le log n'en contient.",
|
||||||
|
"Repasser le backend CAT sur un Yaesu ne risque plus de laisser le port série occupé. Chaque reconnexion ouvrait une nouvelle poignée sans fermer la précédente, et Windows ouvre un port série en exclusivité : la tentative suivante pouvait échouer avec « port occupé ».",
|
||||||
|
"Un Yaesu éteint est signalé comme tel. L'ouverture du port série était journalisée comme « connecté », avec le nom de modèle hérité de la session précédente : une radio simplement hors tension ressemblait à un défaut du logiciel.",
|
||||||
|
"Yaesu : passer RX et TX ensemble sur le récepteur SUB n'est plus affiché comme un split. C'est du simplex sur le VFO secondaire, mais la radio lève quand même son indicateur de split ; OpsLog le déduit désormais du VFO qui émet et de celui qui reçoit.",
|
||||||
|
"L'appel automatique en CW attend désormais l'intervalle réglé APRÈS le message, et non depuis son début. Avec un WinKeyer ou un keyer série, l'attente pouvait se terminer avant la fin de l'appel : un intervalle de 6 s ne laissait qu'environ 2 s de silence après un CQ de 4 s."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.22.0",
|
||||||
|
"date": "2026-07-28",
|
||||||
|
"en": [
|
||||||
|
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes.",
|
||||||
|
"The offline FCC (ULS) database now answers while you type a US callsign, filling state, county and a 6-character grid. It was only applied when the QSO was saved, so without a QRZ.com or HamQTH account you saw nothing but the country and a coarse grid. Online lookups still win — ULS only fills what is blank.",
|
||||||
|
"Icom over the LAN: the frequency no longer stays frozen after another program (WSJT-X through OmniRig, the Remote Utility) takes the CI-V session. The rig kept answering pings while sending nothing, so OpsLog showed the last known frequency until it was restarted; it now reconnects on its own.",
|
||||||
|
"The Icom model list gains the IC-7300MKII (CI-V B6), plus the IC-7100, IC-7410, IC-7600 and IC-7851. The IC-7700 and IC-7800 were listed at the IC-7100's and IC-7410's addresses, so picking them set an address the rig never answers on.",
|
||||||
|
"Filter builder: a confirmation date typed into a condition showed an empty box. The value was stored correctly, only the calendar field failed to display it.",
|
||||||
|
"Bulk edit can now set the contacted station gridsquare, so a batch of QSOs imported without a locator can be corrected in one go.",
|
||||||
|
"Changing band from OpsLog now updates the displayed frequency straight away. The rig moved, but its answer arrived inside the short grace window that protects what you are typing and was discarded — so the frequency stayed on the old band until you nudged the VFO.",
|
||||||
|
"Native Yaesu CAT: a new backend talks to an FTDX10/FTDX101 directly over its serial port, without OmniRig — frequency, mode, VFO A/B, split and PTT. Pick \"Yaesu (native CAT)\" in Settings → CAT. First release, feedback welcome.",
|
||||||
|
"Yaesu console: with the native Yaesu backend, a console tab and a Main-view pane give S/PO/SWR meters, band and mode buttons, AF/RF/squelch, AGC, IPO-AMP1-AMP2, a 6/12/18 dB attenuator, NB, DNR, narrow filter, power, mic gain, VOX, split and ATU tune. CW, RTTY, DIGI and PSK show their sideband — click an active button again to switch it. Pressing SPLIT places the transmit VFO up 1 kHz on CW and data, up 5 kHz on phone, and shows the transmit frequency.",
|
||||||
|
"CAT sharing: OpsLog can now serve its rig connection to other programs (Settings → CAT → Share CAT). WSJT-X, JTDX, MSHV or Log4OM connect with rig model \"Hamlib NET rigctl\" at 127.0.0.1:4532 — needed because a native CAT backend holds the radio serial port on its own. Works with every backend, and with both Hamlib command dialects (JTDX names the VFO, MSHV does not).",
|
||||||
|
"Native Xiegu CAT (G90, X6100, X6200, X5105): a new backend talks CI-V straight to the radio — frequency, mode, split and PTT. Pick \"Xiegu (native CI-V)\" in Settings → CAT. Untested on a radio so far, feedback welcome.",
|
||||||
|
"The Yaesu console follows the mode: in CW the microphone gain and VOX disappear and a CW card takes their place with keyer speed, break-in and ZIN (zero-in).",
|
||||||
|
"CW through the Yaesu keyer: a fifth CW engine sends your macros with the radio own keyer (CAT \"KY\") — no WinKeyer, no second cable. Pick \"Yaesu (rig keyer)\" in Settings → CW keyer. For the FTDX101 / FT-991A / FT-710 family: an FTDX10 does not accept it (DAKY included), and OpsLog says so, pointing to the serial DTR keyer on the rig second COM port, which works.",
|
||||||
|
"Antenna Genius: port A no longer jumps onto port B antenna a few seconds after a change. Only one port can hold the transmit antenna, so the switch reports the same one on both — the display now follows each port own receive antenna.",
|
||||||
|
"CW speed is now one setting wherever you change it: the Yaesu console slider and the CW panel drive the keyer that is actually sending, and the rig own keyer follows so its front panel agrees.",
|
||||||
|
"Yaesu console meters are right: power now reads the meter that follows the RF (in watts, on a curve measured against the rig own display, not the power setting) and SWR shows the ratio itself. Both were reading the wrong index — the SWR bar sat near 80 on a perfect match. The bars also hold their peak for a moment before falling back, so they stay readable through the gaps between words in a CQ, in CW as in SSB.",
|
||||||
|
"Voice keyer: a level control for the messages sent to the radio (Settings → Audio). They went out exactly as recorded, so a quietly recorded microphone drove the rig quietly with nothing in OpsLog to fix it. Play previews at the same level, and the hint names the rig setting to check when it transmits almost nothing (on an FTDX10, SSB MOD SOURCE = REAR).",
|
||||||
|
"The voice keyer PTT list now names the Yaesu and Xiegu backends too — with a native backend selected it showed no CAT name, which read as \"CAT PTT is not available for my radio\".",
|
||||||
|
"WinKeyer 2: keying is fixed. The dit/dah ratio was sent on the command that sets KEY COMPENSATION, so every element got 50 ms of extra key-down — at 25 wpm a dit is 48 ms — and the keyer sent one element then stalled. The compensation is also cleared, since a keyer already set that way keeps it.",
|
||||||
|
"ADIF import accepts a file with no header. Records pasted straight out of another log — a few lines lifted from wsjtx_log.adi into a new .adi — imported as \"0 imported, 0 ignored, 0 total\", which reads as an empty file rather than a missing tag."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les zones CQ et ITU corrigées à la main dans votre profil restent corrigées. Elles proviennent de cty.dat, qui donne les zones de l'entité entière — dans un pays qui en couvre plusieurs, la valeur automatique est fausse et revenait à chaque redémarrage. Elles ne se remplissent désormais que si le champ est vide, et se recalculent quand l'indicatif ou le locator change.",
|
||||||
|
"La base FCC (ULS) hors ligne répond désormais pendant la saisie d'un indicatif américain, en remplissant l'état, le comté et un locator 6 caractères. Elle n'était appliquée qu'à l'enregistrement du QSO : sans compte QRZ.com ou HamQTH, vous ne voyiez que le pays et un locator approximatif. Les recherches en ligne restent prioritaires — l'ULS ne comble que ce qui est vide.",
|
||||||
|
"Icom via le réseau : la fréquence ne reste plus figée après qu'un autre programme (WSJT-X via OmniRig, la Remote Utility) a pris la session CI-V. La radio continuait de répondre aux pings sans rien émettre, si bien qu'OpsLog affichait la dernière fréquence connue jusqu'à son redémarrage ; il se reconnecte désormais tout seul.",
|
||||||
|
"La liste des modèles Icom gagne l'IC-7300MKII (CI-V B6), ainsi que les IC-7100, IC-7410, IC-7600 et IC-7851. Les IC-7700 et IC-7800 y figuraient avec les adresses des IC-7100 et IC-7410 : les choisir réglait une adresse sur laquelle la radio ne répond jamais.",
|
||||||
|
"Constructeur de filtres : une date de confirmation saisie dans une condition s'affichait dans une case vide. La valeur était bien enregistrée, seul le champ calendrier ne la montrait pas.",
|
||||||
|
"L'édition groupée peut désormais définir le locator de la station contactée : un lot de QSO importés sans locator se corrige en une fois.",
|
||||||
|
"Changer de bande depuis OpsLog met désormais la fréquence affichée à jour immédiatement. La radio se déplaçait bien, mais sa réponse arrivait pendant le court délai qui protège votre saisie et était ignorée — la fréquence restait donc sur l'ancienne bande jusqu'à ce qu'on touche le VFO.",
|
||||||
|
"CAT Yaesu natif : un nouveau backend dialogue directement avec un FTDX10/FTDX101 par son port série, sans OmniRig — fréquence, mode, VFO A/B, split et PTT. À choisir dans Réglages → CAT sous « Yaesu (CAT natif) ». Première version, retours bienvenus.",
|
||||||
|
"Console Yaesu : avec le backend Yaesu natif, un onglet console et un volet de la vue principale offrent les mesures S/PO/ROS, les boutons de bande et de mode, AF/RF/squelch, AGC, IPO-AMP1-AMP2, un atténuateur 6/12/18 dB, NB, DNR, filtre étroit, puissance, gain micro, VOX, split et accord d'antenne. CW, RTTY, DIGI et PSK affichent leur bande latérale — recliquez sur le bouton actif pour en changer. Appuyer sur SPLIT place le VFO d'émission à +1 kHz en CW et numérique, +5 kHz en phonie, et affiche la fréquence d'émission.",
|
||||||
|
"Partage du CAT : OpsLog peut désormais servir sa liaison radio aux autres logiciels (Réglages → CAT → Partager le CAT). WSJT-X, JTDX, MSHV ou Log4OM s'y connectent avec le modèle « Hamlib NET rigctl » sur 127.0.0.1:4532 — nécessaire car un backend CAT natif occupe seul le port série. Fonctionne avec tous les backends, et avec les deux dialectes Hamlib (JTDX nomme le VFO, MSHV non).",
|
||||||
|
"CAT Xiegu natif (G90, X6100, X6200, X5105) : un nouveau backend dialogue en CI-V directement avec la radio — fréquence, mode, split et PTT. À choisir dans Réglages → CAT sous « Xiegu (CI-V natif) ». Pas encore testé sur une radio, retours bienvenus.",
|
||||||
|
"La console Yaesu suit le mode : en CW, le gain micro et le VOX disparaissent au profit d'une carte CW avec la vitesse du manipulateur, le break-in et ZIN (zéro-in).",
|
||||||
|
"CW par le keyer Yaesu : un cinquième moteur CW envoie vos macros avec le keyer de la radio (CAT « KY ») — sans WinKeyer ni second câble. À choisir dans Réglages → Keyer CW sous « Yaesu (keyer de la radio) ». Pour la famille FTDX101 / FT-991A / FT-710 : un FTDX10 ne l'accepte pas (DAKY compris), et OpsLog le dit en renvoyant vers le keyer série DTR sur son second port COM, qui fonctionne.",
|
||||||
|
"Antenna Genius : le port A ne bascule plus sur l'antenne du port B quelques secondes après un changement. Un seul port peut porter l'antenne d'émission, le switch renvoie donc la même sur les deux — l'affichage suit désormais l'antenne de réception propre à chaque port.",
|
||||||
|
"La vitesse CW est désormais un réglage unique où que vous la changiez : le curseur de la console Yaesu et le panneau CW pilotent le manipulateur qui émet réellement, et le keyer interne de la radio suit pour que sa façade affiche la même valeur.",
|
||||||
|
"Les mesures de la console Yaesu sont justes : la puissance lit l'instrument qui suit la HF (en watts, sur une courbe relevée face à l'affichage de la radio, et non le réglage de puissance) et le ROS affiche le rapport lui-même. Les deux lisaient le mauvais index — la barre de ROS restait vers 80 sur une antenne parfaite. Les barres retiennent aussi leur crête un instant avant de redescendre : elles restent lisibles pendant les silences entre les mots d’un CQ, en CW comme en phonie.",
|
||||||
|
"Voice keyer : un réglage de niveau pour les messages envoyés à la radio (Réglages → Audio). Ils partaient exactement tels qu'enregistrés : un micro capté faiblement attaquait donc la radio faiblement, sans rien dans OpsLog pour y remédier. Le bouton Lire fait entendre ce même niveau, et l'aide indique le réglage radio à vérifier quand elle n'émet presque rien (sur un FTDX10, SSB MOD SOURCE = REAR).",
|
||||||
|
"La liste PTT du voice keyer nomme aussi les backends Yaesu et Xiegu — avec un backend natif sélectionné, aucun nom de CAT n'apparaissait, ce qui se lisait « il n'y a pas de PTT CAT pour ma radio ».",
|
||||||
|
"WinKeyer 2 : la manipulation est réparée. Le rapport point/trait était envoyé sur la commande qui règle la COMPENSATION DE MANIPULATION : chaque élément recevait donc 50 ms de fermeture supplémentaire — à 25 mots/minute un point dure 48 ms — et le keyer envoyait un élément puis se bloquait. La compensation est aussi remise à zéro, car un keyer déjà réglé ainsi la conserve.",
|
||||||
|
"L'import ADIF accepte un fichier sans en-tête. Des enregistrements collés depuis un autre log — quelques lignes reprises de wsjtx_log.adi dans un nouveau .adi — donnaient « 0 importé, 0 ignoré, 0 au total », ce qui se lit comme un fichier vide plutôt que comme une balise manquante."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.21.8",
|
||||||
|
"date": "2026-07-28",
|
||||||
|
"en": [
|
||||||
|
"Microwave bands are recognised: 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm and above, plus 33cm and the 2190m/630m/560m low bands. A 10 GHz QSO used to come back with NO band at all — so it was logged without one, counted in no award slot, and exported without a BAND field. Band pickers, statistics and award band columns follow.",
|
||||||
|
"QRZ.com confirmation download works again: the date window used an option QRZ rejects, so every fetch failed. An automatic run now asks for everything modified since the last one — so an old QSO confirmed recently comes back — while a date you type fetches the QSOs made between that date and today.",
|
||||||
|
"Port fields can be cleared. Deleting the contents put the default straight back, so entering a different port meant overwriting it digit by digit. Fixed on the cluster editor, where it was reported, and in the eight other places with the same fault (CAT, amplifier, antenna, rotator, database, SMTP)."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les bandes hyperfréquences sont reconnues : 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm et au-delà, ainsi que 33cm et les bandes basses 2190m/630m/560m. Un QSO à 10 GHz revenait sans AUCUNE bande — donc enregistré sans bande, compté dans aucun diplôme, et exporté sans champ BAND. Les listes de bandes, les statistiques et les colonnes de diplômes suivent.",
|
||||||
|
"Le téléchargement des confirmations QRZ.com refonctionne : la fenêtre de date utilisait une option refusée par QRZ, donc chaque récupération échouait. Un téléchargement automatique demande désormais tout ce qui a été modifié depuis le précédent — un QSO ancien confirmé récemment revient donc — tandis qu'une date saisie récupère les QSO faits entre cette date et aujourd'hui.",
|
||||||
|
"Les champs de port peuvent être vidés. Effacer le contenu réinscrivait aussitôt la valeur par défaut, si bien qu'entrer un autre port obligeait à l'écraser chiffre par chiffre. Corrigé dans l'éditeur de cluster, où c'était signalé, et dans les huit autres endroits présentant le même défaut (CAT, amplificateur, antenne, rotator, base de données, SMTP)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.21.7",
|
||||||
|
"date": "2026-07-28",
|
||||||
|
"en": [
|
||||||
|
"The filter builder uses the same confirmation field names as bulk edit, gains the missing QRZ.com received status, and can filter on the sent and received dates (before / after).",
|
||||||
|
"A fresh install now starts on the dark theme. Existing installs keep the theme they are on.",
|
||||||
|
"A QSO logged from WSJT-X or MSHV now keeps the precise 6-character locator from QRZ/HamQTH instead of the 4-character one the digital protocol carries — about 100 km of accuracy that was being discarded on every digital QSO. A lookup that disagrees with the received square is not applied.",
|
||||||
|
"Edit QSO: the QRZ/HamQTH fetch button now bypasses the cache, so it really re-reads the callsign — upgrading a QRZ subscription used to change nothing until the cached entry expired a month later. It also no longer blanks an existing value when the lookup comes back with that field empty.",
|
||||||
|
"The diagnostic log no longer carries the FlexRadio meter readings, which drowned everything else.",
|
||||||
|
"OmniRig: a new \"VFO to read\" setting. OmniRig reports the active VFO from the rig file, and some files get it wrong — an IC-7610 file declared VFO B while the operator was on the main VFO, so OpsLog wrote to one VFO and read the other, and the frequency looked frozen. Force VFO A or B when that happens.",
|
||||||
|
"Bulk edit: fixed \"field is not bulk-editable\" on the QRZ.com received status and the confirmation dates added in 0.21.6 — they were declared in the wrong place and refused at Apply. Bulk-editing State and County, offered since the start, was refused the same way and now works.",
|
||||||
|
"QRZ.com confirmation download: the whole logbook was requested every time, which QRZ cuts short on a large log — the read stopped two thirds of the way through and the rest was never seen. Only the window is requested now. And the last-download date is no longer moved when the download was incomplete: it used to advance over records that had never been read, which skipped them for good.",
|
||||||
|
"The callsign in the top bar is now the station switcher: click it and pick a profile to activate it. Managing profiles is still there, at the bottom of the list.",
|
||||||
|
"The UTC clock moved from the top bar to the status bar at the bottom, beside the database.",
|
||||||
|
"FlexRadio panel: the built-in ATU is now controllable — Tune, Bypass, memories, and the tuner state in words (tuning, tuned, TUNE FAILED…), because a failed tune and one that never ran look identical otherwise.",
|
||||||
|
"Diagnostic log: each database migration now names the database it runs on, and a frequency set through OmniRig is checked again 1.5 s later — both to make a reported problem readable from the log instead of guessed at."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Le constructeur de filtres reprend les mêmes noms de champs de confirmation que l'édition en lot, gagne le statut de réception QRZ.com manquant, et permet de filtrer sur les dates d'envoi et de réception (avant / après).",
|
||||||
|
"Une nouvelle installation démarre désormais sur le thème sombre. Les installations existantes conservent le leur.",
|
||||||
|
"Un QSO enregistré depuis WSJT-X ou MSHV conserve désormais le locator précis à 6 caractères de QRZ/HamQTH au lieu de celui à 4 caractères transporté par le protocole numérique — une centaine de kilomètres de précision perdus jusqu'ici à chaque QSO numérique. Une réponse qui contredit le carré reçu n'est pas appliquée.",
|
||||||
|
"Édition QSO : le bouton de récupération QRZ/HamQTH contourne désormais le cache et relit donc réellement l'indicatif — passer à un abonnement QRZ payant ne changeait rien tant que l'entrée en cache n'avait pas expiré un mois plus tard. Il n'efface plus non plus une valeur existante lorsque la recherche revient sans ce champ.",
|
||||||
|
"Le journal de diagnostic ne contient plus les mesures des instruments FlexRadio, qui noyaient tout le reste.",
|
||||||
|
"OmniRig : nouveau réglage « VFO à lire ». OmniRig indique le VFO actif d'après le fichier radio, et certains fichiers se trompent — un fichier IC-7610 déclarait le VFO B alors que l'opérateur était sur le VFO principal, si bien qu'OpsLog écrivait dans l'un et lisait l'autre, et la fréquence semblait figée. Forcez le VFO A ou B le cas échéant.",
|
||||||
|
"Édition en lot : correction du refus « field is not bulk-editable » sur le statut de réception QRZ.com et les dates de confirmation ajoutées en 0.21.6 — ils étaient déclarés au mauvais endroit et rejetés au moment d'appliquer. L'édition en lot de State et County, proposée depuis toujours, était refusée de la même façon et fonctionne désormais.",
|
||||||
|
"Téléchargement des confirmations QRZ.com : le carnet entier était demandé à chaque fois, ce que QRZ tronque sur un grand log — la lecture s'arrêtait aux deux tiers et le reste n'était jamais vu. Seule la fenêtre est désormais demandée. Et la date du dernier téléchargement n'avance plus lorsque le téléchargement est incomplet : elle passait par-dessus des enregistrements jamais lus, qui étaient alors ignorés définitivement.",
|
||||||
|
"L'indicatif en haut de la fenêtre est désormais le sélecteur de station : un clic, on choisit un profil et il devient actif. La gestion des profils reste accessible, en bas de la liste.",
|
||||||
|
"L'horloge UTC est passée de la barre du haut à la barre d'état en bas, à côté de la base de données.",
|
||||||
|
"Panneau FlexRadio : le coupleur intégré est désormais pilotable — Accord, Bypass, mémoires, et l'état du coupleur en clair (accord en cours, accordé, ÉCHEC ACCORD…), car sans ça un accord raté et un accord jamais lancé se ressemblent.",
|
||||||
|
"Journal de diagnostic : chaque migration de base indique désormais sur quelle base elle tourne, et une fréquence envoyée via OmniRig est relue 1,5 s plus tard — de quoi lire un problème signalé dans le journal au lieu de le deviner."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.21.6",
|
||||||
|
"date": "2026-07-27",
|
||||||
|
"en": [
|
||||||
|
"Statistics: the activity chart now follows the period you selected, and Day / Week / Month / Year choose the bucket size (QSOs per week over the whole period, for instance). It used to show fixed windows — the last 7 days, the last 30 — regardless of the period.",
|
||||||
|
"Statistics: new \"When you are on the air\" card — by hour and by day of week, over the selected period.",
|
||||||
|
"Band map: the CW / data / phone sub-bands are no longer shaded in the status colours — the SSB portion was amber, the same amber that means \"new band\" on the spots above it. They now use distinct, colour-blind-checked hues and appear in the legend.",
|
||||||
|
"Help menu: a Support OpsLog entry, which opens the donation page in your browser.",
|
||||||
|
"The Callsign field is now visually marked out, so it is no longer mistaken for Name next to it.",
|
||||||
|
"Multi-screen: OpsLog reopens on the screen it was closed on, including when it was maximised — it used to come back on the primary screen every time.",
|
||||||
|
"The Windows title bar is gone: minimise, maximise and close now sit in the OpsLog bar, which you drag to move the window (double-click to maximise). That is 32 pixels of screen back.",
|
||||||
|
"Saving the settings no longer freezes OpsLog while a device is slow to answer. Choosing OmniRig while another program held the rig locked the window for about 45 seconds — the time OmniRig takes to give up. The link is now re-established in the background, and a slow restart is written to the diagnostic log.",
|
||||||
|
"Edit QSO → QSL Info: the sent and received dates now have a calendar picker, plus a button for today's date (UTC).",
|
||||||
|
"CW keyer: the diagnostic log now names the keyer generation (WK1 / WK2 / WK3), and Settings → CW keyer can log every byte exchanged with it — for reporting a keyer that behaves oddly.",
|
||||||
|
"Bulk edit: the confirmation fields are renamed \"sent/received status\" instead of \"upload\", the missing QRZ.com received status is added, and every channel now offers its sent and received DATE with a calendar."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Statistiques : le graphique d'activité suit désormais la période choisie, et Jour / Semaine / Mois / Année en choisissent le pas (les QSO par semaine sur toute la période, par exemple). Il affichait auparavant des fenêtres fixes — les 7 derniers jours, les 30 derniers — quelle que soit la période.",
|
||||||
|
"Statistiques : nouvelle carte « Quand vous trafiquez » — par heure et par jour de semaine, sur la période choisie.",
|
||||||
|
"Carte de bande : les portions CW / numérique / phonie ne sont plus teintées avec les couleurs de statut — la partie SSB était ambre, l'ambre qui signifie « nouvelle bande » sur les spots posés dessus. Elles utilisent maintenant des teintes distinctes, vérifiées pour le daltonisme, et figurent dans la légende.",
|
||||||
|
"Menu Aide : une entrée « Soutenir OpsLog », qui ouvre la page de don dans votre navigateur.",
|
||||||
|
"Le champ Indicatif est désormais mis en évidence, pour ne plus être confondu avec le champ Nom voisin.",
|
||||||
|
"Multi-écrans : OpsLog se rouvre sur l'écran où il a été fermé, y compris s'il était maximisé — il revenait jusqu'ici sur l'écran principal à chaque fois.",
|
||||||
|
"La barre de titre Windows a disparu : réduire, agrandir et fermer sont désormais dans la barre OpsLog, qu'on saisit pour déplacer la fenêtre (double-clic pour agrandir). Autant de pixels d'écran repris.",
|
||||||
|
"Enregistrer les réglages ne fige plus OpsLog quand un appareil tarde à répondre. Choisir OmniRig alors qu'un autre logiciel tenait la radio bloquait la fenêtre une quarantaine de secondes — le temps qu'OmniRig renonce. La liaison est désormais rétablie en arrière-plan, et un redémarrage lent est noté dans le journal de diagnostic.",
|
||||||
|
"Édition QSO → Infos QSL : les dates d'envoi et de réception disposent d'un calendrier, et d'un bouton pour la date du jour (UTC).",
|
||||||
|
"Keyer CW : le journal de diagnostic nomme désormais la génération du keyer (WK1 / WK2 / WK3), et Réglages → Keyer CW permet de journaliser chaque octet échangé avec lui — pour signaler un keyer au comportement anormal.",
|
||||||
|
"Édition en lot : les champs de confirmation s'appellent désormais « envoi/réception (statut) » au lieu d'« upload », le statut de réception QRZ.com manquant a été ajouté, et chaque canal propose ses DATES d'envoi et de réception avec un calendrier."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.21.5",
|
||||||
|
"date": "2026-07-27",
|
||||||
|
"en": [
|
||||||
|
"Column layouts (widths, order, hidden columns) now stick — five faults were undoing them.",
|
||||||
|
"Recent QSOs keeps a separate column layout in the Main tab and in its own tab — the pane is half as wide there.",
|
||||||
|
"ACOM and SPE amplifiers can follow OpsLog's frequency, on a second COM port (Settings → Amplifier).",
|
||||||
|
"Portable folder: moving OpsLog to another drive or PC (C:OpsLog → D:OpsLog) no longer loses the databases — paths inside the folder are now stored relative to it."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les dispositions de colonnes (largeurs, ordre, colonnes masquées) tiennent enfin — cinq défauts les défaisaient.",
|
||||||
|
"Les QSO récents gardent une disposition de colonnes distincte dans l'onglet Principal et dans leur propre onglet — le volet y est deux fois moins large.",
|
||||||
|
"Les amplificateurs ACOM et SPE peuvent suivre la fréquence d'OpsLog, sur un second port COM (Réglages → Amplificateur).",
|
||||||
|
"Dossier portable : déplacer OpsLog sur un autre disque ou un autre PC (C:OpsLog → D:OpsLog) ne fait plus perdre les bases — les chemins internes au dossier sont désormais enregistrés relativement à celui-ci."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.21.4",
|
||||||
|
"date": "2026-07-26",
|
||||||
|
"en": [
|
||||||
|
"OmniRig on an FTDX101D: the frequency follows the SUB VFO again, and split is detected. The stock rig file never names a single VFO — it reports only the A/B pair and alternates between the two values, split included, from one poll to the next. OpsLog now takes the displayed frequency as the reference on Yaesu and keeps a split reported once for a few seconds, so the contradicting reading no longer cancels it.",
|
||||||
|
"The SMTP password can no longer be revealed in the settings — the eye button is gone. The settings window is often open while the screen is being shared or shown to someone in the shack.",
|
||||||
|
"Cloudlog and Wavelog: each QSO can now be uploaded to your own instance as it is logged. Settings → External services → CLOUDLOG: the address of your instance, a read/write API key, the station ID, and a Test connection button. Sending is immediate or delayed by 1–2 minutes so a mistake can still be corrected.",
|
||||||
|
"Recent QSOs and Worked before: new Distance (km) column, like the cluster's. It is computed from the QSO's own locator pair, so a contact made portable keeps the distance from where you actually were; QSOs with no locator on either side stay blank.",
|
||||||
|
"OmniRig: the frequency follows the SUB VFO on radios that report the two VFOs as a pair (AA / AB / BA / BB) — the whole Yaesu range. Only the single-VFO form was recognised, so pressing SUB left OpsLog on the main VFO, and a QSO worked on SUB was logged on the wrong frequency.",
|
||||||
|
"OmniRig split is held briefly ONLY for a radio whose rig file reports it erratically. On a radio that reports it correctly, split now clears the instant you cancel it on the front panel instead of a few seconds later.",
|
||||||
|
"The diagnostic log now records what OpsLog concluded from OmniRig (VFO, TX/RX frequencies, split), not just what OmniRig reported.",
|
||||||
|
"Locator: a precise grid from QRZ/HamQTH is no longer replaced by the country centroid (JN05JG becoming JN16 for a French station). The lookup runs more than once per QSO and the provider only gets two seconds to answer; on a slower connection the second answer fell back to cty.dat and downgraded the locator, while the name and QTH stayed — which made it look like QRZ had returned a wrong grid."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"OmniRig sur FTDX101D : la fréquence suit de nouveau le VFO SUB, et le split est détecté. Le fichier radio d'origine ne nomme jamais un VFO seul — il ne rapporte que la paire A/B, et alterne entre les deux valeurs, split compris, d'une interrogation à l'autre. OpsLog prend désormais la fréquence affichée comme référence sur Yaesu et conserve quelques secondes un split annoncé une fois, pour que la lecture contradictoire ne l'annule plus.",
|
||||||
|
"Le mot de passe SMTP ne peut plus être affiché en clair dans les réglages — le bouton en forme d'œil a été retiré. La fenêtre des réglages est souvent ouverte pendant un partage d'écran ou devant quelqu'un au shack.",
|
||||||
|
"Cloudlog et Wavelog : chaque QSO peut désormais être envoyé vers votre propre instance au moment où il est enregistré. Réglages → Services externes → CLOUDLOG : l'adresse de votre instance, une clé API lecture/écriture, l'ID de station, et un bouton de test de connexion. L'envoi est immédiat ou différé de 1 à 2 minutes pour laisser le temps de corriger une erreur.",
|
||||||
|
"QSO récents et Déjà contacté : nouvelle colonne Distance (km), comme celle du cluster. Elle est calculée à partir des locators propres au QSO, si bien qu'un contact fait en portable garde la distance depuis l'endroit où vous étiez réellement ; les QSO sans locator d'un côté ou de l'autre restent vides.",
|
||||||
|
"OmniRig : la fréquence suit le VFO SUB sur les radios qui rapportent les deux VFO sous forme de paire (AA / AB / BA / BB) — toute la gamme Yaesu. Seule la forme à un seul VFO était reconnue, si bien qu'un passage sur SUB laissait OpsLog sur le VFO principal, et qu'un QSO fait sur SUB était enregistré sur la mauvaise fréquence.",
|
||||||
|
"Le split OmniRig n'est maintenu brièvement que pour une radio dont le fichier de définition le rapporte de façon erratique. Sur une radio qui le rapporte correctement, le split disparaît désormais dès que vous le coupez en façade, au lieu de quelques secondes plus tard.",
|
||||||
|
"Le journal de diagnostic enregistre maintenant ce qu'OpsLog a déduit d'OmniRig (VFO, fréquences TX/RX, split), et pas seulement ce qu'OmniRig a rapporté.",
|
||||||
|
"Locator : un locator précis venu de QRZ/HamQTH n'est plus remplacé par le centre du pays (JN05JG devenant JN16 pour une station française). La recherche est lancée plusieurs fois par QSO et le fournisseur ne dispose que de deux secondes ; sur une connexion plus lente, la seconde réponse retombait sur cty.dat et dégradait le locator, alors que le nom et le QTH restaient — d'où l'impression que QRZ renvoyait un mauvais locator."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.21.3",
|
||||||
|
"date": "2026-07-25",
|
||||||
|
"en": [
|
||||||
|
"SteppIR: the direction button no longer snaps back to Normal a few seconds after you select 180° or Bidirectional. OpsLog was reading status frames the controller had queued up minutes earlier; it now flushes them before each poll and holds the direction you chose until the controller confirms it.",
|
||||||
|
"Alert rules: Enter and Space now work in the callsigns box — you can type a list one call per line again.",
|
||||||
|
"Fixed the US county database (USA-CA) download, broken since the FCC moved its weekly files on 24 July 2026. OpsLog now locates the file instead of assuming a fixed address, so it keeps working when the FCC reshuffles its download directories.",
|
||||||
|
"Tuner Genius XL: the power and SWR meters now fill the card instead of leaving a third of it empty. In Station Control the amplifier and tuner cards span the dashboard, so their meters are readable.",
|
||||||
|
"The Audio devices settings panel is now translated — it was still entirely in English.",
|
||||||
|
"Award management: the editor no longer spills outside its window. Wide rows now scroll inside the panel, and the button bar wraps instead of stretching the dialog (it overflowed in French, where the labels are longer).",
|
||||||
|
"Tuner Genius XL: the power and SWR meters now peak-hold like the amplifier's. They were showing raw 400 ms samples, so on SSB they fell to zero between syllables and looked like a dropped connection.",
|
||||||
|
"Fixed recovering name/QTH/locator from the last QSO when you don't use QRZ/HamQTH: the callsign resolved from cty.dat faster than the logbook could answer, so the recovery found no history and never retried. It could also, on a slow logbook, fill in the PREVIOUS station's details — it is now tied to the callsign it belongs to.",
|
||||||
|
"Big logbooks: finding a callsign in your history no longer scans the whole log. The query could not use the callsign index — on a 190 000-QSO log that made it ~70x slower, on every keystroke, slow enough that the entry strip gave up before the history arrived.",
|
||||||
|
"One-off with this update: OpsLog has stored every callsign in your logbook in UPPER CASE — that is what lets the lookup above use the index. It changes letter case and nothing else, and runs once. Because the auto-updater migrates before you get to read this, a copy of your logbook was saved next to it first (logbook.db.pre-0024_normalise_callsign.bak) — delete it once you are happy.",
|
||||||
|
"The diagnostic log now also records what the entry strip does (auto-fill decisions), so a problem that only happens on your station can be reported from the log file instead of guessed at.",
|
||||||
|
"Worked-before grid: the right-click menu now offers bulk edit and the ADIF/Cabrillo export of the selected rows, like the Recent QSOs grid — no need to go looking for the same QSOs in the main log to change them.",
|
||||||
|
"Tuner Genius XL: the SWR meter no longer stays frozen on the last transmission once you stop transmitting.",
|
||||||
|
"Portable callsigns (F4LYI/M, .../P) are looked up on QRZ/HamQTH again. They need two requests — the full form, then the home call — and the time limit only allowed one, so they always fell back to cty.dat even though the operator was listed.",
|
||||||
|
"LoTW download: a rejected login now says so, instead of pasting a fragment of the ARRL web page into the error.",
|
||||||
|
"French UI: field names in the filter builder and bulk edit are back in English. They are ADIF field names — a standard vocabulary — and translating them made it harder to relate a filter to an export or to another logger. The operators and buttons around them stay translated.",
|
||||||
|
"Bulk edit: added Owner callsign. It was filterable but not bulk-editable, because unlike the other fields it has no dedicated column and lives among the ADIF extras; editing it now merges into those and leaves the rest of them alone.",
|
||||||
|
"Some installations opened with the window invisible: the saved size was sanity-checked but the saved position never was, so a window last closed on a monitor that is no longer attached reopened off-screen every time, with no way back. The position is now checked against the monitors actually present.",
|
||||||
|
"Icom console: it now follows the radio, not just drives it. AGC, attenuator, preamp and filter were read once when connecting and never again, so switching AGC from FAST to MID on the rig left the panel showing FAST for good. They are re-read continuously now, one per poll cycle so the CAT link stays free for your own commands.",
|
||||||
|
"Icom: fixed the CI-V model table. The IC-7800 (6Ah) and IC-7700 (74h) were missing, and their addresses were assigned to the wrong radios — 80h is the IC-7410 and 88h the IC-7100. On an IC-7800 that also meant a 20 dB attenuator button the radio does not have, instead of its real 6/12/18 dB steps.",
|
||||||
|
"Icom console: the band row now highlights the band the radio is actually on. The buttons only ever sent a frequency and never showed where you were.",
|
||||||
|
"Voice keyer PTT: the CAT option was labelled \"CAT (OmniRig)\", so operators on an Icom CI-V, FlexRadio or TCI rig assumed it was not for them and fell back to RTS/DTR or VOX. It has always driven whichever CAT backend is active — every one of them supports PTT. It now reads \"CAT — Icom CI-V (USB serial)\" and so on, naming the link you actually configured.",
|
||||||
|
"OmniRig: the frequency now follows the VFO you are actually on. It always read VFO A when the rig reported one, so pressing SUB VFO on an FTDX101D left OpsLog showing the main VFO — and the band and the logged frequency with it.",
|
||||||
|
"OmniRig split: the PM_SPLITON flag is now read as a bit rather than compared for exact equality, so a rig that reports it alongside another flag is no longer seen as simplex. Split still requires two distinct VFOs in the same band, which keeps a stale VFO B from faking one.",
|
||||||
|
"CAT connection failures are now written to the diagnostic log. The status pill condenses everything to a few words (\"OmniRig not found\"), and the real reason — the COM error code, the serial or TCP error — previously existed only in a tooltip, so it never reached a bug report. Logged once per distinct message.",
|
||||||
|
"OmniRig running as administrator while OpsLog is not (or the reverse) is now detected and named. Windows keeps the two privilege levels apart, so OpsLog could not reach OmniRig even with its window open on screen — and reported \"OmniRig not found\", which sent people hunting for a driver or COM-port fault. It now says to start both the same way, and stops repeating the failure in the log every five seconds."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"SteppIR : le bouton de direction ne repasse plus sur Normal quelques secondes après avoir choisi 180° ou Bidirectionnel. OpsLog lisait des trames d'état que le contrôleur avait empilées plusieurs minutes plus tôt ; elles sont désormais purgées avant chaque interrogation, et la direction choisie est conservée jusqu'à confirmation du contrôleur.",
|
||||||
|
"Règles d'alerte : Entrée et Espace fonctionnent de nouveau dans la zone des indicatifs — on peut ressaisir une liste, un indicatif par ligne.",
|
||||||
|
"Correction du téléchargement de la base des comtés américains (USA-CA), cassé depuis le déplacement des fichiers hebdomadaires par la FCC le 24 juillet 2026. OpsLog localise désormais le fichier au lieu de supposer une adresse fixe, et continuera donc de fonctionner lors des prochains remaniements de leurs répertoires.",
|
||||||
|
"Tuner Genius XL : les jauges de puissance et de ROS occupent toute la carte au lieu d'en laisser un tiers vide. Dans Station Control, les cartes ampli et tuner s'étendent sur toute la largeur du tableau de bord pour que leurs jauges soient lisibles.",
|
||||||
|
"Le panneau de réglages « Périphériques audio » est désormais traduit — il était resté entièrement en anglais.",
|
||||||
|
"Gestion des diplômes : l'éditeur ne déborde plus de sa fenêtre. Les lignes trop larges défilent à l'intérieur du panneau et la barre de boutons passe à la ligne au lieu d'élargir la boîte de dialogue (elle débordait en français, où les libellés sont plus longs).",
|
||||||
|
"Tuner Genius XL : les jauges de puissance et de ROS maintiennent la crête, comme celles de l'ampli. Elles affichaient l'échantillon brut toutes les 400 ms et retombaient donc à zéro entre les syllabes en BLU, ce qui ressemblait à une perte de connexion.",
|
||||||
|
"Correction de la récupération du nom/QTH/locator depuis le dernier QSO quand on n'utilise pas QRZ/HamQTH : l'indicatif était résolu par cty.dat plus vite que le log ne répondait, la récupération ne trouvait donc aucun historique et ne réessayait jamais. Sur un log lent, elle pouvait aussi reprendre les données de la station PRÉCÉDENTE — elle est désormais liée à l'indicatif concerné.",
|
||||||
|
"Gros logs : retrouver un indicatif dans l'historique ne parcourt plus tout le log. La requête ne pouvait pas utiliser l'index sur l'indicatif — sur un log de 190 000 QSO, ~70× plus lent que nécessaire, à chaque frappe, et assez lentement pour que la saisie renonce avant l'arrivée de l'historique.",
|
||||||
|
"Une seule fois, avec cette mise à jour : OpsLog a mis tous les indicatifs de votre log en MAJUSCULES — c'est ce qui permet à la recherche ci-dessus d'utiliser l'index. Seule la casse des lettres change, et l'opération ne se produit qu'une fois. Comme la mise à jour automatique s'exécute avant que vous puissiez lire ceci, une copie de votre log a été enregistrée à côté au préalable (logbook.db.pre-0024_normalise_callsign.bak) — supprimez-la quand vous serez rassuré.",
|
||||||
|
"Le journal de diagnostic enregistre désormais aussi ce que fait le bandeau de saisie (décisions de remplissage automatique), pour qu'un problème qui n'arrive que chez vous puisse être rapporté depuis le fichier de log au lieu d'être deviné.",
|
||||||
|
"Grille « Déjà contacté » : le menu du clic droit propose maintenant la modification en masse et l'export ADIF/Cabrillo des lignes sélectionnées, comme la grille des QSO récents — plus besoin d'aller rechercher les mêmes QSO dans le log principal pour les modifier.",
|
||||||
|
"Tuner Genius XL : la jauge de ROS ne reste plus figée sur la dernière émission une fois que vous cessez d'émettre.",
|
||||||
|
"Les indicatifs portables (F4LYI/M, .../P) sont de nouveau trouvés sur QRZ/HamQTH. Ils nécessitent deux requêtes — la forme complète, puis l'indicatif de base — et le délai n'en autorisait qu'une : on retombait donc toujours sur cty.dat alors que l'opérateur y était bien référencé.",
|
||||||
|
"Téléchargement LoTW : un login refusé est désormais annoncé comme tel, au lieu de recopier un fragment de la page web de l'ARRL dans l'erreur.",
|
||||||
|
"Interface française : les noms de champs du constructeur de filtres et de la modification en masse repassent en anglais. Ce sont des noms de champs ADIF — un vocabulaire standard — et les traduire compliquait le rapprochement avec un export ou un autre logiciel. Les opérateurs et les boutons autour restent traduits.",
|
||||||
|
"Modification en masse : ajout de « Owner callsign ». Il était filtrable mais pas modifiable en masse car, contrairement aux autres champs, il n'a pas de colonne dédiée et vit parmi les champs ADIF supplémentaires ; sa modification s'y intègre désormais sans toucher aux autres.",
|
||||||
|
"Chez certains, la fenêtre s'ouvrait invisible : la taille enregistrée était contrôlée mais jamais la position, si bien qu'une fenêtre fermée sur un écran depuis débranché se réouvrait hors champ à chaque fois, sans retour possible. La position est désormais vérifiée contre les écrans réellement présents.",
|
||||||
|
"Console Icom : elle suit désormais la radio et ne fait plus que la piloter. AGC, atténuateur, préampli et filtre étaient lus une seule fois à la connexion et plus jamais ensuite : passer l'AGC de FAST à MID sur le poste laissait le panneau sur FAST définitivement. Ils sont maintenant relus en continu, un par cycle d'interrogation pour laisser la liaison CAT libre pour vos propres commandes.",
|
||||||
|
"Icom : correction de la table des modèles CI-V. L'IC-7800 (6Ah) et l'IC-7700 (74h) étaient absents, et leurs adresses attribuées aux mauvais postes — 80h est l'IC-7410 et 88h l'IC-7100. Sur un IC-7800, cela donnait aussi un bouton d'atténuateur 20 dB que la radio ne possède pas, au lieu de ses vrais crans 6/12/18 dB.",
|
||||||
|
"Console Icom : la rangée des bandes met en évidence celle sur laquelle le poste se trouve réellement. Les boutons ne faisaient qu'envoyer une fréquence, sans jamais indiquer où l'on était.",
|
||||||
|
"PTT du manipulateur vocal : l'option CAT était libellée « CAT (OmniRig) », si bien que les opérateurs en Icom CI-V, FlexRadio ou TCI la croyaient hors de portée et se rabattaient sur RTS/DTR ou le VOX. Elle a toujours piloté le backend CAT actif, quel qu'il soit — tous gèrent le PTT. Elle affiche désormais « CAT — Icom CI-V (USB série) » et ainsi de suite, en nommant la liaison réellement configurée.",
|
||||||
|
"OmniRig : la fréquence suit désormais le VFO réellement actif. Le VFO A était toujours lu dès que le poste en rapportait un, si bien qu'appuyer sur SUB VFO sur un FTDX101D laissait OpsLog sur le VFO principal — et avec lui la bande et la fréquence enregistrée.",
|
||||||
|
"Split OmniRig : le drapeau PM_SPLITON est lu comme un bit au lieu d'être comparé par égalité exacte, donc un poste qui le rapporte accompagné d'un autre drapeau n'est plus vu comme simplex. Le split exige toujours deux VFO distincts sur la même bande, ce qui évite qu'un VFO B périmé en simule un.",
|
||||||
|
"Les échecs de connexion CAT sont désormais écrits dans le journal de diagnostic. La pastille d'état condense tout en quelques mots (« OmniRig not found ») et la vraie raison — le code d'erreur COM, l'erreur série ou TCP — n'existait que dans une infobulle : elle n'arrivait donc jamais jusqu'à un rapport de bug. Journalisé une fois par message distinct.",
|
||||||
|
"OmniRig lancé en administrateur alors qu'OpsLog ne l'est pas (ou l'inverse) est désormais détecté et nommé. Windows sépare les deux niveaux de privilège : OpsLog ne pouvait donc pas atteindre OmniRig, fenêtre ouverte à l'écran, et annonçait « OmniRig not found » — de quoi partir chercher un problème de pilote ou de port COM. Il indique maintenant de lancer les deux de la même façon, et cesse de répéter l'échec dans le journal toutes les cinq secondes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.21.2",
|
||||||
|
"date": "2026-07-25",
|
||||||
|
"en": [
|
||||||
|
"Fixed OpsLog disrupting another Flex client: it could bind to 'SmartSDR CAT' (or DAX) instead of the real GUI client, which made SmartSDR CAT keep disconnecting/reconnecting from the radio while OpsLog was open. OpsLog now only binds to the actual SmartSDR/Maestro GUI client (e.g. a FlexRadio 'M' integrated screen).",
|
||||||
|
"New: Super Check Partial + N+1 callsign helper (like N1MM/DXLog). Enable it in Settings → General to download the community MASTER.SCP list; a docked two-column widget then shows, as you type a call, the known calls that contain it (Partial) and the calls one character away (N+1) — click one to fix a busted call.",
|
||||||
|
"When a callsign isn't found on QRZ/HamQTH (or you don't use a lookup service), the name, QTH, locator and address are recovered from the last time you worked that station — and the precise locator from that QSO is used instead of the coarse cty.dat country centroid. A real QRZ/HamQTH hit still wins."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Correction : OpsLog pouvait perturber un autre client Flex — il pouvait se « binder » sur « SmartSDR CAT » (ou DAX) au lieu du vrai client GUI, ce qui faisait décrocher/reconnecter SmartSDR CAT de la radio en boucle tant qu'OpsLog était ouvert. OpsLog ne se binde désormais qu'au vrai client GUI SmartSDR/Maestro (ex. l'écran intégré d'un FlexRadio « M »).",
|
||||||
|
"Nouveau : assistant indicatifs Super Check Partial + N+1 (façon N1MM/DXLog). Active-le dans Réglages → Général pour télécharger la liste communautaire MASTER.SCP ; un widget ancré en 2 colonnes affiche alors, pendant que tu tapes, les indicatifs connus qui contiennent ta saisie (Partiel) et ceux à une lettre près (N+1) — clique pour corriger un call busté.",
|
||||||
|
"Quand un indicatif est introuvable sur QRZ/HamQTH (ou si tu n'utilises pas de service de lookup), le nom, le QTH, le locator et l'adresse sont récupérés du dernier QSO avec cette station — et c'est le locator précis de ce QSO qui est utilisé, pas le centroïde du pays de cty.dat. Un vrai résultat QRZ/HamQTH reste prioritaire."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.21.1",
|
||||||
|
"date": "2026-07-24",
|
||||||
|
"en": [
|
||||||
|
"Ctrl + mouse wheel zoom is now remembered across restarts (Ctrl+0 resets to 100%).",
|
||||||
|
"Log grid: award column widths are now saved like the other columns, so a width you set survives a restart.",
|
||||||
|
"CW keyer widget: added an F9 macro slot, and empty macros are now hidden (like the voice keyer) — fill them in Settings → CW Keyer.",
|
||||||
|
"New: ESM (Enter Sends Message) for CW, N1MM-style. Enable it in Settings → CW Keyer. With the keyer on in CW, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs the QSO if the macro contains <LOGQSO>.",
|
||||||
|
"The top frequency readout is now scroll-tunable: roll the mouse wheel over the hundreds / tens / units-of-kHz digit to step the frequency by 100 / 10 / 1 kHz, and the rig follows over CAT.",
|
||||||
|
"New: 4O3A Tuner Genius XL control. Enable it in Settings → Tuner Genius (IP only; port is fixed at 9010). Live SWR and forward power with Tune, Bypass and Operate/Standby, the two channels A/B (source, frequency and antenna) shown and click-selectable. Available as a docked widget, a card in the FlexRadio panel (like the PowerGenius) and a card in Station Control. Controlled directly over TCP, so it uses just one of the box's connection slots.",
|
||||||
|
"FlexRadio panel tidy-up: cards collapse from the chevron in their header (Transmit and Receive fold together), all meters are the same size, the MIC and COMP meters only show in phone modes, and the S-meter dBm now sits next to the S-value instead of on a second line.",
|
||||||
|
"Fixed the colour theme sometimes reverting to the default when reopening OpsLog — it's now restored reliably.",
|
||||||
|
"ADIF export field picker: the per-group All / None buttons work again.",
|
||||||
|
"Station Control: cards now pack tightly into balanced columns instead of leaving big gaps under shorter panels — a cleaner, more even dashboard.",
|
||||||
|
"Main-view band map: Ctrl+↑ / Ctrl+↓ jumps to the next spot above / below the current frequency and tunes to it.",
|
||||||
|
"QSO details → Awards: the 'this contact will count for' list is now compact (CODE@REF chips, full name on hover) with a capped height, so it no longer hides the awards you've selected.",
|
||||||
|
"Fixed award references (in the entry strip's Awards tab) not clearing when the callsign changes — clicking one spot then another no longer keeps the previous station's references."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Le zoom Ctrl + molette est maintenant conservé après un redémarrage (Ctrl+0 remet à 100 %).",
|
||||||
|
"Grille du log : les largeurs des colonnes de diplômes sont désormais sauvegardées comme les autres colonnes, une largeur réglée survit au redémarrage.",
|
||||||
|
"Widget keyer CW : ajout d'un emplacement de macro F9, et les macros vides sont maintenant masquées (comme le keyer vocal) — remplis-les dans Réglages → Keyer CW.",
|
||||||
|
"Nouveau : ESM (Entrée envoie le message) en CW, façon N1MM. À activer dans Réglages → Keyer CW. Avec le keyer actif en CW, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue le QSO si le macro contient <LOGQSO>.",
|
||||||
|
"L'affichage de fréquence en haut est maintenant accordable à la molette : roule la molette sur le chiffre des centaines / dizaines / unités de kHz pour changer la fréquence par pas de 100 / 10 / 1 kHz, et la radio suit en CAT.",
|
||||||
|
"Nouveau : contrôle du 4O3A Tuner Genius XL. Active-le dans Réglages → Tuner Genius (IP seulement ; port fixé à 9010). ROS et puissance directe en direct avec Accord, Bypass et Operate/Standby, et les deux canaux A/B (source, fréquence et antenne) affichés et sélectionnables d'un clic. Disponible en widget ancré, en carte dans le panneau FlexRadio (comme le PowerGenius) et en carte dans Station Control. Piloté directement en TCP, il n'utilise qu'une des connexions de la boîte.",
|
||||||
|
"Nettoyage du panneau FlexRadio : les cartes se replient via le chevron de leur en-tête (Transmit et Receive se replient ensemble), tous les meters ont la même taille, les meters MIC et COMP ne s'affichent qu'en phonie, et le dBm du S-mètre est maintenant à côté de la valeur S plutôt que sur une deuxième ligne.",
|
||||||
|
"Correction du thème qui revenait parfois au défaut à la réouverture d'OpsLog — il est maintenant restauré de façon fiable.",
|
||||||
|
"Sélecteur de champs à l'export ADIF : les boutons Tout / Aucun par groupe refonctionnent.",
|
||||||
|
"Station Control : les cartes se rangent en colonnes équilibrées et se tassent au lieu de laisser de gros trous sous les panneaux plus courts — tableau de bord plus propre et régulier.",
|
||||||
|
"Band map de l'écran principal : Ctrl+↑ / Ctrl+↓ saute au spot suivant au-dessus / en dessous de la fréquence courante et s'y accorde.",
|
||||||
|
"Détails du QSO → Diplômes : la liste « ce contact comptera pour » est maintenant compacte (pastilles CODE@REF, nom complet au survol) et de hauteur limitée, elle ne masque plus les diplômes sélectionnés.",
|
||||||
|
"Correction des références de diplômes (onglet Diplômes de la bande de saisie) qui ne se vidaient pas au changement d'indicatif — cliquer un spot puis un autre ne garde plus les références de la station précédente."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.21.0",
|
||||||
|
"date": "2026-07-24",
|
||||||
|
"en": [
|
||||||
|
"⚠️ IMPORTANT — ARCHITECTURE CHANGE. OpsLog now keeps your settings/profiles and your QSO logbook in SEPARATE database files (before, everything was in one file). On the first launch after this update, your existing database is split AUTOMATICALLY and non-destructively: your contacts are copied into a new logbook file (logbook.db) while the originals stay untouched in the settings database as a backup — nothing is deleted, no QSO is lost. Existing installs keep their opslog.db as the settings database; fresh installs name it settings.db. As a precaution, back up your OpsLog data folder before updating. Afterwards, Settings → Database shows the settings database and this profile's logbook as two separate sections.",
|
||||||
|
"Your QSOs and your settings now live in separate files: settings + profiles stay in the settings database, while contacts go to a dedicated logbook file (existing logs are migrated automatically, originals kept as a backup). A profile can also point at its own logbook file — ideal for a visiting operator, whose contacts stay out of your log without ever touching your settings or profiles. The Database panel now clearly shows the two, with an 'Open folder' shortcut, and a logbook file can be renamed/relocated (its QSOs move with it)."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"⚠️ IMPORTANT — CHANGEMENT D'ARCHITECTURE. OpsLog stocke désormais tes réglages/profils et ton journal de QSO dans des fichiers de base de données SÉPARÉS (avant, tout était dans un seul fichier). Au premier lancement après cette mise à jour, ta base existante est scindée AUTOMATIQUEMENT et sans destruction : tes contacts sont copiés dans un nouveau fichier journal (logbook.db) tandis que les originaux restent intacts dans la base de réglages en sauvegarde — rien n'est supprimé, aucun QSO n'est perdu. Les installs existantes gardent leur opslog.db comme base de réglages ; les nouvelles installs la nomment settings.db. Par précaution, sauvegarde ton dossier de données OpsLog avant de mettre à jour. Ensuite, Réglages → Base de données affiche la base de réglages et le journal de ce profil en deux sections distinctes.",
|
||||||
|
"Tes QSO et tes réglages sont désormais dans des fichiers séparés : réglages + profils dans la base de réglages, contacts dans un fichier journal dédié (les journaux existants sont migrés automatiquement, les originaux gardés en sauvegarde). Un profil peut aussi pointer vers son propre fichier journal — idéal pour un opérateur de passage, dont les contacts restent hors de ton journal sans jamais toucher tes réglages ni profils. Le panneau Base de données montre maintenant clairement les deux, avec un raccourci « Ouvrir le dossier », et un fichier journal peut être renommé/déplacé (ses QSO le suivent)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.12",
|
||||||
|
"date": "2026-07-24",
|
||||||
|
"en": [
|
||||||
|
"Fixed a motorized-antenna bug that could leave a FlexRadio permanently unable to transmit (‘Interlock is preventing transmission’). With a SteppIR whose status frequency reads intermittently (it flipped between the commanded frequency and its home value), the ‘follow the rig’ loop re-sent a tune command on almost every poll, and each command re-armed the ‘block TX while the antenna moves’ window — so the interlock never released. The follow loop now keys its deadband off the rig’s frequency (only re-tuning when the RADIO actually QSYs), immune to a flaky antenna status. Also dropped an ‘interlock set reason=’ command that SmartSDR rejects (it’s read-only) and only produced a harmless error line — the transmit-inhibit itself is unchanged and still holds TX safely while the elements move. New: a SteppIR ‘Tunable range’ setting (Settings → Hardware → Antenna, default 13–54 MHz = 20 m–6 m) — on a band outside it OpsLog leaves the antenna and TX completely alone, so tuning to 30 m on a 20 m–6 m SteppIR no longer tries to move it or touches the interlock.",
|
||||||
|
"New built-in award: The Helvetia 26 Award (H26) — the 26 cantons of Switzerland (USKA), matched from the QSO's address or QTH on HF. Each canton also recognises its main cities (Genève, Lausanne, Zürich, Bellinzona…), since operators rarely write the canton itself.",
|
||||||
|
"FlexRadio panel: the RECEIVE card is shorter again. All the noise controls added recently made it tower, so only the everyday ones — NB, NR, ANF — now stay visible; WNB and the SmartSDR v4 DSP block (NRL/NRS/NRF/ANFL and the AI/FFT RNN & ANFT) tuck behind a 'DSP' button you expand when you need them. The button carries a dot and highlights when one of the hidden controls is switched on, so nothing active is ever out of sight, and its open/closed state is remembered.",
|
||||||
|
"ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.",
|
||||||
|
"Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.",
|
||||||
|
"NET Control: the on-air list now follows a mic-pass order you control instead of log time. A pinned '#' column numbers the round, ⬆⬇ buttons move the selected station up/down the queue, new check-ins join the end, and 'Log & end' advances to the next in line. The order is remembered per net. Header sorting is disabled on this list so a click can't shuffle the round.",
|
||||||
|
"The CW decoder (RX audio → text) is back, with a rebuilt decoding engine. The first version struggled on real signals; the new one adds a windowed tone detector, an adaptive dB envelope with noise squelch, two-way debouncing (a brief fade inside a dash no longer shatters it into dots), per-character dit/dah classification (even the first letter of an over decodes at any speed), and gap-fed speed tracking. It rides QSB, ignores QRM on other pitches, follows 12–40 WPM and sloppy hand keying — all proven by a synthetic-signal test suite. Tools → CW decoder (or the ear button): decodes while the mode is CW, with WPM/pitch/level display, pitch lock (follows the FlexRadio CW pitch automatically), and click-a-word to fill the callsign. Weak signals in heavy QRM remain hard — that's CW Skimmer territory — but clean-to-moderate signals now decode solidly.",
|
||||||
|
"Finished translating the Settings dialog: several panels that had stayed in English are now French too — the CW Keyer panel (and its long serial-engine paragraph removed for clarity), Password encryption, the ClubLog exceptions / Most Wanted blocks in General, and the External services tabs (HRDLOG, eQSL, LoTW, POTA).",
|
||||||
|
"Serial CW keyer: fixed the rig dropping to RX between words during a macro (PTT not held). On USB-serial interfaces like the Yaesu SCU-17 (CP210x), the previous method of driving the DTR/RTS lines would drop the held RTS (PTT) as soon as DTR (CW) toggled. OpsLog now drives the lines with the direct Win32 method N1MM/WSJT use (EscapeCommFunction), so RTS stays asserted for the whole transmission. 'Key PTT line' also defaults on for the Serial engine, and PTT / key-line / speed changes apply immediately without reconnecting the keyer.",
|
||||||
|
"New (Settings → General): show the ClubLog 'Most Wanted' rank in the entry-strip band matrix. When on, a 'MW #rank' pill appears next to the DXCC entity name (1 = the most wanted entity), coloured hotter the more wanted it is. The list is fetched from ClubLog and personalised to your callsign, refreshed daily.",
|
||||||
|
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Correction d'un bug d'antenne motorisée qui pouvait laisser un FlexRadio définitivement incapable d'émettre (« Interlock is preventing transmission »). Avec une SteppIR dont la fréquence de statut se lit par intermittence (elle alternait entre la fréquence commandée et sa valeur de repos), la boucle de suivi renvoyait un ordre d'accord à presque chaque cycle, et chaque ordre réarmait la fenêtre « bloquer l'émission pendant que l'antenne bouge » — l'interlock ne se relâchait donc jamais. La boucle de suivi se base maintenant sur la fréquence de la RADIO (elle ne réaccorde que quand le poste change réellement de fréquence), insensible à un statut d'antenne erratique. Retrait aussi d'une commande « interlock set reason= » que SmartSDR refuse (champ en lecture seule) et qui ne produisait qu'une ligne d'erreur sans effet — l'inhibition d'émission elle-même est inchangée et protège toujours pendant le mouvement des éléments. Nouveau : un réglage « Plage accordable » pour la SteppIR (Réglages → Matériel → Antenne, défaut 13-54 MHz = 20 m-6 m) — sur une bande hors de cette plage, OpsLog laisse totalement l'antenne et l'émission tranquilles, donc passer sur 30 m avec une SteppIR 20 m-6 m ne tente plus de la bouger ni ne touche à l'interlock.",
|
||||||
|
"Nouveau diplôme intégré : The Helvetia 26 Award (H26) — les 26 cantons de Suisse (USKA), reconnus depuis l'adresse ou le QTH du QSO en HF. Chaque canton reconnaît aussi ses principales villes (Genève, Lausanne, Zürich, Bellinzone…), car les opérateurs écrivent rarement le canton lui-même.",
|
||||||
|
"Panneau FlexRadio : la carte RÉCEPTION est de nouveau plus compacte. Tous les contrôles de bruit ajoutés récemment la faisaient s'allonger, donc seuls ceux du quotidien — NB, NR, ANF — restent visibles ; le WNB et le bloc DSP SmartSDR v4 (NRL/NRS/NRF/ANFL ainsi que RNN & ANFT IA/FFT) se replient derrière un bouton « DSP » que tu déplies au besoin. Le bouton porte un point et s'illumine quand l'un des contrôles masqués est activé, pour ne jamais perdre de vue quelque chose d'actif, et son état ouvert/fermé est mémorisé.",
|
||||||
|
"L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.",
|
||||||
|
"Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.",
|
||||||
|
"NET Control : la liste on-air suit maintenant un ordre de passage du micro que tu contrôles, au lieu de l'heure de log. Une colonne « # » épinglée numérote le tour, les boutons ⬆⬇ montent/descendent la station sélectionnée dans la file, les nouveaux arrivants rejoignent la fin, et « Logger & terminer » passe au suivant. L'ordre est mémorisé par NET. Le tri par en-tête est désactivé sur cette liste pour qu'un clic ne bouscule pas le tour.",
|
||||||
|
"Le décodeur CW (audio RX → texte) est de retour, avec un moteur de décodage reconstruit. La première version peinait sur les vrais signaux ; la nouvelle ajoute un détecteur de tonalité fenêtré, une enveloppe dB adaptative avec squelch de bruit, un anti-rebond bilatéral (un bref fading dans un trait ne le brise plus en points), une classification point/trait par caractère (même la première lettre d'un over se décode à n'importe quelle vitesse) et un suivi de vitesse nourri par les espaces. Il tient le QSB, ignore le QRM sur d'autres tonalités, suit de 12 à 40 WPM et la manipulation humaine approximative — le tout prouvé par une suite de tests sur signaux synthétiques. Outils → Décodeur CW (ou le bouton oreille) : décode quand le mode est CW, avec affichage WPM/tonalité/niveau, verrouillage de tonalité (suit automatiquement le pitch CW du FlexRadio) et clic-sur-un-mot pour remplir l'indicatif. Les signaux faibles dans un fort QRM restent difficiles — c'est le territoire de CW Skimmer — mais les signaux propres à moyens se décodent maintenant solidement.",
|
||||||
|
"Fin de la traduction de la fenêtre Réglages : plusieurs panneaux restés en anglais sont maintenant en français aussi — le Manipulateur CW (et son long paragraphe moteur série retiré pour l'alléger), le Chiffrement des mots de passe, les blocs Exceptions ClubLog / Most Wanted dans Général, et les onglets Services externes (HRDLOG, eQSL, LoTW, POTA).",
|
||||||
|
"Keyer CW série : correction de la radio qui retombait en RX entre les mots pendant une macro (PTT non tenu). Sur les interfaces USB-série comme le Yaesu SCU-17 (CP210x), l'ancienne méthode de pilotage des lignes DTR/RTS faisait retomber le RTS (PTT) dès que DTR (CW) basculait. OpsLog pilote maintenant les lignes avec la méthode Win32 directe qu'utilisent N1MM/WSJT (EscapeCommFunction), donc RTS reste asserté toute l'émission. « Key PTT line » est aussi activé par défaut pour le moteur Série, et les changements PTT / ligne / vitesse s'appliquent immédiatement sans reconnecter le keyer.",
|
||||||
|
"Nouveau (Réglages → Général) : afficher le rang « Most Wanted » de ClubLog dans la matrice de bandes de la barre de saisie. Activé, une pastille « MW #rang » apparaît à côté du nom de l'entité DXCC (1 = l'entité la plus recherchée), d'autant plus colorée qu'elle est recherchée. La liste est récupérée depuis ClubLog et personnalisée selon ton indicatif, rafraîchie quotidiennement.",
|
||||||
|
"Correction du thème de couleur qui revenait parfois au clair après une mise à jour/relancement : une mise à jour peut vider le localStorage de la WebView, et le repli qui restaure le thème depuis la base de réglages locale abandonnait après ~2,4s — parfois trop tôt pendant le court instant de démarrage avant que ce store soit prêt. Il réessaie maintenant jusqu'à ce que le store réponde vraiment, donc un thème sombre est restauré de façon fiable."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.11",
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"en": [
|
||||||
|
"Fixed clicking a spot / band-map entry not moving the radio on Yaesu (and other non-Icom) rigs controlled through OmniRig — e.g. the FT-891. OpsLog tuned via OmniRig's SetSimplexMode, which on these rigs returns success but silently doesn't move the VFO (mode changes worked, so it looked like a puzzle). It now also writes the VFO frequency property directly, which does move them. Icom rigs are unchanged (there SetSimplexMode is the reliable path).",
|
||||||
|
"New CW keyer engine: 'Serial port (DTR=CW / RTS=PTT)'. OpsLog can now key CW by toggling a serial control line itself — the hardware-keying method N1MM/WSJT use with a Yaesu SCU-17 and generic keying interfaces — without a K1EL WinKeyer. Settings → CW Keyer → Keyer engine: pick 'Serial port', choose the keying COM port and whether CW is on DTR (PTT on RTS) or swapped. Speed, Farnsworth, lead-in/tail and PTT keying all apply; macros, auto-CQ and <LOGQSO> work exactly as with the WinKeyer. An 'Invert keying (active-LOW)' option is there for interfaces that key backwards / transmit at rest.",
|
||||||
|
"Fixed the spectrum scope never displaying on the IC-7300: its CI-V waveform frames actually carry the same leading main-scope selector byte as the dual-scope IC-7610/9700, but OpsLog assumed the 7300 omitted it — so it read the frame sequence number from the wrong byte (always 0), discarded every frame, and drew nothing. The frame layout is now detected from the data itself, so the 7300 (and other single-scope Icoms) render correctly, in both center and fixed modes. The IC-7610/9700 are unaffected.",
|
||||||
|
"IC-7300 scope: the center/fixed mode and edge-frequency commands now include the selector byte the rig requires, so switching the scope between center-on-VFO and fixed span from OpsLog is accepted instead of being rejected.",
|
||||||
|
"Closing OpsLog no longer switches off the scope display on the radio itself: turning the scope off used to send both 'stop CI-V data' and 'display off', so quitting blanked a local IC-7300's own scope screen. It now only stops the CI-V data stream on disable and never touches the radio's display (the display-on command is sent solely when enabling)."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Correction : cliquer un spot / une entrée de band-map ne changeait pas la fréquence sur les Yaesu (et autres radios non-Icom) pilotées via OmniRig — p. ex. le FT-891. OpsLog accordait via SetSimplexMode d'OmniRig, qui sur ces radios renvoie « OK » mais ne bouge pas le VFO (le changement de mode marchait, d'où l'énigme). Il écrit maintenant aussi directement la propriété fréquence du VFO, ce qui les fait bien QSY. Les Icom sont inchangés (là, SetSimplexMode reste la bonne méthode).",
|
||||||
|
"Nouveau moteur de keyer CW : « Port série (DTR=CW / RTS=PTT) ». OpsLog peut désormais manipuler la CW en basculant lui-même une ligne de contrôle série — la méthode de keying matériel qu'utilisent N1MM/WSJT avec un Yaesu SCU-17 et les interfaces génériques — sans WinKeyer K1EL. Réglages → Keyer CW → Moteur : choisis « Port série », le port COM de keying et si la CW est sur DTR (PTT sur RTS) ou l'inverse. Vitesse, Farnsworth, lead-in/tail et PTT s'appliquent ; les macros, l'auto-CQ et <LOGQSO> fonctionnent exactement comme avec le WinKeyer. Une option « Inverser le keying (actif-BAS) » est prévue pour les interfaces qui manipulent à l'envers / émettent au repos.",
|
||||||
|
"Correction du scope spectral qui ne s'affichait jamais sur l'IC-7300 : ses trames de forme d'onde CI-V portent en réalité le même octet sélecteur de scope en tête que les IC-7610/9700 (dual-scope), mais OpsLog supposait que le 7300 ne l'envoyait pas — il lisait donc le numéro de séquence de la trame sur le mauvais octet (toujours 0), jetait toutes les trames et ne dessinait rien. La disposition des trames est maintenant détectée à partir des données elles-mêmes, donc le 7300 (et les autres Icom mono-scope) s'affichent correctement, en mode centre comme en fixe. Les IC-7610/9700 ne sont pas affectés.",
|
||||||
|
"Scope IC-7300 : les commandes de mode centre/fixe et de fréquences de bords incluent maintenant l'octet sélecteur requis par la radio, donc basculer le scope entre centre-sur-VFO et span fixe depuis OpsLog est accepté au lieu d'être rejeté.",
|
||||||
|
"Fermer OpsLog n'éteint plus le scope sur la radio elle-même : couper le scope envoyait à la fois « stop données CI-V » et « affichage OFF », donc quitter éteignait l'écran scope d'un IC-7300 local. Désormais seule la diffusion des données CI-V est coupée à la désactivation, l'affichage de la radio n'est jamais touché (la commande d'allumage n'est envoyée qu'à l'activation)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.10",
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"en": [
|
||||||
|
"Clicking an OpsLog spot on the FlexRadio panadapter now switches the mode too, not just the frequency: cluster spots (which carry no mode) get one inferred from the comment or the band plan, and every spot's mode is sent to the radio as a real SmartSDR mode (SSB resolves to USB/LSB by frequency), so the click tunes AND sets the mode.",
|
||||||
|
"Station Control is now a clean dashboard of fixed-width panels that wrap to fill the window. Each panel has a grip handle on its left edge — drag it to move the panel anywhere, and the order is remembered. Relay buttons are compact. The amplifier panel is now the exact same card as the FlexRadio panel's (OPERATE/STANDBY, ON/OFF, power level, output-power bar and live FWD/drain-current/temperature meters), and if you run several amplifiers they all appear — one card each, no dropdown.",
|
||||||
|
"Station Control: the Ultrabeam element list now shows the right number of elements (3 for a 3-element beam) instead of extra bogus rows that came from over-reading the controller's reply.",
|
||||||
|
"Fixed the FlexRadio amplifier meters showing twice after the amp was power-cycled — the old (stale) meters are now cleared when the amplifier is torn down, so only the fresh set remains.",
|
||||||
|
"Fixed QSO audio recordings silently not being saved: the recording snapshot had moved onto the background (post-log) path, where it raced the entry-form clearing that cancels the recorder — so the audio was discarded before it was captured. The snapshot is now taken the instant you log, before the form clears; the file encoding still happens in the background.",
|
||||||
|
"Digital Voice Keyer: an Auto CQ (like the CW keyer's auto-call) repeats a CQ-labelled voice message on a timer until you stop it, play another slot or press ESC. The DVK now also refuses to transmit on non-phone modes (CW/FT8/RTTY…) — the buttons grey out and it won't key the rig with speech on a data mode.",
|
||||||
|
"The Digital Voice Keyer (DVK) panel now stays open across a relaunch if you had it open — it used to reset to closed every time.",
|
||||||
|
"FlexRadio meter scales now match SmartSDR: MIC (level) tops out at 0 dB and COMP (compression) tops out at -25 dB (was -20).",
|
||||||
|
"New Help → 'Send log to F4BPO' (shown only when you have an SMTP server configured in Settings → E-mail): e-mails OpsLog's diagnostic logs (current session, previous session and any crash log) straight to the developer, so a problem you hit in the field can be looked at without hunting for the log files by hand.",
|
||||||
|
"Bulk edit can now set the frequency: pick 'Frequency (MHz)', enter e.g. 7.155, and every selected QSO gets that frequency with its band recomputed to match. Handy for fixing a run that was logged on a stale/default frequency (e.g. 7.000) after CAT control dropped. Out-of-band values are rejected so a typo can't corrupt the batch.",
|
||||||
|
"Fixed the Cluster spot list appearing to 'freeze' at the UTC midnight rollover: the Time column was sorted as plain text, so a spot at 0001Z sorted below 2359Z and new spots after 0000Z dropped to the bottom of the list instead of the top — it looked like the cluster had stopped. The column now sorts by actual arrival time, which crosses midnight correctly.",
|
||||||
|
"Fixed the spectrum scope being blank on the IC-7300 (and other single-scope Icoms) when the scope was in center / VFO-following mode: the multi-frame waveform header read the second frequency field as an absolute high edge, but in center mode it is a span, so the display got an invalid range and drew nothing. It now interprets center+span vs low+high edges the same way the IC-7610 path already did. FIXED mode was unaffected."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Cliquer sur un spot OpsLog du panadapter FlexRadio change maintenant aussi le mode, plus seulement la fréquence : les spots cluster (qui n'ont pas de mode) en reçoivent un déduit du commentaire ou du plan de bande, et le mode de chaque spot est envoyé à la radio comme un vrai mode SmartSDR (SSB devient USB/LSB selon la fréquence), donc le clic accorde ET règle le mode.",
|
||||||
|
"Station Control est maintenant un tableau de bord de panneaux à largeur fixe qui s'alignent pour remplir la fenêtre. Chaque panneau a une poignée sur son bord gauche — glisse-la pour déplacer le panneau où tu veux, et l'ordre est mémorisé. Les boutons relais sont compacts. Le panneau amplificateur est désormais exactement la même carte que celle du panneau FlexRadio (OPERATE/STANDBY, ON/OFF, niveau de puissance, barre de puissance de sortie et mesures en direct puissance directe/courant de drain/température), et si tu utilises plusieurs amplis ils apparaissent tous — une carte chacun, sans liste déroulante.",
|
||||||
|
"Station Control : la liste des éléments Ultrabeam affiche maintenant le bon nombre d'éléments (3 pour une beam 3 éléments) au lieu de lignes fantômes issues d'une sur-lecture de la réponse du contrôleur.",
|
||||||
|
"Correction des mesures de l'amplificateur FlexRadio affichées en double après un cycle d'alimentation de l'ampli — les anciennes mesures (périmées) sont maintenant effacées quand l'amplificateur est retiré, seul le jeu à jour reste.",
|
||||||
|
"Correction des enregistrements audio des QSO qui n'étaient plus sauvegardés : la capture de l'enregistrement était passée sur le chemin d'arrière-plan (après le log), où elle entrait en concurrence avec le vidage du formulaire qui annule l'enregistreur — l'audio était donc jeté avant d'être capturé. La capture se fait maintenant à l'instant du log, avant le vidage du formulaire ; l'encodage du fichier reste en arrière-plan.",
|
||||||
|
"Manipulateur vocal (DVK) : un Auto CQ (comme l'auto-call du keyer CW) répète un message vocal libellé CQ à intervalle régulier jusqu'à l'arrêt, la lecture d'un autre slot ou ÉCHAP. Le DVK refuse aussi désormais d'émettre hors phonie (CW/FT8/RTTY…) — les boutons sont grisés et il ne manipule plus la radio avec de la voix sur un mode data.",
|
||||||
|
"Le panneau Digital Voice Keyer (DVK) reste maintenant ouvert après un relancement si vous l'aviez ouvert — il se refermait à chaque démarrage.",
|
||||||
|
"Les échelles des meters FlexRadio collent maintenant à SmartSDR : MIC (niveau) plafonne à 0 dB et COMP (compression) à -25 dB (au lieu de -20).",
|
||||||
|
"Nouveau Aide → « Envoyer le journal à F4BPO » (affiché seulement si un serveur SMTP est configuré dans Réglages → E-mail) : envoie par e-mail les journaux de diagnostic d'OpsLog (session en cours, session précédente et éventuel journal de crash) directement au développeur, pour qu'un problème rencontré sur le terrain puisse être examiné sans avoir à chercher les fichiers de journal à la main.",
|
||||||
|
"L'édition groupée permet maintenant de définir la fréquence : choisis « Fréquence (MHz) », saisis p. ex. 7.155, et chaque QSO sélectionné reçoit cette fréquence avec sa bande recalculée en conséquence. Pratique pour corriger une série loguée sur une fréquence par défaut/figée (p. ex. 7.000) après une perte du CAT. Les valeurs hors bande sont refusées pour qu'une faute de frappe ne corrompe pas le lot.",
|
||||||
|
"Correction de la liste des spots Cluster qui semblait « se figer » au passage de minuit UTC : la colonne Heure était triée comme du texte, donc un spot à 0001Z se retrouvait sous 2359Z et les nouveaux spots après 0000Z tombaient en bas de la liste au lieu du haut — on aurait dit que le cluster s'était arrêté. La colonne trie désormais selon l'heure d'arrivée réelle, qui franchit minuit correctement.",
|
||||||
|
"Correction du scope spectral vide sur l'IC-7300 (et autres Icom mono-scope) quand le scope était en mode centre / suivi du VFO : l'en-tête de forme d'onde multi-trames lisait le second champ de fréquence comme un bord haut absolu, alors qu'en mode centre c'est un span — l'affichage recevait donc une plage invalide et ne dessinait rien. Il interprète maintenant centre+span vs bords bas+haut comme le faisait déjà le chemin de l'IC-7610. Le mode FIXED n'était pas affecté."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.9",
|
||||||
|
"date": "2026-07-22",
|
||||||
|
"en": [
|
||||||
|
"The PGXL amplifier chip and Station Control widget now read the OPERATE state from the FlexRadio (like the Flex panel) — the chip no longer shows STANDBY while the amp is operating, and clicking it toggles the amp through the radio.",
|
||||||
|
"Right-click 'Send to LoTW / QRZ / Club Log…' now reports its result as a notification — a failed upload (e.g. TQSL not configured) was completely silent outside the QSL Manager, which looked like nothing was sent.",
|
||||||
|
"The number of selected QSOs is now always visible at the top of the log grid — no need to open the right-click menu to see it.",
|
||||||
|
"NET Control: the worked-before list gets the same right-click menu as Recent QSOs (update from cty/QRZ/Club Log, send to services, recording e-mail, eQSL, delete).",
|
||||||
|
"Leaving compact mode restores the window's previous size AND position (it used to snap to a fixed size wherever the compact strip had been dragged).",
|
||||||
|
"The advanced filter's 'My callsign' field is renamed 'Station callsign' so it is findable under its ADIF name (it filters STATION_CALLSIGN).",
|
||||||
|
"Filtering now shows EVERY match: the on-screen row limit (Max) only applies to the unfiltered log — a filter matching 200 QSOs displays all 200 even with Max at 100 (safety cap 10,000, with a warning to narrow the filter beyond that).",
|
||||||
|
"New 'Select all' button in the log grid toolbar — selects every displayed row (respecting active column filters) in one click, ready for send-to-LoTW, bulk edit or export; once everything is selected it flips to 'Unselect all'.",
|
||||||
|
"NET Control: drag & drop between the two lists — drag a roster station onto the on-air list to start its QSO, and drag an on-air station onto the roster to log it (same as the Log & end button).",
|
||||||
|
"New option (Settings → General): 'Group digital modes as one (DXCC-style)' — when on, the matrix newness badges and the cluster new/new-band/new-mode/new-slot colouring treat FT8/FT4/RTTY/PSK… as a single Digital mode, matching how DXCC counts; when off (default), each digital mode remains its own potential slot.",
|
||||||
|
"Multiple amplifiers: Settings → Amplifier is now a LIST — configure several amps (e.g. two SPEs run in parallel), each with its own name and connection. The amp cards in the Flex panel and Station Control get a dropdown to pick which amp they show, and the bottom status bar shows one clickable chip per amp. Existing single-amp setups migrate automatically.",
|
||||||
|
"FlexRadio panel: the SmartSDR v4 DSP filters are now controllable — NRL and ANFL (legacy LMS), NRS (spectral subtraction), NRF (noise reduction with filter) with their level sliders, plus RNN (AI noise reduction) and ANFT (FFT auto-notch) toggles. The section appears automatically on radios that support them (8000/Aurora series). Layout rebalanced: RIT/XIT moved to the Transmit column and the antenna selection now sits at the top of the Receive column, alongside a new DAX button (SmartSDR's transmit-bar DAX toggle — TX audio from DAX, for WSJT-X & co)."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"La pastille ampli PGXL et le widget Station Control lisent maintenant l'état OPERATE depuis le FlexRadio (comme le panneau Flex) — la pastille n'affiche plus STANDBY quand l'ampli est en service, et un clic bascule l'ampli via la radio.",
|
||||||
|
"Le clic droit « Send to LoTW / QRZ / Club Log… » affiche maintenant son résultat en notification — un envoi échoué (p. ex. TQSL non configuré) était totalement silencieux hors du QSL Manager, comme si rien n'était parti.",
|
||||||
|
"Le nombre de QSO sélectionnés est maintenant toujours visible en haut de la grille du log — plus besoin d'ouvrir le menu clic droit pour le voir.",
|
||||||
|
"NET Control : la liste worked-before dispose du même menu clic droit que QSO récents (mise à jour cty/QRZ/Club Log, envoi aux services, e-mail d'enregistrement, eQSL, suppression).",
|
||||||
|
"Quitter le mode compact restaure la taille ET la position précédentes de la fenêtre (avant, elle reprenait une taille fixe là où la barre compacte avait été déplacée).",
|
||||||
|
"Le champ « Mon indicatif » du filtre avancé est renommé « Station callsign » pour être trouvable sous son nom ADIF (il filtre STATION_CALLSIGN).",
|
||||||
|
"Le filtrage affiche maintenant TOUTES les correspondances : la limite d'affichage (Max) ne s'applique qu'au log non filtré — un filtre qui matche 200 QSO les affiche tous les 200 même avec Max à 100 (plafond de sécurité 10 000, avec un avertissement pour affiner au-delà).",
|
||||||
|
"Nouveau bouton « Tout sélectionner » dans la barre d'outils de la grille — sélectionne toutes les lignes affichées (en respectant les filtres de colonnes actifs) en un clic, prêt pour l'envoi LoTW, le bulk edit ou l'export ; une fois tout sélectionné il devient « Tout désélectionner ».",
|
||||||
|
"NET Control : glisser-déposer entre les deux listes — glisser une station du roster vers la liste on air démarre son QSO, et glisser une station on air vers le roster l'enregistre au log (comme le bouton Logger & terminer).",
|
||||||
|
"Nouvelle option (Réglages → Général) : « Regrouper les modes digitaux en un seul (style DXCC) » — activée, les badges de nouveauté de la matrice et le coloriage cluster new/new-band/new-mode/new-slot traitent FT8/FT4/RTTY/PSK… comme un seul mode Digital, comme le DXCC ; désactivée (défaut), chaque mode digital reste un slot potentiel distinct.",
|
||||||
|
"Plusieurs amplificateurs : Réglages → Amplificateur est maintenant une LISTE — configurez plusieurs amplis (p. ex. deux SPE en parallèle), chacun avec son nom et sa connexion. Les cartes ampli du panneau Flex et de Station Control ont une liste déroulante pour choisir lequel afficher, et la barre du bas montre une pastille cliquable par ampli. Les configurations mono-ampli existantes migrent automatiquement.",
|
||||||
|
"Panneau FlexRadio : les filtres DSP SmartSDR v4 sont maintenant pilotables — NRL et ANFL (LMS legacy), NRS (soustraction spectrale), NRF (réduction de bruit avec filtre) avec leurs curseurs de niveau, plus les interrupteurs RNN (réduction de bruit par IA) et ANFT (notch automatique FFT). La section apparaît automatiquement sur les radios qui les supportent (séries 8000/Aurora). Mise en page rééquilibrée : RIT/XIT passent dans la colonne Transmit et la sélection d'antennes s'affiche en haut de la colonne Receive, avec un nouveau bouton DAX (le toggle DAX du bandeau transmit de SmartSDR — audio d'émission via DAX, pour WSJT-X & co)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.8",
|
||||||
|
"date": "2026-07-21",
|
||||||
|
"en": [
|
||||||
|
"New: microHAM ARCO rotator controlled natively — no PstRotator needed. Over the LAN or over USB: set the ARCO's CONTROL PROTOCOL (LAN or USB) to 'Yaesu GS-232A' and pick 'microHAM ARCO' in Settings → Rotator.",
|
||||||
|
"Amplifier without a FlexRadio: the amplifier controls (OPERATE/STANDBY, ON/OFF, live power/SWR/temperature) now also live in the Station Control tab, and a status chip sits in the bottom bar next to Cluster/CAT/Rotator — green ON AIR = OPERATE, orange = STANDBY, red = offline. Clicking the chip toggles OPERATE/STANDBY on all three amps (PowerGenius XL included, over its direct network link).",
|
||||||
|
"NET Control: after logging a station, the next on-air station is selected automatically (chain log → log → log), and a new 'Log everyone' button logs every on-air station at once when the net ends.",
|
||||||
|
"Switching to a profile whose MySQL database is unreachable now shows a clear warning — before, OpsLog silently stayed on the previous logbook and QSOs could land in the wrong log.",
|
||||||
|
"The ON AIR badge is back for local (SQLite) logbooks — it had disappeared with the removal of the live-status option."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Nouveau : rotator microHAM ARCO contrôlé en natif — sans PstRotator. Par le réseau ou par USB : régler le CONTROL PROTOCOL de l'ARCO (LAN ou USB) sur « Yaesu GS-232A » et choisir « microHAM ARCO » dans Réglages → Rotator.",
|
||||||
|
"Amplificateur sans FlexRadio : les commandes d'ampli (OPERATE/STANDBY, ON/OFF, puissance/ROS/température en direct) sont maintenant aussi dans l'onglet Station Control, et une pastille de statut s'affiche dans la barre du bas à côté de Cluster/CAT/Rotator — vert = OPERATE, orange = STANDBY, rouge = hors ligne. Un clic sur la pastille bascule OPERATE/STANDBY sur les trois amplis (PowerGenius XL compris, via son lien réseau direct).",
|
||||||
|
"NET Control : après avoir loggé une station, la station on air suivante est sélectionnée automatiquement (enchaînement log → log → log), et un nouveau bouton « Logger tout le monde » enregistre toutes les stations on air d'un coup à la fin du net.",
|
||||||
|
"Passer sur un profil dont la base MySQL est injoignable affiche maintenant un avertissement clair — avant, OpsLog restait silencieusement sur l'ancien logbook et les QSO pouvaient partir dans le mauvais log.",
|
||||||
|
"Le badge ON AIR est de retour pour les logbooks locaux (SQLite) — il avait disparu avec la suppression de l'option de statut en direct."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.7",
|
||||||
|
"date": "2026-07-21",
|
||||||
|
"en": [
|
||||||
|
"New: ACOM amplifier support (500S / 600S / 700S / 1200S / 2020S) — OPERATE/STANDBY/OFF, live telemetry (power, SWR, PA temperature, band, fan) in Settings and the radio panel, over USB serial or an RS232-to-Ethernet bridge. Power-ON works over serial when the cable wires the DTR/RTS pins. The Amplifier settings now pick Brand then Model.",
|
||||||
|
"Multi-operator live sync fixed: the shared-MySQL connection pool was too small, so after a while the grid, chat and 'stations on air' silently stopped updating. The pool is now sized for real multi-op use (with 5 operators it stays well within a max_connections=300 server).",
|
||||||
|
"Live operator status is now always published on a shared MySQL logbook — the Settings toggle is gone. You still go off-air automatically 5 minutes after your last QSO, and the 'stations on air' panel refreshes noticeably faster (a redundant table check on every read was removed).",
|
||||||
|
"The By-Continent statistics donut now totals the same as your QSO count — QSOs whose continent couldn't be resolved are shown as an 'Unknown' slice instead of being dropped (the Continents count still lists only real continents)."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Nouveau : prise en charge des amplificateurs ACOM (500S / 600S / 700S / 1200S / 2020S) — OPERATE/STANDBY/OFF, télémétrie en direct (puissance, ROS, température PA, bande, ventilateur) dans les Réglages et le panneau radio, en USB série ou via un pont RS232-vers-Ethernet. La mise en marche fonctionne en série si le câble relie les broches DTR/RTS. Les réglages Amplificateur se font maintenant par Marque puis Modèle.",
|
||||||
|
"Synchronisation multi-opérateurs corrigée : le pool de connexions MySQL partagé était trop petit, et au bout d'un moment la grille, le chat et « stations on air » cessaient silencieusement de se mettre à jour. Le pool est maintenant dimensionné pour un vrai multi-op (à 5 opérateurs, on reste largement sous un serveur à max_connections=300).",
|
||||||
|
"Le statut opérateur en direct est maintenant toujours publié sur un logbook MySQL partagé — l'option des Réglages a disparu. Vous passez toujours hors antenne automatiquement 5 minutes après votre dernier QSO, et le panneau « stations on air » se rafraîchit nettement plus vite (une vérification de table redondante à chaque lecture a été supprimée).",
|
||||||
|
"La donut « Par continent » des statistiques totalise maintenant le même nombre que le total de QSO — les QSO dont le continent n'a pas pu être résolu apparaissent dans un segment « Unknown » au lieu d'être ignorés (le compteur Continents ne liste toujours que les vrais continents)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.6",
|
||||||
|
"date": "2026-07-21",
|
||||||
|
"en": [
|
||||||
|
"Amplifier controls now live right in the FlexRadio panel: OPERATE/STANDBY, ON/OFF, Low/Mid/High power level, an output-power bar and live band / SWR / temperature — for both PowerGenius XL and SPE Expert. The card is hidden when no amplifier is selected.",
|
||||||
|
"Faster startup: the radio (CAT) connects immediately instead of waiting behind the shared-MySQL connection, and MySQL itself connects much faster.",
|
||||||
|
"No more lost QSOs: an FT8/MSHV QSO that can't reach a laggy shared database is parked and re-logged automatically once it recovers; simultaneous QSOs from a multi-stream are now queued instead of failing with 'too many requests'.",
|
||||||
|
"The 'stations on air' widget now updates within a second of a QSO (it used to lag up to ~45s behind the ON-AIR badge).",
|
||||||
|
"Relay boards (Denkovi / USB-serial) stay connected reliably now, with a Test-connection button and detect feedback in the Station Control setup.",
|
||||||
|
"<LOGQSO> in a CW macro logs the contact at that exact point in the macro, and pressing ESC before it runs cancels the log.",
|
||||||
|
"Auto-call now waits exactly the gap you set between calls (it was waiting far longer).",
|
||||||
|
"Statistics: the redundant per-band chart was merged into the CW / phone / data split.",
|
||||||
|
"Cluster self-spot pop-ups now show the band.",
|
||||||
|
"The bottom status bar is no longer hidden behind a scrollbar in a small window.",
|
||||||
|
"Update check now also runs when you open Help - About, and every 5 minutes."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les commandes d'amplificateur sont maintenant directement dans le panneau FlexRadio : OPERATE/STANDBY, Marche/Arrêt, niveau Low/Mid/High, une barre de puissance de sortie et l'état en direct bande / ROS / température — pour PowerGenius XL comme pour SPE Expert. La carte est masquée si aucun ampli n'est sélectionné.",
|
||||||
|
"Démarrage plus rapide : la radio (CAT) se connecte immédiatement au lieu d'attendre derrière la connexion MySQL partagée, et MySQL lui-même se connecte bien plus vite.",
|
||||||
|
"Plus de QSO perdus : un QSO FT8/MSHV qui n'atteint pas une base partagée lente est mis de côté et ré-enregistré automatiquement dès qu'elle répond ; les QSO simultanés d'un multi-stream sont maintenant mis en file au lieu d'échouer avec « too many requests ».",
|
||||||
|
"Le widget « stations on air » se met à jour en une seconde après un QSO (il pouvait accuser jusqu'à ~45s de retard sur le badge ON AIR).",
|
||||||
|
"Les cartes relais (Denkovi / USB-série) restent connectées de façon fiable, avec un bouton « Tester la connexion » et un retour de détection dans la configuration Station Control.",
|
||||||
|
"<LOGQSO> dans une macro CW enregistre le contact à cet endroit précis de la macro, et appuyer sur ÉCHAP avant son exécution annule l'enregistrement.",
|
||||||
|
"L'appel automatique respecte maintenant exactement l'intervalle réglé entre les appels (il attendait bien plus longtemps).",
|
||||||
|
"Statistiques : le graphique par bande redondant a été fusionné avec la répartition CW / phone / data.",
|
||||||
|
"Les popups d'auto-spot du cluster affichent maintenant la bande.",
|
||||||
|
"La barre de statut du bas n'est plus masquée par une barre de défilement en fenêtre réduite.",
|
||||||
|
"La vérification de mise à jour se déclenche aussi à l'ouverture d'Aide - À propos, et toutes les 5 minutes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.5",
|
||||||
|
"date": "2026-07-20",
|
||||||
|
"en": [
|
||||||
|
"New FlexRadio CWX CW keyer: key CW straight over the SmartSDR connection — no WinKeyer or SmartCAT needed. Type-ahead and mid-send backspace supported.",
|
||||||
|
"ESC now stops CW on whichever keyer is active (WinKeyer / Icom / Flex), and typing a callsign aborts a running macro.",
|
||||||
|
"New Amplifier settings section (was 'Power Genius'): control SPE Expert amps (1.3K / 1.5K / 2K) over USB serial or an RS232-to-Ethernet bridge — OPERATE/STANDBY and live status. PowerGenius XL still supported.",
|
||||||
|
"Denkovi USB relay boards supported (4/8 relays, FT245), plus generic CH340/LCUS USB-serial relay boards.",
|
||||||
|
"Rename your database from Settings without losing any configuration.",
|
||||||
|
"Awards: DDFM now finds the French department from the address postal code; refs are stored on each QSO for faster columns; 'Publish to catalog' shares an edited award with your team on the next release; award columns are remembered per profile.",
|
||||||
|
"Statistics: per-band mode split (CW / phone / data), and an activity chart with a Day / Week / Month / Year selector (rolling, real dates).",
|
||||||
|
"'Stations on air' widget updates within seconds and fits more stations.",
|
||||||
|
"The audio CW decoder was removed.",
|
||||||
|
"Fixes: Antenna Genius buttons ignored clicks while a macro was sending; File → Exit; and more.",
|
||||||
|
"This 'What's new' summary now appears on the first launch after each update."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Nouveau keyer CW FlexRadio CWX : manipulation CW directement via la connexion SmartSDR — plus besoin de WinKeyer ni de SmartCAT. Type-ahead et retour arrière en cours d'envoi supportés.",
|
||||||
|
"ESC arrête maintenant le CW sur le moteur actif (WinKeyer / Icom / Flex), et taper un indicatif interrompt une macro en cours.",
|
||||||
|
"Nouvelle section Amplificateur (ex « Power Genius ») : pilotage des SPE Expert (1.3K / 1.5K / 2K) en USB série ou via un pont RS232→Ethernet — OPERATE/STANDBY et état en direct. PowerGenius XL toujours supporté.",
|
||||||
|
"Cartes relais USB Denkovi (4/8 relais, FT245), plus cartes USB-série génériques CH340/LCUS.",
|
||||||
|
"Renommer sa base depuis les réglages sans perdre la configuration.",
|
||||||
|
"Awards : DDFM trouve le département français depuis le code postal de l'adresse ; les réfs sont stockées sur chaque QSO (colonnes plus rapides) ; « Publier au catalogue » partage un award édité avec l'équipe à la prochaine mise à jour ; les colonnes d'award sont mémorisées par profil.",
|
||||||
|
"Statistiques : répartition des modes par bande (CW / phone / data), et un graphe d'activité avec sélecteur Jour / Semaine / Mois / Année (glissant, dates réelles).",
|
||||||
|
"Le widget « Stations on air » se met à jour en quelques secondes et affiche plus de stations.",
|
||||||
|
"Le décodeur CW audio a été retiré.",
|
||||||
|
"Corrections : les boutons Antenna Genius ignoraient les clics pendant l'envoi d'une macro ; Fichier → Quitter ; et d'autres.",
|
||||||
|
"Ce résumé « Nouveautés » s'affiche désormais au premier lancement après chaque mise à jour."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
+1721
-247
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,13 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [rules, setRules] = useState<Rule[]>([]);
|
const [rules, setRules] = useState<Rule[]>([]);
|
||||||
const [draft, setDraft] = useState<Rule | null>(null);
|
const [draft, setDraft] = useState<Rule | null>(null);
|
||||||
|
// Raw text of the callsigns box, kept separately from draft.calls. The list is
|
||||||
|
// normalised (trim + drop empties) on the way into the rule, so binding the
|
||||||
|
// textarea straight to calls.join('\n') round-trips every keystroke through
|
||||||
|
// that normalisation — a trailing newline or space is stripped before React
|
||||||
|
// re-renders, and Enter/Space appear to do nothing. Editing the raw text and
|
||||||
|
// deriving the list from it keeps typing intact.
|
||||||
|
const [callsText, setCallsText] = useState('');
|
||||||
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
|
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
|
||||||
const [emailTo, setEmailTo] = useState('');
|
const [emailTo, setEmailTo] = useState('');
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
@@ -80,6 +87,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
}, []);
|
}, []);
|
||||||
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
|
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
|
||||||
|
|
||||||
|
// loadDraft opens a rule in the editor — always through here, so the raw
|
||||||
|
// callsigns text is re-seeded from whatever rule is now being edited.
|
||||||
|
const loadDraft = (r: Rule | null) => {
|
||||||
|
setDraft(r);
|
||||||
|
setCallsText((r?.calls ?? []).join('\n'));
|
||||||
|
};
|
||||||
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
|
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
|
||||||
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
|
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
|
||||||
if (!d) return d;
|
if (!d) return d;
|
||||||
@@ -92,14 +105,14 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
||||||
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
|
try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(saved as Rule); setErr(''); }
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function del() {
|
async function del() {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!draft.id) { setDraft(null); return; }
|
if (!draft.id) { loadDraft(null); return; }
|
||||||
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
||||||
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
|
try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); }
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,12 +129,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
|
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
|
||||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
|
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span>
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { loadDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-1">
|
<div className="flex-1 overflow-y-auto p-1">
|
||||||
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
|
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
|
||||||
{rules.map((r) => (
|
{rules.map((r) => (
|
||||||
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
<button key={r.id} onClick={() => { loadDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
||||||
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
|
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
|
||||||
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
|
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
|
||||||
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
|
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
|
||||||
@@ -187,8 +200,11 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
<Label className="text-xs">{t('altm.callsigns')}</Label>
|
<Label className="text-xs">{t('altm.callsigns')}</Label>
|
||||||
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
|
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
|
||||||
placeholder={'DL1ABC\nIW3*\n*/P'}
|
placeholder={'DL1ABC\nIW3*\n*/P'}
|
||||||
value={(draft.calls ?? []).join('\n')}
|
value={callsText}
|
||||||
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
|
onChange={(e) => {
|
||||||
|
setCallsText(e.target.value);
|
||||||
|
patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) });
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">{t('altm.countries')}</Label>
|
<Label className="text-xs">{t('altm.countries')}</Label>
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Flame, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel,
|
||||||
|
// so Station Control (and anywhere else that needs it) shows the SAME card instead
|
||||||
|
// of a stripped-down variant. It handles all three amp families:
|
||||||
|
// • SPE Expert / ACOM — driven by OpsLog's own serial/TCP link (amp.spe/amp.acom)
|
||||||
|
// • PowerGenius XL — OPERATE + meters come from the Flex (which reports the
|
||||||
|
// amp), fan mode from the direct GSCP link (amp.pgxl)
|
||||||
|
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
|
||||||
|
// can each get their own card.
|
||||||
|
|
||||||
|
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
||||||
|
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
||||||
|
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
||||||
|
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
|
||||||
|
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<button type="button" onClick={toggle}
|
||||||
|
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
|
||||||
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||||
|
</button>
|
||||||
|
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// speMaxW / powerLevelLabel — same helpers the Flex panel uses.
|
||||||
|
function speMaxW(model?: string): number {
|
||||||
|
const m = (model || '').toUpperCase();
|
||||||
|
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||||
|
if (m.includes('15K')) return 1500;
|
||||||
|
return 1300;
|
||||||
|
}
|
||||||
|
function powerLevelLabel(pl?: string): string {
|
||||||
|
switch ((pl || '').trim().toUpperCase()) {
|
||||||
|
case 'L': return 'Low';
|
||||||
|
case 'M': return 'Mid';
|
||||||
|
case 'H': return 'High';
|
||||||
|
default: return pl || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||||
|
|
||||||
|
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
||||||
|
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
||||||
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
|
const peakHold = (key: string, val: number) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const p = peak.current[key];
|
||||||
|
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
|
return p.v;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSPE = !!amp.spe;
|
||||||
|
const isACOM = !!amp.acom;
|
||||||
|
|
||||||
|
if (isSPE) {
|
||||||
|
const spe = amp.spe;
|
||||||
|
return (
|
||||||
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpOperate(amp.id, !spe.operate).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
||||||
|
{(['L', 'M', 'H'] as const).map((lvl, i) => {
|
||||||
|
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
|
||||||
|
return (
|
||||||
|
<button key={lvl} type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||||
|
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
|
||||||
|
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{powerLevelLabel(lvl)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', spe.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', spe.connected ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')}
|
||||||
|
</span>
|
||||||
|
{spe.connected && (
|
||||||
|
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||||
|
{spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{(spe.warnings || spe.alarms) && (
|
||||||
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {spe.warnings} {spe.alarms}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{spe.connected && (
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={Number(spe.output_w) || 0} unit="W"
|
||||||
|
lo={0} hi={speMaxW(spe.model)}
|
||||||
|
display={`${Number(spe.output_w) || 0} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isACOM) {
|
||||||
|
const acom = amp.acom;
|
||||||
|
return (
|
||||||
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!acom.connected}
|
||||||
|
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
acom.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{acom.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
|
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
|
||||||
|
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||||
|
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||||
|
<button type="button" disabled={!acom.connected}
|
||||||
|
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', acom.connected ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{acom.connected ? acom.state : t('flxp.acomOffline')}
|
||||||
|
</span>
|
||||||
|
{acom.connected && (
|
||||||
|
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||||
|
{acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{acom.err_text && (
|
||||||
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {acom.err_text}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{acom.connected && (
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={Number(acom.fwd_w) || 0} unit="W"
|
||||||
|
lo={0} hi={Number(acom.max_w) || 800}
|
||||||
|
display={`${Number(acom.fwd_w) || 0} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link.
|
||||||
|
const pg = amp.pgxl || {};
|
||||||
|
const viaFlex = !!flex?.amp_available;
|
||||||
|
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||||
|
const connected = !!pg.connected || viaFlex;
|
||||||
|
const fault = flex?.amp_fault;
|
||||||
|
return (
|
||||||
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!connected}
|
||||||
|
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||||||
|
</span>
|
||||||
|
{(pg.host || pg.connected) && (
|
||||||
|
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||||
|
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||||
|
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
||||||
|
<select
|
||||||
|
disabled={!pg.connected}
|
||||||
|
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||||
|
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
|
||||||
|
className="h-8 rounded-md border border-warning-border bg-card px-2 text-xs font-semibold text-warning outline-none focus:border-warning disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||||||
|
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||||
|
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{fault && fault !== 'NONE' && (
|
||||||
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */}
|
||||||
|
{viaFlex && (() => {
|
||||||
|
const meters = (flex?.meters as any[]) || [];
|
||||||
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
|
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
|
if (amps.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
|
{amps.map((m) => {
|
||||||
|
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
||||||
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
||||||
|
}
|
||||||
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
|
let lo = m.lo, hi = m.hi;
|
||||||
|
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||||
|
lo = 0;
|
||||||
|
hi = m.hi >= 25 ? m.hi : 25;
|
||||||
|
}
|
||||||
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,37 @@ function pretty(name: string): string {
|
|||||||
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
|
||||||
|
// would be a new component *type* on every render, so React would unmount and
|
||||||
|
// remount every port button each time the panel re-renders — harmless when that's
|
||||||
|
// rare, but with the CW decoder running the parent re-renders many times a second
|
||||||
|
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
|
||||||
|
// different element instances), so antenna changes silently did nothing.
|
||||||
|
function PortBtn({ port, index, active, tx, onActivate, t }: {
|
||||||
|
port: 1 | 2; index: number; active: boolean; tx: boolean;
|
||||||
|
onActivate: (port: number, antenna: number) => void;
|
||||||
|
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
}) {
|
||||||
|
const letter = port === 1 ? 'A' : 'B';
|
||||||
|
const cls = tx
|
||||||
|
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||||
|
: active
|
||||||
|
? (port === 1
|
||||||
|
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||||
|
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onActivate(port, active ? 0 : index)}
|
||||||
|
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||||
|
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
||||||
// match the app's light theme with soft gradients + glows. Each antenna row has
|
// match the app's light theme with soft gradients + glows. Each antenna row has
|
||||||
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||||
@@ -61,27 +92,6 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
|||||||
if (filtered.length > 0) list = filtered;
|
if (filtered.length > 0) list = filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
|
||||||
const letter = port === 1 ? 'A' : 'B';
|
|
||||||
const cls = tx
|
|
||||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
|
||||||
: active
|
|
||||||
? (port === 1
|
|
||||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
|
||||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
|
||||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onActivate(port, active ? 0 : index)}
|
|
||||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
|
||||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
|
||||||
>
|
|
||||||
{letter}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||||
@@ -124,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
|||||||
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||||
return (
|
return (
|
||||||
<div key={a.index} className="flex items-center gap-1.5">
|
<div key={a.index} className="flex items-center gap-1.5">
|
||||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
|
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} onActivate={onActivate} t={t} />
|
||||||
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
||||||
{pretty(a.name)}
|
{pretty(a.name)}
|
||||||
</div>
|
</div>
|
||||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
|
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||||
|
ExportAwardForCatalog,
|
||||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export type AwardDef = {
|
|||||||
or_rules?: AwardOrRule[];
|
or_rules?: AwardOrRule[];
|
||||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||||
total: number; builtin?: boolean;
|
total: number; builtin?: boolean; version?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AwardOrRule = {
|
type AwardOrRule = {
|
||||||
@@ -62,7 +63,7 @@ const CONFIRM_SRC = [
|
|||||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
||||||
];
|
];
|
||||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm'];
|
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||||
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||||
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
|
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
|
||||||
|
|
||||||
@@ -155,6 +156,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [updating, setUpdating] = useState<string | null>(null);
|
const [updating, setUpdating] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
|
// Version to stamp into a "publish for catalog" export — defaults to one past
|
||||||
|
// the selected award's current version whenever the selection changes.
|
||||||
|
const [catVer, setCatVer] = useState('1');
|
||||||
|
|
||||||
// The err banner doubles as a success/notice area (export path, import counts,
|
// The err banner doubles as a success/notice area (export path, import counts,
|
||||||
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
||||||
@@ -212,6 +216,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
const cur = defs[sel];
|
const cur = defs[sel];
|
||||||
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
||||||
|
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
|
||||||
|
|
||||||
// ── Award tester: run the award's rules against a real QSO and show every step.
|
// ── Award tester: run the award's rules against a real QSO and show every step.
|
||||||
type Rejected = { candidate: string; reason: string };
|
type Rejected = { candidate: string; reason: string };
|
||||||
@@ -299,6 +304,19 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||||
|
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||||
|
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||||
|
async function exportForCatalog() {
|
||||||
|
setErr('');
|
||||||
|
if (!cur) return;
|
||||||
|
const v = Math.trunc(Number(catVer));
|
||||||
|
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
|
||||||
|
try {
|
||||||
|
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
|
||||||
|
if (p) setErr(t('awed.catalogExportedTo', { path: p }));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
// Import: LOOK FIRST, then ask.
|
// Import: LOOK FIRST, then ask.
|
||||||
//
|
//
|
||||||
// This used to merge by code with "imported wins", silently — import a WAPC
|
// This used to merge by code with "imported wins", silently — import a WAPC
|
||||||
@@ -352,14 +370,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
<DialogHeader className="px-5 py-3 border-b">
|
<DialogHeader className="px-5 py-3 border-b">
|
||||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
||||||
{/* Left: award list */}
|
{/* Left: award list */}
|
||||||
<div className="border-r flex flex-col min-h-0">
|
<div className="border-r flex flex-col min-w-0 min-h-0">
|
||||||
<div className="p-2 border-b">
|
<div className="p-2 border-b">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||||
@@ -404,8 +422,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: tabbed editor for selected award */}
|
{/* Right: tabbed editor for selected award.
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
min-w-0 is load-bearing: this sits in a `1fr` grid track, and a 1fr
|
||||||
|
track has min-width:auto — it refuses to shrink below its content's
|
||||||
|
intrinsic width, so a wide row (the band/mode chip lists, a long
|
||||||
|
translated label) grew the track and pushed the whole editor out
|
||||||
|
past the dialog instead of scrolling inside it. Same reason on the
|
||||||
|
Tabs below: a flex item defaults to min-width:auto too. */}
|
||||||
|
<div className="flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||||
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
||||||
{/* A fix shipped for an award this operator has customised. We did NOT
|
{/* A fix shipped for an award this operator has customised. We did NOT
|
||||||
apply it — that would destroy their work — so we offer it, and say
|
apply it — that would destroy their work — so we offer it, and say
|
||||||
@@ -441,7 +465,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
{!cur ? (
|
{!cur ? (
|
||||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
||||||
) : (
|
) : (
|
||||||
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
|
<Tabs defaultValue="info" className="flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||||
<TabsList className="px-3 justify-start">
|
<TabsList className="px-3 justify-start">
|
||||||
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
|
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
|
||||||
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||||
@@ -711,7 +735,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
{/* Eight buttons on one unwrappable row fitted in English and overflowed
|
||||||
|
in French, where the labels are half again as long. Wrap instead of
|
||||||
|
widening the dialog; gap-2 replaces the base sm:space-x-2, whose
|
||||||
|
margin-left approach leaves no vertical gap once a row wraps. */}
|
||||||
|
<DialogFooter className="px-5 py-3 border-t !flex-row flex-wrap sm:space-x-0 gap-2">
|
||||||
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
|
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
|
||||||
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||||
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||||
@@ -726,6 +754,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
title={t('awed.awardsFolderTip')}>
|
title={t('awed.awardsFolderTip')}>
|
||||||
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Publish the selected award to the catalog: stamp a version and write a
|
||||||
|
file to paste over internal/award/catalog/<code>.json, so a release
|
||||||
|
ships your change to the whole team. */}
|
||||||
|
{cur && (
|
||||||
|
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
|
||||||
|
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
|
||||||
|
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
|
||||||
|
<Button variant="outline" onClick={exportForCatalog}>
|
||||||
|
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
||||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
||||||
|
|||||||
@@ -216,8 +216,8 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
|
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected ref chip */}
|
{/* Selected ref chip (nothing shown until one is picked) */}
|
||||||
{selectedRef ? (
|
{selectedRef && (
|
||||||
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
|
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
|
||||||
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
||||||
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
|
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
|
||||||
@@ -225,10 +225,6 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
|
|
||||||
{t('awrs.pickReference')}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add — references are always scoped to the contacted DXCC */}
|
{/* Add — references are always scoped to the contacted DXCC */}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AwardEditor } from '@/components/AwardEditor';
|
import { AwardEditor } from '@/components/AwardEditor';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
type BandCount = { band: string; worked: number; confirmed: number };
|
type BandCount = { band: string; worked: number; confirmed: number };
|
||||||
type AwardRef = {
|
type AwardRef = {
|
||||||
@@ -191,17 +192,52 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
return idx.length ? idx : all;
|
return idx.length ? idx : all;
|
||||||
}, [stats, awardBands]);
|
}, [stats, awardBands]);
|
||||||
|
|
||||||
|
// Sort order for the reference table. Reference is the default because that
|
||||||
|
// is how award lists are published and how an operator reads a paper list;
|
||||||
|
// description answers the other question — "have I worked anything in
|
||||||
|
// Alicante" — which reference order scatters across the whole table.
|
||||||
|
// Remembered through writeUiPref rather than localStorage, so the choice
|
||||||
|
// travels with a copied data/ folder like every other UI preference here.
|
||||||
|
const [refSort, setRefSort] = useState<'ref' | 'name'>(
|
||||||
|
() => (localStorage.getItem('opslog.awardRefSort') === 'name' ? 'name' : 'ref'));
|
||||||
|
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
|
||||||
|
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
|
||||||
|
const toggleRefSort = (k: 'ref' | 'name') => {
|
||||||
|
if (k === refSort) {
|
||||||
|
const d = refSortDir === 'asc' ? 'desc' : 'asc';
|
||||||
|
setRefSortDir(d);
|
||||||
|
writeUiPref('opslog.awardRefSortDir', d);
|
||||||
|
} else {
|
||||||
|
setRefSort(k);
|
||||||
|
setRefSortDir('asc');
|
||||||
|
writeUiPref('opslog.awardRefSort', k);
|
||||||
|
writeUiPref('opslog.awardRefSortDir', 'asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredRefs = useMemo(() => {
|
const filteredRefs = useMemo(() => {
|
||||||
if (!current) return [];
|
if (!current) return [];
|
||||||
const q = refSearch.trim().toUpperCase();
|
const q = refSearch.trim().toUpperCase();
|
||||||
return (current.refs ?? []).filter((r) => {
|
const rows = (current.refs ?? []).filter((r) => {
|
||||||
if (refFilter === 'worked' && !r.worked) return false;
|
if (refFilter === 'worked' && !r.worked) return false;
|
||||||
if (refFilter === 'notworked' && r.worked) return false;
|
if (refFilter === 'notworked' && r.worked) return false;
|
||||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||||
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [current, refSearch, refFilter]);
|
const dir = refSortDir === 'asc' ? 1 : -1;
|
||||||
|
return rows.sort((a, b) => {
|
||||||
|
if (refSort === 'name') {
|
||||||
|
// localeCompare, not a byte comparison: these lists are Spanish, French
|
||||||
|
// and Italian place names, where "Ávila" belongs beside "Avilés" rather
|
||||||
|
// than after "Zaragoza".
|
||||||
|
const c = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
|
||||||
|
if (c !== 0) return c * dir;
|
||||||
|
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
|
||||||
|
}
|
||||||
|
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||||
|
});
|
||||||
|
}, [current, refSearch, refFilter, refSort, refSortDir]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0">
|
<div className="flex h-full min-h-0">
|
||||||
@@ -386,8 +422,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="bg-card">
|
<tr className="bg-card">
|
||||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">{t('awp.ref')}</th>
|
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">
|
||||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
|
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.ref')}
|
||||||
|
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.description')}
|
||||||
|
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
{gridBands.map((b) => (
|
{gridBands.map((b) => (
|
||||||
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
||||||
))}
|
))}
|
||||||
@@ -424,8 +470,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-card">
|
<thead className="sticky top-0 bg-card">
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<tr className="text-left text-muted-foreground border-b border-border">
|
||||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.ref')}</th>
|
<th className="py-1.5 pr-2 font-medium w-24">
|
||||||
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
|
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.ref')}
|
||||||
|
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="py-1.5 pr-2 font-medium">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.name')}
|
||||||
|
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
||||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
||||||
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ interface Props {
|
|||||||
// globally from the band-map tab toolbar.
|
// globally from the band-map tab toolbar.
|
||||||
hideDigital?: boolean;
|
hideDigital?: boolean;
|
||||||
fitToBand?: boolean;
|
fitToBand?: boolean;
|
||||||
|
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
||||||
|
// frequency (and tune to it). Only the docked Main-view band map sets this, so
|
||||||
|
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
|
||||||
|
keyNav?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BAND_RANGES: Record<string, [number, number]> = {
|
const BAND_RANGES: Record<string, [number, number]> = {
|
||||||
@@ -60,25 +64,47 @@ const BAND_RANGES: Record<string, [number, number]> = {
|
|||||||
'70cm': [430000, 440000],
|
'70cm': [430000, 440000],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sub-band shading: CW / digital / phone.
|
||||||
|
//
|
||||||
|
// These are IDENTITIES (which mode the segment is for), not states, so they take
|
||||||
|
// categorical hues — never the status tokens. They used to use success / info /
|
||||||
|
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
|
||||||
|
// is "new band" in this very component's legend, so the whole SSB portion read as
|
||||||
|
// a giant "new band" wash. Status colours are reserved for status.
|
||||||
|
//
|
||||||
|
// All three are drawn from the COOL end of the categorical order and laid down at
|
||||||
|
// low opacity, so the band plan stays background context while the warm spot pills
|
||||||
|
// keep the foreground to themselves.
|
||||||
|
// Checked with the palette validator in BOTH themes. Violet was the first pick
|
||||||
|
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
|
||||||
|
// protan reader there, i.e. the same colour. Magenta clears every check on both
|
||||||
|
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
|
||||||
|
const SEG_CW = 'var(--chart-7)'; // magenta
|
||||||
|
const SEG_DIGI = 'var(--chart-1)'; // blue
|
||||||
|
const SEG_PHONE = 'var(--chart-2)'; // aqua
|
||||||
|
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
||||||
|
// are read on top of it.
|
||||||
|
const SEG_OPACITY = 0.13;
|
||||||
|
|
||||||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||||
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
|
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
|
||||||
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
|
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
|
||||||
'60m': [[5350, 5450, 'fill-warning-muted']],
|
'60m': [[5350, 5450, SEG_PHONE]],
|
||||||
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
|
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
|
||||||
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
|
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
|
||||||
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
|
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
|
||||||
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
|
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
|
||||||
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
|
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
|
||||||
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
|
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
|
||||||
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
|
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
|
||||||
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
|
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Small coloured dot + label used in the band-map legend strip.
|
// Small coloured dot + label used in the band-map legend strip.
|
||||||
function LegendDot({ cls, label }: { cls: string; label: string }) {
|
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<span className={cn('size-2 rounded-full', cls)} />
|
<span className={cn("size-2 rounded-full", cls)} style={colour ? { background: colour } : undefined} />
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -153,7 +179,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
|||||||
// last; ties broken by closeness to the rig freq).
|
// last; ties broken by closeness to the rig freq).
|
||||||
const MAX_VISIBLE_SPOTS = 30;
|
const MAX_VISIBLE_SPOTS = 30;
|
||||||
|
|
||||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false }: Props) {
|
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const range = BAND_RANGES[band];
|
const range = BAND_RANGES[band];
|
||||||
const segments = SEGMENT_COLORS[band] ?? [];
|
const segments = SEGMENT_COLORS[band] ?? [];
|
||||||
@@ -362,6 +388,38 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
|
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
||||||
|
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
||||||
|
// Only active on the docked Main-view map (keyNav) and ignored while typing.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyNav) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
|
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||||
|
const ae = document.activeElement as HTMLElement | null;
|
||||||
|
const tag = (ae?.tagName || '').toLowerCase();
|
||||||
|
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
|
||||||
|
const list = spots
|
||||||
|
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.freq_hz - b.freq_hz);
|
||||||
|
if (!list.length) return;
|
||||||
|
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
|
||||||
|
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
|
||||||
|
let target: Spot | undefined;
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
target = list.find((s) => s.freq_hz > cur + EPS);
|
||||||
|
} else {
|
||||||
|
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
|
||||||
|
}
|
||||||
|
if (!target) return;
|
||||||
|
e.preventDefault();
|
||||||
|
onSpotClick(target);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
|
||||||
|
|
||||||
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
|
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
|
||||||
const showRigPointer = currentKHz >= lo && currentKHz <= hi;
|
const showRigPointer = currentKHz >= lo && currentKHz <= hi;
|
||||||
const rigY = freqToY(currentKHz);
|
const rigY = freqToY(currentKHz);
|
||||||
@@ -411,10 +469,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
height={totalH}
|
height={totalH}
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
{segments.map(([s, e, cls], i) => {
|
{segments.map(([s, e, colour], i) => {
|
||||||
const y1 = freqToY(Math.min(e, hi));
|
const y1 = freqToY(Math.min(e, hi));
|
||||||
const y2 = freqToY(Math.max(s, lo));
|
const y2 = freqToY(Math.max(s, lo));
|
||||||
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
|
return (
|
||||||
|
<rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)}
|
||||||
|
fill={colour} fillOpacity={SEG_OPACITY} />
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -523,7 +584,12 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||||
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
||||||
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
|
<LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
|
||||||
|
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
||||||
|
<span className="mx-0.5 opacity-40">|</span>
|
||||||
|
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
||||||
|
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
|
||||||
|
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||||
{t('bmp.footerHint')}
|
{t('bmp.footerHint')}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
const dxcc = wb?.dxcc ?? 0;
|
const dxcc = wb?.dxcc ?? 0;
|
||||||
const dxccName = wb?.dxcc_name ?? '';
|
const dxccName = wb?.dxcc_name ?? '';
|
||||||
const dxccCount = wb?.dxcc_count ?? 0;
|
const dxccCount = wb?.dxcc_count ?? 0;
|
||||||
|
const mwRank = wb?.mw_rank ?? 0; // ClubLog Most Wanted rank (1 = most wanted; 0 = off/unknown)
|
||||||
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
|
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
|
||||||
const hasDxcc = dxcc > 0;
|
const hasDxcc = dxcc > 0;
|
||||||
const newOne = hasDxcc && dxccCount === 0;
|
const newOne = hasDxcc && dxccCount === 0;
|
||||||
@@ -109,14 +110,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
||||||
// Derived straight from the entity's real band_status (all bands it was
|
// Derived straight from the entity's real band_status (all bands it was
|
||||||
// worked on — not just the operator's configured column list).
|
// worked on — not just the operator's configured column list).
|
||||||
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
|
// By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a
|
||||||
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
|
// group, so FT4 after FT8 is genuinely a new mode. The operator can opt into
|
||||||
// lists every real (band, mode) the entity was worked on.
|
// DXCC-style grouping instead (Settings → General), where all digital modes
|
||||||
|
// count as ONE — then FT4 after FT8 is just "worked".
|
||||||
|
const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1';
|
||||||
|
const normMode = (m: string): string => {
|
||||||
|
const u = (m || '').toUpperCase().trim();
|
||||||
|
if (!groupDigital) return u;
|
||||||
|
return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG';
|
||||||
|
};
|
||||||
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
||||||
const curMode = (currentMode || '').toUpperCase().trim();
|
const curMode = normMode(currentMode);
|
||||||
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
||||||
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
|
const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band)
|
||||||
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
|
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === curMode);
|
||||||
// Mutually-exclusive badges, shown only when the entity is worked but this
|
// Mutually-exclusive badges, shown only when the entity is worked but this
|
||||||
// exact band+mode is NOT yet:
|
// exact band+mode is NOT yet:
|
||||||
// New Band & Mode = both the band AND the mode are new for this entity.
|
// New Band & Mode = both the band AND the mode are new for this entity.
|
||||||
@@ -129,6 +137,20 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
const newMode = slotNew && bandWorked && !modeWorked;
|
const newMode = slotNew && bandWorked && !modeWorked;
|
||||||
const newSlot = slotNew && bandWorked && modeWorked;
|
const newSlot = slotNew && bandWorked && modeWorked;
|
||||||
|
|
||||||
|
// ClubLog Most Wanted rank pill (shown next to the entity name when the feature
|
||||||
|
// is on). Hotter colour the more wanted the entity is.
|
||||||
|
const mwBadge = mwRank > 0 ? (
|
||||||
|
<Badge
|
||||||
|
title={`ClubLog Most Wanted #${mwRank}`}
|
||||||
|
className={cn('px-1.5 py-0 text-[10px] font-bold shrink-0',
|
||||||
|
mwRank <= 100 ? 'bg-danger text-danger-foreground'
|
||||||
|
: mwRank <= 250 ? 'bg-warning text-warning-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground')}
|
||||||
|
>
|
||||||
|
MW #{mwRank}
|
||||||
|
</Badge>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -147,12 +169,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
||||||
{' '}· never worked this entity
|
{' '}· never worked this entity
|
||||||
</span>
|
</span>
|
||||||
|
{mwBadge}
|
||||||
</>
|
</>
|
||||||
) : hasDxcc ? (
|
) : hasDxcc ? (
|
||||||
<>
|
<>
|
||||||
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
||||||
{dxccName || `DXCC #${dxcc}`}
|
{dxccName || `DXCC #${dxcc}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{mwBadge}
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
||||||
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type FieldKind = 'status' | 'text';
|
type FieldKind = 'status' | 'text' | 'freq' | 'date';
|
||||||
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
||||||
|
|
||||||
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
||||||
@@ -21,19 +21,36 @@ type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upp
|
|||||||
// label holds an i18n key (resolved with t() at render time).
|
// label holds an i18n key (resolved with t() at render time).
|
||||||
const FIELDS: FieldDef[] = [
|
const FIELDS: FieldDef[] = [
|
||||||
// QSL / upload status
|
// QSL / upload status
|
||||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
// One channel at a time, each with its status and its date, in the same order
|
||||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
// as the QSL Info tab. "Upload" was the wrong word for half of them — a paper
|
||||||
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
// QSL is not uploaded — so every entry now reads "<channel> sent/received
|
||||||
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
// status" or "… date", which is also what the ADIF field is called.
|
||||||
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qsl_sent_date', label: 'bulk.fQslSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
|
|
||||||
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
|
|
||||||
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
||||||
|
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'lotw_rcvd_date', label: 'bulk.fLotwRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'eqsl_sent_date', label: 'bulk.fEqslSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'eqsl_rcvd_date', label: 'bulk.fEqslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'qrz_sent', label: 'bulk.fQrzSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qrz_sent_date', label: 'bulk.fQrzSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'qrz_rcvd', label: 'bulk.fQrzRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
// My station / operator
|
// My station / operator
|
||||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||||
|
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
|
||||||
|
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
||||||
@@ -65,6 +82,7 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
||||||
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
||||||
// Contacted station (location / activation refs / SIG)
|
// Contacted station (location / activation refs / SIG)
|
||||||
|
{ id: 'grid', label: 'bulk.fGrid', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
|
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
|
||||||
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
@@ -74,6 +92,9 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
||||||
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
||||||
// Misc
|
// Misc
|
||||||
|
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
||||||
|
// logged on a stale/default frequency after CAT dropped.
|
||||||
|
{ id: 'freq', label: 'bulk.fFreq', group: 'Misc', kind: 'freq' },
|
||||||
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
||||||
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
||||||
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
|
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
|
||||||
@@ -112,7 +133,7 @@ type Props = {
|
|||||||
// so they become eligible for upload.
|
// so they become eligible for upload.
|
||||||
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [field, setField] = useState('hrdlog_upload');
|
const [field, setField] = useState('qsl_sent');
|
||||||
const [statusValue, setStatusValue] = useState('R');
|
const [statusValue, setStatusValue] = useState('R');
|
||||||
const [textValue, setTextValue] = useState('');
|
const [textValue, setTextValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -176,11 +197,35 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
) : def.kind === 'date' ? (
|
||||||
|
// ADIF stores YYYYMMDD; the picker speaks YYYY-MM-DD. Clearing it
|
||||||
|
// writes an empty value — which is how a wrong date is removed
|
||||||
|
// from a batch, the same meaning as leaving a text field blank.
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-8 text-xs font-mono"
|
||||||
|
value={/^\d{8}$/.test(textValue) ? `${textValue.slice(0, 4)}-${textValue.slice(4, 6)}-${textValue.slice(6, 8)}` : ''}
|
||||||
|
onChange={(e) => setTextValue(e.target.value ? e.target.value.replace(/-/g, '') : '')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button" variant="outline" size="sm" className="h-8 shrink-0 px-2"
|
||||||
|
title={t('bulk.todayUtc')}
|
||||||
|
onClick={() => {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
// UTC: the whole log is UTC, and near midnight the local day
|
||||||
|
// is the wrong one.
|
||||||
|
setTextValue(`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`);
|
||||||
|
}}
|
||||||
|
>{t('bulk.today')}</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
value={textValue}
|
value={textValue}
|
||||||
placeholder={t('bulk.clearPlaceholder')}
|
inputMode={def.kind === 'freq' ? 'decimal' : undefined}
|
||||||
|
placeholder={def.kind === 'freq' ? t('bulk.freqPlaceholder') : t('bulk.clearPlaceholder')}
|
||||||
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -188,7 +233,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
|
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
||||||
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })}
|
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}{def.kind === 'freq' && effectiveValue !== '' ? ' MHz' : ''}</span> {t('bulk.onQsos', { n: ids.length })}
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="text-xs text-danger">{error}</div>}
|
{error && <div className="text-xs text-danger">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
@@ -149,6 +149,16 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
sort: 'desc',
|
sort: 'desc',
|
||||||
|
// Sort by the real arrival timestamp, NOT the "HHMMZ" display string. A
|
||||||
|
// lexical sort of time_utc breaks at the UTC day rollover: "0001Z" sorts
|
||||||
|
// below "2359Z", so spots received just after midnight fell to the bottom
|
||||||
|
// and looked like the cluster had stopped. received_at is a full datetime
|
||||||
|
// that keeps ordering correct across 0000Z.
|
||||||
|
comparator: (_a: any, _b: any, nodeA: any, nodeB: any) => {
|
||||||
|
const ta = Date.parse(nodeA?.data?.received_at ?? '') || 0;
|
||||||
|
const tb = Date.parse(nodeB?.data?.received_at ?? '') || 0;
|
||||||
|
return ta - tb;
|
||||||
|
},
|
||||||
cellStyle: { color: 'var(--muted-foreground)' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -361,10 +371,28 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
|
||||||
|
// matching column events. Without this guard those events were persisted, so a
|
||||||
|
// single language change overwrote the saved cluster layout — in the cache AND
|
||||||
|
// in the database, with nothing to restore from.
|
||||||
|
const restoringRef = useRef(true);
|
||||||
|
|
||||||
|
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => {
|
||||||
|
restoringRef.current = true;
|
||||||
|
return COL_CATALOG.map((c) => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
return { ...rest, hide: !defaultVisible };
|
return { ...rest, hide: !defaultVisible };
|
||||||
}), [COL_CATALOG]);
|
});
|
||||||
|
}, [COL_CATALOG]);
|
||||||
|
|
||||||
|
// Re-apply the saved state after every rebuild, then re-enable saving.
|
||||||
|
useEffect(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
|
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
|
const tm = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
|
return () => window.clearTimeout(tm);
|
||||||
|
}, [columnDefs]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||||
@@ -384,17 +412,20 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
gridRef.current?.api?.refreshCells({ force: true });
|
gridRef.current?.api?.refreshCells({ force: true });
|
||||||
}, [spotStatus]);
|
}, [spotStatus]);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
// Restore AFTER the profile scope is known — this grid has no key= remount to
|
||||||
|
// save it from reading the wrong (unscoped) cache key at first paint.
|
||||||
|
async function onGridReady(e: GridReadyEvent) {
|
||||||
|
await whenGridPrefsReady();
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
const remote = await loadRemote(COL_STATE_KEY);
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||||
seedLocal(COL_STATE_KEY, remote);
|
seedLocal(COL_STATE_KEY, remote);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -306,11 +306,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
heightClass="flex-1 min-h-0"
|
heightClass="flex-1 min-h-0"
|
||||||
/>
|
/>
|
||||||
{detected.length > 0 && (
|
{detected.length > 0 && (
|
||||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
|
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
|
||||||
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
||||||
{detected.map((r) => (
|
{detected.map((r) => (
|
||||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
|
||||||
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Mic, Square, X, Radio } from 'lucide-react';
|
import { Mic, Square, X, Radio, Repeat } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -12,14 +12,20 @@ type Props = {
|
|||||||
onPlay: (slot: number) => void;
|
onPlay: (slot: number) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
autoCq: boolean;
|
||||||
|
autoCqSecs: number;
|
||||||
|
onToggleAutoCq: (on: boolean) => void;
|
||||||
|
onSetAutoCqSecs: (n: number) => void;
|
||||||
|
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
|
||||||
};
|
};
|
||||||
|
|
||||||
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
||||||
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
||||||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const anyAudio = messages.some((m) => m.has_audio);
|
const recorded = messages.filter((m) => m.has_audio);
|
||||||
|
const anyAudio = recorded.length > 0;
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
|
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
||||||
@@ -43,29 +49,54 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
|||||||
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
|
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-1">
|
// Only the recorded slots are shown. Two columns filling top-to-bottom
|
||||||
{messages.map((m) => (
|
// (rows sized to the count) — use the width instead of scrolling, so the
|
||||||
|
// panel keeps the same height as the other reserved-area widgets.
|
||||||
|
<div className="grid grid-flow-col gap-1"
|
||||||
|
style={{ gridTemplateRows: `repeat(${Math.max(1, Math.ceil(recorded.length / 2))}, minmax(0, auto))` }}>
|
||||||
|
{recorded.map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m.slot}
|
key={m.slot}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!m.has_audio}
|
disabled={!phoneOk}
|
||||||
onClick={() => onPlay(m.slot)}
|
onClick={() => onPlay(m.slot)}
|
||||||
title={m.has_audio ? t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) }) : t('dvkp.empty', { slot: m.slot })}
|
title={!phoneOk ? t('dvkp.notPhone') : t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) })}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
||||||
m.has_audio
|
phoneOk
|
||||||
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
|
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
|
||||||
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
|
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
|
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
|
||||||
<span className="text-xs truncate flex-1">{m.label || (m.has_audio ? t('dvkp.message') : '—')}</span>
|
<span className="text-xs truncate flex-1">{m.label || t('dvkp.message')}</span>
|
||||||
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
|
{(m.label || '').toUpperCase().includes('CQ') && autoCq && <Repeat className="size-3 text-primary shrink-0" />}
|
||||||
|
<span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-CQ footer — repeats a CQ-labelled slot on a timer. Greyed out on a
|
||||||
|
non-phone mode (the DVK won't transmit there). */}
|
||||||
|
<div className="shrink-0 border-t border-border bg-muted/30 px-2 py-1.5 flex items-center gap-2">
|
||||||
|
<label className={cn('flex items-center gap-1.5 text-[11px] cursor-pointer', !phoneOk && 'opacity-50')} title={phoneOk ? t('dvkp.autoCqHint') : t('dvkp.notPhone')}>
|
||||||
|
<input type="checkbox" className="accent-primary" checked={autoCq} disabled={!phoneOk}
|
||||||
|
onChange={(e) => onToggleAutoCq(e.target.checked)} />
|
||||||
|
<Repeat className="size-3" /> {t('dvkp.autoCq')}
|
||||||
|
</label>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t('dvkp.gap')}</span>
|
||||||
|
<input type="number" min={0} max={120} value={autoCqSecs}
|
||||||
|
onChange={(e) => onSetAutoCqSecs(parseInt(e.target.value, 10))}
|
||||||
|
className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono text-center" />
|
||||||
|
<span className="text-[10px] text-muted-foreground">s</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!phoneOk && (
|
||||||
|
<div className="shrink-0 bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center">{t('dvkp.notPhone')}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { LogUIError } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// A render error used to empty the window and leave nothing behind: no line in
|
||||||
|
// opslog.log, no dialog, no way for the operator to say more than "white screen".
|
||||||
|
// Two separate reports — one after logging a 10 GHz QSO, one on starting a DVK
|
||||||
|
// auto-call — stayed undiagnosed for weeks for exactly that reason.
|
||||||
|
//
|
||||||
|
// So this does two things, in order of importance:
|
||||||
|
//
|
||||||
|
// 1. Writes the error and its stack to the app log through LogUIError, so the
|
||||||
|
// next report arrives WITH the cause instead of a description of a blank
|
||||||
|
// screen.
|
||||||
|
// 2. Shows it, with the text selectable and a button to copy it, because the
|
||||||
|
// person who can act on it is the one looking at the screen.
|
||||||
|
//
|
||||||
|
// It deliberately does NOT try to recover the tree. React cannot guarantee the
|
||||||
|
// state is sane after a render throw, and a half-working logger is worse than
|
||||||
|
// one that says plainly what happened and offers a reload.
|
||||||
|
|
||||||
|
type Props = { children: React.ReactNode };
|
||||||
|
type State = { error: Error | null; info: string };
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<Props, State> {
|
||||||
|
state: State = { error: null, info: '' };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
const stack = (info?.componentStack || error.stack || '').trim();
|
||||||
|
this.setState({ info: stack });
|
||||||
|
// Never let the reporting path throw: it runs while the app is already broken.
|
||||||
|
try {
|
||||||
|
LogUIError('render crash', `${error.name}: ${error.message}`, stack);
|
||||||
|
} catch {
|
||||||
|
/* the backend may be gone too — the on-screen copy still works */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { error, info } = this.state;
|
||||||
|
if (!error) return this.props.children;
|
||||||
|
|
||||||
|
const report = `${error.name}: ${error.message}\n\n${info}`;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 24, background: '#0b1220', color: '#e5e7eb', fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||||
|
overflow: 'auto', zIndex: 9999,
|
||||||
|
}}>
|
||||||
|
<div style={{ maxWidth: 820, width: '100%' }}>
|
||||||
|
<h1 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px' }}>
|
||||||
|
OpsLog hit an error and stopped drawing
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, opacity: 0.8, margin: '0 0 14px' }}>
|
||||||
|
Your log is safe — this is the window, not the database. The details below
|
||||||
|
are already in the app log; send them with your report.
|
||||||
|
</p>
|
||||||
|
<pre style={{
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word', userSelect: 'text',
|
||||||
|
fontSize: 11.5, lineHeight: 1.5, background: '#111827', border: '1px solid #1f2937',
|
||||||
|
borderRadius: 8, padding: 12, maxHeight: '48vh', overflow: 'auto', margin: 0,
|
||||||
|
}}>{report}</pre>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { void navigator.clipboard?.writeText(report); }}
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px', borderRadius: 6, border: '1px solid #374151',
|
||||||
|
background: '#1f2937', color: '#e5e7eb', fontSize: 13, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
Copy the details
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px', borderRadius: 6, border: '1px solid #1d4ed8',
|
||||||
|
background: '#2563eb', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
Reload the window
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// installGlobalErrorLogging catches what a boundary cannot: an error thrown from
|
||||||
|
// a timer, an event handler or a rejected promise. Those do not unmount the tree,
|
||||||
|
// so they leave no trace at all — and an auto-call loop lives entirely in
|
||||||
|
// callbacks, which is precisely where a white screen was reported.
|
||||||
|
export function installGlobalErrorLogging() {
|
||||||
|
const report = (kind: string, message: string, stack?: string) => {
|
||||||
|
try {
|
||||||
|
LogUIError(kind, message, stack || '');
|
||||||
|
} catch {
|
||||||
|
/* nothing more we can do from here */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('error', (e: ErrorEvent) => {
|
||||||
|
const src = e.filename ? ` (${e.filename}:${e.lineno}:${e.colno})` : '';
|
||||||
|
report('uncaught error', (e.message || 'unknown error') + src, e.error?.stack);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||||
|
const r: any = e.reason;
|
||||||
|
report('unhandled rejection', String(r?.message ?? r ?? 'unknown'), r?.stack);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { ADIFFields, ADIFExtraFields } from '@/../wailsjs/go/main/App';
|
||||||
|
import { adif } from '@/../wailsjs/go/models';
|
||||||
|
|
||||||
|
type FieldDef = adif.FieldDef;
|
||||||
|
const PREF_KEY = 'opslog.exportFields';
|
||||||
|
|
||||||
|
// One category card with its checkboxes + All/None. Defined at MODULE scope (not
|
||||||
|
// inside ExportFieldsDialog) so its component identity is stable across renders —
|
||||||
|
// an inner component is re-created every render, remounting the whole subtree and
|
||||||
|
// making the All/None buttons and checkboxes feel dead.
|
||||||
|
function GroupCard({ title, tags, warn, sel, allLabel, noneLabel, onAll, onNone, onToggle }: {
|
||||||
|
title: string; tags: string[]; warn?: boolean; sel: Set<string>;
|
||||||
|
allLabel: string; noneLabel: string;
|
||||||
|
onAll: (tags: string[]) => void; onNone: (tags: string[]) => void; onToggle: (name: string, on: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}>
|
||||||
|
<div className="flex items-center justify-between mb-1 gap-2">
|
||||||
|
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span>
|
||||||
|
<span className="flex gap-1.5 shrink-0">
|
||||||
|
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => onAll(tags)}>{allLabel}</button>
|
||||||
|
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => onNone(tags)}>{noneLabel}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tags.map((name) => (
|
||||||
|
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
|
||||||
|
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => onToggle(name, !!c)} />
|
||||||
|
<span className="font-mono break-all">{name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportFieldsDialog lets the operator pick exactly which ADIF fields an export
|
||||||
|
// writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category)
|
||||||
|
// and the OpsLog / non-standard tags actually present in the log's extras. The
|
||||||
|
// chosen set is returned to the parent (which runs the selected/global export)
|
||||||
|
// and persisted so it's the default next time.
|
||||||
|
export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
||||||
|
open: boolean;
|
||||||
|
count: number; // how many QSOs the pending export covers
|
||||||
|
onExport: (fields: string[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [official, setOfficial] = useState<FieldDef[]>([]);
|
||||||
|
const [extras, setExtras] = useState<string[]>([]);
|
||||||
|
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const defaultSet = (flds: FieldDef[]) =>
|
||||||
|
new Set(flds.filter((d) => d.promoted && !d.deprecated).map((d) => d.name));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const [f, e] = await Promise.all([ADIFFields(), ADIFExtraFields()]);
|
||||||
|
if (cancelled) return;
|
||||||
|
const flds = (f ?? []) as FieldDef[];
|
||||||
|
setOfficial(flds);
|
||||||
|
setExtras((e ?? []) as string[]);
|
||||||
|
let saved: string[] | null = null;
|
||||||
|
try { const raw = localStorage.getItem(PREF_KEY); if (raw) saved = JSON.parse(raw); } catch { /* ignore */ }
|
||||||
|
setSel(saved && saved.length ? new Set(saved.map((x) => x.toUpperCase())) : defaultSet(flds));
|
||||||
|
})().catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Official fields grouped by category (deprecated ones are import-only → hidden).
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const m = new Map<string, FieldDef[]>();
|
||||||
|
for (const d of official) {
|
||||||
|
if (d.deprecated) continue;
|
||||||
|
const g = d.category || 'Misc';
|
||||||
|
(m.get(g) ?? m.set(g, []).get(g)!).push(d);
|
||||||
|
}
|
||||||
|
return [...m.entries()];
|
||||||
|
}, [official]);
|
||||||
|
|
||||||
|
const toggle = (name: string, on: boolean) =>
|
||||||
|
setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; });
|
||||||
|
const setMany = (names: string[], on: boolean) =>
|
||||||
|
setSel((s) => { const n = new Set(s); for (const x of names) { if (on) n.add(x); else n.delete(x); } return n; });
|
||||||
|
|
||||||
|
const doExport = () => {
|
||||||
|
const fields = [...sel];
|
||||||
|
writeUiPref(PREF_KEY, JSON.stringify(fields));
|
||||||
|
onExport(fields);
|
||||||
|
};
|
||||||
|
|
||||||
|
const allLabel = t('exf.all');
|
||||||
|
const noneLabel = t('exf.none');
|
||||||
|
const cardProps = {
|
||||||
|
sel, allLabel, noneLabel,
|
||||||
|
onAll: (tags: string[]) => setMany(tags, true),
|
||||||
|
onNone: (tags: string[]) => setMany(tags, false),
|
||||||
|
onToggle: toggle,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('exf.title')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('exf.desc')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
|
||||||
|
<span>{t('exf.chosen', { n: sel.size })}</span>
|
||||||
|
<Button variant="ghost" size="sm" className="ml-auto h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
|
||||||
|
{t('exf.defaults')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
|
||||||
|
{/* OpsLog / non-standard group first (most relevant to keep or drop). */}
|
||||||
|
{extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn {...cardProps} />}
|
||||||
|
{groups.map(([g, list]) => (
|
||||||
|
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} {...cardProps} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>{t('exf.cancel')}</Button>
|
||||||
|
<Button disabled={sel.size === 0} onClick={doExport}>{t('exf.export', { n: count })}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,9 @@ export interface QueryFilter {
|
|||||||
|
|
||||||
// Curated field catalog. `value` MUST match a column in the backend whitelist
|
// Curated field catalog. `value` MUST match a column in the backend whitelist
|
||||||
// (qso.FilterableFields); `type` only drives which operators/value input we show.
|
// (qso.FilterableFields); `type` only drives which operators/value input we show.
|
||||||
type FieldType = 'text' | 'number' | 'date';
|
// 'date' is an ISO timestamp column (qso_date); 'adifdate' is an ADIF YYYYMMDD
|
||||||
|
// string (the confirmation dates) — picked with a calendar, stored 8-digit.
|
||||||
|
type FieldType = 'text' | 'number' | 'date' | 'adifdate';
|
||||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||||
@@ -57,16 +59,30 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
|
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
|
||||||
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
|
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
|
||||||
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
|
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
|
||||||
|
// Same naming as the bulk editor: each entry says whether it is a STATUS or a
|
||||||
|
// DATE and in which direction, grouped by channel. "Upload" was wrong for half
|
||||||
|
// of them — a paper QSL is not uploaded.
|
||||||
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
|
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
|
||||||
|
{ value: 'qsl_sent_date', label: 'fltb.fQslSentDate', type: 'adifdate' },
|
||||||
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
||||||
|
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
||||||
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
||||||
|
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
|
||||||
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||||
|
{ value: 'lotw_rcvd_date', label: 'fltb.fLotwRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
|
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
|
||||||
|
{ value: 'eqsl_sent_date', label: 'fltb.fEqslSentDate', type: 'adifdate' },
|
||||||
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
|
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
|
||||||
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' },
|
{ value: 'eqsl_rcvd_date', label: 'fltb.fEqslRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' },
|
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzSent', type: 'text' },
|
||||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' },
|
{ value: 'qrzcom_qso_upload_date', label: 'fltb.fQrzSentDate', type: 'adifdate' },
|
||||||
|
{ value: 'qrzcom_qso_download_status', label: 'fltb.fQrzRcvd', type: 'text' },
|
||||||
|
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
||||||
|
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
||||||
|
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
||||||
|
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||||
|
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||||
@@ -112,6 +128,8 @@ const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notem
|
|||||||
|
|
||||||
function opsFor(field: string): { value: FilterOp; label: string }[] {
|
function opsFor(field: string): { value: FilterOp; label: string }[] {
|
||||||
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
|
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
|
||||||
|
// 'adifdate' is stored as a string but sorts chronologically, so it takes the
|
||||||
|
// comparison operators (before/after), not the text ones.
|
||||||
const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
|
const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
|
||||||
return OPS.filter((o) => allow.includes(o.value));
|
return OPS.filter((o) => allow.includes(o.value));
|
||||||
}
|
}
|
||||||
@@ -253,12 +271,23 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
// An ADIF date is picked with a calendar but STORED as the
|
||||||
|
// 8-digit form the column holds, so the comparison stays a
|
||||||
|
// plain string one on both sides.
|
||||||
|
type={fieldType === 'date' || fieldType === 'adifdate' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||||
className="h-8 flex-1 text-xs"
|
className="h-8 flex-1 text-xs"
|
||||||
disabled={!needsValue}
|
disabled={!needsValue}
|
||||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
|
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
|
||||||
value={c.value}
|
// \d, not d: the escape was missing, so the test never matched
|
||||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
// an 8-digit ADIF date and the calendar input was handed
|
||||||
|
// "20260728" — which type=date rejects, showing an empty box
|
||||||
|
// over a value that was really there.
|
||||||
|
value={fieldType === 'adifdate' && /^\d{8}$/.test(c.value)
|
||||||
|
? `${c.value.slice(0, 4)}-${c.value.slice(4, 6)}-${c.value.slice(6, 8)}`
|
||||||
|
: c.value}
|
||||||
|
onChange={(e) => setCond(i, {
|
||||||
|
value: fieldType === 'adifdate' ? e.target.value.replace(/-/g, '') : e.target.value,
|
||||||
|
})}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
|
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
|
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX, ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
GetFlexState, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
FlexMox, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
GetPGXLStatus, PGXLSetFanMode,
|
GetPGXLStatus, PGXLSetFanMode,
|
||||||
|
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||||
|
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
|
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
|
||||||
|
FlexSetSpeexNR, FlexSetSpeexNRLevel, FlexSetRNN, FlexSetANFT, FlexSetNRF, FlexSetNRFLevel, FlexSetTXDAX,
|
||||||
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
||||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||||
@@ -16,6 +20,9 @@ import { EventsOn } from '../../wailsjs/runtime/runtime';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { sMeterRST } from '@/lib/rst';
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
import { TunerCard } from '@/components/TunerCard';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
|
|
||||||
type FlexState = {
|
type FlexState = {
|
||||||
available: boolean; model?: string;
|
available: boolean; model?: string;
|
||||||
@@ -30,6 +37,11 @@ type FlexState = {
|
|||||||
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
|
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
|
||||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||||
wnb: boolean; wnb_level: number;
|
wnb: boolean; wnb_level: number;
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora) — dsp_v4 is true once the radio reports them.
|
||||||
|
lms_nr?: boolean; lms_nr_level?: number; lms_anf?: boolean; lms_anf_level?: number;
|
||||||
|
speex_nr?: boolean; speex_nr_level?: number; rnn?: boolean; anft?: boolean;
|
||||||
|
nrf?: boolean; nrf_level?: number; dsp_v4?: boolean;
|
||||||
|
dax_ch?: number; tx_dax?: boolean;
|
||||||
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
||||||
mode?: string;
|
mode?: string;
|
||||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||||
@@ -113,8 +125,8 @@ function Segmented({ value, options, onChange, disabled }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
|
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
|
||||||
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
function Chip({ on, onClick, label, disabled, accent = 'emerald', title }: {
|
||||||
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
|
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; title?: string;
|
||||||
}) {
|
}) {
|
||||||
const onCls = {
|
const onCls = {
|
||||||
emerald: 'bg-success border-success text-success-foreground',
|
emerald: 'bg-success border-success text-success-foreground',
|
||||||
@@ -123,7 +135,7 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
|||||||
amber: 'bg-warning border-warning text-warning-foreground',
|
amber: 'bg-warning border-warning text-warning-foreground',
|
||||||
}[accent];
|
}[accent];
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onClick} disabled={disabled}
|
<button type="button" onClick={onClick} disabled={disabled} title={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
// min width (not fixed) so a longer label like STONE keeps symmetric
|
// min width (not fixed) so a longer label like STONE keeps symmetric
|
||||||
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
|
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
|
||||||
@@ -134,6 +146,37 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SmartSDR reports the tuner as an enum. It is worth decoding rather than
|
||||||
|
// showing raw: TUNE_FAIL and "never tuned" both leave the radio looking normal,
|
||||||
|
// and the operator transmits into a bad match either way.
|
||||||
|
function atuLabel(status: string | undefined, t: (k: string) => string): string {
|
||||||
|
switch ((status || '').toUpperCase()) {
|
||||||
|
case 'TUNE_IN_PROGRESS': return t('flxp.atuTuning');
|
||||||
|
case 'TUNE_SUCCESSFUL':
|
||||||
|
case 'TUNE_OK': return t('flxp.atuOk');
|
||||||
|
case 'TUNE_FAIL':
|
||||||
|
case 'TUNE_FAIL_BYPASS': return t('flxp.atuFail');
|
||||||
|
case 'TUNE_BYPASS':
|
||||||
|
case 'TUNE_MANUAL_BYPASS': return t('flxp.atuBypassed');
|
||||||
|
case 'TUNE_ABORTED': return t('flxp.atuAborted');
|
||||||
|
case 'TUNE_NOT_STARTED':
|
||||||
|
case 'NONE':
|
||||||
|
case '': return t('flxp.atuIdle');
|
||||||
|
default: return status || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function atuTone(status: string | undefined): string {
|
||||||
|
switch ((status || '').toUpperCase()) {
|
||||||
|
case 'TUNE_SUCCESSFUL':
|
||||||
|
case 'TUNE_OK': return 'text-success';
|
||||||
|
case 'TUNE_FAIL':
|
||||||
|
case 'TUNE_FAIL_BYPASS': return 'text-danger';
|
||||||
|
case 'TUNE_IN_PROGRESS': return 'text-warning';
|
||||||
|
default: return 'text-muted-foreground';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
|
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
|
||||||
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||||||
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
|
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
|
||||||
@@ -211,56 +254,50 @@ function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
|
|||||||
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
||||||
// `display` overrides the numeric readout; `segColor` colours segments by their
|
// `display` overrides the numeric readout; `segColor` colours segments by their
|
||||||
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
||||||
const METER_SEGMENTS = 26;
|
function Card({ icon: Icon, title, accent, children, ckey, open: openProp, onToggle }: {
|
||||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string;
|
||||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
open?: boolean; onToggle?: () => void; // controlled mode — lets sibling cards share one collapse state
|
||||||
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const span = hi - lo;
|
// Collapsible: a chevron in the header hides the body. Uncontrolled by default,
|
||||||
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
// persisting per card (keyed by ckey, falling back to the title); when `open`/
|
||||||
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
// `onToggle` are supplied the parent owns the state (e.g. linked TX/RX cards).
|
||||||
|
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
||||||
|
const [openState, setOpenState] = useState(() => localStorage.getItem(storeKey) !== '0');
|
||||||
|
const controlled = openProp !== undefined;
|
||||||
|
const open = controlled ? openProp : openState;
|
||||||
|
const toggle = controlled
|
||||||
|
? (onToggle ?? (() => {}))
|
||||||
|
: () => setOpenState((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
|
||||||
return (
|
return (
|
||||||
<div onClick={onClick} title={title}
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
<button type="button" onClick={toggle}
|
||||||
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
|
||||||
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||||
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
</button>
|
||||||
{display !== undefined ? display : (
|
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||||
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
|
||||||
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
|
||||||
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
|
||||||
const on = i < lit;
|
|
||||||
const frac = i / METER_SEGMENTS;
|
|
||||||
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
|
||||||
style={on
|
|
||||||
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
|
||||||
: { background: '#cfc6ad', opacity: 0.35 }} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
// speMaxW is the amp's rated output, used as the power-bar full scale. Derived from
|
||||||
return (
|
// the model string the amp reports (13K→1.3kW, 15K→1.5kW, 2K/20K→2kW).
|
||||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
function speMaxW(model?: string): number {
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
const m = (model || '').toUpperCase();
|
||||||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
if (m.includes('15K')) return 1500;
|
||||||
</div>
|
return 1300; // 1.3K-FA default
|
||||||
<div className="p-3 space-y-3">{children}</div>
|
}
|
||||||
</div>
|
|
||||||
);
|
// powerLevelLabel spells out the SPE power-level code (L/M/H) in full.
|
||||||
|
function powerLevelLabel(pl?: string): string {
|
||||||
|
switch ((pl || '').trim().toUpperCase()) {
|
||||||
|
case 'L': return 'Low';
|
||||||
|
case 'M': return 'Mid';
|
||||||
|
case 'H': return 'High';
|
||||||
|
default: return pl || '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
|
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
|
||||||
@@ -268,7 +305,24 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
|
|||||||
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [st, setSt] = useState<FlexState>(ZERO);
|
const [st, setSt] = useState<FlexState>(ZERO);
|
||||||
|
// Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT
|
||||||
|
// block) collapse behind a button so the RECEIVE card doesn't grow tall — only
|
||||||
|
// NB/NR/ANF stay visible by default. Remembered across sessions.
|
||||||
|
const [dspOpen, setDspOpen] = useState<boolean>(() => localStorage.getItem('opslog.flexDspOpen') === '1');
|
||||||
|
const toggleDsp = () => setDspOpen((o) => { const n = !o; localStorage.setItem('opslog.flexDspOpen', n ? '1' : '0'); return n; });
|
||||||
const hold = useRef<Record<string, number>>({});
|
const hold = useRef<Record<string, number>>({});
|
||||||
|
// Keep the host's keyer speed (used for CW-length estimates and the keyer panel)
|
||||||
|
// in step with the radio's ACTUAL CW speed — including when it's changed on the
|
||||||
|
// radio / in SmartSDR, not just via the slider below. Notify only on a real change.
|
||||||
|
const lastCwSpeed = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const w = st.cw_speed;
|
||||||
|
if (typeof w === 'number' && w > 0 && w !== lastCwSpeed.current) {
|
||||||
|
lastCwSpeed.current = w;
|
||||||
|
onCWSpeed?.(w);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [st.cw_speed]);
|
||||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||||
// read steadily instead of jumping every poll.
|
// read steadily instead of jumping every poll.
|
||||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
@@ -310,6 +364,51 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// TRANSMIT + RECEIVE share ONE collapse state (they sit side by side, so folding
|
||||||
|
// one folds the other and keeps the row tidy). Persisted like the other cards.
|
||||||
|
const [txrxOpen, setTxrxOpen] = useState(() => localStorage.getItem('opslog.cardOpen.txrx') !== '0');
|
||||||
|
const toggleTxrx = () => setTxrxOpen((o) => { const n = !o; localStorage.setItem('opslog.cardOpen.txrx', n ? '1' : '0'); return n; });
|
||||||
|
|
||||||
|
// Tuner Genius XL direct connection — its own card in the Flex panel (like PGXL).
|
||||||
|
const [tg, setTg] = useState<TGStatus>({ connected: false });
|
||||||
|
const [tgEnabled, setTgEnabled] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
|
||||||
|
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 500); // fast so meters track TX (see App.tsx)
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops
|
||||||
|
// run two SPEs in parallel). The card shows ONE at a time; the dropdown picks
|
||||||
|
// which, and the choice is remembered per panel.
|
||||||
|
const [ampList, setAmpList] = useState<any[]>([]);
|
||||||
|
const [ampSel, setAmpSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.flex') || '');
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => GetAmpStatuses().then((l: any) => alive && setAmpList((l ?? []) as any[])).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const selAmp = ampList.find((a: any) => a.id === ampSel) ?? ampList[0];
|
||||||
|
const isSPE = !!selAmp?.spe;
|
||||||
|
const isACOM = !!selAmp?.acom;
|
||||||
|
const spe = selAmp?.spe ?? { connected: false };
|
||||||
|
const acom = selAmp?.acom ?? { connected: false };
|
||||||
|
const ampPicker = ampList.length > 1 && selAmp ? (
|
||||||
|
<select value={selAmp.id}
|
||||||
|
onChange={(e) => { setAmpSel(e.target.value); localStorage.setItem('opslog.ampSel.flex', e.target.value); }}
|
||||||
|
title={t('flxp.ampPick')}
|
||||||
|
className="h-8 rounded-md border border-border bg-card px-2 text-xs font-semibold outline-none">
|
||||||
|
{ampList.map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</select>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
||||||
hold.current[key] = Date.now() + 900;
|
hold.current[key] = Date.now() + 900;
|
||||||
setSt((p) => ({ ...p, [key]: val }));
|
setSt((p) => ({ ...p, [key]: val }));
|
||||||
@@ -352,6 +451,9 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
return () => off();
|
return () => off();
|
||||||
}, [st.rit, st.rit_freq]);
|
}, [st.rit, st.rit_freq]);
|
||||||
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
||||||
|
// Phone (voice) modes — MIC / COMP meters only make sense here, so they're
|
||||||
|
// hidden in CW and digital.
|
||||||
|
const isPhone = /\b(SSB|USB|LSB|AM|FM|DFM|NFM)\b/i.test(st.mode || '');
|
||||||
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
||||||
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
||||||
const CW_BW = [100, 200, 300, 400, 500];
|
const CW_BW = [100, 200, 300, 400, 500];
|
||||||
@@ -480,21 +582,22 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
};
|
};
|
||||||
const cur = [
|
const cur = [
|
||||||
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
||||||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
// dBm sits inline next to the S-value (no separate line below) to save height.
|
||||||
|
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={`${s.display} | ${dbm.toFixed(0)} dBm`}
|
||||||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||||
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
|
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
|
||||||
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
||||||
); })(),
|
); })(),
|
||||||
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
||||||
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626" value={w} />
|
||||||
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
|
||||||
); })(),
|
); })(),
|
||||||
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||||
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive).
|
// Mic input level in dBFS — SmartSDR's scale is -40…0 dB. Phone modes only.
|
||||||
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a"
|
isPhone && mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={0} accent="#16a34a"
|
||||||
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
|
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
|
||||||
// Speech compression (dB of gain reduction).
|
// Speech compression — original working meter, only the top of the
|
||||||
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={(comp.hi && comp.hi > 0) ? comp.hi : 25} accent="#0891b2" />,
|
// scale changed from the radio-reported 20 to 25 (SmartSDR's -25 max).
|
||||||
|
isPhone && comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={25} accent="#0891b2" />,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||||||
@@ -511,7 +614,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
{/* TX + RX columns */}
|
{/* TX + RX columns */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
{/* TRANSMIT */}
|
{/* TRANSMIT */}
|
||||||
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626">
|
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626" open={txrxOpen} onToggle={toggleTxrx}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span>
|
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span>
|
||||||
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
|
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
|
||||||
@@ -552,6 +655,33 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ATU — the radio's built-in tuner. Sits under TUNE because that is
|
||||||
|
the order of operations: the tuner needs a carrier to measure
|
||||||
|
against, so TUNE first, then start a tuning cycle. */}
|
||||||
|
<div className="flex items-center gap-2 border-t border-border/60 pt-3">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">ATU</span>
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
title={t('flxp.atuTuneHint')}
|
||||||
|
onClick={() => FlexATUStart().catch(() => {})}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-warning text-warning bg-card hover:bg-warning-muted transition-all disabled:opacity-30">
|
||||||
|
{t('flxp.atuTune')}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
title={t('flxp.atuBypassHint')}
|
||||||
|
onClick={() => FlexATUBypass().catch(() => {})}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-border text-muted-foreground bg-card hover:bg-muted transition-all disabled:opacity-30">
|
||||||
|
{t('flxp.atuBypass')}
|
||||||
|
</button>
|
||||||
|
<Chip on={st.atu_memories} disabled={off} label={t('flxp.atuMem')} accent="cyan"
|
||||||
|
title={t('flxp.atuMemHint')}
|
||||||
|
onClick={() => change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} />
|
||||||
|
{/* The status is the whole point: a tune that FAILED and one that
|
||||||
|
never ran look identical on the radio's own front panel. */}
|
||||||
|
<span className={cn('ml-auto text-[11px] font-mono font-semibold whitespace-nowrap', atuTone(st.atu_status))}>
|
||||||
|
{atuLabel(st.atu_status, t)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{!isCW ? (
|
{!isCW ? (
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
@@ -636,12 +766,24 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
|
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* RIT/XIT live on the TRANSMIT side to balance the two columns — the
|
||||||
|
RECEIVE card grew tall with the v4 DSP rows. */}
|
||||||
|
<div className="space-y-1.5 border-t border-border/60 pt-3">
|
||||||
|
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
|
||||||
|
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
|
||||||
|
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
|
||||||
|
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
|
||||||
|
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
|
||||||
|
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* RECEIVE */}
|
{/* RECEIVE */}
|
||||||
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
|
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2" open={txrxOpen} onToggle={toggleTxrx}>
|
||||||
|
{/* Antenna selection sits at the very top of the RX column. */}
|
||||||
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 pb-3 border-b border-border/60">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
||||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
<span className="text-[10px] text-muted-foreground">RX</span>
|
<span className="text-[10px] text-muted-foreground">RX</span>
|
||||||
@@ -656,21 +798,17 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||||||
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
{/* DAX on/off — SmartSDR's transmit-bar DAX button (TX audio from
|
||||||
|
DAX, e.g. WSJT-X). "transmit set dax=", not the slice channel. */}
|
||||||
|
<button type="button" disabled={off} title={t('flxp.daxHint')}
|
||||||
|
onClick={() => change('tx_dax', !st.tx_dax, () => FlexSetTXDAX(!st.tx_dax))}
|
||||||
|
className={cn('h-7 px-2.5 shrink-0 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-40',
|
||||||
|
st.tx_dax ? 'bg-info text-info-foreground border-info' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
DAX
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* RIT / XIT — on the RECEIVE side: RIT is what you reach for while
|
|
||||||
listening (chasing a station that drifts off your transmit frequency),
|
|
||||||
so it belongs with the other things you touch mid-QSO. Both act on the
|
|
||||||
ACTIVE slice and follow slice focus like every control here. */}
|
|
||||||
<div className="space-y-1.5 pb-3 border-b border-border/60">
|
|
||||||
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
|
|
||||||
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
|
|
||||||
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
|
|
||||||
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
|
|
||||||
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
|
|
||||||
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
||||||
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||||||
@@ -694,13 +832,28 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
{/* Core noise controls stay visible; WNB + the SmartSDR v4 DSP block
|
||||||
|
hide behind the 'DSP' toggle so the card doesn't tower. When any
|
||||||
|
hidden control is ON we badge the toggle so it's not forgotten. */}
|
||||||
|
{(() => {
|
||||||
|
const hiddenOn = st.wnb || (st.dsp_v4 && (!!st.lms_nr || !!st.speex_nr || !!st.nrf || (!isCW && !!st.lms_anf) || !!st.rnn || (!isCW && !!st.anft)));
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-bold text-muted-foreground">{t('flxp.dspNoise')}</span>
|
||||||
|
<button type="button" onClick={toggleDsp} title={t('flxp.dspMore')}
|
||||||
|
className={cn('flex items-center gap-1 h-6 px-2 rounded-md border text-[10px] font-bold uppercase tracking-wide transition-colors',
|
||||||
|
hiddenOn && !dspOpen ? 'bg-primary/15 text-primary border-primary/40' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
<SlidersHorizontal className="size-3" />
|
||||||
|
DSP
|
||||||
|
{hiddenOn && !dspOpen && <span className="ml-0.5 size-1.5 rounded-full bg-primary" />}
|
||||||
|
<ChevronDown className={cn('size-3 transition-transform', dspOpen && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
||||||
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
|
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
|
||||||
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
|
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
|
||||||
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
|
|
||||||
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
|
|
||||||
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
|
|
||||||
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
|
|
||||||
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
||||||
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
||||||
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
||||||
@@ -710,6 +863,58 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
||||||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||||
)}
|
)}
|
||||||
|
{/* Advanced / supplementary DSP — collapsed by default. WNB (wideband
|
||||||
|
noise blanker) plus the SmartSDR v4 block (8000/Aurora): NRL/ANFL
|
||||||
|
(legacy LMS), NRS (spectral subtraction), NRF (NR w/ filter), RNN
|
||||||
|
(AI NR), ANFT (FFT notch). The v4 rows only exist when the radio
|
||||||
|
reports those slice keys (older 6000s never do). */}
|
||||||
|
{dspOpen && (
|
||||||
|
<div className="space-y-3 border-t border-dashed border-border/50 pt-3">
|
||||||
|
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
|
||||||
|
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
|
||||||
|
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
|
||||||
|
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
|
||||||
|
{st.dsp_v4 && (
|
||||||
|
<>
|
||||||
|
<LevelRow label="NRL" on={!!st.lms_nr} disabled={rxOff} value={st.lms_nr_level ?? 0} accent="cyan" sliderAccent="#0e7490"
|
||||||
|
onToggle={() => change('lms_nr', !st.lms_nr, () => FlexSetLMSNR(!st.lms_nr))}
|
||||||
|
onLevel={(v) => change('lms_nr_level', v, () => FlexSetLMSNRLevel(v))} />
|
||||||
|
<LevelRow label="NRS" on={!!st.speex_nr} disabled={rxOff} value={st.speex_nr_level ?? 0} accent="cyan" sliderAccent="#06b6d4"
|
||||||
|
onToggle={() => change('speex_nr', !st.speex_nr, () => FlexSetSpeexNR(!st.speex_nr))}
|
||||||
|
onLevel={(v) => change('speex_nr_level', v, () => FlexSetSpeexNRLevel(v))} />
|
||||||
|
<LevelRow label="NRF" on={!!st.nrf} disabled={rxOff} value={st.nrf_level ?? 0} accent="cyan" sliderAccent="#0369a1"
|
||||||
|
onToggle={() => change('nrf', !st.nrf, () => FlexSetNRF(!st.nrf))}
|
||||||
|
onLevel={(v) => change('nrf_level', v, () => FlexSetNRFLevel(v))} />
|
||||||
|
{/* Notch filters target carriers in voice — hidden in CW like ANF. */}
|
||||||
|
{!isCW && (
|
||||||
|
<LevelRow label="ANFL" on={!!st.lms_anf} disabled={rxOff} value={st.lms_anf_level ?? 0} accent="violet" sliderAccent="#8b5cf6"
|
||||||
|
onToggle={() => change('lms_anf', !st.lms_anf, () => FlexSetLMSANF(!st.lms_anf))}
|
||||||
|
onLevel={(v) => change('lms_anf_level', v, () => FlexSetLMSANFLevel(v))} />
|
||||||
|
)}
|
||||||
|
{/* RNN and ANFT are on/off only — no level in the API. */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground" title={t('flxp.dspV4Hint')}>AI/FFT</span>
|
||||||
|
<button type="button" disabled={rxOff}
|
||||||
|
title={t('flxp.rnnHint')}
|
||||||
|
onClick={() => change('rnn', !st.rnn, () => FlexSetRNN(!st.rnn))}
|
||||||
|
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
|
||||||
|
st.rnn ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
RNN
|
||||||
|
</button>
|
||||||
|
{!isCW && (
|
||||||
|
<button type="button" disabled={rxOff}
|
||||||
|
title={t('flxp.anftHint')}
|
||||||
|
onClick={() => change('anft', !st.anft, () => FlexSetANFT(!st.anft))}
|
||||||
|
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
|
||||||
|
st.anft ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
ANFT
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isCW && (
|
{isCW && (
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
@@ -760,10 +965,118 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
{/* SPE Expert amplifier (serial/TCP) — shown when it's the configured amp.
|
||||||
{st.amp_available && (
|
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
|
||||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
SPE link rather than the Flex amplifier object. */}
|
||||||
|
{isSPE && (
|
||||||
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{ampPicker}
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpOperate(selAmp.id, !spe.operate).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||||
|
</div>
|
||||||
|
{/* Output power level: Low / Mid / High. */}
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
||||||
|
{(['L', 'M', 'H'] as const).map((lvl, i) => {
|
||||||
|
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
|
||||||
|
return (
|
||||||
|
<button key={lvl} type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpPowerLevel(selAmp.id, lvl).catch(() => {})}
|
||||||
|
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
|
||||||
|
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{powerLevelLabel(lvl)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', spe.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', spe.connected ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')}
|
||||||
|
</span>
|
||||||
|
{spe.connected && (
|
||||||
|
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||||
|
{spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{(spe.warnings || spe.alarms) && (
|
||||||
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {spe.warnings} {spe.alarms}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{spe.connected && (
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={Number(spe.output_w) || 0} unit="W"
|
||||||
|
lo={0} hi={speMaxW(spe.model)}
|
||||||
|
display={`${Number(spe.output_w) || 0} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
|
||||||
|
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
|
||||||
|
{isACOM && (
|
||||||
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{ampPicker}
|
||||||
|
<button type="button" disabled={!acom.connected}
|
||||||
|
onClick={() => AmpOperate(selAmp.id, !acom.operate).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
acom.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{acom.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
{/* Power ON is a DTR/RTS pulse — serial only, and it works while the amp
|
||||||
|
is off (the port stays open), so gate on port_open not connected. */}
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
|
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
|
||||||
|
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||||
|
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||||
|
<button type="button" disabled={!acom.connected}
|
||||||
|
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', acom.connected ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{acom.connected ? acom.state : t('flxp.acomOffline')}
|
||||||
|
</span>
|
||||||
|
{acom.connected && (
|
||||||
|
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||||
|
{acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{acom.err_text && (
|
||||||
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {acom.err_text}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{acom.connected && (
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={Number(acom.fwd_w) || 0} unit="W"
|
||||||
|
lo={0} hi={Number(acom.max_w) || 800}
|
||||||
|
display={`${Number(acom.fwd_w) || 0} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* External amplifier (PowerGenius XL) — only when the Flex reports one AND
|
||||||
|
PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides
|
||||||
|
this Flex-reported card so two amps don't both show. */}
|
||||||
|
{st.amp_available && !isSPE && !isACOM && (
|
||||||
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{ampPicker}
|
||||||
<button type="button" disabled={off}
|
<button type="button" disabled={off}
|
||||||
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
||||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
@@ -776,7 +1089,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||||
PowerGenius). The dot shows the direct-connection state; the
|
PowerGenius). The dot shows the direct-connection state; the
|
||||||
selector is disabled until connected (hover it for the error). */}
|
selector is disabled until connected (hover it for the error). */}
|
||||||
{(pg.host || pg.connected) && (
|
{selAmp?.type === 'pgxl' && (pg.host || pg.connected) && (
|
||||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||||
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
||||||
@@ -809,7 +1122,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
{amp.map((m) => {
|
{amp.map((m) => {
|
||||||
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
||||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
||||||
}
|
}
|
||||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
// Drain current (ID): the PGXL reports a full-scale far too small
|
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||||
@@ -822,7 +1135,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
lo = 0;
|
lo = 0;
|
||||||
hi = m.hi >= 25 ? m.hi : 25;
|
hi = m.hi >= 25 ? m.hi : 25;
|
||||||
}
|
}
|
||||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -830,6 +1143,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tuner Genius XL — 4O3A ATU, its own card when enabled (Settings →
|
||||||
|
Tuner Genius). Same card shown in Station Control. */}
|
||||||
|
{tgEnabled && <TunerCard status={tg} t={t} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,6 +73,24 @@ function attOptions(model?: string): { v: string; l: string }[] {
|
|||||||
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
|
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bandOfHz names the amateur band a frequency falls in, so the band row can show
|
||||||
|
// where the rig actually is. The buttons only ever SENT a frequency and carried
|
||||||
|
// no active state at all, so nothing was highlighted whatever the rig reported.
|
||||||
|
// Edges are the ITU/IARU band limits, wide enough to cover regional differences —
|
||||||
|
// out-of-band (transverter IF, general coverage RX) matches nothing, as it should.
|
||||||
|
function bandOfHz(hz?: number): string {
|
||||||
|
if (!hz || hz <= 0) return '';
|
||||||
|
const mhz = hz / 1_000_000;
|
||||||
|
const bands: [string, number, number][] = [
|
||||||
|
['160', 1.8, 2.0], ['80', 3.5, 4.0], ['60', 5.25, 5.45], ['40', 7.0, 7.3],
|
||||||
|
['30', 10.1, 10.15], ['20', 14.0, 14.35], ['17', 18.068, 18.168],
|
||||||
|
['15', 21.0, 21.45], ['12', 24.89, 24.99], ['10', 28.0, 29.7],
|
||||||
|
['6', 50.0, 54.0], ['4', 70.0, 70.5], ['2', 144.0, 148.0], ['70', 430.0, 450.0],
|
||||||
|
];
|
||||||
|
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
||||||
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
||||||
function fmtVFO(hz?: number): string {
|
function fmtVFO(hz?: number): string {
|
||||||
@@ -731,12 +749,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
{/* Band buttons + antenna selection. */}
|
{/* Band buttons + antenna selection. */}
|
||||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||||
<div className="grid grid-cols-5 gap-1.5">
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
{BANDS.map((b) => (
|
{BANDS.map((b) => {
|
||||||
|
const here = bandOfHz(mainHz) === b.l;
|
||||||
|
return (
|
||||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||||||
className="px-1 py-1.5 rounded-md text-[11px] font-bold border border-border bg-card text-foreground hover:bg-muted transition-colors">
|
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
||||||
|
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
here
|
||||||
|
? 'border-primary bg-primary text-primary-foreground shadow-[0_0_8px] shadow-primary/40'
|
||||||
|
: 'border-border bg-card text-foreground hover:bg-muted')}>
|
||||||
{b.l}
|
{b.l}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Row label={t('icmp.antenna')}>
|
<Row label={t('icmp.antenna')}>
|
||||||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { nightPolygon } from '../lib/greyline';
|
||||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
@@ -115,6 +116,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||||
// across restarts. Default on.
|
// across restarts. Default on.
|
||||||
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
||||||
|
|
||||||
|
// Grey line — the day/night terminator with its twilight band. Off by
|
||||||
|
// default: it is a working tool for the operator who wants it, not decoration
|
||||||
|
// for the one who does not.
|
||||||
|
const [greyline, setGreyline] = useState(() => localStorage.getItem('opslog.mapGreyline') === '1');
|
||||||
|
const greylineLayer = useRef<L.LayerGroup | null>(null);
|
||||||
const autoZoomRef = useRef(autoZoom);
|
const autoZoomRef = useRef(autoZoom);
|
||||||
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||||||
|
|
||||||
@@ -152,6 +159,80 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
}, [basemap]);
|
}, [basemap]);
|
||||||
|
|
||||||
|
// Draw the grey line, and keep it moving.
|
||||||
|
//
|
||||||
|
// Redrawn every minute: the terminator travels a quarter of a degree in that
|
||||||
|
// time, which is invisible, but a map left on all day would otherwise be
|
||||||
|
// silently wrong by evening — and the whole point of the display is knowing
|
||||||
|
// where the line is NOW.
|
||||||
|
//
|
||||||
|
// It lives in its own pane below the overlay pane so the path and beam are
|
||||||
|
// never hidden behind the shading, and is non-interactive so it can never
|
||||||
|
// swallow a click meant for the map.
|
||||||
|
useEffect(() => {
|
||||||
|
const m = worldMap.current;
|
||||||
|
if (!m) return;
|
||||||
|
|
||||||
|
if (!greyline) {
|
||||||
|
greylineLayer.current?.remove();
|
||||||
|
greylineLayer.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m.getPane('greyline')) {
|
||||||
|
const pane = m.createPane('greyline');
|
||||||
|
pane.style.zIndex = '350'; // above tiles (200), below overlays (400)
|
||||||
|
pane.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const now = new Date();
|
||||||
|
greylineLayer.current?.remove();
|
||||||
|
const g = L.layerGroup([], { pane: 'greyline' });
|
||||||
|
|
||||||
|
// The rings span −180…+180 once, but this map repeats the world sideways
|
||||||
|
// (worldCopyJump, and zoomed out several copies are on screen at once). A
|
||||||
|
// single ring therefore ends in a hard vertical edge wherever a copy
|
||||||
|
// begins — the shading looked like a rectangle laid over the planet.
|
||||||
|
//
|
||||||
|
// So each ring is drawn again shifted a full turn either way. Three copies
|
||||||
|
// cover every zoom level this map reaches; they cost nothing, being three
|
||||||
|
// polygons on a canvas layer.
|
||||||
|
const COPIES = [-360, 0, 360];
|
||||||
|
const shifted = (ring: [number, number][], by: number): [number, number][] =>
|
||||||
|
by === 0 ? ring : ring.map(([lat, lon]) => [lat, lon + by] as [number, number]);
|
||||||
|
|
||||||
|
// Two bands, drawn dark-to-light so they read as one gradient into night:
|
||||||
|
// full darkness (Sun below the horizon) and civil twilight (down to −6°),
|
||||||
|
// which is the band operators actually mean by "grey line".
|
||||||
|
const nightRing = nightPolygon(now, 0);
|
||||||
|
const twilightRing = nightPolygon(now, -6);
|
||||||
|
// The terminator itself, so the line is readable at a glance rather than
|
||||||
|
// being inferred from where the shading fades. Dropping the last two
|
||||||
|
// points leaves the terminator alone, without the segment that closes the
|
||||||
|
// polygon along the dark pole.
|
||||||
|
const lineRing = nightRing.slice(0, -2);
|
||||||
|
|
||||||
|
for (const by of COPIES) {
|
||||||
|
L.polygon(shifted(twilightRing, by), {
|
||||||
|
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.22, interactive: false,
|
||||||
|
}).addTo(g);
|
||||||
|
L.polygon(shifted(nightRing, by), {
|
||||||
|
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.32, interactive: false,
|
||||||
|
}).addTo(g);
|
||||||
|
L.polyline(shifted(lineRing, by), {
|
||||||
|
pane: 'greyline', color: '#f59e0b', weight: 1, opacity: 0.75, interactive: false,
|
||||||
|
}).addTo(g);
|
||||||
|
}
|
||||||
|
g.addTo(m);
|
||||||
|
greylineLayer.current = g;
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
const id = window.setInterval(draw, 60_000);
|
||||||
|
return () => { window.clearInterval(id); };
|
||||||
|
}, [greyline]);
|
||||||
|
|
||||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wm = worldMap.current, wo = worldOverlay.current;
|
const wm = worldMap.current, wo = worldOverlay.current;
|
||||||
@@ -293,6 +374,21 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
>
|
>
|
||||||
Zoom DX
|
Zoom DX
|
||||||
</button>
|
</button>
|
||||||
|
{/* Grey line toggle, under the zoom button. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const v = !greyline;
|
||||||
|
setGreyline(v);
|
||||||
|
writeUiPref('opslog.mapGreyline', v ? '1' : '0');
|
||||||
|
}}
|
||||||
|
title={greyline ? 'Grey line is ON — day/night terminator and twilight band' : 'Show the grey line (day/night terminator)'}
|
||||||
|
className={`absolute top-9 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||||
|
greyline ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Grey line
|
||||||
|
</button>
|
||||||
{path && (
|
{path && (
|
||||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export type MenuItem =
|
export type MenuItem =
|
||||||
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean }
|
// accent highlights an item that is an OFFER rather than a command (Donate).
|
||||||
|
// Generic on purpose: a hard-coded label test in the renderer would break the
|
||||||
|
// moment the menu is translated.
|
||||||
|
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean; accent?: boolean }
|
||||||
| { type: 'separator' };
|
| { type: 'separator' };
|
||||||
|
|
||||||
export interface Menu {
|
export interface Menu {
|
||||||
@@ -77,6 +80,7 @@ export function Menubar({ menus, onAction }: Props) {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={i}
|
key={i}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
|
className={item.accent ? 'text-warning focus:text-warning font-medium' : undefined}
|
||||||
onSelect={() => onAction(item.action)}
|
onSelect={() => onAction(item.action)}
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// MeterBar is the ONE LED-bar meter used across the FlexRadio panel, the amplifier
|
||||||
|
// cards and the Tuner Genius card, so every meter renders at exactly the same size
|
||||||
|
// (segment count, bar height, padding). Keep it the single source of truth — don't
|
||||||
|
// re-declare a local copy in a panel, or the meters drift out of sync.
|
||||||
|
export const METER_SEGMENTS = 26;
|
||||||
|
|
||||||
|
export function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
||||||
|
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||||
|
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const span = hi - lo;
|
||||||
|
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||||
|
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} title={title}
|
||||||
|
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
||||||
|
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
||||||
|
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
||||||
|
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||||||
|
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
||||||
|
{display !== undefined ? display : (
|
||||||
|
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
||||||
|
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
||||||
|
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
||||||
|
const on = i < lit;
|
||||||
|
const frac = i / METER_SEGMENTS;
|
||||||
|
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
||||||
|
style={on
|
||||||
|
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
||||||
|
: { background: '#cfc6ad', opacity: 0.35 }} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } from 'lucide-react';
|
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
@@ -42,14 +42,28 @@ function fmtTimeOn(s: any): string {
|
|||||||
|
|
||||||
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
||||||
|
|
||||||
|
// The right-click actions of the main Recent QSOs grid (update from cty/QRZ/
|
||||||
|
// Club Log, send to services, recording e-mail, eQSL, delete) — passed down so
|
||||||
|
// the worked-before grid here offers the SAME context menu on logged QSOs.
|
||||||
|
type QSOMenuHandlers = {
|
||||||
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
|
onSendRecording?: (ids: number[]) => void;
|
||||||
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onDelete?: (ids: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onLogged?: () => void;
|
onLogged?: () => void;
|
||||||
countries?: string[];
|
countries?: string[];
|
||||||
bands?: string[];
|
bands?: string[];
|
||||||
modes?: string[];
|
modes?: string[];
|
||||||
|
qsoMenuHandlers?: QSOMenuHandlers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [nets, setNets] = useState<Net[]>([]);
|
const [nets, setNets] = useState<Net[]>([]);
|
||||||
const [selId, setSelId] = useState<string>('');
|
const [selId, setSelId] = useState<string>('');
|
||||||
@@ -61,6 +75,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||||
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
||||||
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
||||||
|
// Programmatic row selection in the on-air grid (auto-advance after logging).
|
||||||
|
const [selectRow, setSelectRow] = useState<{ id: number; seq: number }>({ id: 0, seq: 0 });
|
||||||
|
|
||||||
// Add/edit-contact dialog.
|
// Add/edit-contact dialog.
|
||||||
const [contactOpen, setContactOpen] = useState(false);
|
const [contactOpen, setContactOpen] = useState(false);
|
||||||
@@ -68,6 +84,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
const [looking, setLooking] = useState(false);
|
const [looking, setLooking] = useState(false);
|
||||||
|
|
||||||
const rosterGrid = useRef<any>(null);
|
const rosterGrid = useRef<any>(null);
|
||||||
|
// Cross-grid drag & drop: roster row → on-air grid puts the station on air;
|
||||||
|
// on-air row → roster grid logs it (same as the "Log & end" button). AG-Grid
|
||||||
|
// external drop zones need each grid's api + the OTHER grid's container div.
|
||||||
|
const onAirApi = useRef<any>(null);
|
||||||
|
const onAirWrap = useRef<HTMLDivElement | null>(null);
|
||||||
|
const rosterWrap = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dropZonesDone = useRef({ roster: false, onair: false });
|
||||||
|
// Callbacks live in refs so the drop-zone closures (registered once) always
|
||||||
|
// call the CURRENT activate/deactivate, not a stale first-render one.
|
||||||
|
const activateRef = useRef<(call: string) => void>(() => {});
|
||||||
|
const deactivateRef = useRef<(id?: number) => void>(() => {});
|
||||||
|
const wireDropZones = useCallback(() => {
|
||||||
|
const rApi = rosterGrid.current?.api;
|
||||||
|
if (rApi && onAirWrap.current && !dropZonesDone.current.roster) {
|
||||||
|
dropZonesDone.current.roster = true;
|
||||||
|
rApi.addRowDropZone({
|
||||||
|
getContainer: () => onAirWrap.current!,
|
||||||
|
onDragStop: (p: any) => { const call = p.node?.data?.callsign; if (call) activateRef.current(call); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onAirApi.current && rosterWrap.current && !dropZonesDone.current.onair) {
|
||||||
|
dropZonesDone.current.onair = true;
|
||||||
|
onAirApi.current.addRowDropZone({
|
||||||
|
getContainer: () => rosterWrap.current!,
|
||||||
|
onDragStop: (p: any) => { const id = p.node?.data?.id; if (id != null) deactivateRef.current(id); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Worked-before for the clicked station (see if/when we contacted it before).
|
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||||
const [wbCall, setWbCall] = useState('');
|
const [wbCall, setWbCall] = useState('');
|
||||||
@@ -122,6 +166,53 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
catch { /* ignore */ }
|
catch { /* ignore */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ---- Mic pass order ------------------------------------------------------
|
||||||
|
// The on-air grid shows the stations in the order the mic goes around — NOT
|
||||||
|
// by log time. The order is owned here as a list of draft ids: new check-ins
|
||||||
|
// join at the END of the queue, logged/cancelled ones drop out, and the ⬆⬇
|
||||||
|
// buttons move the selected station. Persisted per net so a panel remount
|
||||||
|
// (tab switch) keeps the round.
|
||||||
|
const [orderIds, setOrderIds] = useState<number[]>([]);
|
||||||
|
const orderKey = openId ? `opslog.netOrder.${openId}` : '';
|
||||||
|
// Load the saved order when a net opens.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderKey) { setOrderIds([]); return; }
|
||||||
|
try { setOrderIds(JSON.parse(localStorage.getItem(orderKey) || '[]')); }
|
||||||
|
catch { setOrderIds([]); }
|
||||||
|
}, [orderKey]);
|
||||||
|
// Reconcile with the live list: keep known ids in their order, append new
|
||||||
|
// ones, drop the gone. Persist.
|
||||||
|
useEffect(() => {
|
||||||
|
setOrderIds((cur) => {
|
||||||
|
const liveIds = active.map((a) => a.id as number).filter((id) => id != null);
|
||||||
|
const liveSet = new Set(liveIds);
|
||||||
|
const kept = cur.filter((id) => liveSet.has(id));
|
||||||
|
const keptSet = new Set(kept);
|
||||||
|
const next = [...kept, ...liveIds.filter((id) => !keptSet.has(id))];
|
||||||
|
if (next.length === cur.length && next.every((v, i) => v === cur[i])) return cur;
|
||||||
|
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [active, orderKey]);
|
||||||
|
const activeOrdered = useMemo(() => {
|
||||||
|
const byId = new Map(active.map((a) => [a.id as number, a]));
|
||||||
|
return orderIds.map((id) => byId.get(id)).filter(Boolean) as QSOForm[];
|
||||||
|
}, [active, orderIds]);
|
||||||
|
// Move the selected on-air station up/down the queue.
|
||||||
|
const moveSelected = useCallback((dir: -1 | 1) => {
|
||||||
|
const id = selectedActiveIds[0];
|
||||||
|
if (id == null) return;
|
||||||
|
setOrderIds((cur) => {
|
||||||
|
const i = cur.indexOf(id);
|
||||||
|
const j = i + dir;
|
||||||
|
if (i < 0 || j < 0 || j >= cur.length) return cur;
|
||||||
|
const next = [...cur];
|
||||||
|
[next[i], next[j]] = [next[j], next[i]];
|
||||||
|
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [selectedActiveIds, orderKey]);
|
||||||
|
|
||||||
useEffect(() => { refreshNets(); }, [refreshNets]);
|
useEffect(() => { refreshNets(); }, [refreshNets]);
|
||||||
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
||||||
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
||||||
@@ -177,10 +268,38 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
// Active → logged (end QSO, removed from session, written to the logbook).
|
// Active → logged (end QSO, removed from session, written to the logbook).
|
||||||
|
// Then auto-select the NEXT station in the mic-pass order, so the operator
|
||||||
|
// can chain "log → log → log" without re-clicking a row each time.
|
||||||
async function deactivate(id?: number) {
|
async function deactivate(id?: number) {
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
|
const next = activeOrdered.find((a) => a.id !== id); // computed BEFORE the list shrinks
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
try {
|
||||||
|
await NetDeactivate(id);
|
||||||
|
await refreshActive();
|
||||||
|
onLogged?.();
|
||||||
|
if (next?.id != null) {
|
||||||
|
setSelectRow((s) => ({ id: next.id as number, seq: s.seq + 1 }));
|
||||||
|
setSelectedActiveIds([next.id as number]);
|
||||||
|
showWorkedBefore(next.callsign, (next as any).dxcc ?? 0);
|
||||||
|
}
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
// Keep the drop-zone closures pointed at the CURRENT handlers.
|
||||||
|
activateRef.current = activate;
|
||||||
|
deactivateRef.current = deactivate;
|
||||||
|
|
||||||
|
// Log EVERYONE still on air (end of net): each draft is written to the logbook
|
||||||
|
// exactly as the single-log button would.
|
||||||
|
async function logAll() {
|
||||||
|
if (active.length === 0) return;
|
||||||
|
if (!window.confirm(t('ncp.logAllConfirm', { n: active.length }))) return;
|
||||||
|
try {
|
||||||
|
for (const a of [...active]) {
|
||||||
|
if (a.id != null) await NetDeactivate(a.id as number);
|
||||||
|
}
|
||||||
|
await refreshActive();
|
||||||
|
onLogged?.();
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); await refreshActive(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
||||||
@@ -232,7 +351,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
// rowDrag: drag a roster station onto the on-air grid to start its QSO.
|
||||||
|
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold', rowDrag: true },
|
||||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||||
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
||||||
@@ -281,11 +401,15 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
{t('ncp.onAirActive')}
|
{t('ncp.onAirActive')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-h-0 flex-1">
|
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
rows={active}
|
rows={activeOrdered}
|
||||||
total={active.length}
|
total={activeOrdered.length}
|
||||||
storageKey="net.onair"
|
storageKey="net.onair"
|
||||||
|
selectRowSignal={selectRow}
|
||||||
|
rowDragCall
|
||||||
|
passOrder
|
||||||
|
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
|
||||||
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||||
onRowSelected={setSelectedActiveIds}
|
onRowSelected={setSelectedActiveIds}
|
||||||
@@ -297,6 +421,22 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
||||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Mic pass order: move the selected station up/down the round. */}
|
||||||
|
<div className="flex items-center gap-0.5 pl-1 border-l border-border/50">
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveUp')}
|
||||||
|
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) <= 0}
|
||||||
|
onClick={() => moveSelected(-1)}>
|
||||||
|
<ChevronUp className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveDown')}
|
||||||
|
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) >= orderIds.length - 1}
|
||||||
|
onClick={() => moveSelected(1)}>
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
|
||||||
|
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -330,6 +470,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
rows={(wb.entries ?? []) as any}
|
rows={(wb.entries ?? []) as any}
|
||||||
total={wb.count}
|
total={wb.count}
|
||||||
storageKey="net.wb"
|
storageKey="net.wb"
|
||||||
|
{...qsoMenuHandlers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -343,7 +484,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
{t('ncp.netUsersRoster')}
|
{t('ncp.netUsersRoster')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div ref={rosterWrap} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
<AgGridReact<Station>
|
<AgGridReact<Station>
|
||||||
ref={rosterGrid}
|
ref={rosterGrid}
|
||||||
@@ -352,6 +493,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
columnDefs={rosterCols}
|
columnDefs={rosterCols}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
|
onGridReady={() => wireDropZones()}
|
||||||
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
|
|||||||
@@ -427,6 +427,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
// drives which QSOs the apply-form below updates; a search selects all.
|
// drives which QSOs the apply-form below updates; a search selects all.
|
||||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
// Its OWN column layout. Without a storageKey this fell back to
|
||||||
|
// the main log's key, so every resize or hidden column here
|
||||||
|
// silently rewrote the Recent QSOs layout, and vice versa.
|
||||||
|
storageKey="qslmgr.paper"
|
||||||
rows={paperRows as any}
|
rows={paperRows as any}
|
||||||
total={paperRows.length}
|
total={paperRows.length}
|
||||||
selectAllSignal={paperSelAllSig}
|
selectAllSignal={paperSelAllSig}
|
||||||
@@ -532,6 +536,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
storageKey="qslmgr.upload"
|
||||||
rows={rows as any}
|
rows={rows as any}
|
||||||
total={rows.length}
|
total={rows.length}
|
||||||
selectAllSignal={uploadSelAllSig}
|
selectAllSignal={uploadSelAllSig}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Props = {
|
|||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
@@ -35,7 +36,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|||||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||||
// which used to dismiss the menu the instant it appeared.)
|
// which used to dismiss the menu the instant it appeared.)
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
@@ -137,6 +138,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onExportSelectedFields && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportSelectedFields(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.exportSelectedFields', { n })}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onExportFiltered && (
|
{onExportFiltered && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||||
import { rstOptions, type RSTLists } from '@/lib/rst';
|
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||||
@@ -37,7 +37,7 @@ function pfxOf(call: string): string {
|
|||||||
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||||
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
||||||
// label holds an i18n key (resolved with t() at render time).
|
// label holds an i18n key (resolved with t() at render time).
|
||||||
const QSL_STATUSES = [
|
const QSL_STATUSES = [
|
||||||
@@ -148,6 +148,56 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdifDateInput — a real date picker over an ADIF date.
|
||||||
|
//
|
||||||
|
// ADIF stores YYYYMMDD; the browser's date input speaks YYYY-MM-DD, so the two
|
||||||
|
// are converted at the edge and the log keeps its ADIF form. The native control
|
||||||
|
// is used on purpose: it brings the OS calendar, the locale's day/month order
|
||||||
|
// and keyboard entry for free, which a hand-rolled popover would have to
|
||||||
|
// reimplement and get wrong.
|
||||||
|
//
|
||||||
|
// A value that is NOT a valid 8-digit date (an old hand-typed entry) is shown in
|
||||||
|
// a plain text box instead, so it stays visible and correctable rather than
|
||||||
|
// silently disappearing behind an empty picker.
|
||||||
|
function AdifDateInput({ value, onChange, disabled }: { value?: string; onChange: (v: string) => void; disabled?: boolean }) {
|
||||||
|
const raw = (value ?? '').trim();
|
||||||
|
const valid = /^\d{8}$/.test(raw);
|
||||||
|
const iso = valid ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : '';
|
||||||
|
const today = () => {
|
||||||
|
const d = new Date();
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
// UTC: every date in the log is UTC, and near midnight the local day is the
|
||||||
|
// wrong one.
|
||||||
|
onChange(`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`);
|
||||||
|
};
|
||||||
|
if (raw !== '' && !valid) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Input value={raw} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="font-mono" />
|
||||||
|
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" onClick={() => onChange('')} title="Clear">×</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={iso}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value; // "" when cleared
|
||||||
|
onChange(v ? v.replace(/-/g, '') : '');
|
||||||
|
}}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" disabled={disabled}
|
||||||
|
onClick={today} title="Today (UTC)">
|
||||||
|
<CalendarDays className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
@@ -280,19 +330,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
setLooking(true);
|
setLooking(true);
|
||||||
setLocalErr('');
|
setLocalErr('');
|
||||||
try {
|
try {
|
||||||
const r: any = await LookupCallsign(call);
|
// FRESH: this fetch is a deliberate click, so it bypasses the cache and
|
||||||
|
// refreshes it. A cached answer from a thinner QRZ subscription (or any
|
||||||
|
// stale row) otherwise stayed for its whole 30-day life and the button
|
||||||
|
// appeared to do nothing.
|
||||||
|
const r: any = await LookupCallsignFresh(call);
|
||||||
|
// The lookup WINS over what is in the record — that is the point of asking
|
||||||
|
// for it. But an EMPTY result must never blank a good value: `??` only
|
||||||
|
// guards against null, and Go marshals an unset string as "", so a QRZ
|
||||||
|
// record with no grid used to wipe the grid that was already there.
|
||||||
|
const keep = (found: any, cur: any) => (found === undefined || found === null || found === '' ? cur : found);
|
||||||
setDraft((d) => ({
|
setDraft((d) => ({
|
||||||
...d,
|
...d,
|
||||||
name: r.name ?? d.name,
|
name: keep(r.name, d.name),
|
||||||
qth: r.qth ?? d.qth,
|
qth: keep(r.qth, d.qth),
|
||||||
address: r.address ?? (d as any).address,
|
address: keep(r.address, (d as any).address),
|
||||||
email: r.email ?? (d as any).email,
|
email: keep(r.email, (d as any).email),
|
||||||
country: r.country ?? d.country,
|
country: keep(r.country, d.country),
|
||||||
grid: r.grid ?? d.grid,
|
grid: keep(r.grid, d.grid),
|
||||||
state: r.state ?? d.state,
|
state: keep(r.state, d.state),
|
||||||
cnty: r.cnty ?? d.cnty,
|
cnty: keep(r.cnty, d.cnty),
|
||||||
cont: r.cont ?? d.cont,
|
cont: keep(r.cont, d.cont),
|
||||||
qsl_via: r.qsl_via ?? d.qsl_via,
|
qsl_via: keep(r.qsl_via, d.qsl_via),
|
||||||
dxcc: r.dxcc || d.dxcc,
|
dxcc: r.dxcc || d.dxcc,
|
||||||
cqz: r.cqz || d.cqz,
|
cqz: r.cqz || d.cqz,
|
||||||
ituz: r.ituz || d.ituz,
|
ituz: r.ituz || d.ituz,
|
||||||
@@ -412,9 +471,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-20"><Label>S</Label>
|
<div className="flex flex-col w-20"><Label>S</Label>
|
||||||
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
||||||
<div className="flex flex-col w-20"><Label>R</Label>
|
<div className="flex flex-col w-20"><Label>R</Label>
|
||||||
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
||||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||||
title={t('qedit.fetchTitle')}>
|
title={t('qedit.fetchTitle')}>
|
||||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||||
@@ -580,8 +639,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||||
: <Input disabled value="—" />}
|
: <Input disabled value="—" />}
|
||||||
</div>
|
</div>
|
||||||
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
|
||||||
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
|
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
|
||||||
{def.via && (
|
{def.via && (
|
||||||
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
|
import { qslStatusCellClass } from '@/lib/qslStatus';
|
||||||
|
import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX } from 'lucide-react';
|
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +17,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
||||||
|
|
||||||
// Register every Community feature once. v32+ requires explicit registration;
|
// Register every Community feature once. v32+ requires explicit registration;
|
||||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||||
@@ -27,9 +30,25 @@ const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34
|
|||||||
type Props = {
|
type Props = {
|
||||||
rows: QSOForm[];
|
rows: QSOForm[];
|
||||||
total: number;
|
total: number;
|
||||||
|
// Operator's CURRENT locator — fallback for the distance column on older QSOs
|
||||||
|
// that carry no my_grid of their own.
|
||||||
|
myGrid?: string;
|
||||||
// Bump this number to programmatically select every row (e.g. after a search
|
// Bump this number to programmatically select every row (e.g. after a search
|
||||||
// in the QSL Manager, where the default is "all selected").
|
// in the QSL Manager, where the default is "all selected").
|
||||||
selectAllSignal?: number;
|
selectAllSignal?: number;
|
||||||
|
// Bump `seq` to programmatically select the single row with this id (e.g. NET
|
||||||
|
// Control auto-advancing to the next on-air station after logging one).
|
||||||
|
selectRowSignal?: { id: number; seq: number };
|
||||||
|
// Show a row-drag handle on the callsign column (NET Control: drag an on-air
|
||||||
|
// row onto the roster to log it). Pair with onGridApi so the parent can
|
||||||
|
// register external drop zones via api.addRowDropZone.
|
||||||
|
rowDragCall?: boolean;
|
||||||
|
// Pass-order mode (NET Control on-air queue): the ROW ORDER GIVEN IN `rows`
|
||||||
|
// is the display order — a pinned "#" column numbers it, and all column
|
||||||
|
// sorting is disabled (including saved sorts) so the queue can't be shuffled
|
||||||
|
// by a header click. The parent owns/reorders the array.
|
||||||
|
passOrder?: boolean;
|
||||||
|
onGridApi?: (api: any) => void;
|
||||||
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||||
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||||
// reuse this grid elsewhere with its OWN independent column config.
|
// reuse this grid elsewhere with its OWN independent column config.
|
||||||
@@ -45,10 +64,15 @@ type Props = {
|
|||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
|
// Reports how many rows the grid shows after its COLUMN filters (the funnel
|
||||||
|
// icons), or null when no column filter is active — so the parent's "Showing X
|
||||||
|
// of Y" can reflect them. Fired on filter change and when the data updates.
|
||||||
|
onFilteredCountChange?: (count: number | null) => void;
|
||||||
// One column per defined award; the cell shows the reference this QSO counts
|
// One column per defined award; the cell shows the reference this QSO counts
|
||||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
awardCols?: { code: string; name: string }[];
|
awardCols?: { code: string; name: string }[];
|
||||||
@@ -63,23 +87,13 @@ function fmtMhzDots(hz?: number): string {
|
|||||||
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both formatters follow the display preference (Settings → General). What is
|
||||||
|
// STORED never changes — see lib/dateFormat.
|
||||||
function fmtDateUTC(s: any): string {
|
function fmtDateUTC(s: any): string {
|
||||||
if (!s) return '';
|
return formatDateTimeUTC(s);
|
||||||
const d = new Date(s);
|
|
||||||
if (isNaN(d.getTime())) return s;
|
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
||||||
}
|
}
|
||||||
function fmtDateOnly(s: any): string {
|
function fmtDateOnly(s: any): string {
|
||||||
if (!s) return '';
|
return formatDateOnly(s);
|
||||||
const t = String(s).trim();
|
|
||||||
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
|
|
||||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
|
||||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
|
||||||
const d = new Date(t);
|
|
||||||
if (isNaN(d.getTime())) return t;
|
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||||
@@ -93,7 +107,26 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
|
|||||||
// hooks can't run at module level, so the component calls this with its own t.
|
// hooks can't run at module level, so the component calls this with its own t.
|
||||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
export const makeColCatalog = (t: TFn): ColEntry[] => [
|
// qsoDistanceKm returns the great-circle distance for one row, in km.
|
||||||
|
//
|
||||||
|
// The QSO's OWN my_grid / my_lat / my_lon come first: a log spans years and
|
||||||
|
// portable outings, so the station the QSO was made from is not necessarily the
|
||||||
|
// one configured today. `myGrid` (the current profile) is only the fallback for
|
||||||
|
// the many older records that carry no my_* fields at all.
|
||||||
|
function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
|
||||||
|
if (!d) return undefined;
|
||||||
|
const here =
|
||||||
|
(d.my_grid && gridToLatLon(d.my_grid)) ||
|
||||||
|
(d.my_lat || d.my_lon ? { lat: d.my_lat || 0, lon: d.my_lon || 0 } : null) ||
|
||||||
|
(myGrid ? gridToLatLon(myGrid) : null);
|
||||||
|
const there =
|
||||||
|
(d.grid && gridToLatLon(d.grid)) ||
|
||||||
|
(d.lat || d.lon ? { lat: d.lat || 0, lon: d.lon || 0 } : null);
|
||||||
|
if (!here || !there) return undefined;
|
||||||
|
return Math.round(pathBetweenLatLon(here, there).distanceShort);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||||
// ── QSO basics ──
|
// ── QSO basics ──
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||||
@@ -128,12 +161,17 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
|
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
|
||||||
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||||
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||||
|
// Derived, not stored: computed from the two locations at display time, like
|
||||||
|
// the cluster grid's own distance column.
|
||||||
|
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
|
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
|
||||||
|
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||||
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
||||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||||
|
|
||||||
// ── QSL ──
|
// ── QSL ──
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 },
|
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 },
|
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
||||||
@@ -141,32 +179,32 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
||||||
|
|
||||||
// ── LoTW ──
|
// ── LoTW ──
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 },
|
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 },
|
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
|
||||||
// ── eQSL ──
|
// ── eQSL ──
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||||
|
|
||||||
// ── Uploads (online logbooks) ──
|
// ── Uploads (online logbooks) ──
|
||||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||||
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
||||||
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
||||||
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
||||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
|
||||||
@@ -245,7 +283,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
|||||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
@@ -253,9 +291,15 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
||||||
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
|
||||||
|
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
// Rebuild the columns when the format changes: the formatters are captured
|
||||||
|
// inside the column definitions, so nothing else would notice.
|
||||||
|
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||||
|
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||||
|
|
||||||
// Right-click: if the clicked row isn't already part of the selection,
|
// Right-click: if the clicked row isn't already part of the selection,
|
||||||
// select just it; then open the bulk-action menu on the whole selection.
|
// select just it; then open the bulk-action menu on the whole selection.
|
||||||
@@ -308,17 +352,66 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
saveState(AWARD_SHOWN_KEY, [...next]);
|
saveState(AWARD_SHOWN_KEY, [...next]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Award-column WIDTHS are persisted separately too, for the same reason as
|
||||||
|
// visibility: award columns are stripped from AG Grid's saved column-state
|
||||||
|
// round-trip, so their width would otherwise reset to the default on reopen.
|
||||||
|
// Stored as an array of { code, width } (code upper-cased); mirrored to the DB.
|
||||||
|
const AWARD_WIDTH_KEY = storageKey ? `hamlog.awardColWidths.${storageKey}` : 'hamlog.awardColWidths';
|
||||||
|
const awardWidthsRef = useRef<Record<string, number>>({});
|
||||||
|
const awardWidthsInit = useRef(false);
|
||||||
|
if (!awardWidthsInit.current) {
|
||||||
|
awardWidthsInit.current = true;
|
||||||
|
for (const it of (loadLocal(AWARD_WIDTH_KEY) ?? []) as any[]) {
|
||||||
|
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fresh machine: hydrate widths from the portable DB copy, seed the cache, and
|
||||||
|
// apply them to any award columns already on screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadLocal(AWARD_WIDTH_KEY)) return;
|
||||||
|
loadRemote(AWARD_WIDTH_KEY).then((remote) => {
|
||||||
|
if (!remote || !remote.length) return;
|
||||||
|
for (const it of remote as any[]) {
|
||||||
|
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
|
||||||
|
}
|
||||||
|
seedLocal(AWARD_WIDTH_KEY, remote);
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (api && awardCols?.length) {
|
||||||
|
const ups = awardCols
|
||||||
|
.map((a) => ({ key: `award_${a.code}`, newWidth: awardWidthsRef.current[a.code.toUpperCase()] }))
|
||||||
|
.filter((u) => !!u.newWidth);
|
||||||
|
if (ups.length) api.setColumnWidths(ups);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
restoringRef.current = true;
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
return { ...rest, hide: !defaultVisible };
|
const col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
|
||||||
|
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
|
||||||
|
col.rowDrag = true;
|
||||||
|
}
|
||||||
|
if (passOrder) {
|
||||||
|
delete (col as any).sort; // e.g. the date column's default 'desc'
|
||||||
|
col.sortable = false;
|
||||||
|
}
|
||||||
|
return col;
|
||||||
});
|
});
|
||||||
|
if (passOrder) {
|
||||||
|
base.unshift({
|
||||||
|
colId: '__order', headerName: '#', width: 46, pinned: 'left',
|
||||||
|
sortable: false, resizable: false, suppressMovable: true, filter: false,
|
||||||
|
cellClass: 'font-mono text-muted-foreground tabular-nums',
|
||||||
|
valueGetter: (p) => (p.node?.rowIndex ?? 0) + 1,
|
||||||
|
} as ColDef<QSOForm>);
|
||||||
|
}
|
||||||
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||||
colId: `award_${a.code}`,
|
colId: `award_${a.code}`,
|
||||||
headerName: a.code,
|
headerName: a.code,
|
||||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||||
width: 110,
|
width: awardWidthsRef.current[a.code.toUpperCase()] ?? 110,
|
||||||
cellClass: 'text-[11px]',
|
cellClass: 'text-[11px]',
|
||||||
// Visibility comes from the persisted award-code set, so a column the user
|
// Visibility comes from the persisted award-code set, so a column the user
|
||||||
// showed reappears on reopen and one they didn't stays hidden.
|
// showed reappears on reopen and one they didn't stays hidden.
|
||||||
@@ -326,7 +419,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
}));
|
}));
|
||||||
return [...base, ...awards];
|
return [...base, ...awards];
|
||||||
}, [awardCols, COL_CATALOG, t, awardShown]);
|
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall, passOrder]);
|
||||||
|
|
||||||
// Enforce award-column visibility via the API after the columns (re)appear —
|
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||||
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||||
@@ -334,57 +427,120 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
if (!api || !awardCols?.length) return;
|
if (!api || !awardCols?.length) return;
|
||||||
|
const widthUps: { key: string; newWidth: number }[] = [];
|
||||||
for (const a of awardCols) {
|
for (const a of awardCols) {
|
||||||
const want = awardShown.has(a.code.toUpperCase());
|
const want = awardShown.has(a.code.toUpperCase());
|
||||||
const col = api.getColumn(`award_${a.code}`);
|
const col = api.getColumn(`award_${a.code}`);
|
||||||
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
||||||
|
// Re-apply the saved width — AG Grid keeps an existing column's width across
|
||||||
|
// a columnDefs rebuild instead of re-reading colDef.width (same quirk as hide).
|
||||||
|
const w = awardWidthsRef.current[a.code.toUpperCase()];
|
||||||
|
if (col && w && Math.round(col.getActualWidth()) !== w) widthUps.push({ key: `award_${a.code}`, newWidth: w });
|
||||||
}
|
}
|
||||||
|
if (widthUps.length) api.setColumnWidths(widthUps);
|
||||||
}, [awardCols, awardShown]);
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
|
// No column filters here, deliberately. This grid holds only the most recent
|
||||||
|
// page of QSOs, so a column filter searches THOSE rows and nothing else — and
|
||||||
|
// an empty result then reads as "the log does not contain it" rather than "it
|
||||||
|
// is not on this page". An operator hunting an imported CONTEST_ID across
|
||||||
|
// 28 000 QSOs found nothing and reasonably concluded the import had dropped it.
|
||||||
|
//
|
||||||
|
// The advanced filter queries the whole logbook and is the honest tool for it.
|
||||||
|
// Removing the filter also gives each header back the width of its menu icon.
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true,
|
sortable: !passOrder,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
filter: true,
|
filter: false,
|
||||||
suppressMovable: false,
|
suppressMovable: false,
|
||||||
}), []);
|
}), [passOrder]);
|
||||||
|
|
||||||
|
// In pass-order mode a saved sort (from before the mode existed, or synced
|
||||||
|
// from elsewhere) must not shuffle the queue — strip sorts when restoring.
|
||||||
|
const sanitizeState = useCallback((state: any[]) => (
|
||||||
|
passOrder ? state.map((s) => ({ ...s, sort: null })) : state
|
||||||
|
), [passOrder]);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
|
onGridApi?.(e.api);
|
||||||
const local = loadLocal(colStateKey);
|
const local = loadLocal(colStateKey);
|
||||||
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: sanitizeState(stripAwardCols(local)) as ColumnState[], applyOrder: true });
|
||||||
// Fall back to the portable DB copy when the local cache is empty
|
// Fall back to the portable DB copy when the local cache is empty
|
||||||
// (fresh machine / after a reinstall), then re-seed the cache.
|
// (fresh machine / after a reinstall), then re-seed the cache.
|
||||||
loadRemote(colStateKey).then((remote) => {
|
loadRemote(colStateKey).then((remote) => {
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: sanitizeState(stripAwardCols(remote)) as ColumnState[], applyOrder: true });
|
||||||
seedLocal(colStateKey, remote);
|
seedLocal(colStateKey, remote);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Report the post-column-filter row count (funnel filters) to the parent, or
|
||||||
|
// null when no column filter is active, so "Showing X of Y" reflects them.
|
||||||
|
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||||
|
const api = e?.api ?? gridRef.current?.api;
|
||||||
|
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
|
||||||
|
// Pass-order "#" is a rowIndex-based valueGetter; AG-Grid keeps the same
|
||||||
|
// row node across a reorder (getRowId by id), so the number is cached and
|
||||||
|
// stale until we force it. Refresh it on every model update (a reorder
|
||||||
|
// fires one). force:true bypasses the value cache.
|
||||||
|
if (passOrder && api?.refreshCells) {
|
||||||
|
api.refreshCells({ columns: ['__order'], force: true });
|
||||||
|
}
|
||||||
|
if (!api || !onFilteredCountChange) return;
|
||||||
|
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||||
|
}, [onFilteredCountChange, passOrder]);
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
if (state) saveState(colStateKey, stripAwardCols(state));
|
if (!state) return;
|
||||||
}, []);
|
saveState(colStateKey, stripAwardCols(state));
|
||||||
|
// Award columns are stripped above, so persist their widths on the side.
|
||||||
|
let changed = false;
|
||||||
|
for (const s of state) {
|
||||||
|
const id = String((s as any)?.colId ?? '');
|
||||||
|
if (id.startsWith('award_') && (s as any).width) {
|
||||||
|
const code = id.slice('award_'.length).toUpperCase();
|
||||||
|
if (awardWidthsRef.current[code] !== (s as any).width) {
|
||||||
|
awardWidthsRef.current[code] = (s as any).width;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
saveState(AWARD_WIDTH_KEY, Object.entries(awardWidthsRef.current).map(([code, width]) => ({ code, width })));
|
||||||
|
}
|
||||||
|
}, [colStateKey, AWARD_WIDTH_KEY]);
|
||||||
|
|
||||||
// The award columns load asynchronously; when they arrive (or change) the
|
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
|
||||||
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
|
// award column (both change the memo → restoringRef flips true at line 316). Each
|
||||||
// default — wiping the user's saved visibility (award columns reappear,
|
// rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
|
||||||
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
|
// saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
|
||||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
// LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
|
||||||
|
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
|
||||||
|
// an award reset the other columns AND left restoringRef stuck true (saving off).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
const local = loadLocal(colStateKey);
|
const local = loadLocal(colStateKey);
|
||||||
if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
if (api && local) api.applyColumnState({ state: sanitizeState(stripAwardCols(local)) as ColumnState[], applyOrder: true });
|
||||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCols]);
|
// Keyed on columnDefs ITSELF, not on the reasons it was rebuilt. Listing the
|
||||||
|
// causes here (awardCols/awardShown) missed the others — switching the UI
|
||||||
|
// language rebuilds the memo through `t`, which set restoringRef and left it
|
||||||
|
// set, so every column change for the rest of the session was silently
|
||||||
|
// discarded and the layout reverted on reload. Any future dependency of the
|
||||||
|
// memo is now covered for free.
|
||||||
|
}, [columnDefs]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
}
|
}
|
||||||
function onSelectionChanged() {
|
function onSelectionChanged() {
|
||||||
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
const api = gridRef.current?.api;
|
||||||
|
const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||||
|
setSelCount(sel.length);
|
||||||
|
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
||||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||||
}
|
}
|
||||||
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||||
@@ -393,6 +549,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
gridRef.current?.api?.selectAll();
|
gridRef.current?.api?.selectAll();
|
||||||
}, [selectAllSignal]);
|
}, [selectAllSignal]);
|
||||||
|
|
||||||
|
// Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
|
||||||
|
// tick so a row-data refresh in the same render settles first.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectRowSignal || selectRowSignal.seq === 0) return;
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
api.deselectAll();
|
||||||
|
api.forEachNode((n: any) => {
|
||||||
|
if (n.data?.id === selectRowSignal.id) n.setSelected(true);
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [selectRowSignal?.seq]);
|
||||||
|
|
||||||
// ── Column picker (visibility) ──
|
// ── Column picker (visibility) ──
|
||||||
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
||||||
// state for "which columns are visible" — AG Grid's column state is the
|
// state for "which columns are visible" — AG Grid's column state is the
|
||||||
@@ -449,6 +620,32 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||||
|
{/* Live selection count — visible without opening the context menu. */}
|
||||||
|
{selCount > 0 && (
|
||||||
|
<span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
|
||||||
|
{t('rqg.selectedCount', { n: selCount })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Select every loaded row that passes the active column filters — so a
|
||||||
|
filtered view can be selected in one click (then send to LoTW, bulk
|
||||||
|
edit, export…). Once everything is selected the same button flips to
|
||||||
|
"Unselect all". */}
|
||||||
|
{(() => {
|
||||||
|
const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||||
|
title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
|
||||||
|
onClick={() => {
|
||||||
|
const api: any = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
if (allSelected) api.deselectAll();
|
||||||
|
else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
|
||||||
|
else api.selectAll('filtered');
|
||||||
|
}}>
|
||||||
|
<ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
title={t('rqg.clearFiltersTitle')}>
|
title={t('rqg.clearFiltersTitle')}>
|
||||||
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
||||||
@@ -467,6 +664,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
onGridReady={onGridReady}
|
onGridReady={onGridReady}
|
||||||
|
onFilterChanged={reportFilteredCount}
|
||||||
|
onModelUpdated={reportFilteredCount}
|
||||||
onColumnResized={saveColumnState}
|
onColumnResized={saveColumnState}
|
||||||
onColumnMoved={saveColumnState}
|
onColumnMoved={saveColumnState}
|
||||||
onColumnPinned={saveColumnState}
|
onColumnPinned={saveColumnState}
|
||||||
@@ -495,6 +694,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
onBulkEdit={onBulkEdit}
|
onBulkEdit={onBulkEdit}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
|
onExportSelectedFields={onExportSelectedFields}
|
||||||
onExportFiltered={onExportFiltered}
|
onExportFiltered={onExportFiltered}
|
||||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||||
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { SpellCheck, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export type ScpResult = { partial?: string[]; nplus1?: string[] };
|
||||||
|
|
||||||
|
// ScpPanel — Super Check Partial + N+1 callsign helper, split in two columns.
|
||||||
|
// Left: master calls that CONTAIN what you've typed (spot/correct a call). Right:
|
||||||
|
// calls one edit away (busted-call check). Clicking a suggestion fills the entry.
|
||||||
|
export function ScpPanel({ result, currentCall, count, onPick, onClose }: {
|
||||||
|
result: ScpResult;
|
||||||
|
currentCall: string;
|
||||||
|
count: number; // master-list size (0 = list not downloaded yet)
|
||||||
|
onPick: (call: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const cur = (currentCall || '').trim().toUpperCase();
|
||||||
|
const partial = result.partial ?? [];
|
||||||
|
const nplus1 = result.nplus1 ?? [];
|
||||||
|
|
||||||
|
const Col = ({ title, tone, calls, empty }: { title: string; tone: string; calls: string[]; empty: string }) => (
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
|
<div className={cn('text-[10px] font-bold uppercase tracking-wider px-1.5 py-1 border-b border-border/50', tone)}>{title}</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-1 space-y-0.5">
|
||||||
|
{calls.length === 0 ? (
|
||||||
|
<div className="text-[10px] text-muted-foreground/70 italic px-1 py-1">{empty}</div>
|
||||||
|
) : calls.map((c) => (
|
||||||
|
<button key={c} type="button" onClick={() => onPick(c)}
|
||||||
|
title={t('scp.fill', { call: c })}
|
||||||
|
className={cn('w-full text-left rounded px-1.5 py-0.5 font-mono text-xs transition-colors',
|
||||||
|
c === cur ? 'bg-success/20 text-success font-bold'
|
||||||
|
: 'hover:bg-primary/15 text-foreground/90')}>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||||
|
<SpellCheck className={cn('size-4', count > 0 ? 'text-primary' : 'text-muted-foreground')} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">{t('scp.title')}</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors" title={t('scp.close')}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{count === 0 ? (
|
||||||
|
<div className="flex-1 min-h-0 flex items-center justify-center text-[11px] text-muted-foreground italic text-center px-3">
|
||||||
|
{t('scp.noList')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 flex divide-x divide-border/60">
|
||||||
|
<Col title={t('scp.partial')} tone="text-primary" calls={partial} empty={t('scp.typeMore')} />
|
||||||
|
<Col title="N+1" tone="text-warning" calls={nplus1} empty={t('scp.none')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -8,20 +8,31 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
|
import { AmpCard } from '@/components/AmpCard';
|
||||||
|
import { TunerCard } from '@/components/TunerCard';
|
||||||
|
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
import {
|
import {
|
||||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
|
GetAmpStatuses, GetFlexState,
|
||||||
|
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||||
|
|
||||||
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] };
|
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; channels?: number; labels: string[] };
|
||||||
type Relay = { number: number; label: string; on: boolean };
|
type Relay = { number: number; label: string; on: boolean };
|
||||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||||
|
|
||||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8 };
|
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
|
||||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' };
|
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
|
||||||
|
|
||||||
|
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
||||||
|
// the generic USB-serial board, whose channel count the user picks.
|
||||||
|
const chanCount = (d: Device): number =>
|
||||||
|
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
|
||||||
|
|
||||||
function blankDevice(): Device {
|
function blankDevice(): Device {
|
||||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||||
@@ -199,9 +210,11 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
|||||||
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{/* Hide 0-length elements — an Ultrabeam reports 6 slots but a
|
{/* The READ_BANDS reply is undocumented and its 16-bit parse picks
|
||||||
3-element beam only uses the first few. */}
|
up structural bytes past the real data (varying by band), which
|
||||||
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => (
|
made 6+ bogus "elements" appear. Ultrabeam beams are 3-element,
|
||||||
|
and ModifyElement addresses elements 0..2 — so show just those. */}
|
||||||
|
{lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||||
@@ -253,7 +266,41 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||||
});
|
});
|
||||||
const dragId = useRef<string | null>(null);
|
const dragId = useRef<string | null>(null);
|
||||||
|
// Max columns per row (persisted). "Auto" fills the window; a number caps the
|
||||||
|
// row so cards wrap onto further lines even when there's horizontal room.
|
||||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||||
|
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
||||||
|
// FlexRadio panel still get the controls. EVERY configured amp gets its own
|
||||||
|
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
|
||||||
|
// FlexRadio meters read live; the Flex state rides along because a PGXL's
|
||||||
|
// OPERATE/meters come from the radio, not the direct GSCP link.
|
||||||
|
const [amps, setAmps] = useState<any[]>([]);
|
||||||
|
const [flexState, setFlexState] = useState<any>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => Promise.all([
|
||||||
|
GetAmpStatuses().catch(() => []),
|
||||||
|
GetFlexState().catch(() => null),
|
||||||
|
]).then(([l, fx]: any[]) => { if (alive) { setAmps((l ?? []) as any[]); setFlexState(fx); } });
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
// Tuner Genius XL (4O3A): a card here too, so operators without a FlexRadio
|
||||||
|
// panel still get the controls. Re-read the enabled flag so it appears/hides
|
||||||
|
// without a restart.
|
||||||
|
const [tg, setTg] = useState<TGStatus>({ connected: false });
|
||||||
|
const [tgEnabled, setTgEnabled] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = async () => {
|
||||||
|
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
|
||||||
|
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 500); // fast so meters track TX (see App.tsx)
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadDevices = useCallback(async () => {
|
const loadDevices = useCallback(async () => {
|
||||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||||
@@ -340,25 +387,26 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||||||
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
|
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 grid grid-cols-2 gap-2">
|
{/* Compact one-line relay buttons at a FIXED width so they don't stretch
|
||||||
|
across the whole card — they wrap to fill the available width instead. */}
|
||||||
|
<div className="p-2 flex flex-wrap gap-1.5">
|
||||||
{relays.map((r) => {
|
{relays.map((r) => {
|
||||||
const key = `${dev.id}:${r.number}`;
|
const key = `${dev.id}:${r.number}`;
|
||||||
const label = r.label || `${t('station.relay')} ${r.number}`;
|
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||||||
return (
|
return (
|
||||||
<button key={r.number} type="button" disabled={!st?.connected}
|
<button key={r.number} type="button" disabled={!st?.connected}
|
||||||
|
title={label}
|
||||||
onClick={() => toggle(dev, r.number, !r.on)}
|
onClick={() => toggle(dev, r.number, !r.on)}
|
||||||
className={cn('flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors disabled:opacity-40',
|
className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
|
||||||
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||||
<span className={cn('flex items-center justify-center size-7 rounded-md shrink-0',
|
<span className={cn('flex items-center justify-center size-5 rounded shrink-0',
|
||||||
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||||
{busy[key] ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
|
{busy[key] ? <Loader2 className="size-3 animate-spin" /> : <Power className="size-3" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0">
|
<span className="flex-1 min-w-0 text-xs font-medium truncate">{label}</span>
|
||||||
<span className="block text-xs font-medium truncate">{label}</span>
|
<span className={cn('text-[9px] font-bold shrink-0', r.on ? 'text-success' : 'text-muted-foreground/50')}>
|
||||||
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
|
|
||||||
{r.on ? t('station.on') : t('station.off')}
|
{r.on ? t('station.on') : t('station.off')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -367,36 +415,38 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const widgets: { id: string; node: React.ReactNode }[] = [];
|
// `wide` cards span the whole dashboard instead of sitting in one masonry
|
||||||
|
// column. The amplifier and tuner carry meter rows and a channel selector that
|
||||||
|
// are unreadable squeezed into a ~430px column — they are the same cards the
|
||||||
|
// FlexRadio panel shows full-width, and they need that room here too.
|
||||||
|
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
||||||
if (rot.enabled) {
|
if (rot.enabled) {
|
||||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||||
}
|
}
|
||||||
if (ant.enabled) {
|
if (ant.enabled) {
|
||||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||||
}
|
}
|
||||||
|
// One card per configured amplifier (identical to the Flex panel's card).
|
||||||
|
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
|
||||||
|
// Tuner Genius XL card (identical to the Flex panel's).
|
||||||
|
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
|
||||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||||
|
|
||||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||||
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||||
const widgetIds = ordered.map((w) => w.id);
|
const widgetIds = ordered.map((w) => w.id);
|
||||||
|
|
||||||
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled;
|
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0 && !tgEnabled;
|
||||||
|
|
||||||
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
|
|
||||||
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
|
|
||||||
// fill however many columns are available.
|
|
||||||
const gridCols: Record<string, string> = {
|
|
||||||
auto: 'sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4',
|
|
||||||
'1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', '4': 'grid-cols-4',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
|
||||||
|
card can sit on a further line even with horizontal room to spare. */}
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||||
{(['auto', '2', '3', '4'] as const).map((c) => (
|
{(['auto', '1', '2', '3', '4'] as const).map((c) => (
|
||||||
<button key={c} type="button"
|
<button key={c} type="button"
|
||||||
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||||||
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
@@ -416,14 +466,31 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn('grid gap-3 items-start', gridCols[cols] ?? gridCols.auto)}>
|
{/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so
|
||||||
|
they pack tightly by height (no ragged gaps under short cards). "Auto"
|
||||||
|
fits as many ~430px columns as the window allows; a fixed count caps the
|
||||||
|
container width to that many columns. Each card has a grip handle (left
|
||||||
|
rail) as the drag initiator (the body is full of buttons). */}
|
||||||
|
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
|
||||||
|
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
|
||||||
{ordered.map((w) => (
|
{ordered.map((w) => (
|
||||||
<div key={w.id} draggable
|
// column-span:all lifts a wide card out of the columns and across the
|
||||||
|
// full container, while keeping it in document order — so drag-reorder
|
||||||
|
// still works between wide and normal cards. Capped so it stays a card
|
||||||
|
// and not a banner on an ultra-wide window.
|
||||||
|
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
|
||||||
|
style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
|
||||||
|
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||||
|
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||||
|
<div draggable
|
||||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
onDragEnd={() => { dragId.current = null; }}
|
||||||
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }}
|
title={t('station.dragMove')}
|
||||||
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}>
|
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
|
||||||
{w.node}
|
dragId.current === w.id && 'opacity-60')}>
|
||||||
|
<GripVertical className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">{w.node}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -445,11 +512,56 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
||||||
const setType = (type: string) => {
|
const setType = (type: string) => {
|
||||||
const n = RELAY_COUNT[type] ?? 5;
|
const channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined;
|
||||||
|
const n = chanCount({ ...device, type, channels });
|
||||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||||
onChange({ ...device, type, labels });
|
onChange({ ...device, type, channels, labels });
|
||||||
|
};
|
||||||
|
const setChannels = (channels: number) => {
|
||||||
|
const n = chanCount({ ...device, channels });
|
||||||
|
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||||
|
onChange({ ...device, channels, labels });
|
||||||
};
|
};
|
||||||
const isKM = device.type === 'kmtronic';
|
const isKM = device.type === 'kmtronic';
|
||||||
|
const isDenkovi = device.type === 'denkovi';
|
||||||
|
const isUsbRelay = device.type === 'usbrelay';
|
||||||
|
// COM ports for the generic USB-serial relay picker.
|
||||||
|
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUsbRelay) return;
|
||||||
|
ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([]));
|
||||||
|
}, [isUsbRelay]);
|
||||||
|
// Detected FTDI serials for the Denkovi picker.
|
||||||
|
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
|
||||||
|
const [detecting, setDetecting] = useState(false);
|
||||||
|
const [detectMsg, setDetectMsg] = useState('');
|
||||||
|
const detectDenkovi = async () => {
|
||||||
|
setDetecting(true);
|
||||||
|
setDetectMsg('');
|
||||||
|
try {
|
||||||
|
const list = ((await ListDenkoviDevices()) ?? []) as string[];
|
||||||
|
setDenkoviSerials(list);
|
||||||
|
// Auto-fill when there's exactly one and nothing chosen yet.
|
||||||
|
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
|
||||||
|
setDetectMsg(list.length === 0 ? t('station.detectNone') : t('station.detectFound', { n: list.length }));
|
||||||
|
} catch (e: any) { setDenkoviSerials([]); setDetectMsg(String(e?.message || e || t('station.detectNone'))); }
|
||||||
|
finally { setDetecting(false); }
|
||||||
|
};
|
||||||
|
// Connection test result for the current device config.
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
const testDevice = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setTestMsg(null);
|
||||||
|
try {
|
||||||
|
const r: any = await TestStationDevice(device as any);
|
||||||
|
setTestMsg(r?.ok
|
||||||
|
? { ok: true, text: t('station.testOk', { n: r.relays }) }
|
||||||
|
: { ok: false, text: r?.error || t('station.testFail') });
|
||||||
|
} catch (e: any) { setTestMsg({ ok: false, text: String(e?.message || e || t('station.testFail')) }); }
|
||||||
|
finally { setTesting(false); }
|
||||||
|
};
|
||||||
|
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
|
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
|
||||||
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
||||||
@@ -461,6 +573,8 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||||
|
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||||||
|
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -469,6 +583,55 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{(isDenkovi || isUsbRelay) && (
|
||||||
|
<div className="space-y-1 max-w-[10rem]">
|
||||||
|
<Label>{t('station.channels')}</Label>
|
||||||
|
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isDenkovi ? (
|
||||||
|
<div className="space-y-1 max-w-md">
|
||||||
|
<Label>{t('station.ftdiSerial')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
|
||||||
|
list="denkovi-serials"
|
||||||
|
onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||||
|
<datalist id="denkovi-serials">
|
||||||
|
{denkoviSerials.map((s) => <option key={s} value={s} />)}
|
||||||
|
</datalist>
|
||||||
|
<Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
|
||||||
|
{detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||||
|
{t('station.detect')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
|
||||||
|
{detectMsg && <p className="text-[11px] font-medium text-muted-foreground">{detectMsg}</p>}
|
||||||
|
</div>
|
||||||
|
) : isUsbRelay ? (
|
||||||
|
<div className="space-y-1 max-w-md">
|
||||||
|
<Label>{t('station.comPort')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||||
|
{serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||||
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||||||
<Label>{t('station.host')}</Label>
|
<Label>{t('station.host')}</Label>
|
||||||
@@ -487,6 +650,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('station.labels')}</Label>
|
<Label>{t('station.labels')}</Label>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
@@ -496,10 +660,22 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{testMsg && (
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{testMsg.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||||||
|
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||||
|
{t('station.test')}
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||||
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,20 @@ import { cn } from '@/lib/utils';
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Bucket = { key: string; count: number };
|
type Bucket = { key: string; count: number };
|
||||||
|
type BandCat = { band: string; cw: number; phone: number; data: number; total: number };
|
||||||
type Gap = { start: string; end: string; minutes: number };
|
type Gap = { start: string; end: string; minutes: number };
|
||||||
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
||||||
type Stats = {
|
type Stats = {
|
||||||
total: number; unique_calls: number; entities: number; continents: number;
|
total: number; unique_calls: number; entities: number; continents: number;
|
||||||
first_qso: string; last_qso: string;
|
first_qso: string; last_qso: string;
|
||||||
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||||
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
|
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||||
|
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
|
||||||
|
// Chronological series across the SELECTED period, one per bucket size; empty
|
||||||
|
// when that bucket would be too fine for the period (see the Go side).
|
||||||
|
by_day_win: Bucket[]; by_week_win: Bucket[]; by_month_win: Bucket[]; by_year_win: Bucket[];
|
||||||
|
by_hour_of_day: Bucket[]; by_weekday: Bucket[];
|
||||||
// Period / contest metrics.
|
// Period / contest metrics.
|
||||||
window_start: string; window_end: string; window_hours: number;
|
window_start: string; window_end: string; window_hours: number;
|
||||||
avg_per_hour: number; avg_per_active: number;
|
avg_per_hour: number; avg_per_active: number;
|
||||||
@@ -52,11 +58,14 @@ const nf = (n: number) => n.toLocaleString('en-US');
|
|||||||
|
|
||||||
// ── Shell ────────────────────────────────────────────────────────────────────
|
// ── Shell ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) {
|
function Card({ title, sub, children, className, accent = 'var(--chart-1)' }: { title: string; sub?: string; children: React.ReactNode; className?: string; accent?: string }) {
|
||||||
return (
|
return (
|
||||||
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
|
<section className={cn('rounded-xl border border-border bg-card p-3.5 flex flex-col min-w-0 shadow-sm', className)}>
|
||||||
<header className="mb-3 shrink-0">
|
<header className="mb-3 shrink-0">
|
||||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
|
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
<span className="size-1.5 rounded-full shrink-0" style={{ background: accent }} />
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
||||||
</header>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
@@ -64,15 +73,23 @@ function Card({ title, sub, children, className }: { title: string; sub?: string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A headline number IS the chart — a one-bar bar chart would be noise.
|
// A headline number IS the chart — a one-bar bar chart would be noise. Each tile
|
||||||
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
// carries an accent (a categorical chart hue or a semantic token): a soft tint
|
||||||
|
// wash + a left accent bar give it a colourful identity, while the number itself
|
||||||
|
// stays in high-contrast foreground ink so it reads on every theme.
|
||||||
|
function StatTile({ label, value, sub, accent = 'var(--chart-1)' }: { label: string; value: string; sub?: string; accent?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
|
<div className="relative overflow-hidden rounded-xl border border-border bg-card px-4 py-3 min-w-0 shadow-sm">
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-[0.08]" style={{ background: accent }} />
|
||||||
|
<div className="pointer-events-none absolute -right-5 -top-7 size-20 rounded-full blur-xl opacity-[0.14]" style={{ background: accent }} />
|
||||||
|
<div className="absolute left-0 inset-y-0 w-1" style={{ background: accent }} />
|
||||||
|
<div className="relative">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
||||||
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
||||||
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
||||||
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +97,7 @@ function StatTile({ label, value, sub }: { label: string; value: string; sub?: s
|
|||||||
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||||
// on a tooltip to get a number.
|
// on a tooltip to get a number.
|
||||||
|
|
||||||
function HBars({ data, max, empty, share, labelWidth = 'w-20' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string }) {
|
function HBars({ data, max, empty, share, labelWidth = 'w-20', colorful, color = 'var(--chart-1)' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string; colorful?: boolean; color?: string }) {
|
||||||
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||||
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
||||||
// silently drop the 9th operator, and "who worked what" is the whole question.
|
// silently drop the 9th operator, and "who worked what" is the whole question.
|
||||||
@@ -90,13 +107,13 @@ function HBars({ data, max, empty, share, labelWidth = 'w-20' }: { data: Bucket[
|
|||||||
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5 min-w-0">
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
{top.map((d) => (
|
{top.map((d, i) => (
|
||||||
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||||
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
|
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
|
||||||
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||||||
<div
|
<div
|
||||||
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||||
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : color }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
||||||
@@ -146,6 +163,47 @@ function VBars({ data, empty, height = 150, showValues, colorful }: { data: Buck
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-band mode split (CW / phone / data) as stacked vertical bars. The three
|
||||||
|
// categories are the subject → categorical colour in a fixed order, matching the
|
||||||
|
// mode colours used elsewhere (CW gold, phone green, data blue).
|
||||||
|
const BAND_SPLIT = [
|
||||||
|
{ key: 'cw', label: 'CW', color: 'var(--chart-3)' },
|
||||||
|
{ key: 'phone', label: 'Phone', color: 'var(--chart-2)' },
|
||||||
|
{ key: 'data', label: 'Data', color: 'var(--chart-1)' },
|
||||||
|
] as const;
|
||||||
|
function StackedBandBars({ data, empty, height = 150 }: { data: BandCat[]; empty: string; height?: number }) {
|
||||||
|
if (!data || data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
const peak = Math.max(1, ...data.map((d) => d.total));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-end gap-[3px] min-w-0" style={{ height }}>
|
||||||
|
{data.map((d) => (
|
||||||
|
<div key={d.band} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||||
|
title={`${d.band} — CW ${nf(d.cw)} · Phone ${nf(d.phone)} · Data ${nf(d.data)} (${nf(d.total)})`}>
|
||||||
|
<span className="text-[9px] font-semibold mb-0.5 tabular-nums text-foreground">{nf(d.total)}</span>
|
||||||
|
<div className="w-full flex flex-col justify-end rounded-t-[4px] overflow-hidden"
|
||||||
|
style={{ height: `${Math.max(2, (d.total / peak) * 100)}%` }}>
|
||||||
|
{BAND_SPLIT.map((s) => {
|
||||||
|
const v = d[s.key];
|
||||||
|
if (!v) return null;
|
||||||
|
return <div key={s.key} style={{ flexGrow: v, flexBasis: 0, minHeight: 1, background: s.color }} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">{d.band}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
|
||||||
|
{BAND_SPLIT.map((s) => (
|
||||||
|
<span key={s.key} className="inline-flex items-center gap-1">
|
||||||
|
<span className="size-2.5 rounded-[3px]" style={{ background: s.color }} /> {s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
||||||
// The categories (the operators) ARE the subject, so this is categorical colour,
|
// The categories (the operators) ARE the subject, so this is categorical colour,
|
||||||
// in the FIXED order the backend sends (busiest first). An operator therefore
|
// in the FIXED order the backend sends (busiest first). An operator therefore
|
||||||
@@ -451,6 +509,88 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActivityCard — the "activity over time" chart with a granularity selector. Its
|
||||||
|
// own state, module-scoped so a parent re-render doesn't reset the choice. The
|
||||||
|
// timeline is the chronological month series; day/week/month/year are the cyclical
|
||||||
|
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
|
||||||
|
// The selector is a BUCKET SIZE applied to the period chosen at the top of the
|
||||||
|
// panel — not a window of its own. It used to be four fixed rolling windows
|
||||||
|
// anchored on the newest QSO, which ignored the period filter entirely: picking
|
||||||
|
// a year up top left this chart showing the last 7 days.
|
||||||
|
const ACT_GRAN = [
|
||||||
|
{ key: 'day', tkey: 'stats.granDay' },
|
||||||
|
{ key: 'week', tkey: 'stats.granWeek' },
|
||||||
|
{ key: 'month', tkey: 'stats.granMonth' },
|
||||||
|
{ key: 'year', tkey: 'stats.granYear' },
|
||||||
|
] as const;
|
||||||
|
type Gran = typeof ACT_GRAN[number]['key'];
|
||||||
|
const COARSER: Record<Gran, Gran | null> = { day: 'week', week: 'month', month: 'year', year: null };
|
||||||
|
|
||||||
|
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||||
|
const [g, setG] = useState<Gran | 'auto'>('auto');
|
||||||
|
const seriesFor = (k: Gran): Bucket[] => (
|
||||||
|
k === 'day' ? stats.by_day_win : k === 'week' ? stats.by_week_win : k === 'month' ? stats.by_month_win : stats.by_year_win
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto picks the finest bucket that stays readable: the backend leaves a
|
||||||
|
// series empty when it would be too fine for the period, so "the first
|
||||||
|
// non-empty one" is exactly the right rule and needs no thresholds here.
|
||||||
|
const auto: Gran = (['day', 'week', 'month', 'year'] as Gran[]).find((k) => seriesFor(k).length > 0 && seriesFor(k).length <= 120) ?? 'year';
|
||||||
|
// A bucket the user forces but that is too fine for the period falls back to
|
||||||
|
// the next coarser one rather than showing an empty chart.
|
||||||
|
let shown: Gran = g === 'auto' ? auto : g;
|
||||||
|
while (seriesFor(shown).length === 0 && COARSER[shown]) shown = COARSER[shown]!;
|
||||||
|
const series = seriesFor(shown);
|
||||||
|
const steppedUp = g !== 'auto' && shown !== g;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1 items-center">
|
||||||
|
<button type="button" onClick={() => setG('auto')}
|
||||||
|
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||||
|
g === 'auto' ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t('stats.granAuto')}
|
||||||
|
</button>
|
||||||
|
{ACT_GRAN.map((o) => (
|
||||||
|
<button key={o.key} type="button" onClick={() => setG(o.key)}
|
||||||
|
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||||
|
g === o.key ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t(o.tkey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{steppedUp && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Many buckets read better as a filled trend than as a forest of bars. */}
|
||||||
|
{series.length > 60
|
||||||
|
? <AreaTrend data={series} empty={empty} />
|
||||||
|
: <VBars data={series} empty={empty} showValues height={160} />}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RhythmCard — WHEN the operator is on the air over the selected period, which is
|
||||||
|
// a different question from "how much lately" and so gets its own card. The hour
|
||||||
|
// histogram used to cover a single day, too little to read anything from.
|
||||||
|
function RhythmCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||||
|
const [g, setG] = useState<'hour' | 'weekday'>('hour');
|
||||||
|
return (
|
||||||
|
<Card title={t('stats.rhythm')} sub={t('stats.rhythmSub')} className="lg:col-span-2" accent="var(--chart-2)">
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
|
{([['hour', 'stats.byHourOfDay'], ['weekday', 'stats.byWeekday']] as const).map(([k, tk]) => (
|
||||||
|
<button key={k} type="button" onClick={() => setG(k)}
|
||||||
|
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||||
|
g === k ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t(tk)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<VBars data={g === 'hour' ? stats.by_hour_of_day : stats.by_weekday} empty={empty} showValues height={160} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||||
|
|
||||||
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||||
@@ -513,9 +653,12 @@ export function StatsPanel() {
|
|||||||
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||||
setStats({
|
setStats({
|
||||||
...raw,
|
...raw,
|
||||||
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
|
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator),
|
||||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||||
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||||
|
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
|
||||||
|
by_day_win: arr(raw.by_day_win), by_week_win: arr(raw.by_week_win), by_month_win: arr(raw.by_month_win), by_year_win: arr(raw.by_year_win),
|
||||||
|
by_hour_of_day: arr(raw.by_hour_of_day), by_weekday: arr(raw.by_weekday),
|
||||||
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||||
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||||
} as Stats);
|
} as Stats);
|
||||||
@@ -629,12 +772,12 @@ export function StatsPanel() {
|
|||||||
|
|
||||||
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||||
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
|
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} accent="var(--chart-1)" />
|
||||||
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
|
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
|
||||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
|
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" accent="var(--chart-5)" />
|
||||||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
|
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
|
||||||
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||||
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
|
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
||||||
@@ -723,43 +866,42 @@ export function StatsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||||
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} accent="var(--chart-3)">
|
||||||
<VBars data={stats.by_band} empty={empty} showValues colorful />
|
<StackedBandBars data={stats.by_band_category} empty={empty} />
|
||||||
</Card>
|
</Card>
|
||||||
<Card title={t('stats.byMode')}>
|
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
||||||
<HBars data={stats.by_mode} max={8} empty={empty} />
|
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
|
<ActivityCard stats={stats} t={t} empty={empty} />
|
||||||
<AreaTrend data={stats.by_month} empty={empty} />
|
<RhythmCard stats={stats} t={t} empty={empty} />
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||||
instead of truncating. */}
|
instead of truncating. */}
|
||||||
<Card title={t('stats.byOperator')}>
|
<Card title={t('stats.byOperator')} accent="var(--chart-5)">
|
||||||
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||||
<HBars data={stats.by_operator} empty={empty} share />
|
<HBars data={stats.by_operator} empty={empty} share color="var(--chart-5)" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
|
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')} accent="var(--chart-6)">
|
||||||
<Donut data={stats.by_continent} empty={empty} />
|
<Donut data={stats.by_continent} empty={empty} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title={t('stats.topEntities')}>
|
<Card title={t('stats.topEntities')} accent="var(--chart-4)">
|
||||||
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" />
|
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" color="var(--chart-4)" />
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex flex-col gap-2.5 min-w-0">
|
<div className="flex flex-col gap-2.5 min-w-0">
|
||||||
<Card title={t('stats.confirmations')} className="flex-1">
|
<Card title={t('stats.confirmations')} className="flex-1" accent="var(--success)">
|
||||||
<div className="flex flex-col gap-3 justify-center flex-1">
|
<div className="flex flex-col gap-3 justify-center flex-1">
|
||||||
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
||||||
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
||||||
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title={t('stats.byStation')}>
|
<Card title={t('stats.byStation')} accent="var(--chart-8)">
|
||||||
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||||||
<HBars data={stats.by_station} empty={empty} share />
|
<HBars data={stats.by_station} empty={empty} share color="var(--chart-8)" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Gauge, Radio, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import type { TGStatus, TGChannel } from '@/components/TunerGeniusPanel';
|
||||||
|
import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// TunerCard renders the 4O3A Tuner Genius XL exactly like the amplifier card
|
||||||
|
// (AmpCard) so Station Control and the FlexRadio panel show the SAME card. It
|
||||||
|
// mirrors the native app's two channels (A / B) with their source, frequency and
|
||||||
|
// antenna, a live PWR / SWR pair, and the Tune / Bypass / Operate actions. It
|
||||||
|
// drives the backend directly (no local state) — the caller's ~1.5s poll
|
||||||
|
// reconciles the display, just like AmpCard. Meters come from the shared MeterBar
|
||||||
|
// so they're the exact same size as the Flex/amp meters.
|
||||||
|
|
||||||
|
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
||||||
|
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
||||||
|
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
||||||
|
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
|
||||||
|
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<button type="button" onClick={toggle}
|
||||||
|
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
|
||||||
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||||
|
</button>
|
||||||
|
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelButton — one of the two RF channels (A / B). Clicking it makes that
|
||||||
|
// channel active. Shows the source (RF Sense / Flex / CAT…), frequency and
|
||||||
|
// antenna, matching the two rows of the native 4O3A app.
|
||||||
|
function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: {
|
||||||
|
letter: 'A' | 'B'; ch: TGChannel; active: boolean; ptt: boolean; threeWay: boolean;
|
||||||
|
onSelect: () => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const cls = ptt
|
||||||
|
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)]'
|
||||||
|
: active
|
||||||
|
? 'bg-gradient-to-b from-emerald-500 to-emerald-600 text-white border-emerald-400/50 shadow-[0_0_9px_rgba(16,185,129,0.4)]'
|
||||||
|
: 'bg-card text-foreground/80 border-border hover:bg-muted';
|
||||||
|
const src = ch.mode_str || '—';
|
||||||
|
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)} MHz` : '—';
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onSelect}
|
||||||
|
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
|
||||||
|
className={cn('flex-1 min-w-0 rounded-lg border px-2 py-1.5 text-left transition-all active:scale-[0.98]', cls)}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-extrabold text-sm">{letter}</span>
|
||||||
|
<span className="text-[11px] font-semibold truncate opacity-90">{src}</span>
|
||||||
|
{ptt && <span className="ml-auto text-[9px] font-bold uppercase">TX</span>}
|
||||||
|
{!ptt && active && <span className="ml-auto text-[9px] font-bold uppercase opacity-90">{t('tgp.chActiveTag')}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="font-mono text-[11px] tabular-nums truncate">{freq}</span>
|
||||||
|
{ch.antenna != null && ch.antenna > 0 && (threeWay || ch.antenna > 0) && (
|
||||||
|
<span className="ml-auto text-[10px] font-semibold whitespace-nowrap opacity-90">{t('tgp.ant')} {ch.antenna}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?: any) => string }) {
|
||||||
|
// Peak-hold, exactly as AmpCard does it. Both cards read the same transmitter,
|
||||||
|
// but the tuner is sampled by a 400 ms TCP poll: on SSB that lands in the gaps
|
||||||
|
// between syllables as often as on a peak, so the raw reading collapses to zero
|
||||||
|
// several times a second mid-transmission and reads as a dropped link. The
|
||||||
|
// amplifier looked steady next to it only because it already smoothed this way.
|
||||||
|
// Hold the highest value seen, decaying after 2 s so it follows a real drop.
|
||||||
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
|
const peakHold = (key: string, val: number) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const p = peak.current[key];
|
||||||
|
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
|
return p.v;
|
||||||
|
};
|
||||||
|
|
||||||
|
const connected = !!status.connected;
|
||||||
|
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||||
|
const rawFwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
|
||||||
|
const fwdW = peakHold('fwd', rawFwdW);
|
||||||
|
|
||||||
|
// SWR needs RF to mean anything: sampled on receive the device reports a
|
||||||
|
// meaningless 1.00, which would wipe the figure the operator actually wants —
|
||||||
|
// the one measured while transmitting. So show the live value during TX, keep it
|
||||||
|
// briefly afterwards so it can be read, then let it go.
|
||||||
|
//
|
||||||
|
// That expiry is the point: the first version of this held the last TX value
|
||||||
|
// with no timeout at all, so the meter sat frozen on the previous transmission
|
||||||
|
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
|
||||||
|
// Same 2 s window as the power peak above, so the two meters clear together.
|
||||||
|
const swrHold = useRef<{ v: number; t: number } | null>(null);
|
||||||
|
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
|
||||||
|
const heldSwr = swrHold.current;
|
||||||
|
const vswr = rawFwdW >= 1
|
||||||
|
? rawVswr
|
||||||
|
: heldSwr && Date.now() - heldSwr.t < 2000
|
||||||
|
? heldSwr.v
|
||||||
|
: undefined;
|
||||||
|
const active = status.active ?? 1;
|
||||||
|
const a: TGChannel = status.a ?? {};
|
||||||
|
const b: TGChannel = status.b ?? {};
|
||||||
|
|
||||||
|
const title = `${t('tgp.title')}${status.host ? ' · ' + status.host : ''}`;
|
||||||
|
return (
|
||||||
|
<Card icon={Gauge} title={title} accent="#f59e0b">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!connected}
|
||||||
|
onClick={() => TunerGeniusSetOperate(!status.operate).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
status.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{status.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!connected}
|
||||||
|
onClick={() => TunerGeniusAutotune().catch(() => {})}
|
||||||
|
className={cn('inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
status.tuning ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50 animate-pulse' : 'bg-card text-amber-600 border-amber-500 hover:bg-amber-500/10')}>
|
||||||
|
<Radio className="size-4" />{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!connected}
|
||||||
|
onClick={() => TunerGeniusSetBypass(!status.bypass).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-bold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
status.bypass ? 'bg-sky-500 text-white border-sky-500 shadow-[0_0_12px] shadow-sky-500/40' : 'bg-card text-sky-600 border-sky-500/70 hover:bg-sky-500/10')}>
|
||||||
|
{t('tgp.bypass')}
|
||||||
|
</button>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', connected ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{connected ? (status.bypass ? t('tgp.bypassed') : t('tgp.inLine')) : t('tgp.offline')}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{status.message && (
|
||||||
|
<span className="px-2 py-1 rounded bg-warning-muted text-warning-muted-foreground text-xs font-bold">⚠ {status.message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connected && (
|
||||||
|
<>
|
||||||
|
{/* Channel A / B selector — click to make active (activate ch=1/2). */}
|
||||||
|
<div className="flex items-stretch gap-2">
|
||||||
|
<ChannelButton letter="A" ch={a} active={active === 1} ptt={!!a.ptt} threeWay={!!status.three_way}
|
||||||
|
onSelect={() => TunerGeniusActivate(1).catch(() => {})} t={t} />
|
||||||
|
<ChannelButton letter="B" ch={b} active={active === 2} ptt={!!b.ptt} threeWay={!!status.three_way}
|
||||||
|
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PWR + SWR meters, each taking half the card. The amp card's grid is
|
||||||
|
3-wide because it shows three meters; copying it here left the tuner's
|
||||||
|
two meters in the first two of three columns, with a third of the card
|
||||||
|
empty to the right of SWR. */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<MeterBar label={t('tgp.power')} value={fwdW} unit="W" lo={0} hi={2000}
|
||||||
|
display={fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
<MeterBar label={t('tgp.swr')} value={vswr ?? 1} lo={1} hi={3}
|
||||||
|
display={vswr ? `${vswr.toFixed(2)}:1` : '—'}
|
||||||
|
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { Gauge, X, Power, Radio } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export type TGChannel = {
|
||||||
|
ptt?: boolean; band?: number; mode?: number; mode_str?: string; flex?: string;
|
||||||
|
freq_mhz?: number; bypass?: boolean; antenna?: number;
|
||||||
|
};
|
||||||
|
export type TGStatus = {
|
||||||
|
connected: boolean; host?: string; last_error?: string;
|
||||||
|
fwd_dbm?: number; fwd_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
||||||
|
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
|
||||||
|
antenna?: number; three_way?: boolean; message?: string;
|
||||||
|
a?: TGChannel; b?: TGChannel;
|
||||||
|
relay_c1?: number; relay_l?: number; relay_c2?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// swrColour picks a status colour for the VSWR readout: green ≤1.5, amber ≤2.0,
|
||||||
|
// red above (the usual "safe / caution / high" ATU thresholds).
|
||||||
|
function swrColour(vswr?: number): string {
|
||||||
|
if (!vswr || vswr <= 0) return 'text-muted-foreground';
|
||||||
|
if (vswr <= 1.5) return 'text-success';
|
||||||
|
if (vswr <= 2.0) return 'text-warning';
|
||||||
|
return 'text-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChanRow — a compact A/B channel line in the docked widget. Highlights the
|
||||||
|
// active channel (green) or TX (red), shows the source + frequency + antenna,
|
||||||
|
// and clicking it makes that channel active.
|
||||||
|
function ChanRow({ letter, ch, active, onSelect, t }: {
|
||||||
|
letter: 'A' | 'B'; ch: TGChannel; active: boolean; onSelect: () => void;
|
||||||
|
t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const ptt = !!ch.ptt;
|
||||||
|
const cls = ptt
|
||||||
|
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40'
|
||||||
|
: active
|
||||||
|
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40'
|
||||||
|
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||||
|
const src = ch.mode_str || '—';
|
||||||
|
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)}` : '—';
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onSelect}
|
||||||
|
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
|
||||||
|
className={cn('w-full flex items-center gap-1.5 rounded-lg border px-2 py-1 text-left transition-all active:scale-[0.98]', cls)}>
|
||||||
|
<span className="font-extrabold text-xs w-3 shrink-0">{letter}</span>
|
||||||
|
<span className="text-[10px] font-semibold truncate opacity-90 w-12 shrink-0">{src}</span>
|
||||||
|
<span className="font-mono text-[10px] tabular-nums truncate flex-1">{freq}</span>
|
||||||
|
{ch.antenna != null && ch.antenna > 0 && (
|
||||||
|
<span className="text-[9px] font-semibold whitespace-nowrap opacity-90 shrink-0">{t('tgp.ant')}{ch.antenna}</span>
|
||||||
|
)}
|
||||||
|
{ptt && <span className="text-[9px] font-bold uppercase shrink-0">TX</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunerGeniusPanel — compact docked widget for a 4O3A Tuner Genius XL ATU. Shows
|
||||||
|
// the live SWR / forward power, the two RF channels (A / B, click to activate),
|
||||||
|
// and Tune / Bypass / Operate. A fuller card (TunerCard) is shown in the FlexRadio
|
||||||
|
// panel and Station Control.
|
||||||
|
export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActivate, onClose }: {
|
||||||
|
status: TGStatus;
|
||||||
|
onTune: () => void;
|
||||||
|
onBypass: (on: boolean) => void;
|
||||||
|
onOperate: (on: boolean) => void;
|
||||||
|
onActivate: (ch: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||||
|
const active = status.active ?? 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||||
|
<Gauge className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Tuner Genius</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
||||||
|
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
|
||||||
|
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('tgp.online') : t('tgp.offline')}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('tgp.close')}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!status.connected ? (
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col items-center justify-center text-xs gap-2 p-3">
|
||||||
|
<div className="text-muted-foreground italic animate-pulse">{t('tgp.connecting')}</div>
|
||||||
|
{status.last_error && <div className="text-danger font-mono text-[10px] break-words text-center px-2">{status.last_error}</div>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2.5 space-y-2.5">
|
||||||
|
{/* SWR + forward power readouts */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||||
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.swr')}</div>
|
||||||
|
<div className={cn('text-lg font-bold font-mono leading-tight', swrColour(vswr))}>
|
||||||
|
{vswr ? `${vswr.toFixed(2)}:1` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||||
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
|
||||||
|
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
|
||||||
|
{status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel A / B — click to activate */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<ChanRow letter="A" ch={status.a ?? {}} active={active === 1} onSelect={() => onActivate(1)} t={t} />
|
||||||
|
<ChanRow letter="B" ch={status.b ?? {}} active={active === 2} onSelect={() => onActivate(2)} t={t} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.message && (
|
||||||
|
<div className="rounded-lg border border-warning-border bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center break-words">
|
||||||
|
{status.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tune */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTune}
|
||||||
|
disabled={status.tuning}
|
||||||
|
title={t('tgp.tuneHint')}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg text-sm font-bold py-2 border transition-all active:scale-[0.98]',
|
||||||
|
status.tuning
|
||||||
|
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_10px_rgba(245,158,11,0.5)] animate-pulse'
|
||||||
|
: 'bg-gradient-to-b from-primary/90 to-primary text-primary-foreground border-primary/40 hover:brightness-110',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center justify-center gap-2">
|
||||||
|
<Radio className="size-4" />
|
||||||
|
{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Bypass + Operate toggles */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onBypass(!status.bypass)}
|
||||||
|
title={t('tgp.bypassHint')}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
|
||||||
|
status.bypass
|
||||||
|
? 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]'
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('tgp.bypass')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOperate(!status.operate)}
|
||||||
|
title={t('tgp.operateHint')}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
|
||||||
|
status.operate
|
||||||
|
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Power className="size-3.5" />
|
||||||
|
{status.operate ? t('tgp.operate') : t('tgp.standby')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ interface Props {
|
|||||||
wpm: number;
|
wpm: number;
|
||||||
macros: WKMacro[];
|
macros: WKMacro[];
|
||||||
sent: string; // text echoed back by the keyer as it transmits
|
sent: string; // text echoed back by the keyer as it transmits
|
||||||
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
|
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu'; // CW output engine (chosen in Settings → CW Keyer)
|
||||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
onSetBreakIn?: (mode: number) => void;
|
onSetBreakIn?: (mode: number) => void;
|
||||||
onSelectPort: (p: string) => void;
|
onSelectPort: (p: string) => void;
|
||||||
@@ -101,14 +101,18 @@ export function WinkeyerPanel({
|
|||||||
<Radio className="size-4 text-primary shrink-0" />
|
<Radio className="size-4 text-primary shrink-0" />
|
||||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
|
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : 'WinKeyer'}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{source === 'icom' ? (
|
{source === 'icom' || source === 'flex' || source === 'yaesu' ? (
|
||||||
<span className="text-[11px] font-medium text-muted-foreground">
|
<span className="text-[11px] font-medium text-muted-foreground">
|
||||||
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
|
{source === 'flex'
|
||||||
|
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||||
|
: source === 'yaesu'
|
||||||
|
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||||
|
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||||
</span>
|
</span>
|
||||||
) : !connected ? (
|
) : !connected ? (
|
||||||
<>
|
<>
|
||||||
@@ -183,7 +187,7 @@ export function WinkeyerPanel({
|
|||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||||
{t('wkp.cwText')}
|
{t('wkp.cwText')}
|
||||||
{source === 'winkeyer' && (
|
{(source === 'winkeyer' || source === 'flex') && (
|
||||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||||
title={t('wkp.sendOnTypeHint')}>
|
title={t('wkp.sendOnTypeHint')}>
|
||||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||||
@@ -228,9 +232,14 @@ export function WinkeyerPanel({
|
|||||||
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
|
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short.
|
||||||
|
Empty macros (no label AND no text) are hidden, like the voice keyer;
|
||||||
|
the F-number stays tied to the macro's real index so shortcuts match. */}
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
{macros.map((m, i) => (
|
{macros
|
||||||
|
.map((m, i) => ({ m, i }))
|
||||||
|
.filter(({ m }) => `${m.label ?? ''}${m.text ?? ''}`.trim() !== '')
|
||||||
|
.map(({ m, i }) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||||
|
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
@@ -25,6 +26,8 @@ const hamlogTheme = hamlogGridTheme;
|
|||||||
type WorkedEntry = QSOForm; // entries are now full QSO records
|
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
// Operator's CURRENT locator — fallback for the distance column (see catalog).
|
||||||
|
myGrid?: string;
|
||||||
wb: WorkedBeforeView | null;
|
wb: WorkedBeforeView | null;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
currentCall: string;
|
currentCall: string;
|
||||||
@@ -35,6 +38,10 @@ type Props = {
|
|||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
// One column per defined award (cell = the reference this QSO counts for).
|
// One column per defined award (cell = the reference this QSO counts for).
|
||||||
awardCols?: { code: string; name: string }[];
|
awardCols?: { code: string; name: string }[];
|
||||||
@@ -50,14 +57,16 @@ function fmtDate(s: any): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
|
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
|
||||||
// Localized column catalog (shared with the Recent QSOs grid).
|
// Localized column catalog (shared with the Recent QSOs grid).
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||||
|
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
@@ -107,15 +116,18 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
// Restore AFTER the profile scope is known: this grid has no key= remount to
|
||||||
|
// save it from reading the wrong (unscoped) cache key at first paint, so it
|
||||||
|
// used to miss the cache every time and then save under the scoped key.
|
||||||
|
async function onGridReady(e: GridReadyEvent) {
|
||||||
|
await whenGridPrefsReady();
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
const remote = await loadRemote(COL_STATE_KEY);
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||||
seedLocal(COL_STATE_KEY, remote);
|
seedLocal(COL_STATE_KEY, remote);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
||||||
@@ -131,7 +143,9 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCols]);
|
// columnDefs itself, not the reasons it was rebuilt — see the same note in
|
||||||
|
// RecentQSOsGrid: a language change rebuilt the memo and left saving off.
|
||||||
|
}, [columnDefs]);
|
||||||
|
|
||||||
function isColVisible(colId: string): boolean {
|
function isColVisible(colId: string): boolean {
|
||||||
const col = gridRef.current?.api?.getColumn(colId);
|
const col = gridRef.current?.api?.getColumn(colId);
|
||||||
@@ -247,6 +261,11 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Same menu as Recent QSOs, minus the two "filtered" exports: there they
|
||||||
|
mean "the whole logbook under the active column filters", but this grid
|
||||||
|
is a per-callsign view rather than a filter over the log, so the entry
|
||||||
|
would quietly export everything — not what it would appear to do here.
|
||||||
|
The selection-based exports and bulk edit apply unchanged. */}
|
||||||
<QSOContextMenu
|
<QSOContextMenu
|
||||||
menu={menu}
|
menu={menu}
|
||||||
onClose={() => setMenu(null)}
|
onClose={() => setMenu(null)}
|
||||||
@@ -256,6 +275,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
onSendTo={onSendTo}
|
onSendTo={onSendTo}
|
||||||
onSendRecording={onSendRecording}
|
onSendRecording={onSendRecording}
|
||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
|
onBulkEdit={onBulkEdit}
|
||||||
|
onExportSelected={onExportSelected}
|
||||||
|
onExportSelectedFields={onExportSelectedFields}
|
||||||
|
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetYaesuState, RefreshYaesuPanel,
|
||||||
|
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
|
||||||
|
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
||||||
|
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
|
||||||
|
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
|
||||||
|
type YaesuState = {
|
||||||
|
available: boolean; model?: string; mode?: string; raw_mode?: string;
|
||||||
|
transmitting: boolean; split: boolean;
|
||||||
|
s_meter: number; power_meter: number; swr_meter: number;
|
||||||
|
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
|
||||||
|
agc?: string; preamp: number; att: number;
|
||||||
|
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean;
|
||||||
|
split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZERO: YaesuState = {
|
||||||
|
available: false, transmitting: false, split: false,
|
||||||
|
s_meter: 0, power_meter: 0, swr_meter: 0,
|
||||||
|
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0,
|
||||||
|
preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we
|
||||||
|
// choose: pressing 20 m lands where the operator last was on 20 m, which is what
|
||||||
|
// the radio's own band keys do. That is why these are band names, not Hz.
|
||||||
|
const BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
||||||
|
|
||||||
|
// Mode buttons. CW, RTTY, DIGI and PSK exist on BOTH sidebands on a Yaesu and
|
||||||
|
// the operator is the one who knows which they want, so each shows its sideband
|
||||||
|
// and CLICKING AN ACTIVE BUTTON AGAIN flips it: CW-U → CW-L → CW-U. One button,
|
||||||
|
// one finger, no hidden gesture. SSB takes its sideband from the frequency, as
|
||||||
|
// the band plan dictates, and AM/FM have none.
|
||||||
|
//
|
||||||
|
// PSK rides on the rig's DATA mode, like the other digital modes — the button
|
||||||
|
// exists because the operator thinks in modes, not in what the radio calls them.
|
||||||
|
type ModeBtn = { id: string; label: string; sideband: boolean; rig: (side: 'U' | 'L') => string };
|
||||||
|
const MODES: ModeBtn[] = [
|
||||||
|
{ id: 'SSB', label: 'SSB', sideband: false, rig: () => 'SSB' },
|
||||||
|
{ id: 'CW', label: 'CW', sideband: true, rig: (s) => 'CW-' + s },
|
||||||
|
{ id: 'RTTY', label: 'RTTY', sideband: true, rig: (s) => 'RTTY-' + s },
|
||||||
|
{ id: 'DIGI', label: 'DIGI', sideband: true, rig: (s) => 'DATA-' + s },
|
||||||
|
{ id: 'PSK', label: 'PSK', sideband: true, rig: (s) => 'DATA-' + s },
|
||||||
|
{ id: 'AM', label: 'AM', sideband: false, rig: () => 'AM' },
|
||||||
|
{ id: 'FM', label: 'FM', sideband: false, rig: () => 'FM' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Which button the rig's current raw mode belongs to, and on which sideband.
|
||||||
|
function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null {
|
||||||
|
switch ((raw || '').toUpperCase()) {
|
||||||
|
case 'USB': return { id: 'SSB', side: 'U' };
|
||||||
|
case 'LSB': return { id: 'SSB', side: 'L' };
|
||||||
|
case 'CW-U': return { id: 'CW', side: 'U' };
|
||||||
|
case 'CW-L': return { id: 'CW', side: 'L' };
|
||||||
|
case 'RTTY-U': return { id: 'RTTY', side: 'U' };
|
||||||
|
case 'RTTY-L': return { id: 'RTTY', side: 'L' };
|
||||||
|
case 'DATA-U': return { id: 'DIGI', side: 'U' };
|
||||||
|
case 'DATA-L': return { id: 'DIGI', side: 'L' };
|
||||||
|
case 'AM': return { id: 'AM', side: 'U' };
|
||||||
|
case 'FM': return { id: 'FM', side: 'U' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The FTDX10/FTDX101 preamp is a three-way front-end selector, not an on/off:
|
||||||
|
// IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and
|
||||||
|
// AMP2 add gain. Presenting it as a toggle would hide the middle position.
|
||||||
|
const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }];
|
||||||
|
const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }];
|
||||||
|
// The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle.
|
||||||
|
const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||||
|
|
||||||
|
function fmtVFO(hz?: number): string {
|
||||||
|
if (!hz || hz <= 0) return '––.–––.––';
|
||||||
|
const mhz = Math.floor(hz / 1_000_000);
|
||||||
|
const khz = Math.floor((hz % 1_000_000) / 1000);
|
||||||
|
const h2 = Math.floor((hz % 1000) / 10);
|
||||||
|
return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandOfHz(hz?: number): string {
|
||||||
|
if (!hz || hz <= 0) return '';
|
||||||
|
const mhz = hz / 1_000_000;
|
||||||
|
const bands: [string, number, number][] = [
|
||||||
|
['160m', 1.8, 2.0], ['80m', 3.5, 4.0], ['60m', 5.25, 5.45], ['40m', 7.0, 7.3],
|
||||||
|
['30m', 10.1, 10.15], ['20m', 14.0, 14.35], ['17m', 18.068, 18.168],
|
||||||
|
['15m', 21.0, 21.45], ['12m', 24.89, 24.99], ['10m', 28.0, 29.7], ['6m', 50.0, 54.0],
|
||||||
|
];
|
||||||
|
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Split the 0-100 S-meter reading into S units + dB over S9.
|
||||||
|
//
|
||||||
|
// The FTDX10 answers SM0 on a 0-255 scale and its manual does not say where S9
|
||||||
|
// falls; the front panel puts it at roughly half travel, which is the 50 used
|
||||||
|
// here. That figure is a HYPOTHESIS — if reports come out consistently one S
|
||||||
|
// unit off on a radio, this is the number to correct, not the RST helper.
|
||||||
|
const S9_PCT = 50; // where S9 falls on the 0-100 reading (see above)
|
||||||
|
const DB_PER_PCT = 60 / 50; // above S9 the scale runs to roughly +60 dB
|
||||||
|
|
||||||
|
function sParts(v: number): { s: number; over: number; label: string } {
|
||||||
|
if (v >= S9_PCT) {
|
||||||
|
const over = Math.max(0, Math.round((v - S9_PCT) * DB_PER_PCT));
|
||||||
|
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||||
|
}
|
||||||
|
const s = Math.max(0, Math.min(9, Math.round(v / (S9_PCT / 9))));
|
||||||
|
return { s, over: 0, label: `S${s}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment colour, the way a radio's own meter is printed: green up to S9, amber
|
||||||
|
// through the S9+ range, red once the signal is strong enough to be reported as
|
||||||
|
// 59+20 or more. Derived from the SAME S9 point as the label, so the colour
|
||||||
|
// change always lands exactly where the numbers say it should — if the S9 point
|
||||||
|
// is ever corrected, the colours follow on their own.
|
||||||
|
const RED_OVER_DB = 20;
|
||||||
|
function sSegColor(frac: number): string {
|
||||||
|
const pct = frac * 100;
|
||||||
|
if (pct < S9_PCT) return '#16a34a';
|
||||||
|
if ((pct - S9_PCT) * DB_PER_PCT < RED_OVER_DB) return '#f59e0b';
|
||||||
|
return '#dc2626';
|
||||||
|
}
|
||||||
|
|
||||||
|
function Slider({ value, onChange, disabled, accent = 'var(--primary)', min = 0, max = 100 }: {
|
||||||
|
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; min?: number; max?: number;
|
||||||
|
}) {
|
||||||
|
const v = Math.max(min, Math.min(max, value));
|
||||||
|
const pct = max > min ? ((v - min) / (max - min)) * 100 : 0;
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
// React's onWheel is passive, so preventDefault is ignored there — attach a
|
||||||
|
// native non-passive listener, and read live values through refs so the
|
||||||
|
// handler never closes over a stale value.
|
||||||
|
const valRef = useRef(value); valRef.current = value;
|
||||||
|
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||||
|
const disRef = useRef(disabled); disRef.current = disabled;
|
||||||
|
const minRef = useRef(min); minRef.current = min;
|
||||||
|
const maxRef = useRef(max); maxRef.current = max;
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (disRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + (e.deltaY < 0 ? 1 : -1)));
|
||||||
|
if (nv !== valRef.current) cbRef.current(nv);
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="range" min={min} max={max} value={v} disabled={disabled}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
|
className={cn('flex-1 h-2 rounded-full appearance-none cursor-pointer disabled:opacity-40 disabled:cursor-default',
|
||||||
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full',
|
||||||
|
'[&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow',
|
||||||
|
'[&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing')}
|
||||||
|
// The filled side was invisible against the dark theme: --muted is barely
|
||||||
|
// lighter than the card it sits on, so the whole track read as one bar.
|
||||||
|
// An explicit translucent track keeps both halves distinct in either theme.
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, ${accent} 0%, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) 100%)`,
|
||||||
|
borderColor: accent,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Segmented({ value, options, onChange }: {
|
||||||
|
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button key={o.v} type="button" onClick={() => onChange(o.v)}
|
||||||
|
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
|
||||||
|
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{o.l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ on, onClick, label, title }: { on: boolean; onClick: () => void; label: string; title?: string }) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} title={title}
|
||||||
|
className={cn('shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
||||||
|
onReportRST?: (rst: string) => void;
|
||||||
|
// Told whenever the operator moves the CW speed here, so the app can keep the
|
||||||
|
// keyer that is ACTUALLY sending in step. With DTR/RTS line keying the PC does
|
||||||
|
// the timing and the rig's internal keyer speed changes nothing audible — the
|
||||||
|
// slider looked broken because it was driving the wrong keyer.
|
||||||
|
onKeySpeed?: (wpm: number) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [st, setSt] = useState<YaesuState>(ZERO);
|
||||||
|
// The frequency being LISTENED to. RigState follows ADIF, where freq_hz is the
|
||||||
|
// TRANSMIT frequency — under split that is the other VFO, so taking it as the
|
||||||
|
// main display showed the operator the frequency they transmit on and an
|
||||||
|
// offset of 0 kHz against itself.
|
||||||
|
const [freqHz, setFreqHz] = useState(0);
|
||||||
|
const [txHz, setTxHz] = useState(0);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
// Optimistic local values for the sliders. Without them a drag fights the
|
||||||
|
// poll: the rig's older reading arrives mid-gesture and yanks the thumb back.
|
||||||
|
const [local, setLocal] = useState<Partial<YaesuState>>({});
|
||||||
|
const localAtRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const s = (await GetYaesuState()) as YaesuState;
|
||||||
|
const c = await GetCATState();
|
||||||
|
if (!alive) return;
|
||||||
|
setSt(s);
|
||||||
|
const cs = c as any;
|
||||||
|
const tx = cs?.freq_hz ?? 0;
|
||||||
|
const rx = cs?.split && cs?.freq_rx_hz > 0 ? cs.freq_rx_hz : tx;
|
||||||
|
setFreqHz(rx);
|
||||||
|
setTxHz(tx);
|
||||||
|
// Drop the optimistic overlay once the rig has had time to answer with
|
||||||
|
// the new value — 1.2 s covers the slow-beat settings read.
|
||||||
|
if (Date.now() - localAtRef.current > 1200) setLocal({});
|
||||||
|
setErr('');
|
||||||
|
} catch (e: any) {
|
||||||
|
if (alive) setErr(String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 400);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const view = { ...st, ...local };
|
||||||
|
|
||||||
|
// Every setter follows the same shape: show the value at once, remember when,
|
||||||
|
// and let the poll take over. A rejected command surfaces as an error rather
|
||||||
|
// than as a control that silently springs back.
|
||||||
|
function push<K extends keyof YaesuState>(key: K, value: YaesuState[K], fn: () => Promise<void>) {
|
||||||
|
setLocal((l) => ({ ...l, [key]: value }));
|
||||||
|
localAtRef.current = Date.now();
|
||||||
|
fn().catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const band = bandOfHz(freqHz);
|
||||||
|
// CW changes what belongs on the panel: no microphone, no VOX, but a keyer
|
||||||
|
// speed, break-in and ZIN. Driven by the RIG's mode, not the logged one.
|
||||||
|
const isCW = (view.raw_mode || '').toUpperCase().startsWith('CW');
|
||||||
|
|
||||||
|
if (!st.available) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex items-center justify-center p-6 text-center">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Radio className="size-8 mx-auto text-muted-foreground/50" />
|
||||||
|
<p className="text-sm text-muted-foreground">{t('yaesu.notConnected')}</p>
|
||||||
|
{err && <p className="text-xs text-destructive">{err}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
|
{/* Same wrapper as the Icom and Flex panels: capped width, CENTRED. Capping
|
||||||
|
it without mx-auto left the console pinned to the left edge with a
|
||||||
|
window of empty space beside it. */}
|
||||||
|
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||||
|
{/* VFO + status */}
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{st.model || 'Yaesu'}</div>
|
||||||
|
<div className="text-2xl font-mono tabular-nums font-bold">{fmtVFO(freqHz)}</div>
|
||||||
|
{view.split && txHz > 0 && (
|
||||||
|
<div className="text-[11px] font-mono tabular-nums text-warning">
|
||||||
|
{t('yaesu.txOn')} {fmtVFO(txHz)}
|
||||||
|
{freqHz > 0 ? ' (' + (txHz > freqHz ? '+' : '') + Math.round((txHz - freqHz) / 100) / 10 + ' kHz)' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{st.transmitting && (
|
||||||
|
<span className="px-2 py-1 rounded-md text-[11px] font-bold bg-destructive text-destructive-foreground">TX</span>
|
||||||
|
)}
|
||||||
|
<Chip on={view.split} onClick={() => push('split', !view.split, () => SetYaesuSplit(!view.split))} label="SPLIT" />
|
||||||
|
{/* The usual pile-up offsets. Which one is idiomatic depends on the
|
||||||
|
mode — up 5 on phone, up 1 on CW — so both are offered rather than
|
||||||
|
guessed, and each turns split on in the same action. */}
|
||||||
|
<button type="button" onClick={() => SetYaesuSplitOffset(1000).catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('yaesu.splitUpHint')}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+1k</button>
|
||||||
|
<button type="button" onClick={() => SetYaesuSplitOffset(5000).catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('yaesu.splitUpHint')}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+5k</button>
|
||||||
|
<button type="button" onClick={() => TuneYaesuATU().catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('yaesu.tuneHint')}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||||
|
TUNE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <p className="text-xs text-destructive px-1">{err}</p>}
|
||||||
|
|
||||||
|
{/* Meters — the SHARED MeterBar the Flex and Icom panels use, so the three
|
||||||
|
consoles read alike instead of each having its own instrument style. */}
|
||||||
|
<Card icon={Activity} title={t('yaesu.meters')}>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
<MeterBar label="S-METER" value={view.s_meter} lo={0} hi={100} accent="#16a34a" segColor={sSegColor}
|
||||||
|
display={sParts(view.s_meter).label}
|
||||||
|
onClick={onReportRST ? () => { const sp = sParts(view.s_meter); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); } : undefined}
|
||||||
|
title={onReportRST ? t('yaesu.sToRst') : undefined} />
|
||||||
|
{/* Watts as MEASURED, not the power setting scaled by a percentage:
|
||||||
|
the setting says what was asked for, the meter says what left. */}
|
||||||
|
<MeterBar label="PWR" value={view.transmitting ? (view.power_w ?? 0) : 0} unit="W" lo={0} hi={Math.max(100, view.rf_power || 100)} accent="#0ea5e9"
|
||||||
|
display={view.transmitting ? Math.round(view.power_w ?? 0) + 'W' : '—'} />
|
||||||
|
{/* The RATIO, as the rig shows it — a percentage of meter travel is
|
||||||
|
not something an operator can act on. The bar keeps the travel. */}
|
||||||
|
<MeterBar label="SWR" value={view.transmitting ? view.swr_meter : 0} lo={0} hi={100} accent="#f59e0b"
|
||||||
|
display={view.transmitting ? (view.swr && view.swr >= 1 ? view.swr.toFixed(1) : '1.0') : '—'} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bands + modes */}
|
||||||
|
<Card icon={Antenna} title={t('yaesu.bandMode')}>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{BANDS.map((b) => (
|
||||||
|
<button key={b} type="button"
|
||||||
|
onClick={() => SetYaesuBand(b).catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
band === b ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{b.replace('m', '')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{MODES.map((m) => {
|
||||||
|
const act = activeMode(view.raw_mode);
|
||||||
|
const on = act?.id === m.id;
|
||||||
|
// The sideband shown is the rig's when this mode is active, else the
|
||||||
|
// one the band plan implies — so a button says what pressing it will
|
||||||
|
// actually do rather than a stale letter.
|
||||||
|
const side: 'U' | 'L' = on && act ? act.side : (freqHz > 0 && freqHz < 10_000_000 ? 'L' : 'U');
|
||||||
|
const flip: 'U' | 'L' = side === 'U' ? 'L' : 'U';
|
||||||
|
return (
|
||||||
|
<button key={m.id} type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// Already on this mode → the click means "the other sideband".
|
||||||
|
const target = m.id === 'SSB'
|
||||||
|
? (freqHz > 0 && freqHz < 10_000_000 ? 'LSB' : 'USB')
|
||||||
|
: m.rig(on && m.sideband ? flip : side);
|
||||||
|
SetYaesuModeRaw(target).catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
}}
|
||||||
|
title={m.sideband ? t('yaesu.sidebandHint') : undefined}
|
||||||
|
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
on ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{m.sideband ? m.label + '-' + side : m.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Receive */}
|
||||||
|
<Card icon={AudioLines} title={t('yaesu.receive')}>
|
||||||
|
<Row label="AF">
|
||||||
|
<Slider value={view.af_gain} onChange={(v) => push('af_gain', v, () => SetYaesuAFGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.af_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="RF">
|
||||||
|
<Slider value={view.rf_gain} onChange={(v) => push('rf_gain', v, () => SetYaesuRFGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="SQL">
|
||||||
|
<Slider value={view.squelch} onChange={(v) => push('squelch', v, () => SetYaesuSquelch(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.squelch}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="AGC">
|
||||||
|
<Segmented value={view.agc ?? 'AUTO'} options={AGCS} onChange={(v) => push('agc', v, () => SetYaesuAGC(v))} />
|
||||||
|
</Row>
|
||||||
|
<Row label="FRONT">
|
||||||
|
<Segmented value={String(view.preamp)} options={PREAMPS} onChange={(v) => push('preamp', parseInt(v, 10), () => SetYaesuPreamp(parseInt(v, 10)))} />
|
||||||
|
</Row>
|
||||||
|
<Row label="ATT">
|
||||||
|
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Noise + filter */}
|
||||||
|
<Card icon={SlidersHorizontal} title={t('yaesu.noiseFilter')}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Chip on={view.nb} onClick={() => push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" />
|
||||||
|
<Chip on={view.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
|
||||||
|
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" />
|
||||||
|
</div>
|
||||||
|
<Row label="DNR">
|
||||||
|
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
|
||||||
|
the radio's own display counts 1-15, and matching it is what makes
|
||||||
|
the panel readable next to the front panel. */}
|
||||||
|
<Slider value={view.nr_level || 1} min={1} max={15} disabled={!view.nr}
|
||||||
|
onChange={(v) => push('nr_level', v, () => SetYaesuNRLevel(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.nr_level || 1}</span>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Transmit */}
|
||||||
|
<Card icon={Mic} title={t('yaesu.transmit')}>
|
||||||
|
<Row label="PWR">
|
||||||
|
{/* Watts, not a percentage: the rig reports and takes watts, and a
|
||||||
|
percentage would be a second unit to reconcile every time. */}
|
||||||
|
<Slider value={view.rf_power || 5} min={5} max={100} accent="var(--destructive)"
|
||||||
|
onChange={(v) => push('rf_power', v, () => SetYaesuPower(v))} />
|
||||||
|
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
|
||||||
|
</Row>
|
||||||
|
{/* Microphone gain and VOX are meaningless in CW — the rig ignores both
|
||||||
|
— so they are hidden rather than shown as dead controls. */}
|
||||||
|
{!isCW && (
|
||||||
|
<>
|
||||||
|
<Row label="MIC">
|
||||||
|
<Slider value={view.mic_gain} onChange={(v) => push('mic_gain', v, () => SetYaesuMicGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.mic_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="VOX">
|
||||||
|
<Chip on={view.vox} onClick={() => push('vox', !view.vox, () => SetYaesuVOX(!view.vox))} label="VOX" />
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Row label="">
|
||||||
|
<button type="button" onClick={() => RefreshYaesuPanel().catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
className="ml-auto px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||||
|
{t('yaesu.refresh')}
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* CW — only in CW, where these replace the phone controls above. */}
|
||||||
|
{isCW && (
|
||||||
|
<Card icon={Radio} title={t('yaesu.cw')}>
|
||||||
|
<Row label="SPEED">
|
||||||
|
<Slider value={view.key_speed || 20} min={4} max={60} accent="var(--primary)"
|
||||||
|
onChange={(v) => { push('key_speed', v, () => SetYaesuKeySpeed(v)); onKeySpeed?.(v); }} />
|
||||||
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.key_speed || 20} wpm</span>
|
||||||
|
</Row>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Chip on={!!view.break_in} onClick={() => push('break_in', !view.break_in, () => SetYaesuBreakIn(!view.break_in))}
|
||||||
|
label="BK-IN" title={t('yaesu.breakInHint')} />
|
||||||
|
{/* ZIN is a one-shot: the rig retunes so the station being received
|
||||||
|
lands on the operator's own CW pitch. Not a toggle, so it is a
|
||||||
|
plain button rather than a chip that would look latched. */}
|
||||||
|
<button type="button" onClick={() => YaesuZeroIn().catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('yaesu.zinHint')}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||||
|
ZIN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,7 +64,17 @@ export function Combobox({
|
|||||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||||
// open the list (so tabbing in doesn't pop the dropdown).
|
// open the list (so tabbing in doesn't pop the dropdown).
|
||||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
||||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setQuery(v);
|
||||||
|
setOpen(true);
|
||||||
|
// Commit-on-type pushes the value live to the parent (so a CW macro sent
|
||||||
|
// without leaving the field uses what was just typed). With free text that's
|
||||||
|
// any input; a restricted field (allowFreeText=false) commits ONLY a value
|
||||||
|
// that's actually in the list, so a half-typed or invalid report never
|
||||||
|
// becomes the committed value — blur then reverts the leftover text.
|
||||||
|
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
|
||||||
|
}}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// How dates are DISPLAYED. Nothing here touches what is stored.
|
||||||
|
//
|
||||||
|
// The log keeps ADIF/ISO throughout — the database, ADIF export, LoTW, every
|
||||||
|
// upload. This is the reading layer only, because an operator reading
|
||||||
|
// "2026-07-30" as the 7th of the 30th month is reading their own log wrongly,
|
||||||
|
// and that is a display problem, not a data one.
|
||||||
|
//
|
||||||
|
// Storage stays ISO deliberately and permanently: it sorts correctly as text,
|
||||||
|
// it is what ADIF specifies, and a log whose stored format followed a UI
|
||||||
|
// preference would become unreadable the day the preference changed.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
export type DateFormat = 'iso' | 'fr' | 'us';
|
||||||
|
|
||||||
|
const KEY = 'opslog.dateFormat';
|
||||||
|
|
||||||
|
function read(): DateFormat {
|
||||||
|
const v = localStorage.getItem(KEY);
|
||||||
|
return v === 'fr' || v === 'us' ? v : 'iso';
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: DateFormat = read();
|
||||||
|
|
||||||
|
// A minimal store so every grid and panel re-renders the moment the format
|
||||||
|
// changes, rather than showing the old one until the next restart.
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function getDateFormat(): DateFormat {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDateFormat(f: DateFormat): void {
|
||||||
|
current = f;
|
||||||
|
writeUiPref(KEY, f);
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeDateFormat(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadDateFormat re-reads the stored value — for after the portable prefs
|
||||||
|
// have been synced from the database at startup, which happens AFTER this
|
||||||
|
// module's first read.
|
||||||
|
export function reloadDateFormat(): void {
|
||||||
|
const v = read();
|
||||||
|
if (v !== current) {
|
||||||
|
current = v;
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
// order arranges the three parts. Separators stay '-' in every format: a slash
|
||||||
|
// in the French one would be conventional but it also invites the reader to
|
||||||
|
// take it for a US date, which is exactly the confusion being fixed.
|
||||||
|
function order(y: number, m: number, d: number): string {
|
||||||
|
switch (current) {
|
||||||
|
case 'fr':
|
||||||
|
return `${pad(d)}-${pad(m)}-${y}`;
|
||||||
|
case 'us':
|
||||||
|
return `${pad(m)}-${pad(d)}-${y}`;
|
||||||
|
}
|
||||||
|
return `${y}-${pad(m)}-${pad(d)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDateTimeUTC renders a timestamp with the time, in UTC.
|
||||||
|
//
|
||||||
|
// UTC is not negotiable and is not part of the preference: a logbook is kept in
|
||||||
|
// UTC, and a date shown in local time would disagree with the QSO's own record
|
||||||
|
// twice a year.
|
||||||
|
export function formatDateTimeUTC(s: unknown): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = new Date(String(s));
|
||||||
|
if (isNaN(d.getTime())) return String(s);
|
||||||
|
return `${order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDateOnly renders a date with no time. It accepts both the ADIF form
|
||||||
|
// (YYYYMMDD, used by every QSL and upload date) and anything Date can parse.
|
||||||
|
export function formatDateOnly(s: unknown): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const t = String(s).trim();
|
||||||
|
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||||
|
if (m) return order(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||||
|
const d = new Date(t);
|
||||||
|
if (isNaN(d.getTime())) return t;
|
||||||
|
return order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Grey line — the day/night terminator, and the twilight band around it where
|
||||||
|
// HF propagation briefly does things it does at no other time.
|
||||||
|
//
|
||||||
|
// The maths is the standard low-precision solar position (Meeus, chapter 25, as
|
||||||
|
// used by NOAA's solar calculator): good to well under a degree, which is far
|
||||||
|
// finer than the band being drawn. No ephemeris, no network, no dependency —
|
||||||
|
// this has to work in a field shack with no internet.
|
||||||
|
//
|
||||||
|
// Everything here is pure so it can be tested against known positions; the
|
||||||
|
// drawing lives in the map component.
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
|
||||||
|
// julianDay for a date, including the fraction of the day.
|
||||||
|
export function julianDay(d: Date): number {
|
||||||
|
return d.getTime() / 86400000 + 2440587.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sun's declination and Greenwich hour angle, both in degrees.
|
||||||
|
//
|
||||||
|
// Returned together because they are computed from the same intermediates and
|
||||||
|
// every caller needs both — splitting them into two exported functions would
|
||||||
|
// double the work at each call for the sake of a tidier signature.
|
||||||
|
export function sunPosition(date: Date): { dec: number; gha: number } {
|
||||||
|
const jd = julianDay(date);
|
||||||
|
const n = jd - 2451545.0; // days from J2000.0
|
||||||
|
|
||||||
|
// Mean longitude and mean anomaly of the Sun.
|
||||||
|
const L = (280.460 + 0.9856474 * n) % 360;
|
||||||
|
const g = ((357.528 + 0.9856003 * n) % 360) * DEG;
|
||||||
|
|
||||||
|
// Ecliptic longitude: the equation of centre applied to the mean longitude.
|
||||||
|
const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG;
|
||||||
|
|
||||||
|
// Obliquity of the ecliptic, slowly decreasing.
|
||||||
|
const eps = (23.439 - 0.0000004 * n) * DEG;
|
||||||
|
|
||||||
|
const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG;
|
||||||
|
|
||||||
|
// Right ascension, kept in the same quadrant as the ecliptic longitude —
|
||||||
|
// atan2 alone lands 180° out for half the year, which mirrors the whole
|
||||||
|
// terminator about the prime meridian. Six months of a plausible-looking
|
||||||
|
// wrong answer is exactly the kind of bug that survives review.
|
||||||
|
let ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG;
|
||||||
|
if (ra < 0) ra += 360;
|
||||||
|
|
||||||
|
// Greenwich mean sidereal time, in degrees.
|
||||||
|
const gmst = (280.46061837 + 360.98564736629 * n) % 360;
|
||||||
|
|
||||||
|
let gha = gmst - ra;
|
||||||
|
gha = ((gha % 360) + 360) % 360;
|
||||||
|
return { dec, gha };
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminatorLatitude returns the latitude, at a given longitude, where the Sun
|
||||||
|
// sits at `altitude` degrees above (or below) the horizon.
|
||||||
|
//
|
||||||
|
// altitude 0 is the geometric terminator; -6 is civil twilight, the outer edge
|
||||||
|
// of the grey line as operators use the term.
|
||||||
|
//
|
||||||
|
// Returns null where no such latitude exists at that longitude — inside the
|
||||||
|
// polar day or polar night, where the Sun never crosses that altitude at all.
|
||||||
|
export function terminatorLatitude(lonDeg: number, dec: number, gha: number, altitude = 0): number | null {
|
||||||
|
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||||
|
const d = dec * DEG;
|
||||||
|
const h = altitude * DEG;
|
||||||
|
|
||||||
|
// From sin(alt) = sin(dec)sin(lat) + cos(dec)cos(lat)cos(ha), solved for lat:
|
||||||
|
// A·sin(lat) + B·cos(lat) = sin(alt), A = sin(dec), B = cos(dec)cos(ha)
|
||||||
|
//
|
||||||
|
// That has TWO solutions per meridian, and picking the wrong one is not a
|
||||||
|
// small error — it mirrors the answer about the equator. New York at 02:00
|
||||||
|
// local in December came out as daylight because the branch chosen put the
|
||||||
|
// terminator at +63° where it belongs at −63°: a shaded map that looks
|
||||||
|
// entirely plausible and is exactly wrong.
|
||||||
|
//
|
||||||
|
// So anchor on the geometric terminator, where the answer is unambiguous and
|
||||||
|
// classical, and take whichever branch is nearest it. At altitude 0 that
|
||||||
|
// reproduces it exactly; for the twilight band it follows it by continuity,
|
||||||
|
// which is the physical requirement — the twilight curve is a small offset
|
||||||
|
// from the terminator, never on the other side of the planet.
|
||||||
|
const A = Math.sin(d);
|
||||||
|
const B = Math.cos(d) * Math.cos(ha);
|
||||||
|
const R = Math.hypot(A, B);
|
||||||
|
if (R === 0) return null;
|
||||||
|
const s = Math.sin(h) / R;
|
||||||
|
if (s < -1 || s > 1) return null; // the Sun never reaches this altitude here
|
||||||
|
|
||||||
|
// The classical terminator. Division by zero is deliberate and correct here:
|
||||||
|
// at an equinox tan(dec) → 0 and the terminator becomes a meridian, which is
|
||||||
|
// exactly what atan(±Infinity) = ±90° expresses.
|
||||||
|
const t = Math.tan(d);
|
||||||
|
if (t === 0 && Math.cos(ha) === 0) return null; // degenerate: no crossing to name
|
||||||
|
const lat0 = Math.atan(-Math.cos(ha) / t) / DEG;
|
||||||
|
|
||||||
|
const phi = Math.atan2(B, A); // phase of the A·sin(lat) + B·cos(lat) sum
|
||||||
|
const fold = (x: number) => {
|
||||||
|
let v = ((x + 180) % 360 + 360) % 360 - 180;
|
||||||
|
if (v > 90) v = 180 - v;
|
||||||
|
if (v < -90) v = -180 - v;
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
const c1 = fold((Math.asin(s) - phi) / DEG);
|
||||||
|
const c2 = fold((Math.PI - Math.asin(s) - phi) / DEG);
|
||||||
|
return Math.abs(c1 - lat0) <= Math.abs(c2 - lat0) ? c1 : c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// nightPolygon returns a ring covering the part of the world where the Sun is
|
||||||
|
// below `altitude`, ready for L.polygon (as [lat, lon] pairs).
|
||||||
|
//
|
||||||
|
// The ring runs along the terminator from west to east and closes along
|
||||||
|
// whichever pole is in darkness — which pole that is flips with the season, and
|
||||||
|
// getting it wrong shades the lit half of the planet.
|
||||||
|
export function nightPolygon(date: Date, altitude = 0, stepDeg = 2): [number, number][] {
|
||||||
|
const { dec, gha } = sunPosition(date);
|
||||||
|
const ring: [number, number][] = [];
|
||||||
|
|
||||||
|
for (let lon = -180; lon <= 180; lon += stepDeg) {
|
||||||
|
const lat = terminatorLatitude(lon, dec, gha, altitude);
|
||||||
|
// Inside a polar day/night there is no crossing; clamp to the pole so the
|
||||||
|
// ring stays closed rather than tearing open across the map.
|
||||||
|
ring.push([lat === null ? (dec > 0 ? -90 : 90) : lat, lon]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Northern summer (positive declination) → the SOUTH pole is dark.
|
||||||
|
const pole = dec > 0 ? -90 : 90;
|
||||||
|
ring.push([pole, 180]);
|
||||||
|
ring.push([pole, -180]);
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
@@ -6,11 +6,52 @@
|
|||||||
// back to the DB copy and re-seed the cache.
|
// back to the DB copy and re-seed the cache.
|
||||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with
|
||||||
|
// the active profile id). The localStorage cache, however, is one namespace for
|
||||||
|
// the whole WebView, so without scoping it too a profile switch would keep
|
||||||
|
// serving the previous profile's cached layout and the correct per-profile DB
|
||||||
|
// value would never win. lsScope makes the cache per-profile as well; it's set
|
||||||
|
// once the active profile is known and updated on every profile switch.
|
||||||
|
let lsScope = '';
|
||||||
|
|
||||||
|
// setGridPrefsProfile scopes the localStorage cache to a profile. Call it before
|
||||||
|
// the grids read their state (at startup) and again whenever the active profile
|
||||||
|
// changes so each profile keeps its own column layout / widths.
|
||||||
|
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
||||||
|
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
||||||
|
markReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The active profile is only known after an async call, but the grids mount and
|
||||||
|
// read their state on the first paint. A grid that read before the scope was set
|
||||||
|
// looked up the UNSCOPED cache key (always a miss) and then saved under the
|
||||||
|
// scoped one — so its layout could never be restored, only rewritten. Grids now
|
||||||
|
// wait for this instead of racing it.
|
||||||
|
//
|
||||||
|
// The 5 s fallback matters: if the profile lookup fails outright, the grids must
|
||||||
|
// still come up with whatever the unscoped cache holds rather than hang with no
|
||||||
|
// columns restored.
|
||||||
|
let markReady: () => void = () => {};
|
||||||
|
const gridPrefsReady = new Promise<void>((resolve) => {
|
||||||
|
markReady = resolve;
|
||||||
|
setTimeout(resolve, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function whenGridPrefsReady(): Promise<void> {
|
||||||
|
return gridPrefsReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
||||||
|
// GetUIPref/SetUIPref is left untouched — the backend already scopes it.
|
||||||
|
function lsKey(key: string): string {
|
||||||
|
return lsScope + key;
|
||||||
|
}
|
||||||
|
|
||||||
// loadLocal reads the cached column state synchronously (used in onGridReady
|
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||||
// to apply instantly, before the async DB round-trip).
|
// to apply instantly, before the async DB round-trip).
|
||||||
export function loadLocal(key: string): any[] | null {
|
export function loadLocal(key: string): any[] | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key);
|
const raw = localStorage.getItem(lsKey(key));
|
||||||
const v = raw ? JSON.parse(raw) : null;
|
const v = raw ? JSON.parse(raw) : null;
|
||||||
return Array.isArray(v) ? v : null;
|
return Array.isArray(v) ? v : null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -19,25 +60,70 @@ export function loadLocal(key: string): any[] | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
||||||
export async function loadRemote(key: string): Promise<any[] | null> {
|
//
|
||||||
|
// GetUIPref returns an ERROR — not "" — while the settings store is still
|
||||||
|
// coming up and not yet scoped to the active profile. Treating that as "no
|
||||||
|
// preference" was the silent data-loss path: the grid rendered defaults and the
|
||||||
|
// first column event then wrote those defaults over the good saved copy. So
|
||||||
|
// retry for a few seconds instead, and only give up on a real absence.
|
||||||
|
export async function loadRemote(key: string, attempts = 10): Promise<any[] | null> {
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
try {
|
try {
|
||||||
const v = await GetUIPref(key);
|
const v = await GetUIPref(key);
|
||||||
const parsed = v ? JSON.parse(v) : null;
|
const parsed = v ? JSON.parse(v) : null;
|
||||||
return Array.isArray(parsed) ? parsed : null;
|
return Array.isArray(parsed) ? parsed : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
// Not ready yet (or a parse failure on a corrupt value — one more read
|
||||||
|
// costs nothing). 300 ms × 10 covers a slow startup without hanging.
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveState write-throughs to both the cache and the DB (fire-and-forget).
|
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
|
||||||
|
// the cache key is profile-scoped; the DB key is scoped by the backend.
|
||||||
|
// The DB write is DEBOUNCED. AG-Grid emits a column-resized event per drag
|
||||||
|
// frame, so dragging one border used to fire dozens of settings writes; the
|
||||||
|
// cache write stays synchronous so nothing is lost if the window closes.
|
||||||
|
const pendingDB = new Map<string, string>();
|
||||||
|
const dbTimers = new Map<string, number>();
|
||||||
|
|
||||||
export function saveState(key: string, state: any[]) {
|
export function saveState(key: string, state: any[]) {
|
||||||
const json = JSON.stringify(state);
|
const json = JSON.stringify(state);
|
||||||
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
|
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
|
||||||
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
pendingDB.set(key, json);
|
||||||
|
const prev = dbTimers.get(key);
|
||||||
|
if (prev) clearTimeout(prev);
|
||||||
|
dbTimers.set(key, window.setTimeout(() => flushOne(key), 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushOne(key: string) {
|
||||||
|
const json = pendingDB.get(key);
|
||||||
|
dbTimers.delete(key);
|
||||||
|
if (json == null) return;
|
||||||
|
pendingDB.delete(key);
|
||||||
|
SetUIPref(key, json).catch(() => {
|
||||||
|
// The store may not be scoped yet at startup. Keep the value and try once
|
||||||
|
// more shortly — dropping it here is how a fresh profile ended up with no
|
||||||
|
// portable copy at all.
|
||||||
|
pendingDB.set(key, json);
|
||||||
|
if (!dbTimers.has(key)) dbTimers.set(key, window.setTimeout(() => flushOne(key), 2000));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushGridPrefs writes any debounced state immediately. Call it when the app is
|
||||||
|
// about to close so a resize made in the last moments still reaches the DB.
|
||||||
|
export function flushGridPrefs() {
|
||||||
|
for (const key of Array.from(pendingDB.keys())) {
|
||||||
|
const t = dbTimers.get(key);
|
||||||
|
if (t) clearTimeout(t);
|
||||||
|
flushOne(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedLocal writes a value into the cache without touching the DB (used after
|
// seedLocal writes a value into the cache without touching the DB (used after
|
||||||
// hydrating the cache from the DB on a fresh machine).
|
// hydrating the cache from the DB on a fresh machine).
|
||||||
export function seedLocal(key: string, state: any[]) {
|
export function seedLocal(key: string, state: any[]) {
|
||||||
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
|
try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|||||||
+306
-58
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
|
|||||||
|
// Colouring for QSL / confirmation status letters.
|
||||||
|
//
|
||||||
|
// These columns are read by COLOUR before they are read as letters: an operator
|
||||||
|
// scanning a hundred rows wants to see where the gaps are, not to read "Y" a
|
||||||
|
// hundred times.
|
||||||
|
//
|
||||||
|
// Colour ONLY — no pill, no badge. These values sit among callsigns and dates,
|
||||||
|
// and a column of coloured boxes would shout louder than the callsign it
|
||||||
|
// belongs to.
|
||||||
|
//
|
||||||
|
// The classes are semantic tokens, not fixed colours, so all four themes follow
|
||||||
|
// and the contrast stays right on the light ones.
|
||||||
|
|
||||||
|
// ADIF confirmation values, and what they mean to the operator:
|
||||||
|
// Y confirmed / sent
|
||||||
|
// N not sent, not received
|
||||||
|
// R requested — queued, waiting on the other station or the service
|
||||||
|
// I ignore (ADIF's "invalid/ignore"), left in default ink: it is neither
|
||||||
|
// good news nor bad, and colouring it would put it in one camp or the
|
||||||
|
// other.
|
||||||
|
//
|
||||||
|
// Anything else — a blank, or a status some other logger wrote — is left alone
|
||||||
|
// rather than guessed at.
|
||||||
|
export function qslStatusClass(value: unknown): string {
|
||||||
|
switch (String(value ?? '').trim().toUpperCase()) {
|
||||||
|
case 'Y':
|
||||||
|
return 'text-success';
|
||||||
|
case 'N':
|
||||||
|
return 'text-destructive';
|
||||||
|
case 'R':
|
||||||
|
return 'text-info';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// qslStatusCellClass is the AG-Grid form: same rule, plus the monospace the
|
||||||
|
// status columns already used so the letters stay in a straight column.
|
||||||
|
export function qslStatusCellClass(p: { value?: unknown }): string {
|
||||||
|
const c = qslStatusClass(p?.value);
|
||||||
|
return c ? 'font-mono ' + c : 'font-mono';
|
||||||
|
}
|
||||||
@@ -14,7 +14,11 @@ export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const LS_KEY = 'opslog.theme';
|
export const LS_KEY = 'opslog.theme';
|
||||||
const DEFAULT: ThemeChoice = 'light-warm';
|
// A fresh install starts DARK. A shack is usually a dim room and the screen is
|
||||||
|
// looked at for hours; every other logger defaults the same way. Graphite
|
||||||
|
// specifically, because that is what 'auto' already resolves to for a dark
|
||||||
|
// system — so the two paths agree instead of landing on different darks.
|
||||||
|
const DEFAULT: ThemeChoice = 'dark-graphite';
|
||||||
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
||||||
|
|
||||||
function systemDark(): boolean {
|
function systemDark(): boolean {
|
||||||
@@ -73,16 +77,24 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
const load = () => {
|
const load = () => {
|
||||||
tries += 1;
|
tries += 1;
|
||||||
GetUIPref(LS_KEY).then((raw) => {
|
GetUIPref(LS_KEY).then((raw) => {
|
||||||
|
// Backend ANSWERED (settings store ready). Apply a valid stored value; an
|
||||||
|
// empty/invalid one means the theme is genuinely unset → keep the default.
|
||||||
|
// Either way we're done — do NOT retry (retrying only matters while the
|
||||||
|
// backend is still starting, which now surfaces as a rejected promise).
|
||||||
if (cancelled || userPicked.current) return;
|
if (cancelled || userPicked.current) return;
|
||||||
const v = raw as ThemeChoice;
|
const v = raw as ThemeChoice;
|
||||||
if (v && ALL.includes(v)) {
|
if (v && ALL.includes(v)) {
|
||||||
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
|
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
|
||||||
applyThemeToDom(v); // idempotent — safe to call unconditionally
|
applyThemeToDom(v); // idempotent — safe to call unconditionally
|
||||||
setThemeState(v);
|
setThemeState(v);
|
||||||
return; // restored
|
|
||||||
}
|
}
|
||||||
if (tries < 8) window.setTimeout(load, 300); // empty (unset or backend not ready yet) → retry
|
}).catch(() => {
|
||||||
}).catch(() => { if (!cancelled && tries < 8) window.setTimeout(load, 300); });
|
// Settings store not ready yet — a brief startup window before the backend
|
||||||
|
// has opened the local DB and built the store (the frontend can query it
|
||||||
|
// first). Keep retrying well past the old 2.4s cap so a dark theme isn't
|
||||||
|
// lost to the light default after an update cleared localStorage.
|
||||||
|
if (!cancelled && !userPicked.current && tries < 120) window.setTimeout(load, 300);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
|
|||||||
@@ -19,19 +19,27 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||||
|
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
|
||||||
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
||||||
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
||||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
|
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||||
|
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
|
||||||
|
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
|
||||||
|
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
|
||||||
// Cluster filter selections — restored on reopen.
|
// Cluster filter selections — restored on reopen.
|
||||||
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
'opslog.activeTab', // last selected tab
|
'opslog.activeTab', // last selected tab
|
||||||
'hamlog.awardColsShown', // which award columns are shown in the QSO grid
|
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||||
|
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||||
|
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||||
|
// through this global path would fight that per-profile scoping.
|
||||||
];
|
];
|
||||||
|
|
||||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import App from './App'
|
|||||||
import { syncPortablePrefs } from './lib/uiPref'
|
import { syncPortablePrefs } from './lib/uiPref'
|
||||||
import { I18nProvider } from './lib/i18n'
|
import { I18nProvider } from './lib/i18n'
|
||||||
import { ThemeProvider, initTheme } from './lib/theme'
|
import { ThemeProvider, initTheme } from './lib/theme'
|
||||||
|
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
|
||||||
|
import { reloadDateFormat } from './lib/dateFormat'
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
const container = document.getElementById('root')
|
||||||
|
|
||||||
@@ -17,13 +19,23 @@ syncPortablePrefs().finally(() => {
|
|||||||
// Stamp the persisted theme onto <html> before render so the correct
|
// Stamp the persisted theme onto <html> before render so the correct
|
||||||
// palette is applied immediately (portable pref hydrated just above).
|
// palette is applied immediately (portable pref hydrated just above).
|
||||||
initTheme()
|
initTheme()
|
||||||
|
// The date format module read localStorage at import time, which is BEFORE
|
||||||
|
// the portable prefs were pulled from the database. Re-read it now, or a
|
||||||
|
// copied data/ folder would show ISO until the next launch.
|
||||||
|
reloadDateFormat()
|
||||||
|
// Catch what a boundary cannot: errors thrown from timers, event handlers and
|
||||||
|
// rejected promises. Installed before the first render so a fault during
|
||||||
|
// startup is reported too.
|
||||||
|
installGlobalErrorLogging()
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<App/>
|
<App/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -652,3 +652,37 @@
|
|||||||
border: 2px solid var(--background);
|
border: 2px solid var(--background);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
||||||
|
|
||||||
|
/* ── Frameless window: drag region ──────────────────────────────────────────
|
||||||
|
The OS title bar is gone (main.go, Frameless), so the app header IS the title
|
||||||
|
bar and must be draggable. Everything interactive inside it opts OUT: a drag
|
||||||
|
region swallows clicks, and without this the menus and toolbar buttons would
|
||||||
|
move the window instead of doing their job. Opting out by ROLE rather than
|
||||||
|
listing each child keeps a future button working without anyone remembering
|
||||||
|
this rule. */
|
||||||
|
.app-dragregion {
|
||||||
|
--wails-draggable: drag;
|
||||||
|
}
|
||||||
|
.app-dragregion button,
|
||||||
|
.app-dragregion a,
|
||||||
|
.app-dragregion input,
|
||||||
|
.app-dragregion select,
|
||||||
|
.app-dragregion textarea,
|
||||||
|
.app-dragregion nav,
|
||||||
|
.app-dragregion [role='button'],
|
||||||
|
.app-dragregion [role='menu'],
|
||||||
|
.app-dragregion [data-no-drag] {
|
||||||
|
--wails-draggable: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Native date inputs: the WebView draws its calendar glyph in near-black, which
|
||||||
|
disappears on the dark themes. Invert it there — the control itself is worth
|
||||||
|
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
|
||||||
|
help. */
|
||||||
|
[data-theme='dim-slate'] input[type='date']::-webkit-calendar-picker-indicator,
|
||||||
|
[data-theme='dark-warm'] input[type='date']::-webkit-calendar-picker-indicator,
|
||||||
|
[data-theme='dark-graphite'] input[type='date']::-webkit-calendar-picker-indicator,
|
||||||
|
[data-theme='high-contrast'] input[type='date']::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(1) brightness(1.6);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.19.9';
|
export const APP_VERSION = '0.22.3';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+218
-7
@@ -5,13 +5,17 @@ import {qso} from '../models';
|
|||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
import {cat} from '../models';
|
import {cat} from '../models';
|
||||||
import {profile} from '../models';
|
import {profile} from '../models';
|
||||||
|
import {acom} from '../models';
|
||||||
|
import {catemu} from '../models';
|
||||||
import {antgenius} from '../models';
|
import {antgenius} from '../models';
|
||||||
import {award} from '../models';
|
import {award} from '../models';
|
||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
|
import {spe} from '../models';
|
||||||
import {solar} from '../models';
|
import {solar} from '../models';
|
||||||
|
import {tunergenius} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
import {alerts} from '../models';
|
import {alerts} from '../models';
|
||||||
import {audio} from '../models';
|
import {audio} from '../models';
|
||||||
@@ -21,6 +25,13 @@ import {udp} from '../models';
|
|||||||
import {lotwusers} from '../models';
|
import {lotwusers} from '../models';
|
||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
import {netctl} from '../models';
|
import {netctl} from '../models';
|
||||||
|
import {scp} from '../models';
|
||||||
|
|
||||||
|
export function ACOMSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function ACOMSetPower(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function ADIFExtraFields():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||||
|
|
||||||
@@ -30,6 +41,14 @@ export function ActivateProfile(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||||
|
|
||||||
|
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function AmpPower(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function AmpPowerLevel(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||||
@@ -72,6 +91,8 @@ export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Prom
|
|||||||
|
|
||||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
|
export function CIVTraceEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function CWDecoderRunning():Promise<boolean>;
|
export function CWDecoderRunning():Promise<boolean>;
|
||||||
|
|
||||||
export function ChatAvailable():Promise<boolean>;
|
export function ChatAvailable():Promise<boolean>;
|
||||||
@@ -146,26 +167,34 @@ export function DismissAwardUpdate(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function DownloadAllReferenceLists():Promise<string>;
|
export function DownloadAllReferenceLists():Promise<string>;
|
||||||
|
|
||||||
|
export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
|
export function DownloadClublogMostWanted():Promise<main.ClublogMostWantedInfo>;
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function DownloadLoTWUsers():Promise<number>;
|
export function DownloadLoTWUsers():Promise<number>;
|
||||||
|
|
||||||
|
export function DownloadScp():Promise<number>;
|
||||||
|
|
||||||
export function DownloadULSCounties():Promise<void>;
|
export function DownloadULSCounties():Promise<void>;
|
||||||
|
|
||||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||||
|
|
||||||
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||||
|
|
||||||
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
export function ExportADIF(arg1:string,arg2:boolean,arg3:Array<string>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter,arg4:Array<string>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>,arg4:Array<string>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportAward(arg1:string):Promise<string>;
|
export function ExportAward(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
|
||||||
|
|
||||||
export function ExportAwards():Promise<string>;
|
export function ExportAwards():Promise<string>;
|
||||||
|
|
||||||
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
||||||
@@ -188,8 +217,12 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexMox(arg1:boolean):Promise<void>;
|
export function FlexMox(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSendCW(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
||||||
@@ -198,6 +231,8 @@ export function FlexSetANF(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetANFT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
||||||
@@ -218,8 +253,20 @@ export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetDAX(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSANF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSANFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMic(arg1:number):Promise<void>;
|
export function FlexSetMic(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
||||||
@@ -236,6 +283,10 @@ export function FlexSetNBLevel(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetNR(arg1:boolean):Promise<void>;
|
export function FlexSetNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNRF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNRFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetNRLevel(arg1:number):Promise<void>;
|
export function FlexSetNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetPower(arg1:number):Promise<void>;
|
export function FlexSetPower(arg1:number):Promise<void>;
|
||||||
@@ -248,14 +299,22 @@ export function FlexSetRIT(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetRITFreq(arg1:number):Promise<void>;
|
export function FlexSetRITFreq(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetRNN(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetSpeexNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetSpeexNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetSplit(arg1:boolean):Promise<void>;
|
export function FlexSetSplit(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetTXDAX(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
|
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
||||||
@@ -276,12 +335,24 @@ export function FlexSetXIT(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexStopCW():Promise<void>;
|
||||||
|
|
||||||
export function FlexTune(arg1:boolean):Promise<void>;
|
export function FlexTune(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function GetACOMStatus():Promise<acom.Status>;
|
||||||
|
|
||||||
|
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
|
|
||||||
export function GetAlertEmailTo():Promise<string>;
|
export function GetAlertEmailTo():Promise<string>;
|
||||||
|
|
||||||
|
export function GetAmpBandFollowStatus():Promise<Record<string, catemu.Status>>;
|
||||||
|
|
||||||
|
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
|
||||||
|
|
||||||
|
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
|
||||||
|
|
||||||
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
||||||
|
|
||||||
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
||||||
@@ -314,10 +385,14 @@ export function GetCWDecoderPitch():Promise<number>;
|
|||||||
|
|
||||||
export function GetCatalogCodes():Promise<Array<string>>;
|
export function GetCatalogCodes():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
|
export function GetClublogMostWantedInfo():Promise<main.ClublogMostWantedInfo>;
|
||||||
|
|
||||||
export function GetClusterAutoConnect():Promise<boolean>;
|
export function GetClusterAutoConnect():Promise<boolean>;
|
||||||
|
|
||||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||||
@@ -350,7 +425,7 @@ export function GetIcomState():Promise<cat.IcomTXState>;
|
|||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||||
|
|
||||||
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||||
|
|
||||||
@@ -382,10 +457,18 @@ export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
|||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
|
|
||||||
|
export function GetQSORate():Promise<main.QSORate>;
|
||||||
|
|
||||||
|
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||||
|
|
||||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||||
|
|
||||||
|
export function GetSPEStatus():Promise<spe.Status>;
|
||||||
|
|
||||||
|
export function GetScpStatus():Promise<main.ScpStatus>;
|
||||||
|
|
||||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
|
|
||||||
export function GetSlotStats():Promise<qso.SlotStats>;
|
export function GetSlotStats():Promise<qso.SlotStats>;
|
||||||
@@ -402,16 +485,24 @@ export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
|||||||
|
|
||||||
export function GetTelemetryEnabled():Promise<boolean>;
|
export function GetTelemetryEnabled():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetTunerGeniusSettings():Promise<main.TunerGeniusSettings>;
|
||||||
|
|
||||||
|
export function GetTunerGeniusStatus():Promise<tunergenius.Status>;
|
||||||
|
|
||||||
export function GetUIPref(arg1:string):Promise<string>;
|
export function GetUIPref(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
||||||
|
|
||||||
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||||
|
|
||||||
|
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||||
|
|
||||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||||
|
|
||||||
|
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
||||||
|
|
||||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
export function IcomRefresh():Promise<void>;
|
export function IcomRefresh():Promise<void>;
|
||||||
@@ -500,7 +591,7 @@ export function IcomStopCW():Promise<void>;
|
|||||||
|
|
||||||
export function IcomTune():Promise<void>;
|
export function IcomTune():Promise<void>;
|
||||||
|
|
||||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean,arg5:Record<string, string>):Promise<adif.ImportResult>;
|
||||||
|
|
||||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
@@ -526,6 +617,8 @@ export function ListContests():Promise<Array<contest.Def>>;
|
|||||||
|
|
||||||
export function ListCountries():Promise<Array<string>>;
|
export function ListCountries():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function ListDenkoviDevices():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||||
|
|
||||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||||
@@ -540,12 +633,18 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
|||||||
|
|
||||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
|
|
||||||
|
export function LiveLastQSOAgeSec():Promise<number>;
|
||||||
|
|
||||||
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
|
|
||||||
|
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
export function MotorReadElements():Promise<Array<number>>;
|
export function MotorReadElements():Promise<Array<number>>;
|
||||||
|
|
||||||
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
|
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
|
||||||
@@ -598,6 +697,10 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
|
|||||||
|
|
||||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function PickADIFMonitorFile():Promise<string>;
|
||||||
|
|
||||||
export function PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
|
|
||||||
export function PickBackupFolder():Promise<string>;
|
export function PickBackupFolder():Promise<string>;
|
||||||
@@ -646,20 +749,38 @@ export function QSOAudioBegin():Promise<boolean>;
|
|||||||
|
|
||||||
export function QSOAudioCancel():Promise<void>;
|
export function QSOAudioCancel():Promise<void>;
|
||||||
|
|
||||||
|
export function QSOAudioManualReady():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioManualStart():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioPlayOnAir():Promise<void>;
|
||||||
|
|
||||||
export function QSOAudioResetClock():Promise<boolean>;
|
export function QSOAudioResetClock():Promise<boolean>;
|
||||||
|
|
||||||
export function QSOAudioRestart():Promise<boolean>;
|
export function QSOAudioRestart():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioResume():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioStop():Promise<boolean>;
|
||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
|
|
||||||
|
export function RecomputeAllAwardRefs():Promise<number>;
|
||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function RefreshSolar():Promise<void>;
|
export function RefreshSolar():Promise<void>;
|
||||||
|
|
||||||
|
export function RefreshYaesuPanel():Promise<void>;
|
||||||
|
|
||||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||||
|
|
||||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RenameDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RenameLogbook(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
||||||
|
|
||||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||||
@@ -678,6 +799,8 @@ export function RestartQSORecorder():Promise<void>;
|
|||||||
|
|
||||||
export function RetryOfflineSync():Promise<number>;
|
export function RetryOfflineSync():Promise<number>;
|
||||||
|
|
||||||
|
export function RevealDataFolder():Promise<void>;
|
||||||
|
|
||||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function RotatorPark():Promise<void>;
|
export function RotatorPark():Promise<void>;
|
||||||
@@ -686,10 +809,22 @@ export function RotatorStop():Promise<void>;
|
|||||||
|
|
||||||
export function RunBackupNow():Promise<string>;
|
export function RunBackupNow():Promise<string>;
|
||||||
|
|
||||||
|
export function SMTPConfigured():Promise<boolean>;
|
||||||
|
|
||||||
|
export function SPESetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SPESetPower(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SPESetPowerLevel(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SaveADIFFile():Promise<string>;
|
export function SaveADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
||||||
|
|
||||||
|
export function SaveAmplifiers(arg1:Array<main.AmpConfig>):Promise<void>;
|
||||||
|
|
||||||
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||||
@@ -732,18 +867,24 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
|||||||
|
|
||||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||||
|
|
||||||
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
|
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function ScpLookup(arg1:string):Promise<scp.Result>;
|
||||||
|
|
||||||
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
||||||
|
|
||||||
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
|
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
|
||||||
@@ -754,6 +895,8 @@ export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<voi
|
|||||||
|
|
||||||
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
|
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SendLogToDeveloper():Promise<void>;
|
||||||
|
|
||||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||||
@@ -762,26 +905,70 @@ export function SetCATFrequency(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SetCATMode(arg1:string):Promise<void>;
|
export function SetCATMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetCIVTrace(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetCompactMode(arg1:boolean):Promise<void>;
|
export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
|
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuAFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuAGC(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuAtt(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuBand(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuBreakIn(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuMicGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuModeRaw(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuNB(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuNarrow(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuPower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuPreamp(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuRFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuSplit(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuSplitOffset(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuSquelch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetYaesuVOX(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function StartCWDecoder():Promise<void>;
|
export function StartCWDecoder():Promise<void>;
|
||||||
|
|
||||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||||
@@ -792,6 +979,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
|
|
||||||
|
export function TestCloudlogUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestEQSLUpload():Promise<string>;
|
export function TestEQSLUpload():Promise<string>;
|
||||||
@@ -812,8 +1001,22 @@ export function TestQRZUpload():Promise<string>;
|
|||||||
|
|
||||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
||||||
|
|
||||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function TuneYaesuATU():Promise<void>;
|
||||||
|
|
||||||
|
export function TunerGeniusActivate(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function TunerGeniusAutotune():Promise<void>;
|
||||||
|
|
||||||
|
export function TunerGeniusSetBypass(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function TunerGeniusSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function UILog(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function ULSStatus():Promise<main.ULSStatusResult>;
|
export function ULSStatus():Promise<main.ULSStatusResult>;
|
||||||
|
|
||||||
export function UltrabeamRetract():Promise<void>;
|
export function UltrabeamRetract():Promise<void>;
|
||||||
@@ -846,4 +1049,12 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function WinkeyerStop():Promise<void>;
|
export function WinkeyerStop():Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerTraceEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||||
|
|
||||||
|
export function YaesuSendCW(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function YaesuStopCW():Promise<void>;
|
||||||
|
|
||||||
|
export function YaesuZeroIn():Promise<void>;
|
||||||
|
|||||||
+426
-14
@@ -2,6 +2,18 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function ACOMSetOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['ACOMSetOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ACOMSetPower(arg1) {
|
||||||
|
return window['go']['main']['App']['ACOMSetPower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ADIFExtraFields() {
|
||||||
|
return window['go']['main']['App']['ADIFExtraFields']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ADIFFields() {
|
export function ADIFFields() {
|
||||||
return window['go']['main']['App']['ADIFFields']();
|
return window['go']['main']['App']['ADIFFields']();
|
||||||
}
|
}
|
||||||
@@ -18,6 +30,22 @@ export function AddQSO(arg1) {
|
|||||||
return window['go']['main']['App']['AddQSO'](arg1);
|
return window['go']['main']['App']['AddQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AmpFanMode(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AmpFanMode'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpOperate(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AmpOperate'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpPower(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AmpPower'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpPowerLevel(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AmpPowerLevel'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function AntGeniusActivate(arg1, arg2) {
|
export function AntGeniusActivate(arg1, arg2) {
|
||||||
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
|
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -102,6 +130,10 @@ export function BulkUpdateQSL(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CIVTraceEnabled() {
|
||||||
|
return window['go']['main']['App']['CIVTraceEnabled']();
|
||||||
|
}
|
||||||
|
|
||||||
export function CWDecoderRunning() {
|
export function CWDecoderRunning() {
|
||||||
return window['go']['main']['App']['CWDecoderRunning']();
|
return window['go']['main']['App']['CWDecoderRunning']();
|
||||||
}
|
}
|
||||||
@@ -250,10 +282,18 @@ export function DownloadAllReferenceLists() {
|
|||||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadAndApplyUpdate(arg1) {
|
||||||
|
return window['go']['main']['App']['DownloadAndApplyUpdate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadClublogCty() {
|
export function DownloadClublogCty() {
|
||||||
return window['go']['main']['App']['DownloadClublogCty']();
|
return window['go']['main']['App']['DownloadClublogCty']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadClublogMostWanted() {
|
||||||
|
return window['go']['main']['App']['DownloadClublogMostWanted']();
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1, arg2, arg3) {
|
export function DownloadConfirmations(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -262,6 +302,10 @@ export function DownloadLoTWUsers() {
|
|||||||
return window['go']['main']['App']['DownloadLoTWUsers']();
|
return window['go']['main']['App']['DownloadLoTWUsers']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadScp() {
|
||||||
|
return window['go']['main']['App']['DownloadScp']();
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadULSCounties() {
|
export function DownloadULSCounties() {
|
||||||
return window['go']['main']['App']['DownloadULSCounties']();
|
return window['go']['main']['App']['DownloadULSCounties']();
|
||||||
}
|
}
|
||||||
@@ -274,22 +318,26 @@ export function ExplainAward(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportADIF(arg1, arg2) {
|
export function ExportADIF(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
return window['go']['main']['App']['ExportADIF'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportADIFFiltered(arg1, arg2, arg3) {
|
export function ExportADIFFiltered(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3);
|
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportADIFSelected(arg1, arg2, arg3) {
|
export function ExportADIFSelected(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
|
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportAward(arg1) {
|
export function ExportAward(arg1) {
|
||||||
return window['go']['main']['App']['ExportAward'](arg1);
|
return window['go']['main']['App']['ExportAward'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExportAwardForCatalog(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ExportAwardForCatalog'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExportAwards() {
|
export function ExportAwards() {
|
||||||
return window['go']['main']['App']['ExportAwards']();
|
return window['go']['main']['App']['ExportAwards']();
|
||||||
}
|
}
|
||||||
@@ -334,10 +382,18 @@ export function FlexApplyBandAntenna(arg1) {
|
|||||||
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexBackspaceCW(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexMox(arg1) {
|
export function FlexMox(arg1) {
|
||||||
return window['go']['main']['App']['FlexMox'](arg1);
|
return window['go']['main']['App']['FlexMox'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSendCW(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSendCW'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetAGCMode(arg1) {
|
export function FlexSetAGCMode(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
|
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
|
||||||
}
|
}
|
||||||
@@ -354,6 +410,10 @@ export function FlexSetANFLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
|
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetANFT(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetANFT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetAPF(arg1) {
|
export function FlexSetAPF(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetAPF'](arg1);
|
return window['go']['main']['App']['FlexSetAPF'](arg1);
|
||||||
}
|
}
|
||||||
@@ -394,10 +454,34 @@ export function FlexSetCWSpeed(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
|
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetDAX(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetDAX'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetFilter(arg1, arg2) {
|
export function FlexSetFilter(arg1, arg2) {
|
||||||
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetKeySpeed(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetLMSANF(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetLMSANF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetLMSANFLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetLMSANFLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetLMSNR(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetLMSNR'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetLMSNRLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetLMSNRLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetMic(arg1) {
|
export function FlexSetMic(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||||
}
|
}
|
||||||
@@ -430,6 +514,14 @@ export function FlexSetNR(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetNR'](arg1);
|
return window['go']['main']['App']['FlexSetNR'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetNRF(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNRF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetNRFLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNRFLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetNRLevel(arg1) {
|
export function FlexSetNRLevel(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetNRLevel'](arg1);
|
return window['go']['main']['App']['FlexSetNRLevel'](arg1);
|
||||||
}
|
}
|
||||||
@@ -454,6 +546,10 @@ export function FlexSetRITFreq(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
|
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetRNN(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetRNN'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetRXAntenna(arg1) {
|
export function FlexSetRXAntenna(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
||||||
}
|
}
|
||||||
@@ -462,6 +558,14 @@ export function FlexSetSidetoneLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetSpeexNR(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetSpeexNR'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetSpeexNRLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetSpeexNRLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetSplit(arg1) {
|
export function FlexSetSplit(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetSplit'](arg1);
|
return window['go']['main']['App']['FlexSetSplit'](arg1);
|
||||||
}
|
}
|
||||||
@@ -470,6 +574,10 @@ export function FlexSetTXAntenna(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetTXDAX(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetTXDAX'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetTXFilter(arg1, arg2) {
|
export function FlexSetTXFilter(arg1, arg2) {
|
||||||
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
|
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -510,10 +618,22 @@ export function FlexSetXITFreq(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
|
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexStopCW() {
|
||||||
|
return window['go']['main']['App']['FlexStopCW']();
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexTune(arg1) {
|
export function FlexTune(arg1) {
|
||||||
return window['go']['main']['App']['FlexTune'](arg1);
|
return window['go']['main']['App']['FlexTune'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetACOMStatus() {
|
||||||
|
return window['go']['main']['App']['GetACOMStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetADIFMonitor() {
|
||||||
|
return window['go']['main']['App']['GetADIFMonitor']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetActiveProfile() {
|
export function GetActiveProfile() {
|
||||||
return window['go']['main']['App']['GetActiveProfile']();
|
return window['go']['main']['App']['GetActiveProfile']();
|
||||||
}
|
}
|
||||||
@@ -522,6 +642,18 @@ export function GetAlertEmailTo() {
|
|||||||
return window['go']['main']['App']['GetAlertEmailTo']();
|
return window['go']['main']['App']['GetAlertEmailTo']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAmpBandFollowStatus() {
|
||||||
|
return window['go']['main']['App']['GetAmpBandFollowStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetAmpStatuses() {
|
||||||
|
return window['go']['main']['App']['GetAmpStatuses']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetAmplifiers() {
|
||||||
|
return window['go']['main']['App']['GetAmplifiers']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAntGeniusSettings() {
|
export function GetAntGeniusSettings() {
|
||||||
return window['go']['main']['App']['GetAntGeniusSettings']();
|
return window['go']['main']['App']['GetAntGeniusSettings']();
|
||||||
}
|
}
|
||||||
@@ -586,6 +718,10 @@ export function GetCatalogCodes() {
|
|||||||
return window['go']['main']['App']['GetCatalogCodes']();
|
return window['go']['main']['App']['GetCatalogCodes']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetChangelog() {
|
||||||
|
return window['go']['main']['App']['GetChangelog']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChatHistory(arg1) {
|
export function GetChatHistory(arg1) {
|
||||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -594,6 +730,10 @@ export function GetClublogCtyInfo() {
|
|||||||
return window['go']['main']['App']['GetClublogCtyInfo']();
|
return window['go']['main']['App']['GetClublogCtyInfo']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetClublogMostWantedInfo() {
|
||||||
|
return window['go']['main']['App']['GetClublogMostWantedInfo']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetClusterAutoConnect() {
|
export function GetClusterAutoConnect() {
|
||||||
return window['go']['main']['App']['GetClusterAutoConnect']();
|
return window['go']['main']['App']['GetClusterAutoConnect']();
|
||||||
}
|
}
|
||||||
@@ -658,8 +798,8 @@ export function GetListsSettings() {
|
|||||||
return window['go']['main']['App']['GetListsSettings']();
|
return window['go']['main']['App']['GetListsSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLiveStatusEnabled() {
|
export function GetLiveStations() {
|
||||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
return window['go']['main']['App']['GetLiveStations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLoTWUsersStatus() {
|
export function GetLoTWUsersStatus() {
|
||||||
@@ -722,6 +862,14 @@ export function GetQSO(arg1) {
|
|||||||
return window['go']['main']['App']['GetQSO'](arg1);
|
return window['go']['main']['App']['GetQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetQSORate() {
|
||||||
|
return window['go']['main']['App']['GetQSORate']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetRelayAuto() {
|
||||||
|
return window['go']['main']['App']['GetRelayAuto']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetRotatorHeading() {
|
export function GetRotatorHeading() {
|
||||||
return window['go']['main']['App']['GetRotatorHeading']();
|
return window['go']['main']['App']['GetRotatorHeading']();
|
||||||
}
|
}
|
||||||
@@ -730,6 +878,14 @@ export function GetRotatorSettings() {
|
|||||||
return window['go']['main']['App']['GetRotatorSettings']();
|
return window['go']['main']['App']['GetRotatorSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSPEStatus() {
|
||||||
|
return window['go']['main']['App']['GetSPEStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetScpStatus() {
|
||||||
|
return window['go']['main']['App']['GetScpStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetSecretStatus() {
|
export function GetSecretStatus() {
|
||||||
return window['go']['main']['App']['GetSecretStatus']();
|
return window['go']['main']['App']['GetSecretStatus']();
|
||||||
}
|
}
|
||||||
@@ -762,6 +918,14 @@ export function GetTelemetryEnabled() {
|
|||||||
return window['go']['main']['App']['GetTelemetryEnabled']();
|
return window['go']['main']['App']['GetTelemetryEnabled']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetTunerGeniusSettings() {
|
||||||
|
return window['go']['main']['App']['GetTunerGeniusSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetTunerGeniusStatus() {
|
||||||
|
return window['go']['main']['App']['GetTunerGeniusStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetUIPref(arg1) {
|
export function GetUIPref(arg1) {
|
||||||
return window['go']['main']['App']['GetUIPref'](arg1);
|
return window['go']['main']['App']['GetUIPref'](arg1);
|
||||||
}
|
}
|
||||||
@@ -774,6 +938,10 @@ export function GetUltrabeamStatus() {
|
|||||||
return window['go']['main']['App']['GetUltrabeamStatus']();
|
return window['go']['main']['App']['GetUltrabeamStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetWhatsNew() {
|
||||||
|
return window['go']['main']['App']['GetWhatsNew']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetWinkeyerSettings() {
|
export function GetWinkeyerSettings() {
|
||||||
return window['go']['main']['App']['GetWinkeyerSettings']();
|
return window['go']['main']['App']['GetWinkeyerSettings']();
|
||||||
}
|
}
|
||||||
@@ -782,6 +950,10 @@ export function GetWinkeyerStatus() {
|
|||||||
return window['go']['main']['App']['GetWinkeyerStatus']();
|
return window['go']['main']['App']['GetWinkeyerStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetYaesuState() {
|
||||||
|
return window['go']['main']['App']['GetYaesuState']();
|
||||||
|
}
|
||||||
|
|
||||||
export function HasBuiltinReferences(arg1) {
|
export function HasBuiltinReferences(arg1) {
|
||||||
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
||||||
}
|
}
|
||||||
@@ -958,8 +1130,8 @@ export function IcomTune() {
|
|||||||
return window['go']['main']['App']['IcomTune']();
|
return window['go']['main']['App']['IcomTune']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportADIF(arg1, arg2, arg3, arg4) {
|
export function ImportADIF(arg1, arg2, arg3, arg4, arg5) {
|
||||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4, arg5);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportAwardReferencesText(arg1, arg2) {
|
export function ImportAwardReferencesText(arg1, arg2) {
|
||||||
@@ -1010,6 +1182,10 @@ export function ListCountries() {
|
|||||||
return window['go']['main']['App']['ListCountries']();
|
return window['go']['main']['App']['ListCountries']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListDenkoviDevices() {
|
||||||
|
return window['go']['main']['App']['ListDenkoviDevices']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListOperatingTree() {
|
export function ListOperatingTree() {
|
||||||
return window['go']['main']['App']['ListOperatingTree']();
|
return window['go']['main']['App']['ListOperatingTree']();
|
||||||
}
|
}
|
||||||
@@ -1038,6 +1214,10 @@ export function ListUDPIntegrations() {
|
|||||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LiveLastQSOAgeSec() {
|
||||||
|
return window['go']['main']['App']['LiveLastQSOAgeSec']();
|
||||||
|
}
|
||||||
|
|
||||||
export function LoTWUserInfo(arg1) {
|
export function LoTWUserInfo(arg1) {
|
||||||
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1046,10 +1226,18 @@ export function LogUDPLoggedADIF(arg1) {
|
|||||||
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LogUIError(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['LogUIError'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function LookupCallsign(arg1) {
|
export function LookupCallsign(arg1) {
|
||||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LookupCallsignFresh(arg1) {
|
||||||
|
return window['go']['main']['App']['LookupCallsignFresh'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function MotorReadElements() {
|
export function MotorReadElements() {
|
||||||
return window['go']['main']['App']['MotorReadElements']();
|
return window['go']['main']['App']['MotorReadElements']();
|
||||||
}
|
}
|
||||||
@@ -1154,6 +1342,14 @@ export function PGXLSetFanMode(arg1) {
|
|||||||
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PGXLSetOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['PGXLSetOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PickADIFMonitorFile() {
|
||||||
|
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||||
|
}
|
||||||
|
|
||||||
export function PickAudioFolder() {
|
export function PickAudioFolder() {
|
||||||
return window['go']['main']['App']['PickAudioFolder']();
|
return window['go']['main']['App']['PickAudioFolder']();
|
||||||
}
|
}
|
||||||
@@ -1250,6 +1446,18 @@ export function QSOAudioCancel() {
|
|||||||
return window['go']['main']['App']['QSOAudioCancel']();
|
return window['go']['main']['App']['QSOAudioCancel']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function QSOAudioManualReady() {
|
||||||
|
return window['go']['main']['App']['QSOAudioManualReady']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSOAudioManualStart() {
|
||||||
|
return window['go']['main']['App']['QSOAudioManualStart']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSOAudioPlayOnAir() {
|
||||||
|
return window['go']['main']['App']['QSOAudioPlayOnAir']();
|
||||||
|
}
|
||||||
|
|
||||||
export function QSOAudioResetClock() {
|
export function QSOAudioResetClock() {
|
||||||
return window['go']['main']['App']['QSOAudioResetClock']();
|
return window['go']['main']['App']['QSOAudioResetClock']();
|
||||||
}
|
}
|
||||||
@@ -1258,10 +1466,22 @@ export function QSOAudioRestart() {
|
|||||||
return window['go']['main']['App']['QSOAudioRestart']();
|
return window['go']['main']['App']['QSOAudioRestart']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function QSOAudioResume() {
|
||||||
|
return window['go']['main']['App']['QSOAudioResume']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSOAudioStop() {
|
||||||
|
return window['go']['main']['App']['QSOAudioStop']();
|
||||||
|
}
|
||||||
|
|
||||||
export function QuitApp() {
|
export function QuitApp() {
|
||||||
return window['go']['main']['App']['QuitApp']();
|
return window['go']['main']['App']['QuitApp']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RecomputeAllAwardRefs() {
|
||||||
|
return window['go']['main']['App']['RecomputeAllAwardRefs']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RefreshCtyDat() {
|
export function RefreshCtyDat() {
|
||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
@@ -1270,6 +1490,10 @@ export function RefreshSolar() {
|
|||||||
return window['go']['main']['App']['RefreshSolar']();
|
return window['go']['main']['App']['RefreshSolar']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RefreshYaesuPanel() {
|
||||||
|
return window['go']['main']['App']['RefreshYaesuPanel']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ReloadUDPIntegrations() {
|
export function ReloadUDPIntegrations() {
|
||||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||||
}
|
}
|
||||||
@@ -1278,6 +1502,14 @@ export function RemovePassphrase(arg1) {
|
|||||||
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RenameDatabase(arg1) {
|
||||||
|
return window['go']['main']['App']['RenameDatabase'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenameLogbook(arg1) {
|
||||||
|
return window['go']['main']['App']['RenameLogbook'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function RenderEQSL(arg1, arg2) {
|
export function RenderEQSL(arg1, arg2) {
|
||||||
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -1314,6 +1546,10 @@ export function RetryOfflineSync() {
|
|||||||
return window['go']['main']['App']['RetryOfflineSync']();
|
return window['go']['main']['App']['RetryOfflineSync']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RevealDataFolder() {
|
||||||
|
return window['go']['main']['App']['RevealDataFolder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RotatorGoTo(arg1, arg2) {
|
export function RotatorGoTo(arg1, arg2) {
|
||||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -1330,14 +1566,38 @@ export function RunBackupNow() {
|
|||||||
return window['go']['main']['App']['RunBackupNow']();
|
return window['go']['main']['App']['RunBackupNow']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SMTPConfigured() {
|
||||||
|
return window['go']['main']['App']['SMTPConfigured']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SPESetOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['SPESetOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SPESetPower(arg1) {
|
||||||
|
return window['go']['main']['App']['SPESetPower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SPESetPowerLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['SPESetPowerLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveADIFFile() {
|
export function SaveADIFFile() {
|
||||||
return window['go']['main']['App']['SaveADIFFile']();
|
return window['go']['main']['App']['SaveADIFFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveADIFMonitor(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveADIFMonitor'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAlertRule(arg1) {
|
export function SaveAlertRule(arg1) {
|
||||||
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveAmplifiers(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveAmplifiers'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAntGeniusSettings(arg1) {
|
export function SaveAntGeniusSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
|
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1422,6 +1682,10 @@ export function SaveQSLDefaults(arg1) {
|
|||||||
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveRelayAuto(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1) {
|
export function SaveRotatorSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1434,6 +1698,10 @@ export function SaveStationSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveTunerGeniusSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveTunerGeniusSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveUDPIntegration(arg1) {
|
export function SaveUDPIntegration(arg1) {
|
||||||
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
|
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1446,6 +1714,10 @@ export function SaveWinkeyerSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
|
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ScpLookup(arg1) {
|
||||||
|
return window['go']['main']['App']['ScpLookup'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
|
export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
@@ -1466,6 +1738,10 @@ export function SendEQSL(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3);
|
return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SendLogToDeveloper() {
|
||||||
|
return window['go']['main']['App']['SendLogToDeveloper']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SendQSORecordingEmail(arg1) {
|
export function SendQSORecordingEmail(arg1) {
|
||||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1482,6 +1758,10 @@ export function SetCATMode(arg1) {
|
|||||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetCIVTrace(arg1) {
|
||||||
|
return window['go']['main']['App']['SetCIVTrace'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetCWDecoderPitch(arg1) {
|
export function SetCWDecoderPitch(arg1) {
|
||||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1490,6 +1770,10 @@ export function SetClublogCtyEnabled(arg1) {
|
|||||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetClublogMostWantedEnabled(arg1) {
|
||||||
|
return window['go']['main']['App']['SetClublogMostWantedEnabled'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetClusterAutoConnect(arg1) {
|
export function SetClusterAutoConnect(arg1) {
|
||||||
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
|
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1502,14 +1786,14 @@ export function SetDVKLabel(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetLiveStatusEnabled(arg1) {
|
|
||||||
return window['go']['main']['App']['SetLiveStatusEnabled'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SetPassphrase(arg1) {
|
export function SetPassphrase(arg1) {
|
||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetScpEnabled(arg1) {
|
||||||
|
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetTelemetryEnabled(arg1) {
|
export function SetTelemetryEnabled(arg1) {
|
||||||
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
|
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1522,6 +1806,86 @@ export function SetUltrabeamDirection(arg1) {
|
|||||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetWinkeyerTrace(arg1) {
|
||||||
|
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuAFGain(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuAGC(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuAGC'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuAtt(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuAtt'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuBand(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuBand'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuBreakIn(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuBreakIn'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuKeySpeed(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuKeySpeed'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuMicGain(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuMicGain'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuModeRaw(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuModeRaw'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuNB(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuNB'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuNR(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuNR'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuNRLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuNRLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuNarrow(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuNarrow'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuPower(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuPower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuPreamp(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuPreamp'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuRFGain(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuRFGain'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuSplit(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuSplit'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuSplitOffset(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuSplitOffset'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuSquelch(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuSquelch'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetYaesuVOX(arg1) {
|
||||||
|
return window['go']['main']['App']['SetYaesuVOX'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function StartCWDecoder() {
|
export function StartCWDecoder() {
|
||||||
return window['go']['main']['App']['StartCWDecoder']();
|
return window['go']['main']['App']['StartCWDecoder']();
|
||||||
}
|
}
|
||||||
@@ -1542,6 +1906,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestCloudlogUpload() {
|
||||||
|
return window['go']['main']['App']['TestCloudlogUpload']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestClublogUpload() {
|
export function TestClublogUpload() {
|
||||||
return window['go']['main']['App']['TestClublogUpload']();
|
return window['go']['main']['App']['TestClublogUpload']();
|
||||||
}
|
}
|
||||||
@@ -1582,10 +1950,38 @@ export function TestRotator(arg1) {
|
|||||||
return window['go']['main']['App']['TestRotator'](arg1);
|
return window['go']['main']['App']['TestRotator'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestStationDevice(arg1) {
|
||||||
|
return window['go']['main']['App']['TestStationDevice'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function TestUltrabeam(arg1) {
|
export function TestUltrabeam(arg1) {
|
||||||
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TuneYaesuATU() {
|
||||||
|
return window['go']['main']['App']['TuneYaesuATU']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TunerGeniusActivate(arg1) {
|
||||||
|
return window['go']['main']['App']['TunerGeniusActivate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TunerGeniusAutotune() {
|
||||||
|
return window['go']['main']['App']['TunerGeniusAutotune']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TunerGeniusSetBypass(arg1) {
|
||||||
|
return window['go']['main']['App']['TunerGeniusSetBypass'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TunerGeniusSetOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['TunerGeniusSetOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UILog(arg1) {
|
||||||
|
return window['go']['main']['App']['UILog'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ULSStatus() {
|
export function ULSStatus() {
|
||||||
return window['go']['main']['App']['ULSStatus']();
|
return window['go']['main']['App']['ULSStatus']();
|
||||||
}
|
}
|
||||||
@@ -1650,6 +2046,22 @@ export function WinkeyerStop() {
|
|||||||
return window['go']['main']['App']['WinkeyerStop']();
|
return window['go']['main']['App']['WinkeyerStop']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function WinkeyerTraceEnabled() {
|
||||||
|
return window['go']['main']['App']['WinkeyerTraceEnabled']();
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkedBefore(arg1, arg2) {
|
export function WorkedBefore(arg1, arg2) {
|
||||||
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function YaesuSendCW(arg1) {
|
||||||
|
return window['go']['main']['App']['YaesuSendCW'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YaesuStopCW() {
|
||||||
|
return window['go']['main']['App']['YaesuStopCW']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YaesuZeroIn() {
|
||||||
|
return window['go']['main']['App']['YaesuZeroIn']();
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/cat/civ"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Icom model picker in the settings and civ.ModelName are two hand-kept
|
||||||
|
// copies of the same table, and they had drifted: the UI offered the IC-7700 at
|
||||||
|
// 0x88 and the IC-7800 at 0x80, which are the IC-7100's and the IC-7410's
|
||||||
|
// factory addresses. Picking either set an address the rig never answers on —
|
||||||
|
// the symptom is a rig that simply never replies — and the backend then named it
|
||||||
|
// as the other model.
|
||||||
|
//
|
||||||
|
// The list is read out of the .tsx itself, so a model added on one side alone
|
||||||
|
// fails here rather than on someone's radio.
|
||||||
|
func TestIcomModelAddressesMatch(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("frontend/src/components/SettingsModal.tsx")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("frontend source not available: %v", err)
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`\{ name: '(IC-[A-Za-z0-9-]+)', addr: 0x([0-9A-Fa-f]{2}) \}`)
|
||||||
|
ms := re.FindAllStringSubmatch(string(src), -1)
|
||||||
|
if len(ms) < 5 {
|
||||||
|
t.Fatalf("only %d models found — the parse is wrong, not the data", len(ms))
|
||||||
|
}
|
||||||
|
for _, m := range ms {
|
||||||
|
name := m[1]
|
||||||
|
addr, err := strconv.ParseUint(m[2], 16, 8)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bad address %q for %s", m[2], name)
|
||||||
|
}
|
||||||
|
if got := civ.ModelName(byte(addr)); got != name {
|
||||||
|
t.Errorf("settings offer %s at 0x%02X, but civ.ModelName(0x%02X) = %q",
|
||||||
|
name, addr, addr, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The confirmation statuses an import fills in, and the ones it must not touch.
|
||||||
|
//
|
||||||
|
// An operator asked for this feature believing it missing — it has been there
|
||||||
|
// since v0.14, but the option's description never mentioned it, so nobody could
|
||||||
|
// know. The behaviour is pinned here so a future edit cannot quietly drop what
|
||||||
|
// the description now promises.
|
||||||
|
//
|
||||||
|
// The rule is fill-if-empty: a WSJT-X log carries almost no QSL fields, while a
|
||||||
|
// log exported from another program carries real ones that must survive.
|
||||||
|
func TestQSLDefaultsFillOnlyEmptyFields(t *testing.T) {
|
||||||
|
defaults := QSLDefaults{
|
||||||
|
QSLSent: "N", QSLRcvd: "N",
|
||||||
|
LOTWSent: "N", LOTWRcvd: "N",
|
||||||
|
EQSLSent: "N", EQSLRcvd: "N",
|
||||||
|
ClublogStatus: "N", HRDLogStatus: "N",
|
||||||
|
QRZComStatus: "N", QRZComCfm: "N",
|
||||||
|
}
|
||||||
|
|
||||||
|
// A record as WSJT-X writes it: no confirmation fields at all.
|
||||||
|
bare := &qso.QSO{Callsign: "F4XYZ"}
|
||||||
|
applyQSLDefaultsTo(bare, defaults)
|
||||||
|
for name, got := range map[string]string{
|
||||||
|
"qsl_sent": bare.QSLSent, "qsl_rcvd": bare.QSLRcvd,
|
||||||
|
"lotw_sent": bare.LOTWSent, "lotw_rcvd": bare.LOTWRcvd,
|
||||||
|
"eqsl_sent": bare.EQSLSent, "eqsl_rcvd": bare.EQSLRcvd,
|
||||||
|
"clublog": bare.ClublogUploadStatus, "hrdlog": bare.HRDLogUploadStatus,
|
||||||
|
"qrz_sent": bare.QRZComUploadStatus, "qrz_rcvd": bare.QRZComDownloadStatus,
|
||||||
|
} {
|
||||||
|
if got != "N" {
|
||||||
|
t.Errorf("%s = %q on an empty record, want the default \"N\"", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A record that already carries confirmations — from a real logbook export.
|
||||||
|
// Overwriting these would erase QSLs the operator has actually received.
|
||||||
|
kept := &qso.QSO{
|
||||||
|
Callsign: "F4XYZ",
|
||||||
|
QSLRcvd: "Y", LOTWRcvd: "Y", EQSLSent: "Y",
|
||||||
|
ClublogUploadStatus: "Y", QRZComDownloadStatus: "Y",
|
||||||
|
}
|
||||||
|
applyQSLDefaultsTo(kept, defaults)
|
||||||
|
for name, got := range map[string]string{
|
||||||
|
"qsl_rcvd": kept.QSLRcvd, "lotw_rcvd": kept.LOTWRcvd, "eqsl_sent": kept.EQSLSent,
|
||||||
|
"clublog": kept.ClublogUploadStatus, "qrz_rcvd": kept.QRZComDownloadStatus,
|
||||||
|
} {
|
||||||
|
if got != "Y" {
|
||||||
|
t.Errorf("%s = %q — an existing confirmation was overwritten", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// …while its empty ones still get the default.
|
||||||
|
if kept.QSLSent != "N" || kept.LOTWSent != "N" {
|
||||||
|
t.Errorf("empty fields on a partly-filled record were not defaulted: qsl_sent=%q lotw_sent=%q",
|
||||||
|
kept.QSLSent, kept.LOTWSent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
// Package acom drives the ACOM solid-state amplifiers (500S / 600S / 700S /
|
||||||
|
// 1200S / 2020S) over their (unpublished) RS-232 protocol, reverse-engineered by
|
||||||
|
// the ACOM-Controller project (bjornekelund, C#). The amp is reached either
|
||||||
|
// directly over a serial COM port (9600 8N1) or over TCP via an RS232-to-Ethernet
|
||||||
|
// bridge — both are just an io.ReadWriteCloser to this code, same as the SPE
|
||||||
|
// backend.
|
||||||
|
//
|
||||||
|
// Wire format (host → amp): fixed raw byte strings, validated by the same rule as
|
||||||
|
// telemetry (sum of ALL frame bytes ≡ 0 mod 256):
|
||||||
|
//
|
||||||
|
// enable telemetry: 55 92 04 15
|
||||||
|
// disable telemetry: 55 91 04 16
|
||||||
|
// OPERATE: 55 81 08 02 00 06 00 1A
|
||||||
|
// STANDBY: 55 81 08 02 00 05 00 1B
|
||||||
|
// OFF (power down): 55 81 08 02 00 0A 00 16
|
||||||
|
//
|
||||||
|
// Power ON is NOT a data command: it is a hardware pulse on the serial DTR/RTS
|
||||||
|
// lines (the amp's remote power-on pins) — serial transport only, and only if the
|
||||||
|
// cable wires those pins (a telemetry-only cable uses just RX/TX/GND).
|
||||||
|
//
|
||||||
|
// IMPORTANT: DTR and RTS must be held LOW at all times otherwise — asserting them
|
||||||
|
// permanently blocks the amplifier's front-panel power button.
|
||||||
|
//
|
||||||
|
// Telemetry (amp → host): once enabled, the amp streams 72-byte frames starting
|
||||||
|
// 0x55 0x2F, valid when the sum of all 72 bytes ≡ 0 (mod 256). Field offsets are
|
||||||
|
// decoded in decodeFrame below.
|
||||||
|
package acom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cmdEnableTelemetry = []byte{0x55, 0x92, 0x04, 0x15}
|
||||||
|
cmdDisableTelemetry = []byte{0x55, 0x91, 0x04, 0x16}
|
||||||
|
cmdOperate = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x06, 0x00, 0x1A}
|
||||||
|
cmdStandby = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x05, 0x00, 0x1B}
|
||||||
|
cmdOff = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x0A, 0x00, 0x16}
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
frameLen = 72
|
||||||
|
sync0 = 0x55
|
||||||
|
sync1 = 0x2F
|
||||||
|
dialTimeout = 5 * time.Second
|
||||||
|
ioTimeout = 3 * time.Second
|
||||||
|
// The amp streams roughly 4-5 frames/s once telemetry is on; if nothing valid
|
||||||
|
// arrives for this long the link (or the amp) is considered down.
|
||||||
|
staleAfter = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// model carries the per-model constants: the temperature word offset and the
|
||||||
|
// nominal/max forward power (for UI bar scaling).
|
||||||
|
type model struct {
|
||||||
|
Name string
|
||||||
|
TempOffset int
|
||||||
|
NominalW int
|
||||||
|
MaxW int
|
||||||
|
}
|
||||||
|
|
||||||
|
var models = map[string]model{
|
||||||
|
"500S": {"500S", 282, 500, 600},
|
||||||
|
"600S": {"600S", 273, 600, 700},
|
||||||
|
"700S": {"700S", 282, 700, 800},
|
||||||
|
"1200S": {"1200S", 281, 1200, 1400},
|
||||||
|
"2020S": {"2020S", 282, 1800, 2000},
|
||||||
|
}
|
||||||
|
|
||||||
|
// paStatusNames maps the PAstatus nibble to a display string.
|
||||||
|
var paStatusNames = map[int]string{
|
||||||
|
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
|
||||||
|
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF",
|
||||||
|
}
|
||||||
|
|
||||||
|
// acomBands maps the band nibble to a band label.
|
||||||
|
var acomBands = []string{"?", "160m", "80m", "40/60m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m"}
|
||||||
|
|
||||||
|
// errText translates the error code (frame byte 66) shown on the amp's display.
|
||||||
|
// 0xFF means NO error — everything else, including 0x00, is a fault/warning.
|
||||||
|
func errText(code int) string {
|
||||||
|
switch code {
|
||||||
|
case 0xFF:
|
||||||
|
return ""
|
||||||
|
case 0x00, 0x08:
|
||||||
|
return "Hot switching"
|
||||||
|
case 0x03:
|
||||||
|
return "Drive power at wrong time"
|
||||||
|
case 0x04, 0x05:
|
||||||
|
return "Reflected power warning"
|
||||||
|
case 0x06, 0x07:
|
||||||
|
return "Drive power too high"
|
||||||
|
case 0x0C:
|
||||||
|
return "RF power at wrong time"
|
||||||
|
case 0x0E:
|
||||||
|
return "Stop transmission first"
|
||||||
|
case 0x0F:
|
||||||
|
return "Remove drive power"
|
||||||
|
case 0x24, 0x25, 0x39, 0x44, 0x45, 0x59:
|
||||||
|
return "Excessive PAM current"
|
||||||
|
case 0x70:
|
||||||
|
return "CAT error"
|
||||||
|
default:
|
||||||
|
return "ERROR — see display"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is the decoded amplifier state for the UI.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"` // valid telemetry is flowing
|
||||||
|
PortOpen bool `json:"port_open"` // transport is open (serial port / TCP socket) — power-on possible even when the amp itself is off
|
||||||
|
Transport string `json:"transport"` // "serial" | "tcp" — the UI disables power-ON over tcp (no DTR line)
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
State string `json:"state,omitempty"` // STANDBY / RECEIVE / TRANSMIT / OFF / …
|
||||||
|
Operate bool `json:"operate"` // RECEIVE or TRANSMIT (vs STANDBY)
|
||||||
|
TX bool `json:"tx"`
|
||||||
|
FwdW int `json:"fwd_w"` // forward/output power
|
||||||
|
ReflW int `json:"refl_w"` // reflected power
|
||||||
|
SWR float64 `json:"swr"`
|
||||||
|
DriveW int `json:"drive_w"`
|
||||||
|
DCPowerW int `json:"dc_w"`
|
||||||
|
TempC int `json:"temp_c"` // PA temperature
|
||||||
|
Band string `json:"band,omitempty"`
|
||||||
|
FanLevel int `json:"fan"` // 1..4
|
||||||
|
ErrCode int `json:"err_code"` // raw code from the frame; 0xFF = none
|
||||||
|
ErrText string `json:"err_text,omitempty"` // human message; empty = no error
|
||||||
|
NominalW int `json:"nominal_w"`
|
||||||
|
MaxW int `json:"max_w"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config selects the transport and amplifier model.
|
||||||
|
type Config struct {
|
||||||
|
Model string // "500S" | "600S" | "700S" | "1200S" | "2020S"
|
||||||
|
Transport string // "serial" | "tcp"
|
||||||
|
ComPort string // serial
|
||||||
|
Baud int // serial (the amp is fixed 9600 8N1)
|
||||||
|
Host string // tcp (RS232-to-Ethernet bridge)
|
||||||
|
Port int // tcp
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
mdl model
|
||||||
|
|
||||||
|
mu sync.Mutex // serialises access to the connection
|
||||||
|
conn io.ReadWriteCloser
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
status Status
|
||||||
|
|
||||||
|
// Diagnostics: raw byte count + first-bytes capture, so a hardware session log
|
||||||
|
// tells apart "nothing on the wire" (cable/COM/amp) from "bytes but no valid
|
||||||
|
// frame" (framing/checksum) without a serial sniffer.
|
||||||
|
rawSeen int64
|
||||||
|
dbgBytes []byte
|
||||||
|
dbgLogged bool
|
||||||
|
ckFails int
|
||||||
|
frameLogged bool
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Client {
|
||||||
|
if cfg.Baud <= 0 {
|
||||||
|
cfg.Baud = 9600
|
||||||
|
}
|
||||||
|
mdl, ok := models[strings.ToUpper(strings.TrimSpace(cfg.Model))]
|
||||||
|
if !ok {
|
||||||
|
mdl = models["700S"]
|
||||||
|
}
|
||||||
|
c := &Client{cfg: cfg, mdl: mdl, stop: make(chan struct{})}
|
||||||
|
c.status.Model = mdl.Name
|
||||||
|
c.status.Transport = cfg.Transport
|
||||||
|
c.status.NominalW = mdl.NominalW
|
||||||
|
c.status.MaxW = mdl.MaxW
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
if c.running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.running = true
|
||||||
|
go c.readLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stop)
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
_, _ = c.conn.Write(cmdDisableTelemetry) // best effort: stop the stream
|
||||||
|
}
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() Status {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setErr(msg string) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.Connected = false
|
||||||
|
c.status.LastError = msg
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operate puts the amp in OPERATE (true) or STANDBY (false). Unlike the SPE's
|
||||||
|
// single toggle key, the ACOM protocol has explicit commands for each state.
|
||||||
|
func (c *Client) Operate(on bool) error {
|
||||||
|
if on {
|
||||||
|
return c.send(cmdOperate)
|
||||||
|
}
|
||||||
|
return c.send(cmdStandby)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerOff sends the power-down command (same as pressing OFF on the amp).
|
||||||
|
func (c *Client) PowerOff() error { return c.send(cmdOff) }
|
||||||
|
|
||||||
|
// PowerOn pulses the serial DTR/RTS lines — the amp's remote power-on pins, wired
|
||||||
|
// like a press of the front-panel power button. Hardware line ⇒ serial transport
|
||||||
|
// only (an RS232-to-Ethernet bridge doesn't forward DTR), and the cable must have
|
||||||
|
// those pins connected. Pulse length to be confirmed on real hardware.
|
||||||
|
func (c *Client) PowerOn() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
sp, ok := c.conn.(serial.Port)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("power-on needs the serial DTR/RTS lines — not available over a network bridge")
|
||||||
|
}
|
||||||
|
if err := sp.SetDTR(true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := sp.SetRTS(true); err != nil {
|
||||||
|
_ = sp.SetDTR(false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(2500 * time.Millisecond)
|
||||||
|
err1 := sp.SetDTR(false)
|
||||||
|
err2 := sp.SetRTS(false)
|
||||||
|
if err1 != nil {
|
||||||
|
return err1
|
||||||
|
}
|
||||||
|
return err2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) send(cmd []byte) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
if nc, ok := c.conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||||
|
}
|
||||||
|
_, err := c.conn.Write(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLoop keeps the link up and consumes the telemetry stream. When no valid
|
||||||
|
// frame arrives for a while it re-arms telemetry (the amp forgets the enable
|
||||||
|
// across a power cycle) and, on TCP, reconnects. A serial port is kept open even
|
||||||
|
// with the amp off, so the DTR power-on pulse stays available.
|
||||||
|
func (c *Client) readLoop() {
|
||||||
|
var lastFrame time.Time
|
||||||
|
var lastEnable time.Time
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if err := c.ensureConn(); err != nil {
|
||||||
|
c.setErr("connect: " + err.Error())
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frame, err := c.readFrame()
|
||||||
|
now := time.Now()
|
||||||
|
if err == nil {
|
||||||
|
lastFrame = now
|
||||||
|
if !c.frameLogged {
|
||||||
|
c.frameLogged = true
|
||||||
|
applog.Printf("acom: telemetry up — first valid frame received")
|
||||||
|
}
|
||||||
|
c.decodeFrame(frame)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// No valid frame. Re-send the telemetry enable briskly — there is no way to
|
||||||
|
// know whether the amp has it on (the reference controller re-sends every
|
||||||
|
// 200ms until frames flow), and it revives the stream after a power cycle.
|
||||||
|
if now.Sub(lastEnable) >= 500*time.Millisecond {
|
||||||
|
lastEnable = now
|
||||||
|
_ = c.send(cmdEnableTelemetry)
|
||||||
|
}
|
||||||
|
if lastFrame.IsZero() || now.Sub(lastFrame) > staleAfter {
|
||||||
|
if c.cfg.Transport == "tcp" {
|
||||||
|
// The bridge socket may be dead — force a reconnect.
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
if prev := c.GetStatus(); prev.Connected || prev.LastError == "" {
|
||||||
|
applog.Printf("acom: no telemetry (raw bytes seen so far: %d, checksum fails: %d)", c.rawSeen, c.ckFails)
|
||||||
|
}
|
||||||
|
c.setErr("no telemetry — amplifier off or cable/bridge issue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureConn() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.cfg.Transport == "tcp" {
|
||||||
|
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.conn = nc
|
||||||
|
} else {
|
||||||
|
sp, err := serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// CRITICAL: hold DTR/RTS LOW. Windows may assert them on open, and while
|
||||||
|
// asserted the amp's front-panel power button is blocked (they are its
|
||||||
|
// remote power-on lines).
|
||||||
|
_ = sp.SetDTR(false)
|
||||||
|
_ = sp.SetRTS(false)
|
||||||
|
// Short read timeout so the frame reader can poll the stop channel and
|
||||||
|
// detect staleness rather than blocking forever.
|
||||||
|
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
c.conn = sp
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.PortOpen = true
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
applog.Printf("acom: %s link open (%s)", c.mdl.Name, c.cfg.Transport)
|
||||||
|
_, _ = c.conn.Write(cmdEnableTelemetry)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dropLocked() {
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.PortOpen = false
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readByte reads a single byte, honouring the transport's short timeout. A serial
|
||||||
|
// read that times out returns (0, nil) with go.bug.st/serial — mapped to an error
|
||||||
|
// here so callers can distinguish "no data yet".
|
||||||
|
var errNoData = fmt.Errorf("no data")
|
||||||
|
|
||||||
|
func (c *Client) readByte(deadline time.Time) (byte, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
if conn == nil {
|
||||||
|
return 0, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
for {
|
||||||
|
if nc, ok := conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
|
||||||
|
}
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if n == 1 {
|
||||||
|
c.rawSeen++
|
||||||
|
// Capture the first 144 raw bytes (~2 frames) once, so a session log shows
|
||||||
|
// what the wire actually carries when frames won't validate.
|
||||||
|
if !c.dbgLogged {
|
||||||
|
c.dbgBytes = append(c.dbgBytes, buf[0])
|
||||||
|
if len(c.dbgBytes) >= 144 {
|
||||||
|
c.dbgLogged = true
|
||||||
|
applog.Printf("acom: first raw bytes: % X", c.dbgBytes)
|
||||||
|
c.dbgBytes = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf[0], nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||||
|
err = nil // treat like the serial short-timeout: just no data yet
|
||||||
|
} else {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return 0, fmt.Errorf("stopped")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return 0, errNoData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFrame syncs on 0x55 0x2F, reads the rest of the 72-byte frame and verifies
|
||||||
|
// the mod-256 checksum (sum of ALL 72 bytes ≡ 0).
|
||||||
|
func (c *Client) readFrame() ([]byte, error) {
|
||||||
|
deadline := time.Now().Add(ioTimeout)
|
||||||
|
// Sync: hunt for 0x55 followed by 0x2F.
|
||||||
|
for {
|
||||||
|
b, err := c.readByte(deadline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if b != sync0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b2, err := c.readByte(deadline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if b2 == sync1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame := make([]byte, frameLen)
|
||||||
|
frame[0], frame[1] = sync0, sync1
|
||||||
|
for i := 2; i < frameLen; i++ {
|
||||||
|
b, err := c.readByte(deadline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
frame[i] = b
|
||||||
|
}
|
||||||
|
var sum int
|
||||||
|
for _, b := range frame {
|
||||||
|
sum += int(b)
|
||||||
|
}
|
||||||
|
if sum%256 != 0 {
|
||||||
|
c.ckFails++
|
||||||
|
if c.ckFails <= 3 {
|
||||||
|
applog.Printf("acom: bad checksum (fail #%d): % X", c.ckFails, frame)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("bad checksum")
|
||||||
|
}
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeFrame extracts the telemetry fields (see the package comment for the
|
||||||
|
// reverse-engineered layout; 16-bit values are little-endian lo + hi*256).
|
||||||
|
func (c *Client) decodeFrame(f []byte) {
|
||||||
|
u16 := func(i int) int { return int(f[i]) + int(f[i+1])*256 }
|
||||||
|
paStatus := int(f[3]&0xF0) >> 4
|
||||||
|
state := paStatusNames[paStatus]
|
||||||
|
if state == "" {
|
||||||
|
state = fmt.Sprintf("?%d", paStatus)
|
||||||
|
}
|
||||||
|
bandIdx := int(f[69] & 0x0F)
|
||||||
|
band := ""
|
||||||
|
if bandIdx > 0 && bandIdx < len(acomBands) {
|
||||||
|
band = acomBands[bandIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
defer c.statusMu.Unlock()
|
||||||
|
c.status.Connected = true
|
||||||
|
c.status.LastError = ""
|
||||||
|
c.status.State = state
|
||||||
|
c.status.Operate = paStatus == 6 || paStatus == 7
|
||||||
|
c.status.TX = paStatus == 7
|
||||||
|
c.status.DCPowerW = u16(8) / 10
|
||||||
|
c.status.TempC = u16(16) - c.mdl.TempOffset
|
||||||
|
c.status.DriveW = u16(20)
|
||||||
|
c.status.FwdW = u16(22)
|
||||||
|
c.status.ReflW = u16(24)
|
||||||
|
c.status.SWR = float64(u16(26)) / 100
|
||||||
|
c.status.ErrCode = int(f[66])
|
||||||
|
c.status.ErrText = errText(int(f[66]))
|
||||||
|
c.status.Band = band
|
||||||
|
c.status.FanLevel = int(f[69]&0xF0) >> 4
|
||||||
|
}
|
||||||
+162
-131
@@ -34,6 +34,12 @@ type Exporter struct {
|
|||||||
// export destined for another logger; set true for a full OpsLog→OpsLog
|
// export destined for another logger; set true for a full OpsLog→OpsLog
|
||||||
// round-trip that preserves everything.
|
// round-trip that preserves everything.
|
||||||
IncludeAppFields bool
|
IncludeAppFields bool
|
||||||
|
|
||||||
|
// Fields, when non-nil, restricts the export to exactly these ADIF tags
|
||||||
|
// (uppercase) — the "choose which fields to export" mode. nil = write every
|
||||||
|
// field (the standard/full behaviour above). When set it overrides
|
||||||
|
// IncludeAppFields: an APP_/vendor tag is written iff it's in the set.
|
||||||
|
Fields map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
|
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
|
||||||
@@ -100,7 +106,7 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
|
|||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
err := iter(ctx, func(q qso.QSO) error {
|
err := iter(ctx, func(q qso.QSO) error {
|
||||||
writeRecord(bw, q, e.IncludeAppFields)
|
writeRecord(bw, q, e.IncludeAppFields, e.Fields)
|
||||||
count++
|
count++
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -115,7 +121,7 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
bw := bufio.NewWriter(&b)
|
bw := bufio.NewWriter(&b)
|
||||||
// Uploads target other services — keep it standard (no app-specific tags).
|
// Uploads target other services — keep it standard (no app-specific tags).
|
||||||
writeRecord(bw, q, false)
|
writeRecord(bw, q, false, nil)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -127,7 +133,7 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
func FullRecordADIF(q qso.QSO) string {
|
func FullRecordADIF(q qso.QSO) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
bw := bufio.NewWriter(&b)
|
bw := bufio.NewWriter(&b)
|
||||||
writeRecord(bw, q, true)
|
writeRecord(bw, q, true, nil)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -155,161 +161,181 @@ func BatchRecordsADIF(records []string) string {
|
|||||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||||
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||||
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
||||||
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
|
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool) {
|
||||||
|
// allow == nil → write every promoted field (standard/full behaviour).
|
||||||
|
// Otherwise a promoted tag is written only when it's in the chosen set.
|
||||||
|
// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
|
||||||
|
// stay one-liners.
|
||||||
|
ok := func(tag string) bool { return allow == nil || allow[tag] }
|
||||||
|
w := func(tag, v string) {
|
||||||
|
if ok(tag) {
|
||||||
|
writeField(bw, tag, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wi := func(tag string, p *int) {
|
||||||
|
if ok(tag) {
|
||||||
|
writeIntPtr(bw, tag, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wf := func(tag string, p *float64, decimals int) {
|
||||||
|
if ok(tag) {
|
||||||
|
writeFloatPtr(bw, tag, p, decimals)
|
||||||
|
}
|
||||||
|
}
|
||||||
// --- Core ---
|
// --- Core ---
|
||||||
writeField(bw, "CALL", q.Callsign)
|
w("CALL", q.Callsign)
|
||||||
|
|
||||||
if !q.QSODate.IsZero() {
|
if !q.QSODate.IsZero() {
|
||||||
writeField(bw, "QSO_DATE", q.QSODate.UTC().Format("20060102"))
|
w("QSO_DATE", q.QSODate.UTC().Format("20060102"))
|
||||||
writeField(bw, "TIME_ON", q.QSODate.UTC().Format("150405"))
|
w("TIME_ON", q.QSODate.UTC().Format("150405"))
|
||||||
}
|
}
|
||||||
if !q.QSODateOff.IsZero() {
|
if !q.QSODateOff.IsZero() {
|
||||||
writeField(bw, "QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
|
w("QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
|
||||||
writeField(bw, "TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
||||||
}
|
}
|
||||||
writeField(bw, "BAND", q.Band)
|
w("BAND", q.Band)
|
||||||
writeField(bw, "BAND_RX", q.BandRX)
|
w("BAND_RX", q.BandRX)
|
||||||
|
|
||||||
mode, submode := modeForExport(q.Mode, q.Submode)
|
mode, submode := modeForExport(q.Mode, q.Submode)
|
||||||
writeField(bw, "MODE", mode)
|
w("MODE", mode)
|
||||||
writeField(bw, "SUBMODE", submode)
|
w("SUBMODE", submode)
|
||||||
|
|
||||||
if q.FreqHz != nil && *q.FreqHz > 0 {
|
if q.FreqHz != nil && *q.FreqHz > 0 {
|
||||||
writeField(bw, "FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
||||||
}
|
}
|
||||||
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
|
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
|
||||||
writeField(bw, "FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
|
w("FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
|
||||||
}
|
}
|
||||||
|
|
||||||
writeField(bw, "RST_SENT", q.RSTSent)
|
w("RST_SENT", q.RSTSent)
|
||||||
writeField(bw, "RST_RCVD", q.RSTRcvd)
|
w("RST_RCVD", q.RSTRcvd)
|
||||||
|
|
||||||
// --- Contacted ---
|
// --- Contacted ---
|
||||||
writeField(bw, "NAME", q.Name)
|
w("NAME", q.Name)
|
||||||
writeField(bw, "QTH", q.QTH)
|
w("QTH", q.QTH)
|
||||||
writeField(bw, "ADDRESS", q.Address)
|
w("ADDRESS", q.Address)
|
||||||
writeField(bw, "EMAIL", q.Email)
|
w("EMAIL", q.Email)
|
||||||
writeField(bw, "WEB", q.Web)
|
w("WEB", q.Web)
|
||||||
writeField(bw, "GRIDSQUARE", q.Grid)
|
w("GRIDSQUARE", q.Grid)
|
||||||
writeField(bw, "GRIDSQUARE_EXT", q.GridExt)
|
w("GRIDSQUARE_EXT", q.GridExt)
|
||||||
writeField(bw, "VUCC_GRIDS", q.VUCCGrids)
|
w("VUCC_GRIDS", q.VUCCGrids)
|
||||||
writeField(bw, "COUNTRY", q.Country)
|
w("COUNTRY", q.Country)
|
||||||
writeField(bw, "STATE", q.State)
|
w("STATE", q.State)
|
||||||
writeField(bw, "CNTY", q.County)
|
w("CNTY", q.County)
|
||||||
writeIntPtr(bw, "DXCC", q.DXCC)
|
wi("DXCC", q.DXCC)
|
||||||
writeField(bw, "CONT", q.Continent)
|
w("CONT", q.Continent)
|
||||||
writeIntPtr(bw, "CQZ", q.CQZ)
|
wi("CQZ", q.CQZ)
|
||||||
writeIntPtr(bw, "ITUZ", q.ITUZ)
|
wi("ITUZ", q.ITUZ)
|
||||||
writeField(bw, "IOTA", q.IOTA)
|
w("IOTA", q.IOTA)
|
||||||
writeField(bw, "SOTA_REF", q.SOTARef)
|
w("SOTA_REF", q.SOTARef)
|
||||||
writeField(bw, "POTA_REF", q.POTARef)
|
w("POTA_REF", q.POTARef)
|
||||||
writeIntPtr(bw, "AGE", q.Age)
|
wi("AGE", q.Age)
|
||||||
writeFloatPtr(bw, "LAT", q.Lat, 6)
|
wf("LAT", q.Lat, 6)
|
||||||
writeFloatPtr(bw, "LON", q.Lon, 6)
|
wf("LON", q.Lon, 6)
|
||||||
writeField(bw, "RIG", q.Rig)
|
w("RIG", q.Rig)
|
||||||
writeField(bw, "ANT", q.Ant)
|
w("ANT", q.Ant)
|
||||||
|
|
||||||
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
|
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
|
||||||
writeField(bw, "QSL_SENT", q.QSLSent)
|
w("QSL_SENT", q.QSLSent)
|
||||||
writeField(bw, "QSL_RCVD", q.QSLRcvd)
|
w("QSL_RCVD", q.QSLRcvd)
|
||||||
writeField(bw, "QSLSDATE", q.QSLSentDate)
|
w("QSLSDATE", q.QSLSentDate)
|
||||||
writeField(bw, "QSLRDATE", q.QSLRcvdDate)
|
w("QSLRDATE", q.QSLRcvdDate)
|
||||||
writeField(bw, "QSL_VIA", q.QSLVia)
|
w("QSL_VIA", q.QSLVia)
|
||||||
writeField(bw, "QSLMSG", q.QSLMsg)
|
w("QSLMSG", q.QSLMsg)
|
||||||
writeField(bw, "QSLMSG_RCVD", q.QSLMsgRcvd)
|
w("QSLMSG_RCVD", q.QSLMsgRcvd)
|
||||||
writeField(bw, "LOTW_QSL_SENT", q.LOTWSent)
|
w("LOTW_QSL_SENT", q.LOTWSent)
|
||||||
writeField(bw, "LOTW_QSL_RCVD", q.LOTWRcvd)
|
w("LOTW_QSL_RCVD", q.LOTWRcvd)
|
||||||
writeField(bw, "LOTW_QSLSDATE", q.LOTWSentDate)
|
w("LOTW_QSLSDATE", q.LOTWSentDate)
|
||||||
writeField(bw, "LOTW_QSLRDATE", q.LOTWRcvdDate)
|
w("LOTW_QSLRDATE", q.LOTWRcvdDate)
|
||||||
writeField(bw, "EQSL_QSL_SENT", q.EQSLSent)
|
w("EQSL_QSL_SENT", q.EQSLSent)
|
||||||
writeField(bw, "EQSL_QSL_RCVD", q.EQSLRcvd)
|
w("EQSL_QSL_RCVD", q.EQSLRcvd)
|
||||||
writeField(bw, "EQSL_QSLSDATE", q.EQSLSentDate)
|
w("EQSL_QSLSDATE", q.EQSLSentDate)
|
||||||
writeField(bw, "EQSL_QSLRDATE", q.EQSLRcvdDate)
|
w("EQSL_QSLRDATE", q.EQSLRcvdDate)
|
||||||
writeField(bw, "CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
|
w("CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
|
||||||
writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
|
w("CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
|
||||||
writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
|
w("HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
|
||||||
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
w("HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
||||||
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
|
w("QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
|
||||||
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||||
|
|
||||||
// --- Contest ---
|
// --- Contest ---
|
||||||
writeField(bw, "CONTEST_ID", q.ContestID)
|
w("CONTEST_ID", q.ContestID)
|
||||||
writeIntPtr(bw, "SRX", q.SRX)
|
wi("SRX", q.SRX)
|
||||||
writeIntPtr(bw, "STX", q.STX)
|
wi("STX", q.STX)
|
||||||
writeField(bw, "SRX_STRING", q.SRXString)
|
w("SRX_STRING", q.SRXString)
|
||||||
writeField(bw, "STX_STRING", q.STXString)
|
w("STX_STRING", q.STXString)
|
||||||
writeField(bw, "CHECK", q.Check)
|
w("CHECK", q.Check)
|
||||||
writeField(bw, "PRECEDENCE", q.Precedence)
|
w("PRECEDENCE", q.Precedence)
|
||||||
writeField(bw, "ARRL_SECT", q.ARRLSect)
|
w("ARRL_SECT", q.ARRLSect)
|
||||||
|
|
||||||
// --- Satellite / propagation ---
|
// --- Satellite / propagation ---
|
||||||
writeField(bw, "PROP_MODE", q.PropMode)
|
w("PROP_MODE", q.PropMode)
|
||||||
writeField(bw, "SAT_NAME", q.SatName)
|
w("SAT_NAME", q.SatName)
|
||||||
writeField(bw, "SAT_MODE", q.SatMode)
|
w("SAT_MODE", q.SatMode)
|
||||||
writeFloatPtr(bw, "ANT_AZ", q.AntAz, 1)
|
wf("ANT_AZ", q.AntAz, 1)
|
||||||
writeFloatPtr(bw, "ANT_EL", q.AntEl, 1)
|
wf("ANT_EL", q.AntEl, 1)
|
||||||
writeField(bw, "ANT_PATH", q.AntPath)
|
w("ANT_PATH", q.AntPath)
|
||||||
|
|
||||||
// --- My station / operator ---
|
// --- My station / operator ---
|
||||||
writeField(bw, "STATION_CALLSIGN", q.StationCallsign)
|
w("STATION_CALLSIGN", q.StationCallsign)
|
||||||
writeField(bw, "OPERATOR", q.Operator)
|
w("OPERATOR", q.Operator)
|
||||||
writeField(bw, "MY_GRIDSQUARE", q.MyGrid)
|
w("MY_GRIDSQUARE", q.MyGrid)
|
||||||
writeField(bw, "MY_GRIDSQUARE_EXT", q.MyGridExt)
|
w("MY_GRIDSQUARE_EXT", q.MyGridExt)
|
||||||
writeField(bw, "MY_COUNTRY", q.MyCountry)
|
w("MY_COUNTRY", q.MyCountry)
|
||||||
writeField(bw, "MY_STATE", q.MyState)
|
w("MY_STATE", q.MyState)
|
||||||
writeField(bw, "MY_CNTY", q.MyCounty)
|
w("MY_CNTY", q.MyCounty)
|
||||||
writeField(bw, "MY_IOTA", q.MyIOTA)
|
w("MY_IOTA", q.MyIOTA)
|
||||||
writeField(bw, "MY_SOTA_REF", q.MySOTARef)
|
w("MY_SOTA_REF", q.MySOTARef)
|
||||||
writeField(bw, "MY_POTA_REF", q.MyPOTARef)
|
w("MY_POTA_REF", q.MyPOTARef)
|
||||||
writeIntPtr(bw, "MY_DXCC", q.MyDXCC)
|
wi("MY_DXCC", q.MyDXCC)
|
||||||
writeIntPtr(bw, "MY_CQ_ZONE", q.MyCQZone)
|
wi("MY_CQ_ZONE", q.MyCQZone)
|
||||||
writeIntPtr(bw, "MY_ITU_ZONE", q.MyITUZone)
|
wi("MY_ITU_ZONE", q.MyITUZone)
|
||||||
writeFloatPtr(bw, "MY_LAT", q.MyLat, 6)
|
wf("MY_LAT", q.MyLat, 6)
|
||||||
writeFloatPtr(bw, "MY_LON", q.MyLon, 6)
|
wf("MY_LON", q.MyLon, 6)
|
||||||
writeField(bw, "MY_STREET", q.MyStreet)
|
w("MY_STREET", q.MyStreet)
|
||||||
writeField(bw, "MY_CITY", q.MyCity)
|
w("MY_CITY", q.MyCity)
|
||||||
writeField(bw, "MY_POSTAL_CODE", q.MyPostalCode)
|
w("MY_POSTAL_CODE", q.MyPostalCode)
|
||||||
writeField(bw, "MY_RIG", q.MyRig)
|
w("MY_RIG", q.MyRig)
|
||||||
writeField(bw, "MY_ANTENNA", q.MyAntenna)
|
w("MY_ANTENNA", q.MyAntenna)
|
||||||
|
|
||||||
// --- Misc ---
|
// --- Misc ---
|
||||||
writeFloatPtr(bw, "TX_PWR", q.TXPower, 1)
|
wf("TX_PWR", q.TXPower, 1)
|
||||||
writeField(bw, "COMMENT", q.Comment)
|
w("COMMENT", q.Comment)
|
||||||
writeField(bw, "NOTES", q.Notes)
|
w("NOTES", q.Notes)
|
||||||
|
|
||||||
// --- ADIF 3.1.7 additional promoted fields ---
|
// --- ADIF 3.1.7 additional promoted fields ---
|
||||||
writeField(bw, "SIG", q.SIG)
|
w("SIG", q.SIG)
|
||||||
writeField(bw, "SIG_INFO", q.SIGInfo)
|
w("SIG_INFO", q.SIGInfo)
|
||||||
writeField(bw, "MY_SIG", q.MySIG)
|
w("MY_SIG", q.MySIG)
|
||||||
writeField(bw, "MY_SIG_INFO", q.MySIGInfo)
|
w("MY_SIG_INFO", q.MySIGInfo)
|
||||||
writeField(bw, "WWFF_REF", q.WWFFRef)
|
w("WWFF_REF", q.WWFFRef)
|
||||||
writeField(bw, "MY_WWFF_REF", q.MyWWFFRef)
|
w("MY_WWFF_REF", q.MyWWFFRef)
|
||||||
writeFloatPtr(bw, "DISTANCE", q.Distance, 1)
|
wf("DISTANCE", q.Distance, 1)
|
||||||
writeFloatPtr(bw, "RX_PWR", q.RXPower, 1)
|
wf("RX_PWR", q.RXPower, 1)
|
||||||
writeFloatPtr(bw, "A_INDEX", q.AIndex, 0)
|
wf("A_INDEX", q.AIndex, 0)
|
||||||
writeFloatPtr(bw, "K_INDEX", q.KIndex, 0)
|
wf("K_INDEX", q.KIndex, 0)
|
||||||
writeFloatPtr(bw, "SFI", q.SFI, 0)
|
wf("SFI", q.SFI, 0)
|
||||||
writeField(bw, "SKCC", q.SKCC)
|
w("SKCC", q.SKCC)
|
||||||
writeField(bw, "FISTS", q.FISTS)
|
w("FISTS", q.FISTS)
|
||||||
writeField(bw, "TEN_TEN", q.TenTen)
|
w("TEN_TEN", q.TenTen)
|
||||||
writeField(bw, "CONTACTED_OP", q.ContactedOp)
|
w("CONTACTED_OP", q.ContactedOp)
|
||||||
writeField(bw, "EQ_CALL", q.EqCall)
|
w("EQ_CALL", q.EqCall)
|
||||||
writeField(bw, "PFX", q.PFX)
|
w("PFX", q.PFX)
|
||||||
writeField(bw, "MY_NAME", q.MyName)
|
w("MY_NAME", q.MyName)
|
||||||
writeField(bw, "CLASS", q.Class)
|
w("CLASS", q.Class)
|
||||||
writeField(bw, "DARC_DOK", q.DarcDOK)
|
w("DARC_DOK", q.DarcDOK)
|
||||||
writeField(bw, "MY_DARC_DOK", q.MyDarcDOK)
|
w("MY_DARC_DOK", q.MyDarcDOK)
|
||||||
writeField(bw, "REGION", q.Region)
|
w("REGION", q.Region)
|
||||||
writeField(bw, "SILENT_KEY", q.SilentKey)
|
w("SILENT_KEY", q.SilentKey)
|
||||||
writeField(bw, "SWL", q.SWL)
|
w("SWL", q.SWL)
|
||||||
writeField(bw, "QSO_COMPLETE", q.QSOComplete)
|
w("QSO_COMPLETE", q.QSOComplete)
|
||||||
writeField(bw, "QSO_RANDOM", q.QSORandom)
|
w("QSO_RANDOM", q.QSORandom)
|
||||||
writeField(bw, "CREDIT_GRANTED", q.CreditGranted)
|
w("CREDIT_GRANTED", q.CreditGranted)
|
||||||
writeField(bw, "CREDIT_SUBMITTED", q.CreditSubmitted)
|
w("CREDIT_SUBMITTED", q.CreditSubmitted)
|
||||||
writeField(bw, "MY_ARRL_SECT", q.MyARRLSect)
|
w("MY_ARRL_SECT", q.MyARRLSect)
|
||||||
writeField(bw, "MY_VUCC_GRIDS", q.MyVUCCGrids)
|
w("MY_VUCC_GRIDS", q.MyVUCCGrids)
|
||||||
|
|
||||||
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
||||||
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
|
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
|
||||||
@@ -318,7 +344,12 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
|
|||||||
// extra for a lossless OpsLog round-trip.
|
// extra for a lossless OpsLog round-trip.
|
||||||
for k, v := range q.Extras {
|
for k, v := range q.Extras {
|
||||||
tag := strings.ToUpper(k)
|
tag := strings.ToUpper(k)
|
||||||
if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
|
if allow != nil {
|
||||||
|
// Chosen-fields mode: the set alone decides (incl. APP_/vendor tags).
|
||||||
|
if !allow[tag] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
writeField(bw, tag, v)
|
writeField(bw, tag, v)
|
||||||
|
|||||||
@@ -46,6 +46,21 @@ type Importer struct {
|
|||||||
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
|
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
|
||||||
// source file (common with contest loggers) is corrected on the way in.
|
// source file (common with contest loggers) is corrected on the way in.
|
||||||
Enrich func(*qso.QSO)
|
Enrich func(*qso.QSO)
|
||||||
|
// FieldMap moves ADIF fields around before a record is converted, keyed
|
||||||
|
// lowercase source → lowercase destination.
|
||||||
|
//
|
||||||
|
// Contest software puts things where the operator, not the standard, expects
|
||||||
|
// them. The RSGB IOTA contest is the case that prompted this: N1MM and
|
||||||
|
// friends export the island reference in STATE, because that is the column
|
||||||
|
// their contest module uses for the received exchange. Imported verbatim,
|
||||||
|
// every QSO gets a US-state field holding "EU005" and the IOTA award sees
|
||||||
|
// nothing at all.
|
||||||
|
//
|
||||||
|
// Applied to the RAW record, so it works for promoted columns and Extras
|
||||||
|
// alike, and so the destination is parsed exactly as if the file had carried
|
||||||
|
// it there in the first place. A destination that already has a value is
|
||||||
|
// never overwritten — the file's own IOTA tag outranks a guess about STATE.
|
||||||
|
FieldMap map[string]string
|
||||||
// OnProgress, when set, is called periodically with (processed, total)
|
// OnProgress, when set, is called periodically with (processed, total)
|
||||||
// record counts so the UI can show a progress bar. total is an estimate
|
// record counts so the UI can show a progress bar. total is an estimate
|
||||||
// from counting <EOR> tags up front.
|
// from counting <EOR> tags up front.
|
||||||
@@ -161,6 +176,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
||||||
res.Total++
|
res.Total++
|
||||||
reportProgress(false)
|
reportProgress(false)
|
||||||
|
applyFieldMap(rec, im.FieldMap)
|
||||||
q, ok := recordToQSO(rec)
|
q, ok := recordToQSO(rec)
|
||||||
if !ok {
|
if !ok {
|
||||||
res.Skipped++
|
res.Skipped++
|
||||||
@@ -303,6 +319,49 @@ func stringSet(items ...string) map[string]struct{} {
|
|||||||
// RecordToQSO is the exported alias used by the UDP auto-log path so it
|
// RecordToQSO is the exported alias used by the UDP auto-log path so it
|
||||||
// can convert a freshly received ADIF record into a QSO and then enrich
|
// can convert a freshly received ADIF record into a QSO and then enrich
|
||||||
// it with lookup + operating data before inserting.
|
// it with lookup + operating data before inserting.
|
||||||
|
// applyFieldMap moves values between tags of a record, in place.
|
||||||
|
//
|
||||||
|
// The source is CLEARED after the move: a reference that belongs in IOTA should
|
||||||
|
// not also be left behind in STATE, where it would show up as the contacted
|
||||||
|
// station's US state in the grid and in every export.
|
||||||
|
func applyFieldMap(rec Record, m map[string]string) {
|
||||||
|
if len(m) == 0 || rec == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read every source first. Applied one at a time, a swap (a→b, b→a) would
|
||||||
|
// read back what the previous move had just written.
|
||||||
|
orig := make(map[string]string, len(rec))
|
||||||
|
for k, v := range rec {
|
||||||
|
orig[k] = v
|
||||||
|
}
|
||||||
|
vals := make(map[string]string, len(m))
|
||||||
|
for from := range m {
|
||||||
|
if v := strings.TrimSpace(rec[from]); v != "" {
|
||||||
|
vals[from] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for from := range m {
|
||||||
|
delete(rec, from)
|
||||||
|
}
|
||||||
|
for from, to := range m {
|
||||||
|
v, ok := vals[from]
|
||||||
|
if !ok || from == to || to == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Whether the destination was already filled is judged against the record
|
||||||
|
// as it ARRIVED, not as we are rewriting it — otherwise the outcome would
|
||||||
|
// depend on Go's map iteration order, which is deliberately random.
|
||||||
|
//
|
||||||
|
// A destination that is itself a mapping source is an explicit swap, and
|
||||||
|
// overwriting it is the whole point; anything else keeps what the file
|
||||||
|
// carried, because the file's own tag outranks a guess about where a
|
||||||
|
// reference might have been put.
|
||||||
|
if _, swap := m[to]; swap || strings.TrimSpace(orig[to]) == "" {
|
||||||
|
rec[to] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) }
|
func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) }
|
||||||
|
|
||||||
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
|
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package adif_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/adif"
|
||||||
|
"hamlog/internal/db"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// End to end: an operator's own record, through a real SQLite logbook and back.
|
||||||
|
//
|
||||||
|
// Written because CONTEST_ID was reported as "not imported". It is imported, and
|
||||||
|
// this pins the whole path — parse, remap, insert, read back — rather than the
|
||||||
|
// conversion step alone, which was already green while the report stood. A field
|
||||||
|
// can be lost at five places between the file and the grid; only the round trip
|
||||||
|
// says which.
|
||||||
|
func TestContestIDSurvivesImport(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
conn, err := db.Open(filepath.Join(dir, "logbook.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
const file = "<CALL:6>2E0CVN <QSO_DATE:8>20260725 <TIME_ON:6>120039 <STATE:5>EU005 <BAND:3>20M <FREQ:6>14.014 <MODE:2>CW <CONTEST_ID:9>RSGB-IOTA <RST_RCVD:3>599 <RST_SENT:3>599 <SRX_STRING:8>001EU005 <EOR>\n"
|
||||||
|
path := filepath.Join(dir, "in.adi")
|
||||||
|
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := qso.NewRepo(conn)
|
||||||
|
im := &adif.Importer{Repo: repo, FieldMap: map[string]string{"state": "iota"}}
|
||||||
|
res, err := im.ImportFile(context.Background(), path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("result: %+v", res)
|
||||||
|
|
||||||
|
rows, err := conn.Query("SELECT callsign, contest_id, iota, state FROM qso")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
n := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var call, contest, iota, state any
|
||||||
|
if err := rows.Scan(&call, &contest, &iota, &state); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
t.Logf("stored: call=%v contest_id=%v iota=%v state=%v", call, contest, iota, state)
|
||||||
|
if s := strings.TrimSpace(toS(contest)); s != "RSGB-IOTA" {
|
||||||
|
t.Errorf("contest_id in the DATABASE = %q, want RSGB-IOTA", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
t.Fatal("nothing was stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toS(v any) string {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
return x
|
||||||
|
case []byte:
|
||||||
|
return string(x)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package adif
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Field mapping, from the RSGB IOTA contest case.
|
||||||
|
//
|
||||||
|
// N1MM and similar export the island reference in STATE, because that is the
|
||||||
|
// column their contest module uses for the received exchange. Imported
|
||||||
|
// verbatim, every QSO gets a US-state field reading "EU005" and the IOTA award
|
||||||
|
// sees nothing — the reference is in the log but not where anything looks.
|
||||||
|
func TestApplyFieldMap(t *testing.T) {
|
||||||
|
// The real record, from an operator's TM2Q log.
|
||||||
|
rec := Record{
|
||||||
|
"call": "2E0CVN", "band": "20m", "mode": "CW",
|
||||||
|
"state": "EU005", "srx_string": "001EU005",
|
||||||
|
}
|
||||||
|
applyFieldMap(rec, map[string]string{"state": "iota"})
|
||||||
|
if rec["iota"] != "EU005" {
|
||||||
|
t.Errorf("iota = %q, want EU005", rec["iota"])
|
||||||
|
}
|
||||||
|
// Cleared, not copied: a reference left behind in STATE would show up as the
|
||||||
|
// contacted station's US state in the grid and in every export.
|
||||||
|
if _, ok := rec["state"]; ok {
|
||||||
|
t.Errorf("state survived the move as %q", rec["state"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A destination the file already filled is never overwritten — the file's own
|
||||||
|
// tag outranks a guess about where the reference might be.
|
||||||
|
rec2 := Record{"call": "UT0RM", "state": "EU005", "iota": "EU030"}
|
||||||
|
applyFieldMap(rec2, map[string]string{"state": "iota"})
|
||||||
|
if rec2["iota"] != "EU030" {
|
||||||
|
t.Errorf("iota = %q — the file's own value must win", rec2["iota"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty source moves nothing (the second QSO of that log has no STATE).
|
||||||
|
rec3 := Record{"call": "UT0RM", "state": " "}
|
||||||
|
applyFieldMap(rec3, map[string]string{"state": "iota"})
|
||||||
|
if v, ok := rec3["iota"]; ok {
|
||||||
|
t.Errorf("an empty source created iota=%q", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A swap reads both sources before writing either.
|
||||||
|
rec4 := Record{"state": "EU005", "iota": "NA001"}
|
||||||
|
applyFieldMap(rec4, map[string]string{"state": "iota", "iota": "state"})
|
||||||
|
if rec4["iota"] != "EU005" || rec4["state"] != "NA001" {
|
||||||
|
t.Errorf("swap gave iota=%q state=%q, want EU005 / NA001", rec4["iota"], rec4["state"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// No mapping, no change.
|
||||||
|
rec5 := Record{"call": "F4BPO", "state": "EU005"}
|
||||||
|
applyFieldMap(rec5, nil)
|
||||||
|
if rec5["state"] != "EU005" {
|
||||||
|
t.Error("a nil mapping must leave the record alone")
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-7
@@ -22,9 +22,11 @@ import (
|
|||||||
// Record is a single ADIF record. Keys are lowercased field names.
|
// Record is a single ADIF record. Keys are lowercased field names.
|
||||||
type Record map[string]string
|
type Record map[string]string
|
||||||
|
|
||||||
// Parse reads an ADI stream and invokes fn for each record (after <EOH>).
|
// Parse reads an ADI stream and invokes fn for each record.
|
||||||
// Returning a non-nil error from fn stops parsing and is propagated.
|
// Returning a non-nil error from fn stops parsing and is propagated.
|
||||||
// The header (text before <EOH>) is silently discarded.
|
//
|
||||||
|
// The header (text before <EOH>) is silently discarded. A file with NO header is
|
||||||
|
// read as records from the first tag — hand-assembled files often have none.
|
||||||
func Parse(r io.Reader, fn func(Record) error) error {
|
func Parse(r io.Reader, fn func(Record) error) error {
|
||||||
return parseWith(r, nil, fn)
|
return parseWith(r, nil, fn)
|
||||||
}
|
}
|
||||||
@@ -44,7 +46,6 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
|||||||
br := bufio.NewReaderSize(r, 64*1024)
|
br := bufio.NewReaderSize(r, 64*1024)
|
||||||
|
|
||||||
rec := Record{}
|
rec := Record{}
|
||||||
headerDone := false
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// Seek next '<'. Bytes before it are either header text or
|
// Seek next '<'. Bytes before it are either header text or
|
||||||
@@ -65,11 +66,19 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
|||||||
name, length := parseSpec(spec)
|
name, length := parseSpec(spec)
|
||||||
switch name {
|
switch name {
|
||||||
case "eoh":
|
case "eoh":
|
||||||
headerDone = true
|
rec = Record{} // everything collected so far was the header
|
||||||
rec = Record{}
|
|
||||||
continue
|
continue
|
||||||
case "eor":
|
case "eor":
|
||||||
if headerDone && len(rec) > 0 {
|
// An <eor> proves we are in the record section, whether or not an <eoh>
|
||||||
|
// came first. Files pasted together by hand — a few lines lifted out of
|
||||||
|
// wsjtx_log.adi into a new file — have no header at all, and requiring
|
||||||
|
// one made every record invisible: the import reported 0 imported,
|
||||||
|
// 0 ignored, 0 total, which reads as "the file is empty" rather than
|
||||||
|
// "there was no header".
|
||||||
|
//
|
||||||
|
// A real header cannot contain <eor>, so accepting it costs nothing for
|
||||||
|
// well-formed files: theirs ends at <eoh> long before any record.
|
||||||
|
if len(rec) > 0 {
|
||||||
if err := fn(rec); err != nil {
|
if err := fn(rec); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -95,7 +104,14 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
|||||||
if decodeValue == nil && !utf8.Valid(val) {
|
if decodeValue == nil && !utf8.Valid(val) {
|
||||||
val = extendToRunes(br, val, length)
|
val = extendToRunes(br, val, length)
|
||||||
}
|
}
|
||||||
if headerDone && name != "" {
|
// Fields are collected ALWAYS, and thrown away at <eoh>.
|
||||||
|
//
|
||||||
|
// Collecting them only after the header meant a headerless file lost its
|
||||||
|
// FIRST record whole: its fields were discarded as header text, and the
|
||||||
|
// <eor> that followed found nothing to emit. Discarding at <eoh> instead
|
||||||
|
// keeps real headers out of the data just as well — that tag is exactly
|
||||||
|
// what says "everything before this was the header".
|
||||||
|
if name != "" {
|
||||||
if decodeValue != nil {
|
if decodeValue != nil {
|
||||||
rec[name] = decodeValue(val)
|
rec[name] = decodeValue(val)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -51,19 +51,28 @@ func TestParseValueWithAngleBracket(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseNoHeader(t *testing.T) {
|
func TestParseNoHeader(t *testing.T) {
|
||||||
// Some loggers omit the header entirely — records before <EOH> are
|
// A file without a header is IMPORTED, not discarded.
|
||||||
// discarded by design. Verify nothing is emitted in that case.
|
//
|
||||||
|
// This test asserted the opposite until 2026-07-29, when an operator pasted a
|
||||||
|
// dozen lines out of wsjtx_log.adi into a new .adi and got "0 imported,
|
||||||
|
// 0 ignored, 0 total". Discarding records before <EOH> was a defensible
|
||||||
|
// reading of the spec, but the file it rejects is one people really make, and
|
||||||
|
// the rejection is silent: the count says the file was empty, not that a tag
|
||||||
|
// was missing. An <eor> is proof enough that a record ended.
|
||||||
src := `<CALL:5>F4XYZ<EOR>`
|
src := `<CALL:5>F4XYZ<EOR>`
|
||||||
var got int
|
var got []Record
|
||||||
err := Parse(strings.NewReader(src), func(r Record) error {
|
err := Parse(strings.NewReader(src), func(r Record) error {
|
||||||
got++
|
got = append(got, r)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parse: %v", err)
|
t.Fatalf("parse: %v", err)
|
||||||
}
|
}
|
||||||
if got != 0 {
|
if len(got) != 1 {
|
||||||
t.Errorf("expected 0 records without <EOH>, got %d", got)
|
t.Fatalf("expected the record to be imported without <EOH>, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0]["call"] != "F4XYZ" {
|
||||||
|
t.Errorf("call = %q, want F4XYZ", got[0]["call"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,3 +91,57 @@ func TestParseTypedField(t *testing.T) {
|
|||||||
t.Errorf("freq mismatch: %q", got["freq"])
|
t.Errorf("freq mismatch: %q", got["freq"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A file with NO header must still import.
|
||||||
|
//
|
||||||
|
// Reported from the field: an operator lifted a dozen lines out of wsjtx_log.adi
|
||||||
|
// into a new .adi and got "0 imported, 0 ignored, 0 total" — which reads as an
|
||||||
|
// empty file rather than as a missing header. Records were only emitted after
|
||||||
|
// <EOH>, so a hand-assembled file was invisible in its entirety.
|
||||||
|
func TestParseHeaderlessFile(t *testing.T) {
|
||||||
|
// The operator's exact shape: records straight from a WSJT-X log, no header.
|
||||||
|
in := "<call:5>PY2VE <gridsquare:4>GG67 <mode:3>FT8 <rst_sent:3>-18 <rst_rcvd:3>-15 " +
|
||||||
|
"<qso_date:8>20260728 <time_on:6>194200 <band:3>15m <eor>\n" +
|
||||||
|
"<call:5>W8OBX <gridsquare:4>FM25 <mode:3>FT8 <rst_sent:3>-19 <rst_rcvd:3>-12 " +
|
||||||
|
"<qso_date:8>20260728 <time_on:6>194500 <band:3>15m <eor>\n"
|
||||||
|
|
||||||
|
var got []Record
|
||||||
|
if err := Parse(strings.NewReader(in), func(r Record) error {
|
||||||
|
got = append(got, r)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("parsed %d records from a headerless file, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0]["call"] != "PY2VE" || got[1]["call"] != "W8OBX" {
|
||||||
|
t.Errorf("callsigns = %q, %q — want PY2VE, W8OBX", got[0]["call"], got[1]["call"])
|
||||||
|
}
|
||||||
|
if got[0]["gridsquare"] != "GG67" {
|
||||||
|
t.Errorf("gridsquare = %q, want GG67", got[0]["gridsquare"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a normal file must be unaffected: its header is still discarded, so header
|
||||||
|
// fields never leak into the first record.
|
||||||
|
func TestParseHeaderStillDiscarded(t *testing.T) {
|
||||||
|
in := "Generated by SomeLogger\n<adif_ver:5>3.1.4 <programid:6>OpsLog <eoh>\n" +
|
||||||
|
"<call:4>F4AA <mode:2>CW <eor>\n"
|
||||||
|
var got []Record
|
||||||
|
if err := Parse(strings.NewReader(in), func(r Record) error {
|
||||||
|
got = append(got, r)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("parsed %d records, want 1", len(got))
|
||||||
|
}
|
||||||
|
if _, leaked := got[0]["programid"]; leaked {
|
||||||
|
t.Error("a header field leaked into the record")
|
||||||
|
}
|
||||||
|
if got[0]["call"] != "F4AA" {
|
||||||
|
t.Errorf("call = %q, want F4AA", got[0]["call"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestPromotedFieldsRoundTrip(t *testing.T) {
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
bw := bufio.NewWriter(&buf)
|
bw := bufio.NewWriter(&buf)
|
||||||
bw.WriteString("<EOH>\n")
|
bw.WriteString("<EOH>\n")
|
||||||
writeRecord(bw, in, true)
|
writeRecord(bw, in, true, nil)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
|
|
||||||
var rec Record
|
var rec Record
|
||||||
@@ -115,7 +115,7 @@ func TestStandardExportDropsNonStandard(t *testing.T) {
|
|||||||
func renderRecord(q qso.QSO, includeApp bool) string {
|
func renderRecord(q qso.QSO, includeApp bool) string {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
bw := bufio.NewWriter(&buf)
|
bw := bufio.NewWriter(&buf)
|
||||||
writeRecord(bw, q, includeApp)
|
writeRecord(bw, q, includeApp, nil)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type Client struct {
|
|||||||
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
||||||
antBands map[int]int // index → band bitmask (which bands the antenna covers)
|
antBands map[int]int // index → band bitmask (which bands the antenna covers)
|
||||||
antRawN int // one-shot: how many raw antenna lines we've logged
|
antRawN int // one-shot: how many raw antenna lines we've logged
|
||||||
|
lastShown map[int]int // port id → antenna last reported, so only CHANGES are logged
|
||||||
|
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
running bool
|
running bool
|
||||||
@@ -87,6 +88,7 @@ func New(host string, port int, password string) *Client {
|
|||||||
stop: make(chan struct{}),
|
stop: make(chan struct{}),
|
||||||
antennas: map[int]string{},
|
antennas: map[int]string{},
|
||||||
antBands: map[int]int{},
|
antBands: map[int]int{},
|
||||||
|
lastShown: map[int]int{},
|
||||||
status: Status{Host: host},
|
status: Status{Host: host},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,9 +400,27 @@ func (c *Client) parsePort(msg string) {
|
|||||||
}
|
}
|
||||||
tx := kvInt(msg, "txant")
|
tx := kvInt(msg, "txant")
|
||||||
rx := kvInt(msg, "rxant")
|
rx := kvInt(msg, "rxant")
|
||||||
active := tx
|
// The RX antenna is what identifies a port's selection, and it is the only
|
||||||
|
// thing Activate sets. Preferring the TX antenna made both ports converge on
|
||||||
|
// the same row a few seconds after a change: on an 8x2 only ONE port can hold
|
||||||
|
// the transmit antenna, so the device reports the same txant on both, and a
|
||||||
|
// port A genuinely on the 80 m vertical was redrawn as the beam that port B
|
||||||
|
// transmits through.
|
||||||
|
//
|
||||||
|
// TX remains the fallback for a port that reports no RX antenna at all.
|
||||||
|
active := rx
|
||||||
if active == 0 {
|
if active == 0 {
|
||||||
active = rx
|
active = tx
|
||||||
|
}
|
||||||
|
// Logged only when the shown antenna CHANGES: the device pushes port state
|
||||||
|
// every few seconds, and logging each one would bury everything else — but
|
||||||
|
// without any trace, "port A jumped to the wrong antenna" is unfalsifiable.
|
||||||
|
c.statusMu.Lock()
|
||||||
|
prev, seen := c.lastShown[id]
|
||||||
|
c.lastShown[id] = active
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
if !seen || prev != active {
|
||||||
|
applog.Printf("antgenius: port %d → antenna %d (rxant=%d txant=%d) raw=%q", id, active, rx, tx, msg)
|
||||||
}
|
}
|
||||||
txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
|
txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
|
||||||
c.setStatus(func(s *Status) {
|
c.setStatus(func(s *Status) {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package antgenius
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Which antenna a port SHOWS.
|
||||||
|
//
|
||||||
|
// Reported on an 8x2: port A was correctly on the 80 m vertical, then a few
|
||||||
|
// seconds later both A and B were drawn on the same beam. The cause is that only
|
||||||
|
// ONE port can hold the transmit antenna, so the device reports the same txant
|
||||||
|
// on both — and the display preferred txant. The RX antenna is the per-port
|
||||||
|
// selection, and the only thing Activate sets, so it has to win.
|
||||||
|
func TestPortShowsRXAntenna(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg string
|
||||||
|
wantPort int
|
||||||
|
wantAnt int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// The reported case: port A listening on antenna 2 while the shared TX
|
||||||
|
// antenna is 1. Before the fix this drew port A on antenna 1.
|
||||||
|
name: "rx and tx disagree — rx wins",
|
||||||
|
msg: "port 1 rxant=2 txant=1 tx=0",
|
||||||
|
wantPort: 1, wantAnt: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "they agree",
|
||||||
|
msg: "port 2 rxant=3 txant=3 tx=0",
|
||||||
|
wantPort: 2, wantAnt: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A port with no RX antenna still shows something meaningful.
|
||||||
|
name: "no rx antenna — fall back to tx",
|
||||||
|
msg: "port 1 rxant=0 txant=4 tx=1",
|
||||||
|
wantPort: 1, wantAnt: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nothing selected",
|
||||||
|
msg: "port 2 rxant=0 txant=0 tx=0",
|
||||||
|
wantPort: 2, wantAnt: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
cl := New("host", 0, "")
|
||||||
|
cl.parsePort(c.msg)
|
||||||
|
st := cl.GetStatus()
|
||||||
|
got := st.PortA
|
||||||
|
if c.wantPort == 2 {
|
||||||
|
got = st.PortB
|
||||||
|
}
|
||||||
|
if got != c.wantAnt {
|
||||||
|
t.Errorf("%s: port %d shows antenna %d, want %d (%q)", c.name, c.wantPort, got, c.wantAnt, c.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two ports must stay independent: a message about one must never move the
|
||||||
|
// other, which is the shape the operator actually saw on screen.
|
||||||
|
func TestPortsAreIndependent(t *testing.T) {
|
||||||
|
c := New("host", 0, "")
|
||||||
|
c.parsePort("port 1 rxant=2 txant=0 tx=0")
|
||||||
|
c.parsePort("port 2 rxant=1 txant=1 tx=1")
|
||||||
|
st := c.GetStatus()
|
||||||
|
if st.PortA != 2 {
|
||||||
|
t.Errorf("port A = %d, want 2 — port B's message moved it", st.PortA)
|
||||||
|
}
|
||||||
|
if st.PortB != 1 {
|
||||||
|
t.Errorf("port B = %d, want 1", st.PortB)
|
||||||
|
}
|
||||||
|
if !st.TxB || st.TxA {
|
||||||
|
t.Errorf("transmit flags crossed: TxA=%v TxB=%v", st.TxA, st.TxB)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,10 +47,15 @@ func Init(dataDir string) (string, error) {
|
|||||||
_ = os.Rename(oldLog, logPath)
|
_ = os.Rename(oldLog, logPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Truncate if the file grew past ~5MB so we don't accumulate logs
|
// Rotate (don't delete) once the file grows past ~10MB: rename it to
|
||||||
// forever. We keep one file — simple and adequate for diagnostics.
|
// opslog.log.1 so the PREVIOUS session's log survives. Deleting it outright used
|
||||||
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 5*1024*1024 {
|
// to erase exactly the diagnostics we needed when a user reported an issue from
|
||||||
_ = os.Remove(logPath)
|
// the session that just ended. One generation kept — enough, without unbounded growth.
|
||||||
|
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 10*1024*1024 {
|
||||||
|
_ = os.Remove(logPath + ".1") // drop the older generation
|
||||||
|
if err := os.Rename(logPath, logPath+".1"); err != nil {
|
||||||
|
_ = os.Remove(logPath) // rename failed (locked?) → fall back to the old behaviour
|
||||||
|
}
|
||||||
}
|
}
|
||||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -104,11 +104,27 @@ func (m *Manager) IsPlaying() bool {
|
|||||||
|
|
||||||
// Play renders a WAV file to deviceID. Any current playback is stopped first.
|
// Play renders a WAV file to deviceID. Any current playback is stopped first.
|
||||||
// Returns immediately; playback runs in the background.
|
// Returns immediately; playback runs in the background.
|
||||||
func (m *Manager) Play(deviceID, path string) error {
|
// Play sends a recorded message to a device, amplified by gainPct (100 = as
|
||||||
|
// recorded).
|
||||||
|
//
|
||||||
|
// The gain exists because nothing else could raise the level: the message went
|
||||||
|
// out exactly as captured, so a mic recorded quietly drove the rig quietly and
|
||||||
|
// the operator had no control anywhere in OpsLog — only the radio's own USB
|
||||||
|
// input level, buried in its menus, and the Windows mixer.
|
||||||
|
func (m *Manager) Play(deviceID, path string, gainPct int) error {
|
||||||
pcm, rate, ch, bits, err := readWAV(path)
|
pcm, rate, ch, bits, err := readWAV(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if gainPct > 0 && gainPct != 100 && bits == 16 {
|
||||||
|
g := float64(gainPct) / 100
|
||||||
|
// In place: the buffer is this call's own copy of the file.
|
||||||
|
for i := 0; i+1 < len(pcm); i += 2 {
|
||||||
|
v := int16(uint16(pcm[i]) | uint16(pcm[i+1])<<8)
|
||||||
|
v = scalePCM(v, g)
|
||||||
|
pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
|
||||||
|
}
|
||||||
|
}
|
||||||
m.StopPlayback()
|
m.StopPlayback()
|
||||||
stop := make(chan struct{})
|
stop := make(chan struct{})
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -265,3 +281,16 @@ func (m *Manager) TXAudioActive() bool {
|
|||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
return m.txStop != nil
|
return m.txStop != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scalePCM applies a gain to one sample, clamping rather than wrapping — an
|
||||||
|
// overflow that wraps turns loud speech into a burst of noise on the air.
|
||||||
|
func scalePCM(s int16, g float64) int16 {
|
||||||
|
v := float64(s) * g
|
||||||
|
if v > 32767 {
|
||||||
|
return 32767
|
||||||
|
}
|
||||||
|
if v < -32768 {
|
||||||
|
return -32768
|
||||||
|
}
|
||||||
|
return int16(v)
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ type Recorder struct {
|
|||||||
// Mixed output state (guarded by mu).
|
// Mixed output state (guarded by mu).
|
||||||
ring []int16 // last prerollSamples of mixed audio
|
ring []int16 // last prerollSamples of mixed audio
|
||||||
active bool
|
active bool
|
||||||
|
// paused freezes an ACTIVE take: nothing more is accumulated, but everything
|
||||||
|
// captured so far is kept and the take still ends normally when the QSO is
|
||||||
|
// logged. It is what lets an operator stop, play the recording back on the
|
||||||
|
// air to the station they are working, and still have it saved with the QSO.
|
||||||
|
paused bool
|
||||||
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
|
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +119,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
|||||||
r.twoSrc = micDev != "" && micDev != fromDev
|
r.twoSrc = micDev != "" && micDev != fromDev
|
||||||
r.stopCh = make(chan struct{})
|
r.stopCh = make(chan struct{})
|
||||||
r.running = true
|
r.running = true
|
||||||
r.ring, r.acc, r.active, r.bufA, r.bufB = nil, nil, false, nil, nil
|
r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil
|
||||||
stop := r.stopCh
|
stop := r.stopCh
|
||||||
twoSrc := r.twoSrc
|
twoSrc := r.twoSrc
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
@@ -206,7 +211,7 @@ func (r *Recorder) mixTick() {
|
|||||||
if len(r.ring) > r.prerollSamples {
|
if len(r.ring) > r.prerollSamples {
|
||||||
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
|
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
|
||||||
}
|
}
|
||||||
if r.active {
|
if r.active && !r.paused {
|
||||||
r.acc = append(r.acc, mixed...)
|
r.acc = append(r.acc, mixed...)
|
||||||
}
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
@@ -221,7 +226,7 @@ func (r *Recorder) BeginQSO() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.acc = append([]int16(nil), r.ring...)
|
r.acc = append([]int16(nil), r.ring...)
|
||||||
r.active = true
|
r.active, r.paused = true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestartQSO begins a fresh accumulation even if one is already active —
|
// RestartQSO begins a fresh accumulation even if one is already active —
|
||||||
@@ -236,7 +241,7 @@ func (r *Recorder) RestartQSO() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.acc = append([]int16(nil), r.ring...)
|
r.acc = append([]int16(nil), r.ring...)
|
||||||
r.active = true
|
r.active, r.paused = true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
||||||
@@ -266,7 +271,7 @@ func (r *Recorder) TakeQSO() ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("no active recording")
|
return nil, fmt.Errorf("no active recording")
|
||||||
}
|
}
|
||||||
samples := r.acc
|
samples := r.acc
|
||||||
r.acc, r.active = nil, false
|
r.acc, r.active, r.paused = nil, false, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if len(samples) == 0 {
|
if len(samples) == 0 {
|
||||||
return nil, fmt.Errorf("recording was empty")
|
return nil, fmt.Errorf("recording was empty")
|
||||||
@@ -295,7 +300,7 @@ func (r *Recorder) SaveQSO(path string) error {
|
|||||||
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
||||||
func (r *Recorder) DiscardQSO() {
|
func (r *Recorder) DiscardQSO() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.acc, r.active = nil, false
|
r.acc, r.active, r.paused = nil, false, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +318,7 @@ func (r *Recorder) Stop() {
|
|||||||
close(stop)
|
close(stop)
|
||||||
r.wg.Wait()
|
r.wg.Wait()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.ring, r.acc, r.active = nil, nil, false
|
r.ring, r.acc, r.active, r.paused = nil, nil, false, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
r.bufA, r.bufB = nil, nil
|
r.bufA, r.bufB = nil, nil
|
||||||
@@ -346,3 +351,55 @@ func int16sToBytes(s []int16) []byte {
|
|||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PauseQSO freezes the active take without ending it: nothing more is recorded,
|
||||||
|
// nothing is thrown away, and logging the QSO still writes the file.
|
||||||
|
//
|
||||||
|
// This exists for one situation, which is common enough on the bands to be
|
||||||
|
// worth the state: you are working a station, you have been recording, and they
|
||||||
|
// ask to hear it. You stop, you play it back to them on the air, and the
|
||||||
|
// recording is still saved with the QSO afterwards.
|
||||||
|
func (r *Recorder) PauseQSO() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.active {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.paused = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeQSO continues an interrupted take, appending to what is already there.
|
||||||
|
func (r *Recorder) ResumeQSO() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.active {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.paused = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paused reports whether the active take is frozen.
|
||||||
|
func (r *Recorder) Paused() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.active && r.paused
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeekQSO returns what has been captured so far WITHOUT ending the take.
|
||||||
|
//
|
||||||
|
// Unlike TakeQSO this keeps the audio, because the take is going to be played
|
||||||
|
// back and then still saved with the QSO. The copy is deliberate: the caller
|
||||||
|
// gets bytes it can hold while the recorder keeps appending to its own slice.
|
||||||
|
func (r *Recorder) PeekQSO() ([]byte, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.active {
|
||||||
|
return nil, fmt.Errorf("no active recording")
|
||||||
|
}
|
||||||
|
if len(r.acc) == 0 {
|
||||||
|
return nil, fmt.Errorf("recording is empty")
|
||||||
|
}
|
||||||
|
return int16sToBytes(append([]int16(nil), r.acc...)), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// QSO may yield several references (e.g. a Note holding "D74 D73").
|
// QSO may yield several references (e.g. a Note holding "D74 D73").
|
||||||
//
|
//
|
||||||
// Examples:
|
// Examples:
|
||||||
|
//
|
||||||
// DXCC : field "dxcc" (no pattern) → entity number
|
// DXCC : field "dxcc" (no pattern) → entity number
|
||||||
// WAS : field "state", DXCCFilter [291,110,6] → US state
|
// WAS : field "state", DXCCFilter [291,110,6] → US state
|
||||||
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
|
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
|
||||||
@@ -1326,7 +1327,11 @@ func setToSorted(m map[string]struct{}) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"}
|
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
|
||||||
|
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm",
|
||||||
|
// Microwave. A band missing from this order sorts to the end of an award's
|
||||||
|
// band columns rather than in frequency order — and 3 cm was missing.
|
||||||
|
"9cm", "6cm", "3cm", "1.25cm", "6mm", "4mm", "2.5mm", "2mm", "1mm"}
|
||||||
|
|
||||||
func sortedBands(m map[string]int) []string {
|
func sortedBands(m map[string]int) []string {
|
||||||
idx := map[string]int{}
|
idx := map[string]int{}
|
||||||
|
|||||||
@@ -4,11 +4,27 @@
|
|||||||
"name": "Départements Français Métropolitains",
|
"name": "Départements Français Métropolitains",
|
||||||
"valid": true,
|
"valid": true,
|
||||||
"protected": true,
|
"protected": true,
|
||||||
|
"ref_display": "name",
|
||||||
"type": "QSOFIELDS",
|
"type": "QSOFIELDS",
|
||||||
"field": "note",
|
"field": "note",
|
||||||
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "code",
|
||||||
|
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||||
|
"prefix": "D"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "code",
|
||||||
|
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||||
|
"prefix": "D"
|
||||||
|
}
|
||||||
|
],
|
||||||
"dxcc_filter": [
|
"dxcc_filter": [
|
||||||
227
|
227,
|
||||||
|
214
|
||||||
],
|
],
|
||||||
"confirm": [
|
"confirm": [
|
||||||
"lotw",
|
"lotw",
|
||||||
@@ -18,6 +34,777 @@
|
|||||||
"lotw"
|
"lotw"
|
||||||
],
|
],
|
||||||
"total": 96,
|
"total": 96,
|
||||||
"builtin": true
|
"builtin": true,
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "D01",
|
||||||
|
"name": "Ain",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D02",
|
||||||
|
"name": "Aisne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D03",
|
||||||
|
"name": "Allier",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D04",
|
||||||
|
"name": "Alpes-de-Haute-Provence",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D05",
|
||||||
|
"name": "Hautes-Alpes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D06",
|
||||||
|
"name": "Alpes-Maritimes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D07",
|
||||||
|
"name": "Ardèche",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D08",
|
||||||
|
"name": "Ardennes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D09",
|
||||||
|
"name": "Ariège",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D10",
|
||||||
|
"name": "Aube",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D11",
|
||||||
|
"name": "Aude",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D12",
|
||||||
|
"name": "Aveyron",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D13",
|
||||||
|
"name": "Bouches-du-Rhône",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D14",
|
||||||
|
"name": "Calvados",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D15",
|
||||||
|
"name": "Cantal",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D16",
|
||||||
|
"name": "Charente",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D17",
|
||||||
|
"name": "Charente-Maritime",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D18",
|
||||||
|
"name": "Cher",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D19",
|
||||||
|
"name": "Corrèze",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D21",
|
||||||
|
"name": "Côte-d'Or",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D22",
|
||||||
|
"name": "Côtes-d'Armor",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D23",
|
||||||
|
"name": "Creuse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D24",
|
||||||
|
"name": "Dordogne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D25",
|
||||||
|
"name": "Doubs",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D26",
|
||||||
|
"name": "Drôme",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D27",
|
||||||
|
"name": "Eure",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D28",
|
||||||
|
"name": "Eure-et-Loir",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D29",
|
||||||
|
"name": "Finistère",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D2A",
|
||||||
|
"name": "Corse-du-Sud",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D2B",
|
||||||
|
"name": "Haute-Corse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D30",
|
||||||
|
"name": "Gard",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D31",
|
||||||
|
"name": "Haute-Garonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D32",
|
||||||
|
"name": "Gers",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D33",
|
||||||
|
"name": "Gironde",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D34",
|
||||||
|
"name": "Hérault",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D35",
|
||||||
|
"name": "Ille-et-Vilaine",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D36",
|
||||||
|
"name": "Indre",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D37",
|
||||||
|
"name": "Indre-et-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D38",
|
||||||
|
"name": "Isère",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D39",
|
||||||
|
"name": "Jura",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D40",
|
||||||
|
"name": "Landes",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D41",
|
||||||
|
"name": "Loir-et-Cher",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D42",
|
||||||
|
"name": "Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D43",
|
||||||
|
"name": "Haute-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D44",
|
||||||
|
"name": "Loire-Atlantique",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D45",
|
||||||
|
"name": "Loiret",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D46",
|
||||||
|
"name": "Lot",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D47",
|
||||||
|
"name": "Lot-et-Garonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D48",
|
||||||
|
"name": "Lozère",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D49",
|
||||||
|
"name": "Maine-et-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D50",
|
||||||
|
"name": "Manche",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D51",
|
||||||
|
"name": "Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D52",
|
||||||
|
"name": "Haute-Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D53",
|
||||||
|
"name": "Mayenne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D54",
|
||||||
|
"name": "Meurthe-et-Moselle",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D55",
|
||||||
|
"name": "Meuse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D56",
|
||||||
|
"name": "Morbihan",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D57",
|
||||||
|
"name": "Moselle",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D58",
|
||||||
|
"name": "Nièvre",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D59",
|
||||||
|
"name": "Nord",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D60",
|
||||||
|
"name": "Oise",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D61",
|
||||||
|
"name": "Orne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D62",
|
||||||
|
"name": "Pas-de-Calais",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D63",
|
||||||
|
"name": "Puy-de-Dôme",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D64",
|
||||||
|
"name": "Pyrénées-Atlantiques",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D65",
|
||||||
|
"name": "Hautes-Pyrénées",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D66",
|
||||||
|
"name": "Pyrénées-Orientales",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D67",
|
||||||
|
"name": "Bas-Rhin",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D68",
|
||||||
|
"name": "Haut-Rhin",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D69",
|
||||||
|
"name": "Rhône",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D70",
|
||||||
|
"name": "Haute-Saône",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D71",
|
||||||
|
"name": "Saône-et-Loire",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D72",
|
||||||
|
"name": "Sarthe",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D73",
|
||||||
|
"name": "Savoie",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D74",
|
||||||
|
"name": "Haute-Savoie",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D75",
|
||||||
|
"name": "Paris",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D76",
|
||||||
|
"name": "Seine-Maritime",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D77",
|
||||||
|
"name": "Seine-et-Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D78",
|
||||||
|
"name": "Yvelines",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D79",
|
||||||
|
"name": "Deux-Sèvres",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D80",
|
||||||
|
"name": "Somme",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D81",
|
||||||
|
"name": "Tarn",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D82",
|
||||||
|
"name": "Tarn-et-Garonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D83",
|
||||||
|
"name": "Var",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D84",
|
||||||
|
"name": "Vaucluse",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D85",
|
||||||
|
"name": "Vendée",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D86",
|
||||||
|
"name": "Vienne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D87",
|
||||||
|
"name": "Haute-Vienne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D88",
|
||||||
|
"name": "Vosges",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D89",
|
||||||
|
"name": "Yonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D90",
|
||||||
|
"name": "Territoire de Belfort",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D91",
|
||||||
|
"name": "Essonne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D92",
|
||||||
|
"name": "Hauts-de-Seine",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D93",
|
||||||
|
"name": "Seine-Saint-Denis",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D94",
|
||||||
|
"name": "Val-de-Marne",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D95",
|
||||||
|
"name": "Val-d'Oise",
|
||||||
|
"dxcc": 227,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "H26",
|
||||||
|
"name": "The Helvetia 26 Award HF",
|
||||||
|
"description": "26 Cantons of Switzerland",
|
||||||
|
"valid": true,
|
||||||
|
"url": "https://uska.ch/en/contest/uska-diplome/",
|
||||||
|
"ref_url": "https://uska.ch/en/contest/uska-diplome/",
|
||||||
|
"valid_from": "1980-01-01",
|
||||||
|
"valid_to": "9999-12-01",
|
||||||
|
"ref_display": "name",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
287
|
||||||
|
],
|
||||||
|
"valid_bands": [
|
||||||
|
"2190m",
|
||||||
|
"630m",
|
||||||
|
"160m",
|
||||||
|
"80m",
|
||||||
|
"40m",
|
||||||
|
"30m",
|
||||||
|
"20m",
|
||||||
|
"17m",
|
||||||
|
"15m",
|
||||||
|
"12m",
|
||||||
|
"10m"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true,
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AG",
|
||||||
|
"name": "Aargau",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Aarau|Baden|Wettingen|Wohlen|Rheinfelden|Zofingen|Lenzburg|Brugg|Oftringen|Suhr|Spreitenbach)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AI",
|
||||||
|
"name": "Appenzell Innerrhoden",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Appenzell|Oberegg)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AR",
|
||||||
|
"name": "Appenzell Ausserrhoden",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Herisau|Teufen|Speicher|Heiden|Gais|Urnäsch|Waldstatt)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BE",
|
||||||
|
"name": "Bern",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Bern|Berne|Thun|Biel|Bienne|Köniz|Burgdorf|Langenthal|Steffisburg|Münsingen|Spiez|Interlaken|Ostermundigen|Lyss|Zollikofen)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BL",
|
||||||
|
"name": "Basel-Landschaft",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Liestal|Allschwil|Reinach|Muttenz|Pratteln|Binningen|Münchenstein|Birsfelden|Aesch|Sissach|Oberwil)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BS",
|
||||||
|
"name": "Basel-Stadt",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Basel|Bâle|Bale|Riehen|Bettingen)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FR",
|
||||||
|
"name": "Fribourg",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Fribourg|Freiburg|Bulle|Marly|Düdingen|Estavayer|Murten|Morat|Villars-sur-Glâne|Guin)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GE",
|
||||||
|
"name": "Geneva",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Genève|Geneve|Sezenove|Chene-Bourg|Chene Bourg|Geneva|Genf|Carouge|Vernier|Lancy|Meyrin|Onex|Thônex|Thonex|Versoix|Bernex|Plan-les-Ouates)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GL",
|
||||||
|
"name": "Glarus",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Glarus|Glaris|Näfels|Netstal|Ennenda|Mollis)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GR",
|
||||||
|
"name": "Graubünden (Grisons)",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Chur|Coire|Kueblis|Davos|Sankt Moritz|St\\.? Moritz|Landquart|Arosa|Klosters|Ilanz|Thusis|Poschiavo|Domat|Ems)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "JU",
|
||||||
|
"name": "Jura",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Delémont|Delemont|Porrentruy|Bassecourt|Courroux|Saignelégier|Alle)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LU",
|
||||||
|
"name": "Lucerne",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Luzern|Lucerne|Emmen|Kriens|Horw|Ebikon|Sursee|Hochdorf|Willisau|Rothenburg)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NE",
|
||||||
|
"name": "Neuchâtel",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Neuchâtel|Neuchatel|Neuenburg|La Chaux-de-Fonds|Chaux-de-Fonds|Le Locle|Peseux|Boudry|Colombier|Marin)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NW",
|
||||||
|
"name": "Nidwalden",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Stans|Hergiswil|Buochs|Stansstad|Beckenried|Ennetbürgen)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OW",
|
||||||
|
"name": "Obwalden",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Sarnen|Kerns|Alpnach|Engelberg|Sachseln|Giswil)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SG",
|
||||||
|
"name": "St. Gallen",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Sankt Gallen|Saint-Gall|Rapperswil|Wil|Gossau|Rorschach|Uzwil|Flawil|Altstätten|Jona)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SH",
|
||||||
|
"name": "Schaffhausen",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Schaffhausen|Schaffhouse|Neuhausen|Stein am Rhein|Thayngen)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SO",
|
||||||
|
"name": "Solothurn",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Solothurn|Soleure|Olten|Grenchen|Zuchwil|Dornach|Oensingen|Balsthal|Biberist)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SZ",
|
||||||
|
"name": "Schwyz",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Schwyz|Einsiedeln|Freienbach|Küssnacht|Arth|Lachen|Wollerau|Brunnen|Goldau)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TG",
|
||||||
|
"name": "Thurgau",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Frauenfeld|Kreuzlingen|Arbon|Amriswil|Weinfelden|Romanshorn|Sirnach|Aadorf|Münchwilen)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TI",
|
||||||
|
"name": "Ticino",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Lugano|Bellinzona|Locarno|Mendrisio|Chiasso|Biasca|Ascona|Losone|Minusio|Giubiasco|Massagno)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "UR",
|
||||||
|
"name": "Uri",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Altdorf|Schattdorf|Erstfeld|Andermatt|Flüelen|Bürglen)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VD",
|
||||||
|
"name": "Vaud",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Lausanne|Chavornay|Blonay|Saint-Cergue|St-Cergue|St Cergue|Yverdon|Gingins|Montreux|Nyon|Vevey|Renens|Morges|Gland|Pully|Prilly|Ecublens|Aigle|Payerne|Rolle|Lutry|Bussigny)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VS",
|
||||||
|
"name": "Valais",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Sion|Sitten|Martigny|Monthey|Sierre|Brig|Brigue|Naters|Visp|Viège|Conthey|Fully|Verbier|Zermatt|Crans-Montana|Saas-Fee|Savièse|Bagnes)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ZG",
|
||||||
|
"name": "Zug",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Zug|Zoug|Baar|Cham|Steinhausen|Risch|Rotkreuz|Hünenberg|Unterägeri|Oberägeri)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ZH",
|
||||||
|
"name": "Zurich",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Zürich|Zurich|Winterthur|Uster|Dübendorf|Dietikon|Wetzikon|Kloten|Wädenswil|Horgen|Thalwil|Opfikon|Küsnacht|Meilen|Bülach|Regensdorf|Schlieren|Adliswil|Volketswil)\\b",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -304,6 +304,22 @@ type FlexTXState struct {
|
|||||||
ANFLevel int `json:"anf_level"`
|
ANFLevel int `json:"anf_level"`
|
||||||
WNB bool `json:"wnb"`
|
WNB bool `json:"wnb"`
|
||||||
WNBLevel int `json:"wnb_level"`
|
WNBLevel int `json:"wnb_level"`
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora series): NRL/ANFL (legacy LMS), NRS
|
||||||
|
// (spectral subtraction), RNN (AI NR), ANFT (FFT notch), NRF (NR w/filter).
|
||||||
|
// DSPV4 is true once the radio reported any of these keys — gates the UI.
|
||||||
|
LMSNR bool `json:"lms_nr"`
|
||||||
|
LMSNRLevel int `json:"lms_nr_level"`
|
||||||
|
LMSANF bool `json:"lms_anf"`
|
||||||
|
LMSANFLevel int `json:"lms_anf_level"`
|
||||||
|
SpeexNR bool `json:"speex_nr"`
|
||||||
|
SpeexNRLevel int `json:"speex_nr_level"`
|
||||||
|
RNN bool `json:"rnn"`
|
||||||
|
ANFT bool `json:"anft"`
|
||||||
|
NRF bool `json:"nrf"`
|
||||||
|
NRFLevel int `json:"nrf_level"`
|
||||||
|
DSPV4 bool `json:"dsp_v4"`
|
||||||
|
DAXCh int `json:"dax_ch"` // DAX audio channel of the active slice (0 = off, 1-8)
|
||||||
|
TXDAX bool `json:"tx_dax"` // DAX is the TX audio source (SmartSDR transmit-bar DAX button)
|
||||||
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
|
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
|
||||||
// moving the slice. The offset survives the switch being turned off, so
|
// moving the slice. The offset survives the switch being turned off, so
|
||||||
// re-enabling restores it, exactly like the radio's own knob.
|
// re-enabling restores it, exactly like the radio's own knob.
|
||||||
@@ -386,6 +402,19 @@ type FlexController interface {
|
|||||||
SetAPFLevel(int) error
|
SetAPFLevel(int) error
|
||||||
SetWNB(bool) error
|
SetWNB(bool) error
|
||||||
SetWNBLevel(int) error
|
SetWNBLevel(int) error
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora): NRL / ANFL / NRS / RNN / ANFT / NRF.
|
||||||
|
SetLMSNR(bool) error
|
||||||
|
SetLMSNRLevel(int) error
|
||||||
|
SetLMSANF(bool) error
|
||||||
|
SetLMSANFLevel(int) error
|
||||||
|
SetSpeexNR(bool) error
|
||||||
|
SetSpeexNRLevel(int) error
|
||||||
|
SetRNN(bool) error
|
||||||
|
SetANFT(bool) error
|
||||||
|
SetNRF(bool) error
|
||||||
|
SetNRFLevel(int) error
|
||||||
|
SetDAX(int) error // slice RX DAX channel 0 (off) - 8
|
||||||
|
SetTXDAX(bool) error // TX audio from DAX (transmit set dax=)
|
||||||
SetRIT(bool) error
|
SetRIT(bool) error
|
||||||
SetRITFreq(int) error
|
SetRITFreq(int) error
|
||||||
SetXIT(bool) error
|
SetXIT(bool) error
|
||||||
@@ -394,6 +423,14 @@ type FlexController interface {
|
|||||||
SetCWSpeed(int) error
|
SetCWSpeed(int) error
|
||||||
SetCWPitch(int) error
|
SetCWPitch(int) error
|
||||||
SetCWBreakInDelay(int) error
|
SetCWBreakInDelay(int) error
|
||||||
|
// CWX keyer — buffered CW keying via SmartSDR's CWX subsystem, so a Flex needs
|
||||||
|
// no WinKeyer / SmartCAT. SendCW queues text (the radio buffers and keys it);
|
||||||
|
// StopCW clears the buffer, aborting the send. BackspaceCW removes the last n
|
||||||
|
// not-yet-keyed characters from the buffer (un-send while sending — for
|
||||||
|
// type-ahead corrections).
|
||||||
|
SendCW(string) error
|
||||||
|
StopCW() error
|
||||||
|
BackspaceCW(int) error
|
||||||
SetCWSidetone(bool) error
|
SetCWSidetone(bool) error
|
||||||
SetSidetoneLevel(int) error
|
SetSidetoneLevel(int) error
|
||||||
SetCWFilter(int) error
|
SetCWFilter(int) error
|
||||||
@@ -612,15 +649,29 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
|||||||
const reconnectEvery = 5 * time.Second
|
const reconnectEvery = 5 * time.Second
|
||||||
connected := false
|
connected := false
|
||||||
var lastAttempt time.Time
|
var lastAttempt time.Time
|
||||||
|
var lastConnErr string // last connect failure logged, so the retry loop says it once
|
||||||
tryConnect := func() {
|
tryConnect := func() {
|
||||||
if connected || time.Since(lastAttempt) < reconnectEvery {
|
if connected || time.Since(lastAttempt) < reconnectEvery {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastAttempt = time.Now()
|
lastAttempt = time.Now()
|
||||||
if err := b.Connect(); err != nil {
|
if err := b.Connect(); err != nil {
|
||||||
|
// Log it — the message used to live only in RigState.Error, i.e. in a
|
||||||
|
// tooltip. The status pill condenses everything to "OmniRig not found",
|
||||||
|
// so a user reporting that had no way to tell us WHY: the COM HRESULT,
|
||||||
|
// the serial error, the refused TCP connect, all invisible. Logged once
|
||||||
|
// per distinct message so the retry loop doesn't flood the file.
|
||||||
|
if msg := err.Error(); msg != lastConnErr {
|
||||||
|
lastConnErr = msg
|
||||||
|
debugLog.Printf("%s connect failed: %s", b.Name(), msg)
|
||||||
|
}
|
||||||
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
|
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if lastConnErr != "" {
|
||||||
|
debugLog.Printf("%s connected (after: %s)", b.Name(), lastConnErr)
|
||||||
|
lastConnErr = ""
|
||||||
|
}
|
||||||
connected = true
|
connected = true
|
||||||
}
|
}
|
||||||
tryConnect()
|
tryConnect()
|
||||||
@@ -725,6 +776,14 @@ func stateUserEqual(a, b RigState) bool {
|
|||||||
func BandFromHz(hz int64) string {
|
func BandFromHz(hz int64) string {
|
||||||
mhz := float64(hz) / 1_000_000
|
mhz := float64(hz) / 1_000_000
|
||||||
switch {
|
switch {
|
||||||
|
// LF / MF, below 160 m. An operator on 630 m had their band come back empty,
|
||||||
|
// which then cost them the band on the QSO and in every award count.
|
||||||
|
case mhz >= 0.1357 && mhz <= 0.1378:
|
||||||
|
return "2190m"
|
||||||
|
case mhz >= 0.472 && mhz <= 0.479:
|
||||||
|
return "630m"
|
||||||
|
case mhz >= 0.501 && mhz <= 0.504:
|
||||||
|
return "560m"
|
||||||
case mhz >= 1.8 && mhz <= 2.0:
|
case mhz >= 1.8 && mhz <= 2.0:
|
||||||
return "160m"
|
return "160m"
|
||||||
case mhz >= 3.5 && mhz <= 4.0:
|
case mhz >= 3.5 && mhz <= 4.0:
|
||||||
@@ -759,6 +818,54 @@ func BandFromHz(hz int64) string {
|
|||||||
return "33cm"
|
return "33cm"
|
||||||
case mhz >= 1240.0 && mhz <= 1300.0:
|
case mhz >= 1240.0 && mhz <= 1300.0:
|
||||||
return "23cm"
|
return "23cm"
|
||||||
|
// Microwave. The table used to stop at 23 cm, so 10 GHz — a band people
|
||||||
|
// actually work, and spot — resolved to an empty band: no band on the QSO, no
|
||||||
|
// band-slot, nothing in the awards. Ranges and names are the ADIF 3.1.7 ones,
|
||||||
|
// which is what an export has to carry.
|
||||||
|
case mhz >= 2300.0 && mhz <= 2450.0:
|
||||||
|
return "13cm"
|
||||||
|
case mhz >= 3300.0 && mhz <= 3500.0:
|
||||||
|
return "9cm"
|
||||||
|
case mhz >= 5650.0 && mhz <= 5925.0:
|
||||||
|
return "6cm"
|
||||||
|
case mhz >= 10000.0 && mhz <= 10500.0:
|
||||||
|
return "3cm"
|
||||||
|
case mhz >= 24000.0 && mhz <= 24250.0:
|
||||||
|
return "1.25cm"
|
||||||
|
case mhz >= 47000.0 && mhz <= 47200.0:
|
||||||
|
return "6mm"
|
||||||
|
case mhz >= 75500.0 && mhz <= 81000.0:
|
||||||
|
return "4mm"
|
||||||
|
case mhz >= 119980.0 && mhz <= 123000.0:
|
||||||
|
return "2.5mm"
|
||||||
|
case mhz >= 134000.0 && mhz <= 149000.0:
|
||||||
|
return "2mm"
|
||||||
|
case mhz >= 241000.0 && mhz <= 250000.0:
|
||||||
|
return "1mm"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YaesuState returns the panel snapshot, or (zero, false) when the active
|
||||||
|
// backend isn't a Yaesu — same shape as IcomState.
|
||||||
|
func (m *Manager) YaesuState() (YaesuTXState, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
b := m.backend
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if yc, ok := b.(YaesuController); ok {
|
||||||
|
return yc.YaesuState(), true
|
||||||
|
}
|
||||||
|
return YaesuTXState{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// YaesuDo dispatches a Yaesu control onto the CAT goroutine, so a panel click
|
||||||
|
// and the poll loop never share the serial port at the same instant.
|
||||||
|
func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
yc, ok := b.(YaesuController)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("active CAT backend is not a Yaesu")
|
||||||
|
}
|
||||||
|
return fn(yc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+46
-10
@@ -160,16 +160,29 @@ func FreqToBCD(hz int64) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
|
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
|
||||||
func BCDToFreq(b []byte) int64 {
|
//
|
||||||
|
// It returns ok=false when a nibble is not a decimal digit. That check is the
|
||||||
|
// point of the function: a CI-V byte stream that has lost frame sync — a reply
|
||||||
|
// read at the wrong offset, a collision with the scope stream — still decodes
|
||||||
|
// into a number if 0x0A..0x0F are quietly treated as 10..15. An operator on a
|
||||||
|
// 2 m FM channel then saw entries like 16445550000 Hz appear in the status bar
|
||||||
|
// between good readings (F4JND, 2026-07-30). Garbage that LOOKS like a
|
||||||
|
// frequency is worse than a refused frame: it reaches the display, the band
|
||||||
|
// slots, and eventually the log.
|
||||||
|
func BCDToFreq(b []byte) (int64, bool) {
|
||||||
var hz int64
|
var hz int64
|
||||||
mult := int64(1)
|
mult := int64(1)
|
||||||
for i := 0; i < len(b) && i < 5; i++ {
|
for i := 0; i < len(b) && i < 5; i++ {
|
||||||
hz += int64(b[i]&0x0F) * mult
|
lo, hi := b[i]&0x0F, b[i]>>4
|
||||||
|
if lo > 9 || hi > 9 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
hz += int64(lo) * mult
|
||||||
mult *= 10
|
mult *= 10
|
||||||
hz += int64(b[i]>>4) * mult
|
hz += int64(hi) * mult
|
||||||
mult *= 10
|
mult *= 10
|
||||||
}
|
}
|
||||||
return hz
|
return hz, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
|
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
|
||||||
@@ -307,22 +320,45 @@ func ModeToADIF(m byte, data bool) string {
|
|||||||
|
|
||||||
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
|
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
|
||||||
// model. Unknown addresses fall back to a hex label.
|
// model. Unknown addresses fall back to a hex label.
|
||||||
|
//
|
||||||
|
// The name is not cosmetic: the UI derives model-dependent behaviour from it —
|
||||||
|
// notably the attenuator steps, which are 6/12/18 dB on the big rigs and a single
|
||||||
|
// 20 dB on the small ones. An address missing here therefore shows the WRONG
|
||||||
|
// attenuator buttons, and the rig NAKs them.
|
||||||
|
//
|
||||||
|
// Two entries here used to be wrong in a way that pointed at each other: 0x80 was
|
||||||
|
// labelled IC-7800 (it is the IC-7410) and 0x88 IC-7700 (it is the IC-7100), while
|
||||||
|
// the real IC-7800 (0x6A) and IC-7700 (0x74) were absent — so a 7800 came up as
|
||||||
|
// "Icom (0x6A)" with a 20 dB attenuator it does not have.
|
||||||
|
//
|
||||||
|
// Addresses cross-checked against the TR4W CI-V table and an independent
|
||||||
|
// published list; both agree on every value below.
|
||||||
func ModelName(addr byte) string {
|
func ModelName(addr byte) string {
|
||||||
switch addr {
|
switch addr {
|
||||||
|
case 0x6A:
|
||||||
|
return "IC-7800"
|
||||||
|
case 0x74:
|
||||||
|
return "IC-7700"
|
||||||
|
case 0x7A:
|
||||||
|
return "IC-7600"
|
||||||
|
case 0x7C:
|
||||||
|
return "IC-9100"
|
||||||
|
case 0x80:
|
||||||
|
return "IC-7410"
|
||||||
|
case 0x88:
|
||||||
|
return "IC-7100"
|
||||||
|
case 0x8E:
|
||||||
|
return "IC-7851" // shared with the IC-7850
|
||||||
case 0x94:
|
case 0x94:
|
||||||
return "IC-7300"
|
return "IC-7300"
|
||||||
case 0x98:
|
case 0x98:
|
||||||
return "IC-7610"
|
return "IC-7610"
|
||||||
case 0x7C:
|
|
||||||
return "IC-9100"
|
|
||||||
case 0xA2:
|
case 0xA2:
|
||||||
return "IC-9700"
|
return "IC-9700"
|
||||||
case 0xA4:
|
case 0xA4:
|
||||||
return "IC-705"
|
return "IC-705"
|
||||||
case 0x88:
|
case 0xB6:
|
||||||
return "IC-7700"
|
return "IC-7300MKII"
|
||||||
case 0x80:
|
|
||||||
return "IC-7800"
|
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Icom (0x%02X)", addr)
|
return fmt.Sprintf("Icom (0x%02X)", addr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ func TestFreqBCDRoundTrip(t *testing.T) {
|
|||||||
if len(b) != 5 {
|
if len(b) != 5 {
|
||||||
t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b))
|
t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b))
|
||||||
}
|
}
|
||||||
if got := BCDToFreq(b); got != hz {
|
got, ok := BCDToFreq(b)
|
||||||
t.Errorf("round trip %d → % X → %d", hz, b, got)
|
if !ok || got != hz {
|
||||||
|
t.Errorf("round trip %d → % X → %d (ok=%v)", hz, b, got, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,8 +50,8 @@ func TestScanSingleFreqResponse(t *testing.T) {
|
|||||||
if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq {
|
if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq {
|
||||||
t.Errorf("addrs/cmd wrong: %+v", f)
|
t.Errorf("addrs/cmd wrong: %+v", f)
|
||||||
}
|
}
|
||||||
if hz := BCDToFreq(f.Data); hz != 14250000 {
|
if hz, ok := BCDToFreq(f.Data); !ok || hz != 14250000 {
|
||||||
t.Errorf("decoded freq %d, want 14250000", hz)
|
t.Errorf("decoded freq %d (ok=%v), want 14250000", hz, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,3 +131,76 @@ func TestModelName(t *testing.T) {
|
|||||||
t.Errorf("ModelName(0x12) = %q, want fallback", got)
|
t.Errorf("ModelName(0x12) = %q, want fallback", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CI-V addresses are hardware constants: a wrong one means the console shows the
|
||||||
|
// wrong model, and with it the wrong attenuator steps (6/12/18 dB on the big
|
||||||
|
// rigs, a single 20 dB on the small ones) — buttons the rig then NAKs.
|
||||||
|
//
|
||||||
|
// Two entries here were previously wrong in a way that pointed at each other:
|
||||||
|
// 0x80 was labelled IC-7800 (it is the IC-7410) and 0x88 IC-7700 (it is the
|
||||||
|
// IC-7100), while the real IC-7800 (0x6A) and IC-7700 (0x74) were missing — so an
|
||||||
|
// IC-7800 came up as "Icom (0x6A)" with a 20 dB attenuator it does not have.
|
||||||
|
func TestModelNameAddresses(t *testing.T) {
|
||||||
|
for addr, want := range map[byte]string{
|
||||||
|
0x6A: "IC-7800",
|
||||||
|
0x74: "IC-7700",
|
||||||
|
0x7A: "IC-7600",
|
||||||
|
0x7C: "IC-9100",
|
||||||
|
0x80: "IC-7410",
|
||||||
|
0x88: "IC-7100",
|
||||||
|
0x8E: "IC-7851",
|
||||||
|
0x94: "IC-7300",
|
||||||
|
0x98: "IC-7610",
|
||||||
|
0xA2: "IC-9700",
|
||||||
|
0xA4: "IC-705",
|
||||||
|
0xB6: "IC-7300MKII",
|
||||||
|
} {
|
||||||
|
if got := ModelName(addr); got != want {
|
||||||
|
t.Errorf("ModelName(0x%02X) = %q, want %q", addr, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An unknown address must stay identifiable rather than masquerade as a model.
|
||||||
|
if got := ModelName(0x42); got != "Icom (0x42)" {
|
||||||
|
t.Errorf("ModelName(0x42) = %q, want the hex fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A frame that is not decimal BCD is refused rather than decoded.
|
||||||
|
//
|
||||||
|
// From an operator's log (F4JND, 2026-07-30): a rig sitting on 145.550 MHz FM
|
||||||
|
// produced status lines reading 16445550000, 8364550000, 5817408364 and 12640 Hz
|
||||||
|
// between good readings. CI-V had lost frame sync — a reply read at the wrong
|
||||||
|
// offset, alongside PTT timeouts and a scope stream the rig was rejecting — and
|
||||||
|
// the decoder treated the nibbles 0x0A..0x0F as the digits 10..15, turning noise
|
||||||
|
// into a plausible-looking number.
|
||||||
|
//
|
||||||
|
// Garbage that looks like a frequency is worse than a refused frame: it reaches
|
||||||
|
// the status bar, the band slots and the log, and it is what an operator then
|
||||||
|
// has to disprove.
|
||||||
|
func TestBCDToFreqRejectsNonDecimal(t *testing.T) {
|
||||||
|
// Valid: 145.550 MHz, encoded by the same package rather than by hand — my
|
||||||
|
// hand-written fixture was byte-reversed and this caught it.
|
||||||
|
good := FreqToBCD(145550000)
|
||||||
|
if hz, ok := BCDToFreq(good); !ok || hz != 145550000 {
|
||||||
|
t.Errorf("valid BCD % X decoded as %d (ok=%v), want 145550000", good, hz, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every nibble value above 9 must be refused, in either half of the byte.
|
||||||
|
bad := [][]byte{
|
||||||
|
{0x0A, 0x00, 0x55, 0x45, 0x01}, // low nibble
|
||||||
|
{0x00, 0x00, 0xF5, 0x45, 0x01}, // high nibble
|
||||||
|
{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, // an idle line read as data
|
||||||
|
{0x00, 0x00, 0x00, 0x0B, 0x00},
|
||||||
|
}
|
||||||
|
for _, b := range bad {
|
||||||
|
if hz, ok := BCDToFreq(b); ok {
|
||||||
|
t.Errorf("% X was accepted as %d Hz — it is not decimal BCD", b, hz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short frame still decodes what it holds: the caller decides whether a
|
||||||
|
// partial read is usable, and refusing it here would break the 4-byte forms.
|
||||||
|
if _, ok := BCDToFreq([]byte{0x00, 0x50}); !ok {
|
||||||
|
t.Error("a short but valid BCD frame must still decode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+195
-21
@@ -50,7 +50,6 @@ type Flex struct {
|
|||||||
meterMeta map[int]meterInfo
|
meterMeta map[int]meterInfo
|
||||||
meterVal map[int]float64
|
meterVal map[int]float64
|
||||||
meterSub map[int]bool // ids we've already sent "sub meter <id>" for
|
meterSub map[int]bool // ids we've already sent "sub meter <id>" for
|
||||||
meterLogAt time.Time // throttle for value logging
|
|
||||||
vitaSeen int // count of UDP datagrams (first few logged for diag)
|
vitaSeen int // count of UDP datagrams (first few logged for diag)
|
||||||
meterRawLogged bool // log the first raw meter-definition status once
|
meterRawLogged bool // log the first raw meter-definition status once
|
||||||
txRawLogged bool // log the first raw transmit status once (field-name audit)
|
txRawLogged bool // log the first raw transmit status once (field-name audit)
|
||||||
@@ -90,6 +89,22 @@ type flexSlice struct {
|
|||||||
apfLevel int
|
apfLevel int
|
||||||
wnb bool // wideband noise blanker
|
wnb bool // wideband noise blanker
|
||||||
wnbLevel int
|
wnbLevel int
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora series). Key names per the FlexLib slice
|
||||||
|
// docs: lms_nr(NRL) / lms_anf(ANFL) / speex_nr(NRS) / rnnoise(RNN) /
|
||||||
|
// anft(ANFT) / nrf(NRF). Older radios never report them — dspV4 flips true
|
||||||
|
// the first time any of these keys appears, and gates the extra UI rows.
|
||||||
|
lmsNR bool // NRL — legacy LMS noise reduction
|
||||||
|
lmsNRLevel int
|
||||||
|
lmsANF bool // ANFL — legacy LMS auto-notch
|
||||||
|
lmsANFLevel int
|
||||||
|
speexNR bool // NRS — spectral subtraction NR
|
||||||
|
speexNRLevel int
|
||||||
|
rnnoise bool // RNN — AI noise reduction (on/off only)
|
||||||
|
anft bool // ANFT — FFT-based auto-notch (on/off only)
|
||||||
|
nrf bool // NRF — noise reduction w/ filter
|
||||||
|
nrfLevel int
|
||||||
|
dspV4 bool
|
||||||
|
daxCh int // DAX audio channel for this slice (0 = off, 1-8)
|
||||||
rit bool // receive incremental tuning enabled
|
rit bool // receive incremental tuning enabled
|
||||||
ritFreq int // RIT offset in Hz (negative = down)
|
ritFreq int // RIT offset in Hz (negative = down)
|
||||||
xit bool // transmit incremental tuning enabled
|
xit bool // transmit incremental tuning enabled
|
||||||
@@ -120,6 +135,7 @@ type flexTX struct {
|
|||||||
micLevel int
|
micLevel int
|
||||||
filterLow int // TX filter low cut (Hz)
|
filterLow int // TX filter low cut (Hz)
|
||||||
filterHigh int // TX filter high cut (Hz)
|
filterHigh int // TX filter high cut (Hz)
|
||||||
|
dax bool // TX audio comes from DAX (SmartSDR's transmit-bar DAX button)
|
||||||
atuStatus string
|
atuStatus string
|
||||||
atuMemories bool
|
atuMemories bool
|
||||||
// CW keyer params (set via the top-level "cw" commands).
|
// CW keyer params (set via the top-level "cw" commands).
|
||||||
@@ -431,6 +447,8 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.tx.tunePower = atoiDefault(val, f.tx.tunePower)
|
f.tx.tunePower = atoiDefault(val, f.tx.tunePower)
|
||||||
case "tune":
|
case "tune":
|
||||||
f.tx.tune = val == "1"
|
f.tx.tune = val == "1"
|
||||||
|
case "dax":
|
||||||
|
f.tx.dax = val == "1"
|
||||||
case "vox_enable":
|
case "vox_enable":
|
||||||
f.tx.voxEnable = val == "1"
|
f.tx.voxEnable = val == "1"
|
||||||
case "vox_level":
|
case "vox_level":
|
||||||
@@ -499,7 +517,14 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
alreadyBound := f.boundClientID != ""
|
alreadyBound := f.boundClientID != ""
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
lp := strings.ToLower(program)
|
lp := strings.ToLower(program)
|
||||||
isGUI := program == "" || strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro")
|
// The real GUI client is SmartSDR (Windows) or Maestro. Its non-GUI
|
||||||
|
// helpers "SmartSDR CAT" and "SmartSDR DAX" also carry "smartsdr" in the
|
||||||
|
// name, so exclude them — and require an explicit GUI name (dropping the
|
||||||
|
// old program=="" fallback that could match CAT before its program field
|
||||||
|
// arrived). Binding to CAT/DAX is invalid and the radio was seen to drop
|
||||||
|
// SmartSDR CAT (connect/disconnect loop) when a logger did this.
|
||||||
|
isGUI := (strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro")) &&
|
||||||
|
!strings.Contains(lp, "cat") && !strings.Contains(lp, "dax")
|
||||||
if !disconnected && clientID != "" && !alreadyBound && isGUI {
|
if !disconnected && clientID != "" && !alreadyBound && isGUI {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.boundClientID = clientID
|
f.boundClientID = clientID
|
||||||
@@ -630,6 +655,18 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
}
|
}
|
||||||
if removed {
|
if removed {
|
||||||
f.amp = flexAmp{}
|
f.amp = flexAmp{}
|
||||||
|
// The amp's meters (FWD / ID / TEMP …) don't always get an individual
|
||||||
|
// "meter removed" push, so power-cycling the amp left the OLD meter
|
||||||
|
// ids in the maps while the re-registered amp added NEW ones — the
|
||||||
|
// panel then showed every amp meter twice. Drop every AMP-sourced
|
||||||
|
// meter here so only the fresh set survives.
|
||||||
|
for id, mi := range f.meterMeta {
|
||||||
|
if strings.Contains(strings.ToUpper(mi.src), "AMP") {
|
||||||
|
delete(f.meterMeta, id)
|
||||||
|
delete(f.meterVal, id)
|
||||||
|
delete(f.meterSub, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -641,6 +678,26 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.meterRawLogged = true
|
f.meterRawLogged = true
|
||||||
debugLog.Printf("Flex: meter status raw: %s", payload)
|
debugLog.Printf("Flex: meter status raw: %s", payload)
|
||||||
}
|
}
|
||||||
|
// Meter removal — "meter <id> removed": drop it so a stale value can't
|
||||||
|
// linger (e.g. after an amp/slice is torn down).
|
||||||
|
removedMeter := false
|
||||||
|
for _, tok := range fields[1:] {
|
||||||
|
if tok == "removed" || tok == "in_use=0" {
|
||||||
|
removedMeter = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removedMeter {
|
||||||
|
f.mu.Lock()
|
||||||
|
for _, tok := range fields[1:] {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(tok)); err == nil {
|
||||||
|
delete(f.meterMeta, n)
|
||||||
|
delete(f.meterVal, n)
|
||||||
|
delete(f.meterSub, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
var newIDs []int
|
var newIDs []int
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
for _, tok := range fields[1:] {
|
for _, tok := range fields[1:] {
|
||||||
@@ -710,11 +767,17 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.meterMeta[num] = old
|
f.meterMeta[num] = old
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
// One line for the whole batch, not one per meter: a Flex announces
|
||||||
|
// dozens at connect and that was a wall of text every time.
|
||||||
|
var names []string
|
||||||
for _, id := range newIDs {
|
for _, id := range newIDs {
|
||||||
mi := f.meterMeta[id]
|
mi := f.meterMeta[id]
|
||||||
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s lo=%g hi=%g → sub", id, mi.src, mi.name, mi.unit, mi.lo, mi.hi)
|
names = append(names, nonEmpty(mi.name, strconv.Itoa(id)))
|
||||||
f.subscribeMeter(id)
|
f.subscribeMeter(id)
|
||||||
}
|
}
|
||||||
|
if len(names) > 0 {
|
||||||
|
debugLog.Printf("Flex: subscribed to %d meter(s): %s", len(names), strings.Join(names, " "))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Spot status: "spot <index> …". Track the index so we can clear the
|
// Spot status: "spot <index> …". Track the index so we can clear the
|
||||||
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
|
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
|
||||||
@@ -809,6 +872,28 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.wnb = val == "1"
|
s.wnb = val == "1"
|
||||||
case "wnb_level":
|
case "wnb_level":
|
||||||
s.wnbLevel = atoiDefault(val, s.wnbLevel)
|
s.wnbLevel = atoiDefault(val, s.wnbLevel)
|
||||||
|
case "lms_nr":
|
||||||
|
s.lmsNR, s.dspV4 = val == "1", true
|
||||||
|
case "lms_nr_level":
|
||||||
|
s.lmsNRLevel, s.dspV4 = atoiDefault(val, s.lmsNRLevel), true
|
||||||
|
case "lms_anf":
|
||||||
|
s.lmsANF, s.dspV4 = val == "1", true
|
||||||
|
case "lms_anf_level":
|
||||||
|
s.lmsANFLevel, s.dspV4 = atoiDefault(val, s.lmsANFLevel), true
|
||||||
|
case "speex_nr":
|
||||||
|
s.speexNR, s.dspV4 = val == "1", true
|
||||||
|
case "speex_nr_level":
|
||||||
|
s.speexNRLevel, s.dspV4 = atoiDefault(val, s.speexNRLevel), true
|
||||||
|
case "rnnoise":
|
||||||
|
s.rnnoise, s.dspV4 = val == "1", true
|
||||||
|
case "anft":
|
||||||
|
s.anft, s.dspV4 = val == "1", true
|
||||||
|
case "nrf":
|
||||||
|
s.nrf, s.dspV4 = val == "1", true
|
||||||
|
case "nrf_level":
|
||||||
|
s.nrfLevel, s.dspV4 = atoiDefault(val, s.nrfLevel), true
|
||||||
|
case "dax":
|
||||||
|
s.daxCh = atoiDefault(val, s.daxCh)
|
||||||
case "rit_on":
|
case "rit_on":
|
||||||
s.rit = val == "1"
|
s.rit = val == "1"
|
||||||
case "rit_freq":
|
case "rit_freq":
|
||||||
@@ -1121,7 +1206,11 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
}
|
}
|
||||||
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
||||||
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
||||||
if m := flexEncode(s.Mode); m != "" {
|
// Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the
|
||||||
|
// slice mode on a spot click when mode= is one of ITS mode names — "SSB" or a
|
||||||
|
// bare cluster label is ignored, so the mode wouldn't change. adifModeToFlex
|
||||||
|
// also resolves SSB→USB/LSB from the spot frequency.
|
||||||
|
if m := flexEncode(adifModeToFlex(s.Mode, s.FreqHz)); m != "" {
|
||||||
cmd += " mode=" + m
|
cmd += " mode=" + m
|
||||||
}
|
}
|
||||||
if c := flexEncode(s.Comment); c != "" {
|
if c := flexEncode(s.Comment); c != "" {
|
||||||
@@ -1274,6 +1363,7 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
MicLevel: f.tx.micLevel,
|
MicLevel: f.tx.micLevel,
|
||||||
TXFilterLow: f.tx.filterLow,
|
TXFilterLow: f.tx.filterLow,
|
||||||
TXFilterHigh: f.tx.filterHigh,
|
TXFilterHigh: f.tx.filterHigh,
|
||||||
|
TXDAX: f.tx.dax,
|
||||||
MicProfile: f.micProfile,
|
MicProfile: f.micProfile,
|
||||||
MicProfiles: f.micProfiles,
|
MicProfiles: f.micProfiles,
|
||||||
ATUStatus: f.tx.atuStatus,
|
ATUStatus: f.tx.atuStatus,
|
||||||
@@ -1328,6 +1418,18 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
st.APFLevel = rx.apfLevel
|
st.APFLevel = rx.apfLevel
|
||||||
st.WNB = rx.wnb
|
st.WNB = rx.wnb
|
||||||
st.WNBLevel = rx.wnbLevel
|
st.WNBLevel = rx.wnbLevel
|
||||||
|
st.LMSNR = rx.lmsNR
|
||||||
|
st.LMSNRLevel = rx.lmsNRLevel
|
||||||
|
st.LMSANF = rx.lmsANF
|
||||||
|
st.LMSANFLevel = rx.lmsANFLevel
|
||||||
|
st.SpeexNR = rx.speexNR
|
||||||
|
st.SpeexNRLevel = rx.speexNRLevel
|
||||||
|
st.RNN = rx.rnnoise
|
||||||
|
st.ANFT = rx.anft
|
||||||
|
st.NRF = rx.nrf
|
||||||
|
st.NRFLevel = rx.nrfLevel
|
||||||
|
st.DSPV4 = rx.dspV4
|
||||||
|
st.DAXCh = rx.daxCh
|
||||||
st.RIT = rx.rit
|
st.RIT = rx.rit
|
||||||
st.RITFreq = rx.ritFreq
|
st.RITFreq = rx.ritFreq
|
||||||
st.XIT = rx.xit
|
st.XIT = rx.xit
|
||||||
@@ -1396,6 +1498,28 @@ func (f *Flex) sendSlice(param string, val any) error {
|
|||||||
rx.apf = val == "1"
|
rx.apf = val == "1"
|
||||||
case "apf_level":
|
case "apf_level":
|
||||||
rx.apfLevel = toInt(val)
|
rx.apfLevel = toInt(val)
|
||||||
|
case "lms_nr":
|
||||||
|
rx.lmsNR = val == "1"
|
||||||
|
case "lms_nr_level":
|
||||||
|
rx.lmsNRLevel = toInt(val)
|
||||||
|
case "lms_anf":
|
||||||
|
rx.lmsANF = val == "1"
|
||||||
|
case "lms_anf_level":
|
||||||
|
rx.lmsANFLevel = toInt(val)
|
||||||
|
case "speex_nr":
|
||||||
|
rx.speexNR = val == "1"
|
||||||
|
case "speex_nr_level":
|
||||||
|
rx.speexNRLevel = toInt(val)
|
||||||
|
case "rnnoise":
|
||||||
|
rx.rnnoise = val == "1"
|
||||||
|
case "anft":
|
||||||
|
rx.anft = val == "1"
|
||||||
|
case "nrf":
|
||||||
|
rx.nrf = val == "1"
|
||||||
|
case "nrf_level":
|
||||||
|
rx.nrfLevel = toInt(val)
|
||||||
|
case "dax":
|
||||||
|
rx.daxCh = toInt(val)
|
||||||
case "rxant":
|
case "rxant":
|
||||||
rx.rxAnt = fmt.Sprint(val)
|
rx.rxAnt = fmt.Sprint(val)
|
||||||
case "txant":
|
case "txant":
|
||||||
@@ -1515,6 +1639,7 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on))
|
|||||||
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
||||||
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
||||||
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
||||||
|
|
||||||
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
|
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
|
||||||
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
|
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
|
||||||
// switch is off, so turning RIT back on restores the last offset — same as the
|
// switch is off, so turning RIT back on restores the last offset — same as the
|
||||||
@@ -1537,6 +1662,37 @@ func clampOffset(hz int) int {
|
|||||||
|
|
||||||
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
||||||
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
||||||
|
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora series) — the extra noise tools SmartSDR ships
|
||||||
|
// beyond nb/nr/anf/wnb. Key names per the FlexLib slice command docs.
|
||||||
|
func (f *Flex) SetLMSNR(on bool) error { return f.sendSlice("lms_nr", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetLMSNRLevel(l int) error { return f.sendSlice("lms_nr_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetLMSANF(on bool) error { return f.sendSlice("lms_anf", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetLMSANFLevel(l int) error { return f.sendSlice("lms_anf_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetSpeexNR(on bool) error { return f.sendSlice("speex_nr", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetSpeexNRLevel(l int) error { return f.sendSlice("speex_nr_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetRNN(on bool) error { return f.sendSlice("rnnoise", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetANFT(on bool) error { return f.sendSlice("anft", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetNRF(on bool) error { return f.sendSlice("nrf", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetNRFLevel(l int) error { return f.sendSlice("nrf_level", clampLevel(l)) }
|
||||||
|
|
||||||
|
// SetDAX routes the active slice's RX audio to a DAX channel (0 = off, 1-8) —
|
||||||
|
// the SmartSDR slice "DAX" selector, e.g. to feed WSJT-X via DAX audio.
|
||||||
|
func (f *Flex) SetDAX(ch int) error {
|
||||||
|
if ch < 0 {
|
||||||
|
ch = 0
|
||||||
|
}
|
||||||
|
if ch > 8 {
|
||||||
|
ch = 8
|
||||||
|
}
|
||||||
|
return f.sendSlice("dax", ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTXDAX toggles DAX as the TRANSMIT audio source — SmartSDR's transmit-bar
|
||||||
|
// DAX button ("transmit set dax="), the one used to send WSJT-X audio.
|
||||||
|
func (f *Flex) SetTXDAX(on bool) error {
|
||||||
|
return f.txSet("transmit set dax="+boolFlex(on), "dax", func(t *flexTX) { t.dax = on })
|
||||||
|
}
|
||||||
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
|
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
|
||||||
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
|
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
|
||||||
|
|
||||||
@@ -1581,6 +1737,37 @@ func (f *Flex) SetCWBreakInDelay(ms int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendCW queues text on the radio's CWX keyer. SmartSDR buffers and keys it, so
|
||||||
|
// no WinKeyer / SmartCAT is needed — and because the radio owns the buffer,
|
||||||
|
// characters fed while it's already sending append and key in order (the basis
|
||||||
|
// for type-ahead). Quotes/backslashes are escaped for the "cwx send \"…\"" form.
|
||||||
|
func (f *Flex) SendCW(text string) error {
|
||||||
|
text = strings.TrimRight(text, "\r\n")
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(text)
|
||||||
|
f.send(`cwx send "` + esc + `"`)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCW clears the CWX buffer, aborting whatever is currently being keyed.
|
||||||
|
func (f *Flex) StopCW() error {
|
||||||
|
f.send("cwx clear")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackspaceCW removes the last n not-yet-keyed characters from the CWX buffer —
|
||||||
|
// the "un-send while sending" a serial WinKeyer can't do. Used for type-ahead
|
||||||
|
// corrections (backspacing a mistyped call before the radio has keyed it).
|
||||||
|
func (f *Flex) BackspaceCW(n int) error {
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("cwx erase %d", n))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) SetCWSidetone(on bool) error {
|
func (f *Flex) SetCWSidetone(on bool) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.tx.cwSidetone = on
|
f.tx.cwSidetone = on
|
||||||
@@ -1714,15 +1901,11 @@ func (f *Flex) SetTune(on bool) error {
|
|||||||
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
|
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
|
||||||
// footswitch or an external keyer, which a software PTT block could not.
|
// footswitch or an external keyer, which a software PTT block could not.
|
||||||
//
|
//
|
||||||
// It also labels the interlock reason "OpsLog" so SmartSDR shows WHY TX is
|
// `transmit set inhibit=` is the whole mechanism. We used to also send
|
||||||
// blocked. The reason is best-effort (a separate interlock object); the inhibit
|
// `interlock set reason=OpsLog` to label WHY TX was blocked, but `reason` is a
|
||||||
// is what actually blocks TX, so a rig that ignores the reason still stays safe.
|
// read-only status field on the interlock object — writing it is rejected
|
||||||
|
// (cmd error 5000002D on SmartSDR v1.4/v3) and does nothing, so it's dropped.
|
||||||
func (f *Flex) SetTXInhibit(on bool) error {
|
func (f *Flex) SetTXInhibit(on bool) error {
|
||||||
if on {
|
|
||||||
f.send("interlock set reason=OpsLog")
|
|
||||||
} else {
|
|
||||||
f.send("interlock set reason=")
|
|
||||||
}
|
|
||||||
return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
|
return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1976,15 +2159,6 @@ func (f *Flex) parseVita(p []byte, seen int) {
|
|||||||
raw := int16(binary.BigEndian.Uint16(payload[i+2 : i+4]))
|
raw := int16(binary.BigEndian.Uint16(payload[i+2 : i+4]))
|
||||||
f.meterVal[id] = scaleMeter(raw, f.meterMeta[id].unit)
|
f.meterVal[id] = scaleMeter(raw, f.meterMeta[id].unit)
|
||||||
}
|
}
|
||||||
if time.Since(f.meterLogAt) > 5*time.Second { // throttled dump to validate names
|
|
||||||
f.meterLogAt = time.Now()
|
|
||||||
var b strings.Builder
|
|
||||||
for id, v := range f.meterVal {
|
|
||||||
mi := f.meterMeta[id]
|
|
||||||
fmt.Fprintf(&b, "%s=%.1f%s ", nonEmpty(mi.name, strconv.Itoa(id)), v, mi.unit)
|
|
||||||
}
|
|
||||||
debugLog.Printf("Flex: meters %s", strings.TrimSpace(b.String()))
|
|
||||||
}
|
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The rule that decides whether a silent-but-alive Icom network session is
|
||||||
|
// tolerated or torn down. It was "always tolerate", and that is what froze the
|
||||||
|
// frequency display for ever when another program took the CI-V session.
|
||||||
|
//
|
||||||
|
// The decision is pinned here rather than left implicit, because the two cases
|
||||||
|
// it separates pull in opposite directions: a rig in standby must NOT be
|
||||||
|
// reconnected every few seconds (the panel and its ON button would blink away),
|
||||||
|
// while a hijacked session must recover without restarting OpsLog.
|
||||||
|
func TestIcomSilenceTolerance(t *testing.T) {
|
||||||
|
// tolerate mirrors the condition in ReadState.
|
||||||
|
tolerate := func(alive, readerGone bool, lastGood time.Time, silentFor, grace time.Duration) bool {
|
||||||
|
return alive && !readerGone && (lastGood.IsZero() || silentFor < grace)
|
||||||
|
}
|
||||||
|
never := time.Time{}
|
||||||
|
some := time.Now()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
alive bool
|
||||||
|
readerGone bool
|
||||||
|
lastGood time.Time
|
||||||
|
silentFor time.Duration
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"never answered since connect — rig is off, keep the panel", true, false, never, time.Hour, true},
|
||||||
|
{"brief silence during a band change", true, false, some, 2 * time.Second, true},
|
||||||
|
{"silence past the grace — reconnect", true, false, some, icomSilentGrace + time.Second, false},
|
||||||
|
{"reader goroutine gone — dead however alive the link looks", true, true, some, time.Second, false},
|
||||||
|
{"control link itself dead", false, false, some, time.Second, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := tolerate(c.alive, c.readerGone, c.lastGood, c.silentFor, icomSilentGrace)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("%s: tolerate = %v, want %v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The backoff must actually converge on the ceiling: a rig left switched off is
|
||||||
|
// silent for hours, and a window that stayed at 30 s would re-tear its session
|
||||||
|
// about a hundred times an hour.
|
||||||
|
func TestIcomSilenceBackoff(t *testing.T) {
|
||||||
|
g := icomSilentGrace
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if g < icomSilentGraceMax {
|
||||||
|
g *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g < icomSilentGraceMax {
|
||||||
|
t.Fatalf("backoff never reached the ceiling: %s < %s", g, icomSilentGraceMax)
|
||||||
|
}
|
||||||
|
if g > 2*icomSilentGraceMax {
|
||||||
|
t.Errorf("backoff overshot the ceiling: %s", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,6 +136,11 @@ type icomNet struct {
|
|||||||
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
||||||
lastRx atomic.Int64
|
lastRx atomic.Int64
|
||||||
|
|
||||||
|
// dead is set when the rig explicitly tears the session down (control 0x05):
|
||||||
|
// Alive() then returns false immediately so ReadState fails on the next poll and
|
||||||
|
// the manager reconnects cleanly, instead of waiting out the 6 s lastRx timeout.
|
||||||
|
dead atomic.Bool
|
||||||
|
|
||||||
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
||||||
// Torn down alongside the CI-V/control streams in Close.
|
// Torn down alongside the CI-V/control streams in Close.
|
||||||
audio *icomAudio
|
audio *icomAudio
|
||||||
@@ -178,6 +183,9 @@ func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }
|
|||||||
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
||||||
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
||||||
func (n *icomNet) Alive() bool {
|
func (n *icomNet) Alive() bool {
|
||||||
|
if n.dead.Load() {
|
||||||
|
return false // rig sent an explicit disconnect — reconnect now, don't wait
|
||||||
|
}
|
||||||
last := n.lastRx.Load()
|
last := n.lastRx.Load()
|
||||||
if last == 0 {
|
if last == 0 {
|
||||||
return true // just connected, nothing received yet — give it a chance
|
return true // just connected, nothing received yet — give it a chance
|
||||||
@@ -292,6 +300,7 @@ func (n *icomNet) ctrlPump() {
|
|||||||
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case 0x05: // rig-initiated disconnect — it dropped US
|
case 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||||
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||||
default:
|
default:
|
||||||
// Anything else on the control stream is (almost always) the rig's
|
// Anything else on the control stream is (almost always) the rig's
|
||||||
@@ -367,6 +376,7 @@ func (n *icomNet) civPump() {
|
|||||||
n.resend(icnLE.Uint16(buf[6:]))
|
n.resend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||||
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
||||||
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
||||||
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
||||||
|
|||||||
+189
-24
@@ -37,6 +37,19 @@ type aliveTransport interface {
|
|||||||
Alive() bool
|
Alive() bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// How long the network backend accepts "the control link answers but the rig
|
||||||
|
// sends no CI-V" before declaring the session lost. Long enough to cover a
|
||||||
|
// band change or a rig booting from standby; short enough that an operator
|
||||||
|
// whose CI-V session was stolen (WSJT-X via OmniRig, the Remote Utility) gets
|
||||||
|
// a live frequency back on his own rather than restarting OpsLog.
|
||||||
|
icomSilentGrace = 30 * time.Second
|
||||||
|
// Ceiling for the backoff applied when the silence persists — a rig switched
|
||||||
|
// OFF is silent for hours, and re-tearing its session every 30 s would blink
|
||||||
|
// the panel (and its ON button) away continuously.
|
||||||
|
icomSilentGraceMax = 4 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
// scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope
|
// scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope
|
||||||
// (0x27) frames on a SEPARATE channel from control replies. The network transport
|
// (0x27) frames on a SEPARATE channel from control replies. The network transport
|
||||||
// implements it so the continuous panadapter stream can't crowd control replies
|
// implements it so the continuous panadapter stream can't crowd control replies
|
||||||
@@ -95,6 +108,10 @@ type IcomSerial struct {
|
|||||||
splitOn bool // last read split state (refreshed every few cycles)
|
splitOn bool // last read split state (refreshed every few cycles)
|
||||||
splitTXFreq int64 // last read unselected/TX VFO freq while in split
|
splitTXFreq int64 // last read unselected/TX VFO freq while in split
|
||||||
readFails int // consecutive ReadState freq-read failures (transient tolerance)
|
readFails int // consecutive ReadState freq-read failures (transient tolerance)
|
||||||
|
lastGoodAt time.Time // last SUCCESSFUL frequency read — bounds the network
|
||||||
|
// "alive but silent" tolerance below, which used to be
|
||||||
|
// unbounded and left the display frozen for ever
|
||||||
|
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
|
||||||
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
||||||
// the panel's set-once controls once the rig actually answers)
|
// the panel's set-once controls once the rig actually answers)
|
||||||
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
||||||
@@ -224,11 +241,14 @@ func (b *IcomSerial) Connect() error {
|
|||||||
idAddr = f.Data[1]
|
idAddr = f.Data[1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
|
// Whether the rig's scope protocol carries a leading main/sub SELECTOR byte on
|
||||||
// selector byte; single-scope rigs (IC-7300…) do not. Decide from the
|
// every 0x27 frame and command. The IC-7610/9700 (dual scope) do — and the logs
|
||||||
// IDENTIFIED model, NOT the configured address: an IC-7300 run at 0x98 must
|
// prove the IC-7300 does too (frames arrive as `27 00 00 <seq> <total> …` and a
|
||||||
// still parse single-scope frames (this was the "scope blank on the 7300" bug).
|
// mode read answers `27 14 00 <mode>`), even though it has a single scope. The
|
||||||
b.dualScope = idAddr == 0x98 || idAddr == 0xA2
|
// waveform parser auto-detects this per-frame regardless, so a 7300 at a
|
||||||
|
// non-default address still RENDERS; this flag only drives the SET/read commands
|
||||||
|
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
|
||||||
|
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
|
||||||
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network
|
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network
|
||||||
// the rig may still be booting (or off) at Connect, so an immediate readDSP
|
// the rig may still be booting (or off) at Connect, so an immediate readDSP
|
||||||
// would time out and leave every control at 0 / off with no retry. ReadState
|
// would time out and leave every control at 0 / off with no retry. ReadState
|
||||||
@@ -264,7 +284,34 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
|||||||
// rig is switched on) rather than tearing the whole UDP session down and
|
// rig is switched on) rather than tearing the whole UDP session down and
|
||||||
// flapping every few seconds. The panel stays up so the ON button works.
|
// flapping every few seconds. The panel stays up so the ON button works.
|
||||||
if at, ok := b.port.(aliveTransport); ok {
|
if at, ok := b.port.(aliveTransport); ok {
|
||||||
if at.Alive() {
|
// The reader goroutine is what feeds every CI-V reply. If it has
|
||||||
|
// exited, no read can ever succeed again, however healthy the control
|
||||||
|
// link looks — that is not a silent rig, it is a dead connection.
|
||||||
|
readerGone := false
|
||||||
|
if b.readerDone != nil {
|
||||||
|
select {
|
||||||
|
case <-b.readerDone:
|
||||||
|
readerGone = true
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "Alive but silent" is the rig in standby, or mid band-change. It was
|
||||||
|
// tolerated for ever, and that is the freeze: when ANOTHER program takes
|
||||||
|
// the CI-V session (WSJT-X through OmniRig, say) the rig keeps answering
|
||||||
|
// pings on the control stream while sending us no CI-V at all. Alive()
|
||||||
|
// stayed true, the cached frequency was re-published with a fresh
|
||||||
|
// timestamp on every poll, and the operator saw a confident, frozen
|
||||||
|
// number until OpsLog was restarted. Bounded now: past the grace period
|
||||||
|
// the error is reported so the Manager tears the session down and
|
||||||
|
// reconnects, which re-takes the CI-V stream.
|
||||||
|
silentFor := time.Duration(0)
|
||||||
|
if !b.lastGoodAt.IsZero() {
|
||||||
|
silentFor = time.Since(b.lastGoodAt)
|
||||||
|
}
|
||||||
|
if b.silentGrace <= 0 {
|
||||||
|
b.silentGrace = icomSilentGrace
|
||||||
|
}
|
||||||
|
if at.Alive() && !readerGone && (b.lastGoodAt.IsZero() || silentFor < b.silentGrace) {
|
||||||
b.readFails = 0
|
b.readFails = 0
|
||||||
s.FreqHz = b.curFreq // 0 until the rig is powered on and first read
|
s.FreqHz = b.curFreq // 0 until the rig is powered on and first read
|
||||||
if b.curModeByte != 0 {
|
if b.curModeByte != 0 {
|
||||||
@@ -283,8 +330,25 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
|||||||
b.dspMu.Unlock()
|
b.dspMu.Unlock()
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
switch {
|
||||||
|
case readerGone:
|
||||||
|
debugLog.Printf("icom net: the CI-V reader has exited — the connection is dead however alive the control link looks → reconnecting")
|
||||||
|
case at.Alive():
|
||||||
|
debugLog.Printf("icom net: control link answers but no CI-V reply for %s → reconnecting. Another program (WSJT-X/OmniRig, the Remote Utility) has most likely taken the CI-V session.", silentFor.Round(time.Second))
|
||||||
|
default:
|
||||||
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
|
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
|
||||||
return RigState{}, err // control link dead → let the Manager reconnect
|
}
|
||||||
|
// Restart the clock, and widen the window each time it fires without a
|
||||||
|
// good read in between. A hijacked session recovers on the first shot;
|
||||||
|
// a rig simply switched OFF never will, and re-tearing its session every
|
||||||
|
// 30 s would make the panel — and its ON button — blink away
|
||||||
|
// continuously. Backing off to minutes keeps the standby case quiet
|
||||||
|
// while still recovering on its own.
|
||||||
|
b.lastGoodAt = time.Now()
|
||||||
|
if b.silentGrace < icomSilentGraceMax {
|
||||||
|
b.silentGrace *= 2
|
||||||
|
}
|
||||||
|
return RigState{}, err // let the Manager reconnect
|
||||||
}
|
}
|
||||||
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
||||||
// switches band/VFO. Tolerate a few consecutive misses as transient — keep
|
// switches band/VFO. Tolerate a few consecutive misses as transient — keep
|
||||||
@@ -305,6 +369,8 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
|||||||
return RigState{}, err
|
return RigState{}, err
|
||||||
}
|
}
|
||||||
b.readFails = 0
|
b.readFails = 0
|
||||||
|
b.lastGoodAt = time.Now()
|
||||||
|
b.silentGrace = icomSilentGrace // the rig answers: back to the short window
|
||||||
s.FreqHz = hz
|
s.FreqHz = hz
|
||||||
b.curFreq = hz
|
b.curFreq = hz
|
||||||
|
|
||||||
@@ -371,10 +437,54 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
|||||||
if !b.dspLoaded {
|
if !b.dspLoaded {
|
||||||
b.readDSP()
|
b.readDSP()
|
||||||
b.dspLoaded = true
|
b.dspLoaded = true
|
||||||
|
} else {
|
||||||
|
b.refreshFrontPanel()
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshFrontPanel re-reads the few controls an operator actually reaches for on
|
||||||
|
// the rig itself, so the console follows the radio instead of only driving it.
|
||||||
|
//
|
||||||
|
// readDSP loads everything but runs ONCE per connection (dspLoaded), which left
|
||||||
|
// the panel showing whatever was set at connect: switch AGC from FAST to MID on
|
||||||
|
// the front panel and OpsLog still said FAST, indefinitely. Commands worked, so
|
||||||
|
// the link was plainly fine — only this direction was missing.
|
||||||
|
//
|
||||||
|
// ONE read per poll cycle, in rotation. The full snapshot is ~30 CI-V round trips
|
||||||
|
// and refreshing it wholesale would hog the CAT thread for seconds at a time —
|
||||||
|
// including the operator's own Set* commands, which is far worse than a stale
|
||||||
|
// label. The rotation completes in 8 cycles; anything not covered here is still a
|
||||||
|
// connect-time or ↻ Refresh read.
|
||||||
|
func (b *IcomSerial) refreshFrontPanel() {
|
||||||
|
switch b.pollN % 8 {
|
||||||
|
case 1:
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
|
||||||
|
b.dspMu.Lock()
|
||||||
|
b.dsp.AGC = agcName(v)
|
||||||
|
b.dspMu.Unlock()
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
if v, ok := b.readAtt(); ok {
|
||||||
|
b.dspMu.Lock()
|
||||||
|
b.dsp.Att = v
|
||||||
|
b.dspMu.Unlock()
|
||||||
|
}
|
||||||
|
case 5:
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
|
||||||
|
b.dspMu.Lock()
|
||||||
|
b.dsp.Preamp = int(v)
|
||||||
|
b.dspMu.Unlock()
|
||||||
|
}
|
||||||
|
case 7:
|
||||||
|
if _, f, ok := b.readModeFilter(); ok {
|
||||||
|
b.dspMu.Lock()
|
||||||
|
b.dsp.Filter = int(f)
|
||||||
|
b.dspMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (b *IcomSerial) SetFrequency(hz int64) error {
|
func (b *IcomSerial) SetFrequency(hz int64) error {
|
||||||
if hz <= 0 {
|
if hz <= 0 {
|
||||||
return fmt.Errorf("invalid frequency")
|
return fmt.Errorf("invalid frequency")
|
||||||
@@ -447,7 +557,9 @@ func (b *IcomSerial) write(payload ...byte) error {
|
|||||||
// recv() only sees the reply to THIS request (avoids a previous command's ack
|
// recv() only sees the reply to THIS request (avoids a previous command's ack
|
||||||
// or an unsolicited dial-turn update being mistaken for our response).
|
// or an unsolicited dial-turn update being mistaken for our response).
|
||||||
b.drainResp()
|
b.drainResp()
|
||||||
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...))
|
frame := civ.Frame(b.rigAddr, civ.AddrController, payload...)
|
||||||
|
traceCIV("TX", frame)
|
||||||
|
_, err := b.port.Write(frame)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,6 +605,9 @@ func (b *IcomSerial) reader(port civTransport, done chan struct{}) {
|
|||||||
continue // read timeout with no data
|
continue // read timeout with no data
|
||||||
}
|
}
|
||||||
rx = append(rx, tmp[:n]...)
|
rx = append(rx, tmp[:n]...)
|
||||||
|
// Traced BEFORE framing: the bytes as they arrived are what shows a lost
|
||||||
|
// frame boundary, which a decoded view would hide.
|
||||||
|
traceCIV("RX", tmp[:n])
|
||||||
frames, consumed := civ.Scan(rx)
|
frames, consumed := civ.Scan(rx)
|
||||||
if consumed > 0 {
|
if consumed > 0 {
|
||||||
rx = append(rx[:0], rx[consumed:]...)
|
rx = append(rx[:0], rx[consumed:]...)
|
||||||
@@ -603,12 +718,20 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
|||||||
rawN++
|
rawN++
|
||||||
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
|
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
|
||||||
}
|
}
|
||||||
|
// Frame layout after the 0x00 sub-command byte is one of:
|
||||||
|
// [selector][seq][total][data…] (IC-7610/9700 AND, as the logs prove,
|
||||||
|
// the IC-7300 — selector 0x00 = main)
|
||||||
|
// [seq][total][data…] (a rig with no selector byte)
|
||||||
|
// The sequence starts at 1, so a 0x00 at Data[1] can only be the main-scope
|
||||||
|
// selector — never a valid sequence. Keying this off the rig ADDRESS was the
|
||||||
|
// long-standing "blank scope on the IC-7300" bug: the 7300 DOES send the
|
||||||
|
// selector, so idx stayed 1, seq was read as 0x00, and every frame was
|
||||||
|
// dropped. Detect it from the byte itself instead — address-independent.
|
||||||
idx := 1
|
idx := 1
|
||||||
if b.dualScope {
|
if len(f.Data) >= 2 && f.Data[1] == 0x00 {
|
||||||
if len(f.Data) < 2 || f.Data[1] != 0x00 {
|
idx = 2 // leading main-scope selector byte present
|
||||||
continue // only the MAIN scope
|
} else if b.dualScope {
|
||||||
}
|
continue // dual-scope rig SUB frame (selector != 0x00) — main only
|
||||||
idx = 2
|
|
||||||
}
|
}
|
||||||
if len(f.Data) < idx+2 {
|
if len(f.Data) < idx+2 {
|
||||||
continue
|
continue
|
||||||
@@ -632,8 +755,13 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
|||||||
// (e.g. 14.200 MHz + 100 kHz → centred 14.150-14.250; 21.000 +
|
// (e.g. 14.200 MHz + 100 kHz → centred 14.150-14.250; 21.000 +
|
||||||
// 21.070 → fixed 21.000-21.070). Guessing wrong here gave the absurd
|
// 21.070 → fixed 21.000-21.070). Guessing wrong here gave the absurd
|
||||||
// 21.000-42.070 span (low + a 21 MHz "span").
|
// 21.000-42.070 span (low + a 21 MHz "span").
|
||||||
v1 := civ.BCDToFreq(region[1:6])
|
v1, ok1 := civ.BCDToFreq(region[1:6])
|
||||||
v2 := civ.BCDToFreq(region[6:11])
|
v2, ok2 := civ.BCDToFreq(region[6:11])
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
// Not decimal BCD — the frame was read at the wrong offset. Keep the
|
||||||
|
// edges we had; a wrong span is drawn, noticed, and mistrusted.
|
||||||
|
break
|
||||||
|
}
|
||||||
var low, high int64
|
var low, high int64
|
||||||
if v2 > v1 {
|
if v2 > v1 {
|
||||||
low, high = v1, v2 // absolute low/high edges (fixed edge set)
|
low, high = v1, v2 // absolute low/high edges (fixed edge set)
|
||||||
@@ -661,9 +789,28 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
|||||||
if seq == 1 { // header frame — begins a new sweep, no waveform data
|
if seq == 1 { // header frame — begins a new sweep, no waveform data
|
||||||
regions = make(map[byte][]byte)
|
regions = make(map[byte][]byte)
|
||||||
total = tot
|
total = tot
|
||||||
if len(region) >= 11 { // [info][low 5][high 5]
|
if len(region) >= 11 { // [info][freq1 5-BCD][freq2 5-BCD]
|
||||||
low := civ.BCDToFreq(region[1:6])
|
// Same center/fixed disambiguation as the single-frame (net) path:
|
||||||
high := civ.BCDToFreq(region[6:11])
|
// FIXED sends [low-edge][high-edge] (both absolute), CENTER sends
|
||||||
|
// [center][span]. Tell them apart by magnitude — a second value
|
||||||
|
// BIGGER than the first is a real high edge; a smaller one is a
|
||||||
|
// span. Without this, an IC-7300 in center (VFO-following) mode set
|
||||||
|
// high = span (e.g. 100 kHz), so high < low and the panadapter had
|
||||||
|
// no valid range to map the trace onto → blank scope. FIXED mode
|
||||||
|
// parsed fine, which is why only center mode was broken.
|
||||||
|
v1, ok1 := civ.BCDToFreq(region[1:6])
|
||||||
|
v2, ok2 := civ.BCDToFreq(region[6:11])
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
// Not decimal BCD — the frame was read at the wrong offset. Keep the
|
||||||
|
// edges we had; a wrong span is drawn, noticed, and mistrusted.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var low, high int64
|
||||||
|
if v2 > v1 {
|
||||||
|
low, high = v1, v2 // absolute low/high edges (fixed)
|
||||||
|
} else {
|
||||||
|
low, high = v1-v2/2, v1+v2/2 // centre + span (centre-on-VFO)
|
||||||
|
}
|
||||||
b.scopeMu.Lock()
|
b.scopeMu.Lock()
|
||||||
b.scopeLow, b.scopeHigh = low, high
|
b.scopeLow, b.scopeHigh = low, high
|
||||||
b.scopeMu.Unlock()
|
b.scopeMu.Unlock()
|
||||||
@@ -711,12 +858,19 @@ func (b *IcomSerial) SetScope(on bool) error {
|
|||||||
// but nothing shows, compare its `icom scope raw` frames against this.
|
// but nothing shows, compare its `icom scope raw` frames against this.
|
||||||
applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)",
|
applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)",
|
||||||
b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope])
|
b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope])
|
||||||
|
// Turn the scope DISPLAY on — needed so the rig actually streams (esp. when
|
||||||
|
// remote and we can't touch the front panel). ONLY on enable: we must never
|
||||||
|
// switch the display OFF, because that is the operator's own scope on the
|
||||||
|
// radio, and closing OpsLog (SetScope(false)) blanking a local IC-7300's
|
||||||
|
// screen is exactly the regression this avoids. Some firmwares don't ack a
|
||||||
|
// 0x27 set; a timeout isn't fatal, so log and continue.
|
||||||
|
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, 0x01); err != nil {
|
||||||
|
applog.Printf("icom scope: display on ack: %v", err)
|
||||||
}
|
}
|
||||||
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
|
|
||||||
// continue rather than abort the second command.
|
|
||||||
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
|
|
||||||
applog.Printf("icom scope: display on=%v ack: %v", on, err)
|
|
||||||
}
|
}
|
||||||
|
// Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially —
|
||||||
|
// the ONLY thing we switch off on disable, so the radio's own scope display is
|
||||||
|
// left exactly as the operator had it.
|
||||||
if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil {
|
if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil {
|
||||||
applog.Printf("icom scope: output on=%v ack: %v", on, err)
|
applog.Printf("icom scope: output on=%v ack: %v", on, err)
|
||||||
}
|
}
|
||||||
@@ -1003,7 +1157,14 @@ func (b *IcomSerial) readFreq() (int64, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return civ.BCDToFreq(f.Data), nil
|
hz, ok := civ.BCDToFreq(f.Data)
|
||||||
|
if !ok {
|
||||||
|
// A frame that is not decimal BCD is a desynchronised read, not a
|
||||||
|
// frequency. Reporting the error keeps the caller on its last good value
|
||||||
|
// instead of publishing a number like 16445550000 Hz to the display.
|
||||||
|
return 0, fmt.Errorf("icom: frequency frame is not BCD: % X", f.Data)
|
||||||
|
}
|
||||||
|
return hz, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readSplit reads the rig's split state (CI-V 0x0F). 0x01 = split on; 0x10/0x11
|
// readSplit reads the rig's split state (CI-V 0x0F). 0x01 = split on; 0x10/0x11
|
||||||
@@ -1033,7 +1194,11 @@ func (b *IcomSerial) readTXFreq() (int64, bool) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
return civ.BCDToFreq(f.Data[1:]), true
|
hz, ok := civ.BCDToFreq(f.Data[1:])
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return hz, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
|
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
|
||||||
|
|||||||
@@ -0,0 +1,562 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// Native Kenwood CAT — TS-590, TS-890, TS-2000 and the many rigs that speak the
|
||||||
|
// same dialect (Elecraft K3/K4, and the "Kenwood/Elecraft" setting on Flex and
|
||||||
|
// SunSDR). Plain ASCII, every command terminated by ';', same shape as Yaesu but
|
||||||
|
// a different vocabulary.
|
||||||
|
//
|
||||||
|
// The dialect is already proven in this repository from the other side:
|
||||||
|
// internal/catemu ANSWERS these commands, pretending to be a TS-2000 so an ACOM
|
||||||
|
// amplifier follows OpsLog. The frame layouts here and there are the same ones.
|
||||||
|
//
|
||||||
|
// Commands used:
|
||||||
|
//
|
||||||
|
// FA; → FA00014025000; VFO A frequency, ELEVEN digits, Hz
|
||||||
|
// FB; → FB00014030000; VFO B frequency
|
||||||
|
// IF; → 38-char status frame: frequency, RX/TX, mode, VFO, split —
|
||||||
|
// the whole operating state in ONE round trip, which is why it
|
||||||
|
// is the poll rather than asking four separate questions.
|
||||||
|
// MD; → MD3; mode (1=LSB 2=USB 3=CW 4=FM 5=AM 6=FSK
|
||||||
|
// 7=CW-R 9=FSK-R)
|
||||||
|
// FR0;/FR1; receive VFO — 0 = A, 1 = B
|
||||||
|
// FT0;/FT1; transmit VFO (split = the two differ)
|
||||||
|
// TX;/RX; key / unkey
|
||||||
|
// ID; → ID020; model number
|
||||||
|
// AI0; silence unsolicited status reports
|
||||||
|
//
|
||||||
|
// Why not OmniRig: the same reason the Yaesu backend exists. Every Kenwood
|
||||||
|
// fault reported through OmniRig came from its interpretation layer rather than
|
||||||
|
// from the radio, and the rig file decides what a "Freq" property means.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kenwood is the native backend. One serial port, one mutex: a command and its
|
||||||
|
// reply are never interleaved with another exchange.
|
||||||
|
type Kenwood struct {
|
||||||
|
portName string
|
||||||
|
baud int
|
||||||
|
// host is "address:port" for a serial link reached over the network — a
|
||||||
|
// ser2net daemon, an Ethernet-serial adapter, a Raspberry Pi in the shack.
|
||||||
|
//
|
||||||
|
// This is NOT Kenwood's own network protocol. A TS-890 or TS-990 speaks
|
||||||
|
// KNS/ARCP over its Ethernet socket, with a session and authentication, and
|
||||||
|
// that is a different piece of work needing one of those radios to confirm
|
||||||
|
// it. What this covers is the same CAT byte stream over a socket instead of
|
||||||
|
// a wire, which is how most operators actually put a rig on the network.
|
||||||
|
host string
|
||||||
|
digital string // mode name logged for data (FT8 by default)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
port serial.Port
|
||||||
|
|
||||||
|
// dialPort, when set, replaces serial.Open. It exists so the backend can be
|
||||||
|
// driven against internal/catemu — which already SPEAKS this dialect to
|
||||||
|
// satisfy an ACOM amplifier — without a radio, a COM port or a null-modem
|
||||||
|
// pair. Written for a Kenwood backend nobody here owns a rig to test.
|
||||||
|
dialPort func() (serial.Port, error)
|
||||||
|
|
||||||
|
model string
|
||||||
|
curFreq int64
|
||||||
|
curRXFreq int64
|
||||||
|
curVFO string // "A" or "B"
|
||||||
|
// Commands this rig answered "?;" to — asked once, then never again.
|
||||||
|
unsupported map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
|
||||||
|
// a COM port (ser2net and friends).
|
||||||
|
func NewKenwoodTCP(hostPort, digital string) *Kenwood {
|
||||||
|
k := NewKenwood("", 0, digital)
|
||||||
|
k.host = strings.TrimSpace(hostPort)
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKenwood(portName string, baud int, digital string) *Kenwood {
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = 9600 // TS-590 factory default; TS-890 ships at 115200
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(digital) == "" {
|
||||||
|
digital = "FT8"
|
||||||
|
}
|
||||||
|
return &Kenwood{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) Name() string { return "kenwood" }
|
||||||
|
|
||||||
|
func (k *Kenwood) Connect() error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.portName == "" && k.host == "" {
|
||||||
|
return fmt.Errorf("kenwood: no serial port or network address configured")
|
||||||
|
}
|
||||||
|
// Close any handle still held before opening another: Connect runs again on
|
||||||
|
// every reconnect, and Windows opens a serial port exclusively, so a leaked
|
||||||
|
// handle makes the next open fail with "port busy" (the fault found in the
|
||||||
|
// Yaesu backend — same shape here).
|
||||||
|
if k.port != nil {
|
||||||
|
_ = k.port.Close()
|
||||||
|
k.port = nil
|
||||||
|
}
|
||||||
|
p, err := k.openPort()
|
||||||
|
if err != nil {
|
||||||
|
if k.host != "" {
|
||||||
|
return fmt.Errorf("kenwood: connect %s: %w", k.host, err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err)
|
||||||
|
}
|
||||||
|
p.SetReadTimeout(300 * time.Millisecond)
|
||||||
|
k.port = p
|
||||||
|
k.unsupported = map[string]bool{}
|
||||||
|
|
||||||
|
// Silence unsolicited status reports: they interleave with our
|
||||||
|
// request/response pairs and make a reply impossible to attribute. We poll.
|
||||||
|
_ = k.write("AI0;")
|
||||||
|
|
||||||
|
answered := false
|
||||||
|
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
|
||||||
|
answered = true
|
||||||
|
code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
|
||||||
|
if name, ok := kenwoodModels[code]; ok {
|
||||||
|
k.model = name
|
||||||
|
} else {
|
||||||
|
k.model = "Kenwood (" + code + ")"
|
||||||
|
debugLog.Printf("kenwood: unknown model id %q — add it to kenwoodModels", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// IF is the command everything else depends on, so it is also the honest
|
||||||
|
// test of whether a radio is really there.
|
||||||
|
if r, err := k.ask("IF;"); err == nil && strings.HasPrefix(r, "IF") {
|
||||||
|
answered = true
|
||||||
|
}
|
||||||
|
if !answered {
|
||||||
|
k.model = ""
|
||||||
|
if k.host != "" {
|
||||||
|
return fmt.Errorf("kenwood: %s accepted the connection but the rig is not answering — check that the serial bridge points at the radio and that the radio is switched on", k.host)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("kenwood: %s opened but the rig is not answering — check that it is switched on and set to %d baud", k.portName, k.baud)
|
||||||
|
}
|
||||||
|
// Name what was actually connected to. A log reading "connected on @ 0 baud"
|
||||||
|
// after a network connect is the kind of line that sends someone hunting a
|
||||||
|
// serial fault that does not exist.
|
||||||
|
if k.host != "" {
|
||||||
|
debugLog.Printf("kenwood: connected to %s (network serial bridge), model=%q", k.host, k.model)
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) Disconnect() {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port != nil {
|
||||||
|
_ = k.port.Close()
|
||||||
|
k.port = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadState polls the rig. IF carries frequency, mode, VFO, split and TX state
|
||||||
|
// in one frame; the other VFO is only asked for when split is actually on, so
|
||||||
|
// the common simplex case costs a single round trip.
|
||||||
|
func (k *Kenwood) ReadState() (RigState, error) {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return RigState{}, fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
raw, err := k.ask("IF;")
|
||||||
|
if err != nil {
|
||||||
|
return RigState{}, err
|
||||||
|
}
|
||||||
|
f, ok := parseKenwoodIF(raw)
|
||||||
|
if !ok {
|
||||||
|
return RigState{}, fmt.Errorf("kenwood: unparsable IF frame %q", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := RigState{Connected: true, Backend: "kenwood"}
|
||||||
|
k.curVFO = f.VFO
|
||||||
|
s.Vfo = f.VFO
|
||||||
|
s.Mode = kenwoodModeToADIF(f.Mode, k.digital)
|
||||||
|
s.Rig = k.model
|
||||||
|
|
||||||
|
// IF reports the frequency of the VFO in USE (what the operator hears).
|
||||||
|
rx := f.FreqHz
|
||||||
|
tx := rx
|
||||||
|
|
||||||
|
// IF's split bit is not filled in by every rig that speaks this dialect —
|
||||||
|
// reported on a Flex through its Kenwood CAT emulation, where the frequency
|
||||||
|
// reads perfectly and split never appears. So ask the question directly as
|
||||||
|
// well: split IS "the transmit VFO differs from the receive VFO", which is
|
||||||
|
// what FR/FT answer, and it is the same rule the Yaesu backend settled on.
|
||||||
|
//
|
||||||
|
// A rig that rejects FR/FT answers "?;" once and is never asked again, so
|
||||||
|
// this costs two short commands per poll only where it actually works.
|
||||||
|
split := f.Split
|
||||||
|
if !split {
|
||||||
|
rxv, rxOK := k.askVFO("FR;")
|
||||||
|
txv, txOK := k.askVFO("FT;")
|
||||||
|
if rxOK && txOK && rxv != txv {
|
||||||
|
split = true
|
||||||
|
// Trust FR over IF for which VFO is in use: they were asked in the
|
||||||
|
// same breath, and a rig that leaves the split bit empty may be just
|
||||||
|
// as vague about the VFO field.
|
||||||
|
// f.VFO too, not just the reported state: the block below picks the
|
||||||
|
// TRANSMIT VFO as "the other one" from f.VFO, and leaving the two
|
||||||
|
// disagreeing would read the transmit frequency off the wrong dial.
|
||||||
|
if rxv == "A" || rxv == "B" {
|
||||||
|
k.curVFO, s.Vfo, f.VFO = rxv, rxv, rxv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.Split = split
|
||||||
|
|
||||||
|
if f.Split {
|
||||||
|
// The transmit VFO is the other one. Read it rather than assume, and fall
|
||||||
|
// back to simplex if it cannot be read: a wrong TX frequency is written
|
||||||
|
// into the log, which is worse than showing no split at all.
|
||||||
|
other := "FB;"
|
||||||
|
if f.VFO == "B" {
|
||||||
|
other = "FA;"
|
||||||
|
}
|
||||||
|
if r, err := k.ask(other); err == nil {
|
||||||
|
if hz, ok := parseKenwoodFreq(r, strings.TrimSuffix(other, ";")); ok && hz > 0 && hz != rx {
|
||||||
|
tx = hz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tx != rx {
|
||||||
|
s.FreqHz = tx // ADIF: FREQ is the TRANSMIT frequency
|
||||||
|
s.RxFreqHz = rx
|
||||||
|
s.Split = true
|
||||||
|
} else {
|
||||||
|
s.FreqHz = rx
|
||||||
|
}
|
||||||
|
k.curFreq = s.FreqHz
|
||||||
|
if s.Split {
|
||||||
|
k.curRXFreq = s.RxFreqHz
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency tunes the VFO the operator is actually on — writing FA blind is
|
||||||
|
// what makes a display disagree with the radio when they are on B.
|
||||||
|
func (k *Kenwood) SetFrequency(hz int64) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
if hz <= 0 || hz > 99_999_999_999 {
|
||||||
|
return fmt.Errorf("kenwood: frequency %d out of the 11-digit CAT range", hz)
|
||||||
|
}
|
||||||
|
cmd := "FA"
|
||||||
|
if k.curVFO == "B" {
|
||||||
|
cmd = "FB"
|
||||||
|
}
|
||||||
|
return k.write(fmt.Sprintf("%s%011d;", cmd, hz))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) SetMode(mode string) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
d := kenwoodModeDigit(mode, k.curFreq)
|
||||||
|
if d == 0 {
|
||||||
|
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
|
||||||
|
}
|
||||||
|
return k.write(fmt.Sprintf("MD%c;", d))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) SetPTT(on bool) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
return k.write("TX;")
|
||||||
|
}
|
||||||
|
return k.write("RX;")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) write(cmd string) error {
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
traceText("kenwood", "TX", cmd)
|
||||||
|
_, err := k.port.Write([]byte(cmd))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask sends a query and returns the reply belonging to THAT command. Anything
|
||||||
|
// else on the wire is discarded: a stray frame parsed as a frequency reads as
|
||||||
|
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping
|
||||||
|
// for no reason (found the hard way on the Yaesu backend).
|
||||||
|
func (k *Kenwood) ask(cmd string) (string, error) {
|
||||||
|
want := cmdPrefix(cmd)
|
||||||
|
if k.unsupported[want] {
|
||||||
|
return "", fmt.Errorf("kenwood: %s is not supported by this rig", want)
|
||||||
|
}
|
||||||
|
if err := k.write(cmd); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
buf := make([]byte, 0, 64)
|
||||||
|
tmp := make([]byte, 64)
|
||||||
|
deadline := time.Now().Add(600 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := k.port.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue // read timeout — the rig may still be composing its answer
|
||||||
|
}
|
||||||
|
buf = append(buf, tmp[:n]...)
|
||||||
|
for {
|
||||||
|
i := strings.IndexByte(string(buf), ';')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
frame := string(buf[:i+1])
|
||||||
|
buf = buf[i+1:]
|
||||||
|
traceText("kenwood", "RX", frame)
|
||||||
|
if frame == "?;" {
|
||||||
|
// The rig rejected the command. Remember it so the poll loop stops
|
||||||
|
// paying a 600 ms timeout for it on every cycle.
|
||||||
|
k.unsupported[want] = true
|
||||||
|
debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd)
|
||||||
|
return "", fmt.Errorf("kenwood: %s rejected", want)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(frame, want) {
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frame parsing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// kenwoodIF is what the 38-character IF status frame carries.
|
||||||
|
type kenwoodIF struct {
|
||||||
|
FreqHz int64
|
||||||
|
Mode byte
|
||||||
|
VFO string // "A" or "B"
|
||||||
|
Split bool
|
||||||
|
// TX is parsed even though RigState has no PTT field: it is one character of
|
||||||
|
// the same frame, and having it here means a future "transmitting" indicator
|
||||||
|
// costs no extra round trip.
|
||||||
|
TX bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKenwoodIF reads the TS-2000/TS-590 status frame. Its layout — the same
|
||||||
|
// one internal/catemu emits — is:
|
||||||
|
//
|
||||||
|
// IF | freq(11) | step(4) | RIT(±5) | RIT/XIT/bank(3) | mem(2) | rx-tx(1) |
|
||||||
|
// mode(1) | VFO(1) | scan(1) | split(1) | tone(1) | tone#(2) | shift(1) | ;
|
||||||
|
//
|
||||||
|
// Fields are read by POSITION, so the length is checked first: a short frame
|
||||||
|
// means a truncated read, and indexing into it would panic or, worse, silently
|
||||||
|
// yield a wrong frequency.
|
||||||
|
func parseKenwoodIF(reply string) (kenwoodIF, bool) {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(r, "IF") || len(r) < 38 {
|
||||||
|
return kenwoodIF{}, false
|
||||||
|
}
|
||||||
|
hz, err := strconv.ParseInt(strings.TrimSpace(r[2:13]), 10, 64)
|
||||||
|
if err != nil || hz <= 0 {
|
||||||
|
return kenwoodIF{}, false
|
||||||
|
}
|
||||||
|
out := kenwoodIF{FreqHz: hz, Mode: r[29], VFO: "A", TX: r[28] == '1', Split: r[32] == '1'}
|
||||||
|
if r[30] == '1' {
|
||||||
|
out.VFO = "B"
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKenwoodFreq reads an FA/FB reply — ELEVEN digits on Kenwood, where Yaesu
|
||||||
|
// uses nine. The prefix is checked so an FB reply is never accepted as FA.
|
||||||
|
func parseKenwoodFreq(reply, prefix string) (int64, bool) {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(r, prefix) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
|
||||||
|
if digits == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
hz, err := strconv.ParseInt(digits, 10, 64)
|
||||||
|
if err != nil || hz <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return hz, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// kenwoodModeDigit maps an ADIF mode to the Kenwood digit. The sideband follows
|
||||||
|
// the frequency by worldwide convention — a backend that puts USB on 40 m makes
|
||||||
|
// every SSB QSO in the log wrong.
|
||||||
|
func kenwoodModeDigit(mode string, hz int64) byte {
|
||||||
|
m := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
switch m {
|
||||||
|
case "":
|
||||||
|
return 0
|
||||||
|
case "LSB":
|
||||||
|
return '1'
|
||||||
|
case "USB":
|
||||||
|
return '2'
|
||||||
|
case "CW":
|
||||||
|
return '3'
|
||||||
|
case "CW-R", "CWR":
|
||||||
|
return '7'
|
||||||
|
case "FM":
|
||||||
|
return '4'
|
||||||
|
case "AM":
|
||||||
|
return '5'
|
||||||
|
case "RTTY", "FSK":
|
||||||
|
return '6'
|
||||||
|
case "RTTY-R", "FSK-R":
|
||||||
|
return '9'
|
||||||
|
case "SSB":
|
||||||
|
if hz > 0 && hz < 10_000_000 {
|
||||||
|
return '1'
|
||||||
|
}
|
||||||
|
return '2'
|
||||||
|
}
|
||||||
|
// Any other digital mode rides on the data sideband, which on Kenwood is
|
||||||
|
// plain USB/LSB with the rig's DATA input selected.
|
||||||
|
if hz > 0 && hz < 10_000_000 {
|
||||||
|
return '1'
|
||||||
|
}
|
||||||
|
return '2'
|
||||||
|
}
|
||||||
|
|
||||||
|
// kenwoodModeToADIF turns the rig's mode digit into what the log records.
|
||||||
|
// Digital modes are indistinguishable from SSB over CAT — the rig only knows
|
||||||
|
// it is on USB — so the operator's configured digital mode is used, exactly as
|
||||||
|
// the other backends do.
|
||||||
|
func kenwoodModeToADIF(d byte, digital string) string {
|
||||||
|
switch d {
|
||||||
|
case '1':
|
||||||
|
return "LSB"
|
||||||
|
case '2':
|
||||||
|
return "USB"
|
||||||
|
case '3', '7':
|
||||||
|
return "CW"
|
||||||
|
case '4':
|
||||||
|
return "FM"
|
||||||
|
case '5':
|
||||||
|
return "AM"
|
||||||
|
case '6', '9':
|
||||||
|
return "RTTY"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// kenwoodModels maps the ID reply to a name for the status bar. An unknown code
|
||||||
|
// is shown as-is rather than refused: the model name is cosmetic, and a rig that
|
||||||
|
// answers everything else must not be rejected over it.
|
||||||
|
var kenwoodModels = map[string]string{
|
||||||
|
"017": "TS-570",
|
||||||
|
"019": "TS-2000",
|
||||||
|
"020": "TS-480",
|
||||||
|
"021": "TS-590S",
|
||||||
|
"023": "TS-590SG",
|
||||||
|
"024": "TS-990S",
|
||||||
|
"025": "TS-890S",
|
||||||
|
}
|
||||||
|
|
||||||
|
// openPort opens the serial link, or whatever dialPort provides in a test.
|
||||||
|
func (k *Kenwood) openPort() (serial.Port, error) {
|
||||||
|
if k.dialPort != nil {
|
||||||
|
return k.dialPort()
|
||||||
|
}
|
||||||
|
if k.host != "" {
|
||||||
|
c, err := net.DialTimeout("tcp", k.host, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tcpSerial{conn: c}, nil
|
||||||
|
}
|
||||||
|
return serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
||||||
|
}
|
||||||
|
|
||||||
|
// tcpSerial presents a TCP connection as a serial.Port, so the backend has one
|
||||||
|
// code path whether the rig is on a wire or on the network.
|
||||||
|
//
|
||||||
|
// The modem-control methods are no-ops rather than errors: a bridge has no DTR
|
||||||
|
// to raise, and failing them would break a caller that sets them defensively.
|
||||||
|
type tcpSerial struct{ conn net.Conn }
|
||||||
|
|
||||||
|
func (t *tcpSerial) Read(p []byte) (int, error) {
|
||||||
|
n, err := t.conn.Read(p)
|
||||||
|
// A read deadline expiring is this transport's "no data yet", exactly what a
|
||||||
|
// serial read timeout means to the caller — not a dead link. Reporting it as
|
||||||
|
// an error would make ask() abandon a rig that is merely thinking.
|
||||||
|
if err != nil {
|
||||||
|
var ne net.Error
|
||||||
|
if errors.As(err, &ne) && ne.Timeout() {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
func (t *tcpSerial) Write(p []byte) (int, error) { return t.conn.Write(p) }
|
||||||
|
func (t *tcpSerial) Close() error { return t.conn.Close() }
|
||||||
|
func (t *tcpSerial) SetReadTimeout(d time.Duration) error {
|
||||||
|
if d <= 0 {
|
||||||
|
return t.conn.SetReadDeadline(time.Time{})
|
||||||
|
}
|
||||||
|
return t.conn.SetReadDeadline(time.Now().Add(d))
|
||||||
|
}
|
||||||
|
func (t *tcpSerial) SetMode(*serial.Mode) error { return nil }
|
||||||
|
func (t *tcpSerial) Drain() error { return nil }
|
||||||
|
func (t *tcpSerial) ResetInputBuffer() error { return nil }
|
||||||
|
func (t *tcpSerial) ResetOutputBuffer() error { return nil }
|
||||||
|
func (t *tcpSerial) SetDTR(bool) error { return nil }
|
||||||
|
func (t *tcpSerial) SetRTS(bool) error { return nil }
|
||||||
|
func (t *tcpSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
|
||||||
|
return &serial.ModemStatusBits{}, nil
|
||||||
|
}
|
||||||
|
func (t *tcpSerial) Break(time.Duration) error { return nil }
|
||||||
|
|
||||||
|
// askVFO asks FR; or FT; and returns "A" or "B".
|
||||||
|
//
|
||||||
|
// The reply is FR0; / FR1; — the digit right after the two-letter command.
|
||||||
|
// Anything else (a rig that answers with more fields, or not at all) returns
|
||||||
|
// false, and the caller keeps whatever IF said rather than inventing a split.
|
||||||
|
func (k *Kenwood) askVFO(cmd string) (string, bool) {
|
||||||
|
r, err := k.ask(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
want := cmdPrefix(cmd)
|
||||||
|
body := strings.TrimSuffix(strings.TrimPrefix(r, want), ";")
|
||||||
|
if body == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
switch body[0] {
|
||||||
|
case '0':
|
||||||
|
return "A", true
|
||||||
|
case '1':
|
||||||
|
return "B", true
|
||||||
|
}
|
||||||
|
// 2 is "sub receiver" on a TS-2000 — real, but not a VFO we track. Saying
|
||||||
|
// nothing is better than mapping it onto A or B and reporting a split that
|
||||||
|
// does not exist.
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Kenwood backend driven against a rig that answers, with no hardware.
|
||||||
|
//
|
||||||
|
// Nobody here owns a Kenwood, and a backend that has never completed a single
|
||||||
|
// exchange is a guess however carefully it was written. But this repository
|
||||||
|
// already contains the other half of the conversation: internal/catemu ANSWERS
|
||||||
|
// this dialect, pretending to be a TS-2000 so an ACOM amplifier follows OpsLog.
|
||||||
|
// The responder below replies exactly as catemu does — same FA/FB widths, same
|
||||||
|
// 38-character IF layout, same ID019 — so the two halves are checked against
|
||||||
|
// each other rather than against my reading of a manual.
|
||||||
|
//
|
||||||
|
// What this proves: the round trip completes, the model is identified, and
|
||||||
|
// frequency, mode, VFO and split come back correct, including the second read
|
||||||
|
// that split requires. What it cannot prove: that a real TS-590 answers on the
|
||||||
|
// same timings, or that its firmware fills every IF field the way the
|
||||||
|
// documentation says. Those still need a radio.
|
||||||
|
|
||||||
|
// fakeSerial is one end of an in-memory serial link. Only Read and Write carry
|
||||||
|
// meaning; the rest satisfy the interface.
|
||||||
|
type fakeSerial struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
toRig *strings.Builder // what the backend has written
|
||||||
|
fromRig []byte // what the rig has queued for the backend
|
||||||
|
answer func(cmd string) string
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSerial) Write(p []byte) (int, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.closed {
|
||||||
|
return 0, fmt.Errorf("closed")
|
||||||
|
}
|
||||||
|
f.toRig.Write(p)
|
||||||
|
// A rig answers as each terminated command arrives.
|
||||||
|
for {
|
||||||
|
s := f.toRig.String()
|
||||||
|
i := strings.IndexByte(s, ';')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cmd := s[:i+1]
|
||||||
|
rest := s[i+1:]
|
||||||
|
f.toRig.Reset()
|
||||||
|
f.toRig.WriteString(rest)
|
||||||
|
if r := f.answer(cmd); r != "" {
|
||||||
|
f.fromRig = append(f.fromRig, r...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSerial) Read(p []byte) (int, error) {
|
||||||
|
// A real port blocks until data or timeout; the backend polls, so returning
|
||||||
|
// (0, nil) on an empty buffer models a read timeout with no bytes.
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.closed {
|
||||||
|
f.mu.Unlock()
|
||||||
|
return 0, fmt.Errorf("closed")
|
||||||
|
}
|
||||||
|
if len(f.fromRig) > 0 {
|
||||||
|
n := copy(p, f.fromRig)
|
||||||
|
f.fromRig = f.fromRig[n:]
|
||||||
|
f.mu.Unlock()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSerial) Close() error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSerial) SetMode(*serial.Mode) error { return nil }
|
||||||
|
func (f *fakeSerial) Drain() error { return nil }
|
||||||
|
func (f *fakeSerial) ResetInputBuffer() error { return nil }
|
||||||
|
func (f *fakeSerial) ResetOutputBuffer() error { return nil }
|
||||||
|
func (f *fakeSerial) SetDTR(bool) error { return nil }
|
||||||
|
func (f *fakeSerial) SetRTS(bool) error { return nil }
|
||||||
|
func (f *fakeSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
|
||||||
|
return &serial.ModemStatusBits{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeSerial) SetReadTimeout(time.Duration) error { return nil }
|
||||||
|
func (f *fakeSerial) Break(time.Duration) error { return nil }
|
||||||
|
|
||||||
|
// ts2000 answers as internal/catemu does. vfoA/vfoB are the two dials; mode is
|
||||||
|
// the Kenwood digit; split selects which VFO transmits.
|
||||||
|
type ts2000 struct {
|
||||||
|
vfoA, vfoB int64
|
||||||
|
mode byte
|
||||||
|
onB bool
|
||||||
|
split bool
|
||||||
|
// lazyIF models a rig that answers FR/FT correctly but never fills IF's
|
||||||
|
// split bit — the behaviour reported on a Flex through its Kenwood CAT
|
||||||
|
// emulation, where the frequency reads perfectly and split never appears.
|
||||||
|
lazyIF bool
|
||||||
|
// noVFOCmds models a rig that rejects FR/FT outright ("?;").
|
||||||
|
noVFOCmds bool
|
||||||
|
seen []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ts2000) answer(cmd string) string {
|
||||||
|
r.seen = append(r.seen, cmd)
|
||||||
|
cur := r.vfoA
|
||||||
|
vfoDigit := byte('0')
|
||||||
|
if r.onB {
|
||||||
|
cur, vfoDigit = r.vfoB, '1'
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case cmd == "ID;":
|
||||||
|
return "ID019;" // TS-2000, exactly what catemu reports
|
||||||
|
case cmd == "FA;":
|
||||||
|
return fmt.Sprintf("FA%011d;", r.vfoA)
|
||||||
|
case cmd == "FB;":
|
||||||
|
return fmt.Sprintf("FB%011d;", r.vfoB)
|
||||||
|
case cmd == "IF;":
|
||||||
|
split := byte('0')
|
||||||
|
if r.split && !r.lazyIF {
|
||||||
|
split = '1'
|
||||||
|
}
|
||||||
|
// The catemu layout: IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) |
|
||||||
|
// rx/tx | mode | VFO | scan | split | tone | tone#(2) | shift | ;
|
||||||
|
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%c%01d%c%01d%02d%01d;",
|
||||||
|
cur, 0, 0, 0, 0, 0, r.mode, vfoDigit, 0, split, 0, 0, 0)
|
||||||
|
case strings.HasPrefix(cmd, "FA") && len(cmd) > 3:
|
||||||
|
fmt.Sscanf(cmd, "FA%d;", &r.vfoA)
|
||||||
|
return ""
|
||||||
|
case strings.HasPrefix(cmd, "FB") && len(cmd) > 3:
|
||||||
|
fmt.Sscanf(cmd, "FB%d;", &r.vfoB)
|
||||||
|
return ""
|
||||||
|
case strings.HasPrefix(cmd, "MD") && len(cmd) == 4:
|
||||||
|
r.mode = cmd[2]
|
||||||
|
return ""
|
||||||
|
case cmd == "FR;" || cmd == "FT;":
|
||||||
|
if r.noVFOCmds {
|
||||||
|
return "?;" // rejected, as a rig that does not know the command answers
|
||||||
|
}
|
||||||
|
// FR is the receive VFO, FT the transmit one. They differ exactly when
|
||||||
|
// the rig is in split.
|
||||||
|
v := vfoDigit
|
||||||
|
if cmd == "FT;" && r.split {
|
||||||
|
if vfoDigit == '0' {
|
||||||
|
v = '1'
|
||||||
|
} else {
|
||||||
|
v = '0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%c;", strings.TrimSuffix(cmd, ";"), v)
|
||||||
|
}
|
||||||
|
return "" // AI0;, TX;, RX; — set commands, no reply, as on a real rig
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialTo(rig *ts2000) func() (serial.Port, error) {
|
||||||
|
return func() (serial.Port, error) {
|
||||||
|
return &fakeSerial{toRig: &strings.Builder{}, answer: rig.answer}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKenwoodAgainstEmulatedRig(t *testing.T) {
|
||||||
|
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2'} // 20 m USB
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||||
|
k.dialPort = dialTo(rig)
|
||||||
|
|
||||||
|
if err := k.Connect(); err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer k.Disconnect()
|
||||||
|
if k.model != "TS-2000" {
|
||||||
|
t.Errorf("model = %q, want TS-2000 (from ID019)", k.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplex on A.
|
||||||
|
s, err := k.ReadState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if !s.Connected || s.FreqHz != 14250000 || s.Mode != "USB" || s.Vfo != "A" || s.Split {
|
||||||
|
t.Errorf("simplex A gave %+v — want 14250000 USB on A, no split", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// On B: everything must follow the VFO in use, the fault that took several
|
||||||
|
// rounds to settle in the Yaesu backend.
|
||||||
|
rig.onB = true
|
||||||
|
if s, err = k.ReadState(); err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if s.FreqHz != 14260000 || s.Vfo != "B" {
|
||||||
|
t.Errorf("on B gave %d on VFO %s — want 14260000 on B", s.FreqHz, s.Vfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tuning must write to the VFO in use, not blindly to FA.
|
||||||
|
if err := k.SetFrequency(14265000); err != nil {
|
||||||
|
t.Fatalf("set freq: %v", err)
|
||||||
|
}
|
||||||
|
if rig.vfoB != 14265000 {
|
||||||
|
t.Errorf("VFO B = %d, want 14265000 — the write went to the wrong VFO", rig.vfoB)
|
||||||
|
}
|
||||||
|
if rig.vfoA != 14250000 {
|
||||||
|
t.Errorf("VFO A was disturbed: %d", rig.vfoA)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split: receive on B, transmit on A. ADIF says FREQ is the TRANSMIT
|
||||||
|
// frequency, so the two must not be swapped — a mistake that writes the
|
||||||
|
// wrong frequency into every logged QSO.
|
||||||
|
rig.split = true
|
||||||
|
if s, err = k.ReadState(); err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if !s.Split || s.FreqHz != 14250000 || s.RxFreqHz != 14265000 {
|
||||||
|
t.Errorf("split gave tx=%d rx=%d split=%v — want tx 14250000 (A), rx 14265000 (B)",
|
||||||
|
s.FreqHz, s.RxFreqHz, s.Split)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode: CW below 10 MHz stays CW; the sideband convention only governs SSB.
|
||||||
|
rig.split = false
|
||||||
|
if err := k.SetMode("CW"); err != nil {
|
||||||
|
t.Fatalf("set mode: %v", err)
|
||||||
|
}
|
||||||
|
if rig.mode != '3' {
|
||||||
|
t.Errorf("mode digit = %q, want '3' (CW)", rig.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTT is a bare command, and the rig must have actually seen it.
|
||||||
|
if err := k.SetPTT(true); err != nil {
|
||||||
|
t.Fatalf("ptt: %v", err)
|
||||||
|
}
|
||||||
|
if err := k.SetPTT(false); err != nil {
|
||||||
|
t.Fatalf("ptt off: %v", err)
|
||||||
|
}
|
||||||
|
seen := strings.Join(rig.seen, " ")
|
||||||
|
for _, want := range []string{"AI0;", "TX;", "RX;"} {
|
||||||
|
if !strings.Contains(seen, want) {
|
||||||
|
t.Errorf("the rig never received %s — sent: %s", want, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A port that opens onto silence must be reported as such, not as a connected
|
||||||
|
// radio. A powered-off rig logged as "connected" wasted an evening of a user's
|
||||||
|
// time on the Yaesu backend.
|
||||||
|
func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||||
|
k.dialPort = func() (serial.Port, error) {
|
||||||
|
return &fakeSerial{toRig: &strings.Builder{}, answer: func(string) string { return "" }}, nil
|
||||||
|
}
|
||||||
|
err := k.Connect()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a silent port was reported as a connected rig")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not answering") {
|
||||||
|
t.Errorf("error was %q — it should say the rig is not answering", err)
|
||||||
|
}
|
||||||
|
if k.model != "" {
|
||||||
|
t.Errorf("a stale model survived a failed connect: %q", k.model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split found through FR/FT when the rig never fills IF's split bit.
|
||||||
|
//
|
||||||
|
// Reported on a Flex through its Kenwood CAT emulation: frequency read
|
||||||
|
// perfectly, split never appeared. Rather than guess at which IF column that
|
||||||
|
// firmware populates, ask the question that DEFINES split — is the transmit VFO
|
||||||
|
// a different VFO from the receive one — which is what FR and FT answer, and
|
||||||
|
// the same rule the Yaesu backend settled on after several wrong turns.
|
||||||
|
func TestKenwoodSplitFromFRFT(t *testing.T) {
|
||||||
|
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', lazyIF: true}
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||||
|
k.dialPort = dialTo(rig)
|
||||||
|
if err := k.Connect(); err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer k.Disconnect()
|
||||||
|
|
||||||
|
// Simplex: FR and FT agree, and nothing may be invented from that.
|
||||||
|
s, err := k.ReadState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if s.Split {
|
||||||
|
t.Errorf("split reported while FR and FT agree: tx=%d rx=%d", s.FreqHz, s.RxFreqHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on, IF still silent about it: receive on A, transmit on B.
|
||||||
|
rig.split = true
|
||||||
|
if s, err = k.ReadState(); err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if !s.Split {
|
||||||
|
t.Fatal("split not detected — FR/FT disagreed and IF's bit was empty, which is the reported case")
|
||||||
|
}
|
||||||
|
if s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
|
||||||
|
t.Errorf("tx=%d rx=%d — want tx 14260000 (B), rx 14250000 (A)", s.FreqHz, s.RxFreqHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rig that rejects FR/FT is asked once, then left alone — and its IF split
|
||||||
|
// bit still works. The fallback must not cost a timeout on every poll, nor
|
||||||
|
// break the rigs that were already fine.
|
||||||
|
func TestKenwoodSplitWhenFRFTRejected(t *testing.T) {
|
||||||
|
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', noVFOCmds: true}
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||||
|
k.dialPort = dialTo(rig)
|
||||||
|
if err := k.Connect(); err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer k.Disconnect()
|
||||||
|
|
||||||
|
if _, err := k.ReadState(); err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
asked := 0
|
||||||
|
for _, c := range rig.seen {
|
||||||
|
if c == "FR;" || c == "FT;" {
|
||||||
|
asked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := k.ReadState(); err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
after := 0
|
||||||
|
for _, c := range rig.seen {
|
||||||
|
if c == "FR;" || c == "FT;" {
|
||||||
|
after++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if after != asked {
|
||||||
|
t.Errorf("a rejected command was asked again: %d then %d", asked, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IF's own split bit still drives the result on such a rig.
|
||||||
|
rig.split = true
|
||||||
|
s, err := k.ReadState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if !s.Split || s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
|
||||||
|
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The IF status frame, read by POSITION.
|
||||||
|
//
|
||||||
|
// One frame carries frequency, TX state, mode, VFO and split, which is why it is
|
||||||
|
// the poll. Every field is a fixed offset, so a length check comes first: a
|
||||||
|
// truncated read would otherwise index into the wrong characters and yield a
|
||||||
|
// plausible-looking wrong frequency — the worst kind, because it reaches the log.
|
||||||
|
//
|
||||||
|
// The layout is the one internal/catemu already EMITS to satisfy an ACOM
|
||||||
|
// amplifier, so these two halves of the repository agree by construction.
|
||||||
|
func TestParseKenwoodIF(t *testing.T) {
|
||||||
|
// IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) | rx/tx | mode | VFO | scan |
|
||||||
|
// split | tone | tone#(2) | shift | ; → 38 characters
|
||||||
|
const rx20mUSB = "IF00014250000" + "0000" + "+00000" + "000" + "00" + "0" + "2" + "0" + "0" + "0" + "0" + "00" + "0" + ";"
|
||||||
|
if len(rx20mUSB) != 38 {
|
||||||
|
t.Fatalf("the test's own frame is %d chars, not 38 — fix the fixture first", len(rx20mUSB))
|
||||||
|
}
|
||||||
|
|
||||||
|
f, ok := parseKenwoodIF(rx20mUSB)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("a valid frame was rejected: %q", rx20mUSB)
|
||||||
|
}
|
||||||
|
if f.FreqHz != 14250000 {
|
||||||
|
t.Errorf("frequency = %d, want 14250000", f.FreqHz)
|
||||||
|
}
|
||||||
|
if f.Mode != '2' {
|
||||||
|
t.Errorf("mode = %q, want '2' (USB)", f.Mode)
|
||||||
|
}
|
||||||
|
if f.VFO != "A" || f.Split || f.TX {
|
||||||
|
t.Errorf("got VFO=%s split=%v tx=%v — want A, simplex, receiving", f.VFO, f.Split, f.TX)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same frame transmitting, on VFO B, split, in CW.
|
||||||
|
const txSplitB = "IF00007030000" + "0000" + "+00000" + "000" + "00" + "1" + "3" + "1" + "0" + "1" + "0" + "00" + "0" + ";"
|
||||||
|
f2, ok := parseKenwoodIF(txSplitB)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("valid frame rejected: %q", txSplitB)
|
||||||
|
}
|
||||||
|
if !f2.TX || f2.Mode != '3' || f2.VFO != "B" || !f2.Split {
|
||||||
|
t.Errorf("got tx=%v mode=%q vfo=%s split=%v — want transmitting, CW, B, split",
|
||||||
|
f2.TX, f2.Mode, f2.VFO, f2.Split)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejections: anything that would make position-reading unsafe.
|
||||||
|
for _, bad := range []string{
|
||||||
|
"",
|
||||||
|
"IF;", // query echoed with no payload
|
||||||
|
"IF0001425000;", // short — the trap this guards against
|
||||||
|
"FA00014250000;", // another command's reply
|
||||||
|
"IF0001425000x0000+00000000000203000000;", // non-numeric frequency
|
||||||
|
} {
|
||||||
|
if _, ok := parseKenwoodIF(bad); ok {
|
||||||
|
t.Errorf("accepted a frame it should have refused: %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FA/FB carry ELEVEN digits on Kenwood where Yaesu uses nine — the single most
|
||||||
|
// likely place to copy the Yaesu backend and be wrong by a factor of a hundred.
|
||||||
|
func TestParseKenwoodFreq(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
reply, prefix string
|
||||||
|
want int64
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"FA00014250000;", "FA", 14250000, true},
|
||||||
|
{"FB00007030000;", "FB", 7030000, true},
|
||||||
|
{"FA00000474000;", "FA", 474000, true}, // 630 m — leading zeros must not truncate
|
||||||
|
{"FA10368000000;", "FA", 10368000000, true}, // 3 cm
|
||||||
|
{"FA00014250000;", "FB", 0, false}, // wrong VFO is never silently accepted
|
||||||
|
{"FA;", "FA", 0, false},
|
||||||
|
{"FAxxxxxxxxxxx;", "FA", 0, false},
|
||||||
|
{"", "FA", 0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := parseKenwoodFreq(c.reply, c.prefix)
|
||||||
|
if got != c.want || ok != c.ok {
|
||||||
|
t.Errorf("parseKenwoodFreq(%q,%q) = %d,%v — want %d,%v", c.reply, c.prefix, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sideband follows the frequency by worldwide convention. A backend that
|
||||||
|
// puts USB on 40 m makes every SSB QSO in the log wrong, which is why this is
|
||||||
|
// pinned rather than left to the rig.
|
||||||
|
func TestKenwoodModeDigit(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
mode string
|
||||||
|
hz int64
|
||||||
|
want byte
|
||||||
|
}{
|
||||||
|
{"SSB", 7150000, '1'}, // LSB below 10 MHz
|
||||||
|
{"SSB", 14250000, '2'}, // USB above
|
||||||
|
{"LSB", 14250000, '1'}, // an explicit choice wins over the convention
|
||||||
|
{"USB", 7150000, '2'},
|
||||||
|
{"CW", 7030000, '3'},
|
||||||
|
{"RTTY", 14080000, '6'},
|
||||||
|
{"AM", 7150000, '5'},
|
||||||
|
{"FM", 145000000, '4'},
|
||||||
|
{"FT8", 7074000, '1'}, // data rides on the sideband for the band
|
||||||
|
{"FT8", 14074000, '2'},
|
||||||
|
{"", 14074000, 0}, // nothing to set
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := kenwoodModeDigit(c.mode, c.hz); got != c.want {
|
||||||
|
t.Errorf("kenwoodModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the log records for each mode digit the rig reports.
|
||||||
|
func TestKenwoodModeToADIF(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
d byte
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{'1', "LSB"}, {'2', "USB"},
|
||||||
|
{'3', "CW"}, {'7', "CW"}, // CW-R is still CW in the log
|
||||||
|
{'4', "FM"}, {'5', "AM"},
|
||||||
|
{'6', "RTTY"}, {'9', "RTTY"}, // FSK-R likewise
|
||||||
|
{'0', ""}, // unknown → say nothing rather than guess
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := kenwoodModeToADIF(c.d, "FT8"); got != c.want {
|
||||||
|
t.Errorf("kenwoodModeToADIF(%q) = %q, want %q", c.d, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-4
@@ -4,6 +4,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LogSink, when set by the host app at startup, receives every CAT debug
|
// LogSink, when set by the host app at startup, receives every CAT debug
|
||||||
@@ -15,13 +17,21 @@ var LogSink func(format string, args ...any)
|
|||||||
// catLogger forwards Printf either to the host LogSink (preferred) or to a
|
// catLogger forwards Printf either to the host LogSink (preferred) or to a
|
||||||
// local file/stderr fallback. Keeps the call sites (debugLog.Printf(...))
|
// local file/stderr fallback. Keeps the call sites (debugLog.Printf(...))
|
||||||
// unchanged.
|
// unchanged.
|
||||||
type catLogger struct{ fallback *log.Logger }
|
type catLogger struct {
|
||||||
|
once sync.Once
|
||||||
|
fallback *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
func (c *catLogger) Printf(format string, args ...any) {
|
func (c *catLogger) Printf(format string, args ...any) {
|
||||||
if LogSink != nil {
|
if LogSink != nil {
|
||||||
LogSink("cat: "+format, args...)
|
LogSink("cat: "+format, args...)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Only now, on a line that genuinely has nowhere else to go, is the fallback
|
||||||
|
// file opened. It used to be created at package init, so every installation
|
||||||
|
// grew an %APPDATA%\OpsLog\cat.log that nothing ever wrote to once the app
|
||||||
|
// wired LogSink — a decoy for anyone told to "check the CAT log".
|
||||||
|
c.once.Do(func() { c.fallback = openFallbackLog() })
|
||||||
if c.fallback != nil {
|
if c.fallback != nil {
|
||||||
c.fallback.Printf(format, args...)
|
c.fallback.Printf(format, args...)
|
||||||
}
|
}
|
||||||
@@ -30,7 +40,7 @@ func (c *catLogger) Printf(format string, args ...any) {
|
|||||||
// debugLog writes CAT debug events so users can diagnose mode/freq mismatches
|
// debugLog writes CAT debug events so users can diagnose mode/freq mismatches
|
||||||
// without rebuilding with a console. Once LogSink is set, lines flow into the
|
// without rebuilding with a console. Once LogSink is set, lines flow into the
|
||||||
// main opslog.log.
|
// main opslog.log.
|
||||||
var debugLog = &catLogger{fallback: openFallbackLog()}
|
var debugLog = &catLogger{}
|
||||||
|
|
||||||
func openFallbackLog() *log.Logger {
|
func openFallbackLog() *log.Logger {
|
||||||
base, err := os.UserConfigDir()
|
base, err := os.UserConfigDir()
|
||||||
@@ -49,12 +59,69 @@ func openFallbackLog() *log.Logger {
|
|||||||
return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
|
return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DebugLogPath returns where the fallback cat.log lives, for surfacing in the
|
// DebugLogPath returns where the fallback cat.log lives, or "" when CAT lines are
|
||||||
// UI / docs. When LogSink is wired, CAT lines are in the main app log instead.
|
// going to the app log instead — which is the normal case, and the answer callers
|
||||||
|
// actually need. It previously returned the path unconditionally, so the one place
|
||||||
|
// that displayed it sent operators to an empty file in %APPDATA% while their CAT
|
||||||
|
// diagnostics sat in data\opslog.log.
|
||||||
func DebugLogPath() string {
|
func DebugLogPath() string {
|
||||||
|
if LogSink != nil {
|
||||||
|
return "" // lines go to the unified app log; there is no separate cat.log
|
||||||
|
}
|
||||||
base, err := os.UserConfigDir()
|
base, err := os.UserConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return filepath.Join(base, "OpsLog", "cat.log")
|
return filepath.Join(base, "OpsLog", "cat.log")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CAT wire trace ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Opt-in, off by default. Turning it on logs every frame sent and received —
|
||||||
|
// CI-V as hex, the ASCII backends (Kenwood, Yaesu) as the text they exchange.
|
||||||
|
//
|
||||||
|
// It exists because a fault nobody could reason about — "the keyer sends one
|
||||||
|
// element then stalls" — was settled in one line the moment the actual bytes
|
||||||
|
// were visible, and the CAT links have since produced the same class of report:
|
||||||
|
// a MOX button that sets split instead of transmitting, and a split the Kenwood
|
||||||
|
// backend does not recognise on a rig whose frequency it reads perfectly.
|
||||||
|
//
|
||||||
|
// Both are questions about what the RIG actually said. Guessing at that from a
|
||||||
|
// protocol document has already cost this project a wrong fix that broke the
|
||||||
|
// case which already worked.
|
||||||
|
var catTrace atomic.Bool
|
||||||
|
|
||||||
|
// SetCIVTrace turns the CAT wire trace on or off. Named for the CI-V link it
|
||||||
|
// was written for; it now covers the text backends too.
|
||||||
|
func SetCIVTrace(on bool) {
|
||||||
|
catTrace.Store(on)
|
||||||
|
if on {
|
||||||
|
debugLog.Printf("wire trace ON — every CAT frame to and from the rig is logged")
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("wire trace OFF")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CIVTraceEnabled reports whether the trace is running (for the settings UI).
|
||||||
|
func CIVTraceEnabled() bool { return catTrace.Load() }
|
||||||
|
|
||||||
|
// traceCIV logs one CI-V frame. dir is "TX" (to the rig) or "RX" (from it).
|
||||||
|
func traceCIV(dir string, b []byte) {
|
||||||
|
if !catTrace.Load() || len(b) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debugLog.Printf("civ %s % X", dir, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// traceText logs one ASCII exchange, for the Kenwood and Yaesu backends.
|
||||||
|
//
|
||||||
|
// The text is QUOTED. A truncated or empty reply must be visible as such
|
||||||
|
// rather than vanish into the line: "" and ";" look identical unquoted, and
|
||||||
|
// telling them apart is the whole question when a rig answers a command it
|
||||||
|
// does not really support.
|
||||||
|
func traceText(backend, dir, s string) {
|
||||||
|
if !catTrace.Load() || s == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debugLog.Printf("%s %s %q", backend, dir, s)
|
||||||
|
}
|
||||||
|
|||||||
+350
-73
@@ -29,9 +29,23 @@ const (
|
|||||||
type OmniRig struct {
|
type OmniRig struct {
|
||||||
RigNum int // 1 (Rig1) or 2 (Rig2)
|
RigNum int // 1 (Rig1) or 2 (Rig2)
|
||||||
|
|
||||||
|
// ForceVFO overrides which VFO to read: "" trusts the rig file, "A"/"B" do
|
||||||
|
// not. OmniRig's VFO report is only as good as the .ini behind it, and a
|
||||||
|
// wrong one is invisible: an IC-7610 file was seen declaring VFO B while the
|
||||||
|
// operator worked on the main VFO, so OpsLog wrote to A (SetSimplexMode acts
|
||||||
|
// on the main VFO) and read B — the frequency "never followed the knob",
|
||||||
|
// while it was following the other one all along.
|
||||||
|
ForceVFO string
|
||||||
|
|
||||||
omnirig *ole.IDispatch
|
omnirig *ole.IDispatch
|
||||||
rig *ole.IDispatch
|
rig *ole.IDispatch
|
||||||
lastSig string // last logged Split/VFO signature — only log on change
|
lastSig string // last logged Split/VFO signature — only log on change
|
||||||
|
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
|
||||||
|
|
||||||
|
// connLogged holds the connect failure already written to the log, so the
|
||||||
|
// 5-second reconnect loop reports a persistent problem once instead of
|
||||||
|
// forever. Cleared on success.
|
||||||
|
connLogged string
|
||||||
|
|
||||||
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
||||||
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
||||||
@@ -40,20 +54,90 @@ type OmniRig struct {
|
|||||||
// the sideband (freq moved, but mode read the old band → wrong sideband).
|
// the sideband (freq moved, but mode read the old band → wrong sideband).
|
||||||
lastSetFreq int64
|
lastSetFreq int64
|
||||||
lastSetFreqAt time.Time
|
lastSetFreqAt time.Time
|
||||||
|
|
||||||
|
// lastSplitOnAt is when OmniRig last reported PM_SPLITON cleanly. See the
|
||||||
|
// FTDX101D note in ReadState — some .ini files alternate between ON and OFF
|
||||||
|
// on consecutive polls, so the flag has to be latched to be usable.
|
||||||
|
lastSplitOnAt time.Time
|
||||||
|
|
||||||
|
// splitFlaky records that THIS rig's .ini flips the split flag on its own,
|
||||||
|
// which is what arms the latch. lastSplitFlag / splitFlips / splitFlipWindow
|
||||||
|
// count the flips inside a rolling window to detect it.
|
||||||
|
lastSplitFlag bool
|
||||||
|
splitFlaky bool
|
||||||
|
splitFlips int
|
||||||
|
splitFlipWindow time.Time
|
||||||
|
|
||||||
|
// pendingReadback schedules one delayed frequency log after a Set — see
|
||||||
|
// SetFrequency. Zero when nothing is pending.
|
||||||
|
pendingReadback time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
||||||
func NewOmniRig(rigNum int) *OmniRig {
|
// NewOmniRig builds the backend. forceVFO is "" to follow whatever the rig file
|
||||||
|
// reports, or "A"/"B" to override it — see the ForceVFO field.
|
||||||
|
func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
|
||||||
if rigNum < 1 || rigNum > 2 {
|
if rigNum < 1 || rigNum > 2 {
|
||||||
rigNum = 1
|
rigNum = 1
|
||||||
}
|
}
|
||||||
return &OmniRig{RigNum: rigNum}
|
v := strings.ToUpper(strings.TrimSpace(forceVFO))
|
||||||
|
if v != "A" && v != "B" {
|
||||||
|
v = ""
|
||||||
|
}
|
||||||
|
return &OmniRig{RigNum: rigNum, ForceVFO: v}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OmniRig) Name() string { return "omnirig" }
|
func (o *OmniRig) Name() string { return "omnirig" }
|
||||||
|
|
||||||
|
// elevationHint recognises the COM refusal that happens when OmniRig runs
|
||||||
|
// elevated (as administrator) and OpsLog does not — or the reverse. Windows keeps
|
||||||
|
// the two integrity levels apart, so the client cannot bind to the running
|
||||||
|
// server's object and COM falls back to launching a fresh one, which then needs
|
||||||
|
// elevation the client cannot grant.
|
||||||
|
//
|
||||||
|
// It is worth naming explicitly: the operator SEES OmniRig running, with its
|
||||||
|
// settings window open, so "OmniRig not found" reads as nonsense and sends them
|
||||||
|
// hunting for a driver or COM-port problem that does not exist. The fix is thirty
|
||||||
|
// seconds of work once you know what to look for.
|
||||||
|
func elevationHint(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
// Matched on the HRESULT text in whatever language Windows is running in, so
|
||||||
|
// the code is checked too: 0x800702E4 = ERROR_ELEVATION_REQUIRED.
|
||||||
|
if strings.Contains(msg, "elevation") || strings.Contains(msg, "élévation") ||
|
||||||
|
strings.Contains(msg, "0x800702e4") || strings.Contains(msg, "access denied") ||
|
||||||
|
strings.Contains(msg, "accès refusé") {
|
||||||
|
return "OmniRig and OpsLog are running at different privilege levels — Windows keeps them apart, " +
|
||||||
|
"so OpsLog cannot reach OmniRig even though it is running. Start BOTH the same way: either " +
|
||||||
|
"un-tick \"Run as administrator\" on the OmniRig shortcut (and its Compatibility tab), or run " +
|
||||||
|
"OpsLog as administrator too"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// logConnFailure writes a connect failure once per distinct cause. The reconnect
|
||||||
|
// loop retries every 5 seconds forever, and a station whose OmniRig was simply
|
||||||
|
// elevated had this filling its log at roughly 1500 lines an hour — which buries
|
||||||
|
// the very diagnostics someone would go looking for.
|
||||||
|
func (o *OmniRig) logConnFailure(msg string) {
|
||||||
|
if o.connLogged == msg {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
o.connLogged = msg
|
||||||
|
debugLog.Printf("OmniRig Rig%d: %s", o.RigNum, msg)
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OmniRig) Connect() error {
|
func (o *OmniRig) Connect() error {
|
||||||
debugLog.Printf("OmniRig.Connect Rig%d — log path: %s", o.RigNum, DebugLogPath())
|
// This used to announce DebugLogPath() on every attempt — the path of the
|
||||||
|
// FALLBACK cat.log, which nothing writes to once the app has wired LogSink and
|
||||||
|
// everything goes to data\opslog.log. It pointed operators at an empty file in
|
||||||
|
// %APPDATA% while the lines they wanted were somewhere else entirely. Dropped;
|
||||||
|
// and logged once per failure run rather than every 5-second retry.
|
||||||
|
if o.connLogged == "" {
|
||||||
|
debugLog.Printf("OmniRig.Connect Rig%d", o.RigNum)
|
||||||
|
}
|
||||||
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
|
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
|
||||||
// 0x1 = S_FALSE → COM already initialised on this thread, fine.
|
// 0x1 = S_FALSE → COM already initialised on this thread, fine.
|
||||||
if oerr, ok := err.(*ole.OleError); !ok || oerr.Code() != 0x00000001 {
|
if oerr, ok := err.(*ole.OleError); !ok || oerr.Code() != 0x00000001 {
|
||||||
@@ -61,15 +145,43 @@ func (o *OmniRig) Connect() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unk, err := oleutil.CreateObject("Omnirig.OmnirigX")
|
const progID = "Omnirig.OmnirigX"
|
||||||
if err != nil {
|
var omnirig *ole.IDispatch
|
||||||
return fmt.Errorf("Omnirig.OmnirigX not available — is OmniRig installed and running?: %w", err)
|
unk, err := oleutil.CreateObject(progID)
|
||||||
}
|
if err == nil {
|
||||||
omnirig, err := unk.QueryInterface(ole.IID_IDispatch)
|
omnirig, err = unk.QueryInterface(ole.IID_IDispatch)
|
||||||
unk.Release()
|
unk.Release()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("query interface: %w", err)
|
return fmt.Errorf("query interface: %w", err)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// A privilege mismatch is final — retrying, or trying the 32-bit server,
|
||||||
|
// cannot cross an integrity boundary. Say what to do instead of dressing it
|
||||||
|
// up as "not installed", which is what sends operators looking in the wrong
|
||||||
|
// place entirely.
|
||||||
|
if hint := elevationHint(err); hint != "" {
|
||||||
|
o.logConnFailure(hint)
|
||||||
|
return fmt.Errorf("%s (Windows said: %v)", hint, err)
|
||||||
|
}
|
||||||
|
// Otherwise it may be a partial registration; try activating the 32-bit
|
||||||
|
// server explicitly before giving up (see omnirig_activate32.go).
|
||||||
|
disp, err32 := createOmniRig32(progID)
|
||||||
|
if err32 != nil {
|
||||||
|
o.logConnFailure(fmt.Sprintf("CreateObject(%s) failed: %v; 32-bit activation also failed: %v", progID, err, err32))
|
||||||
|
// Name the version requirement. HB9RYZ's OmniRig v2.1 is a different
|
||||||
|
// product that its own author states is not compatible with v1, and it
|
||||||
|
// does not provide v1's IOmniRigX interface — so an operator who has
|
||||||
|
// only v2 installed sees OmniRig running and OpsLog failing, with
|
||||||
|
// nothing to connect the two facts.
|
||||||
|
return fmt.Errorf("OmniRig (v1) not reachable: %w — OpsLog needs OmniRig v1.19/v1.20 "+
|
||||||
|
"(VE3NEA/Alex), the interface every logger uses. HB9RYZ's OmniRig v2.1 is a separate, "+
|
||||||
|
"incompatible product and cannot serve OpsLog; the two may be installed side by side, "+
|
||||||
|
"but v1 must be present and running", err)
|
||||||
|
}
|
||||||
|
debugLog.Printf("OmniRig: reached via explicit 32-bit activation after CreateObject failed (%v)", err)
|
||||||
|
omnirig = disp
|
||||||
|
}
|
||||||
|
o.connLogged = "" // connected: re-arm the one-shot failure logging
|
||||||
|
|
||||||
rigVar, err := oleutil.GetProperty(omnirig, fmt.Sprintf("Rig%d", o.RigNum))
|
rigVar, err := oleutil.GetProperty(omnirig, fmt.Sprintf("Rig%d", o.RigNum))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,9 +191,26 @@ func (o *OmniRig) Connect() error {
|
|||||||
o.omnirig = omnirig
|
o.omnirig = omnirig
|
||||||
o.rig = rigVar.ToIDispatch()
|
o.rig = rigVar.ToIDispatch()
|
||||||
|
|
||||||
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
// Log WHICH OmniRig answered. There are two incompatible products called
|
||||||
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
|
// OmniRig: v1.19/1.20 (Alex, VE3NEA), whose IOmniRigX interface every logger
|
||||||
|
// including OpsLog uses, and HB9RYZ's v2.1, which its own author states is "not
|
||||||
|
// compatible" with v1 and works only with programs written for it. They can
|
||||||
|
// coexist, and OmniRig's own window looks much the same either way — so an
|
||||||
|
// operator running only v2 sees OmniRig on screen, sees OpsLog fail, and has no
|
||||||
|
// way to know the two were never going to talk. These two version numbers
|
||||||
|
// settle it in one line of a bug report.
|
||||||
|
var iv, sv int64 = -1, -1
|
||||||
|
if v, err := oleutil.GetProperty(o.omnirig, "InterfaceVersion"); err == nil {
|
||||||
|
iv = v.Val
|
||||||
}
|
}
|
||||||
|
if v, err := oleutil.GetProperty(o.omnirig, "SoftwareVersion"); err == nil {
|
||||||
|
sv = v.Val
|
||||||
|
}
|
||||||
|
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||||
|
o.rigType = rt.ToString()
|
||||||
|
}
|
||||||
|
debugLog.Printf("OmniRig connected: Rig%d type=%q (OmniRig interface=%d software=%d)",
|
||||||
|
o.RigNum, o.rigType, iv, sv)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,63 +282,165 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
splitRaw = v.Val
|
splitRaw = v.Val
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so
|
// FTDX101D field capture: OmniRig alternates between two contradictory
|
||||||
// normal operation stays quiet but toggling split on the radio is captured —
|
// readings on consecutive polls — "Vfo=AB Split=0x10000(OFF)" then
|
||||||
// needed to pin down this rig's PM_SPLITON value.
|
// "Vfo=BA Split=0x8000(ON)", ~1.5 s apart, with the rig untouched. The stock
|
||||||
|
// .ini evidently has two status commands that each write these params. A
|
||||||
|
// sample-by-sample test therefore reports split for half the polls and no
|
||||||
|
// split for the other half, which the UI shows as no split at all. Latch the
|
||||||
|
// ON flag briefly so one truthful sample survives the contradicting one; the
|
||||||
|
// latch expires on its own once the rig stops reporting ON, so cancelling
|
||||||
|
// split on the radio still clears within a few seconds.
|
||||||
|
//
|
||||||
|
// The latch is ARMED ONLY for a rig that actually oscillates, because it costs
|
||||||
|
// ~6 s before split clears on screen. A correct .ini (FTDX10 with VS; and FT;
|
||||||
|
// read as separate frames, confirmed on the air 2026-07-26) never flips
|
||||||
|
// unprompted, and there the latch would be a pure delay on a reading that was
|
||||||
|
// already right.
|
||||||
|
//
|
||||||
|
// 8 flips in 30 s: a first cut at 3-in-15s was armed by the OPERATOR toggling
|
||||||
|
// split three times while testing, which then imposed the 6 s delay on a rig
|
||||||
|
// that did not need it. A misreading .ini flips every 1.5–3 s without being
|
||||||
|
// touched — a dozen in the same window — so the gap is wide. The arming also
|
||||||
|
// expires after 30 s without a flip, so a rig that behaves is never stuck with
|
||||||
|
// the delay because of one burst.
|
||||||
|
const flipWindow, flipsToArm = 30 * time.Second, 8
|
||||||
|
now := time.Now()
|
||||||
|
flagOn := splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0
|
||||||
|
if flagOn {
|
||||||
|
o.lastSplitOnAt = now
|
||||||
|
}
|
||||||
|
if flagOn != o.lastSplitFlag {
|
||||||
|
o.lastSplitFlag = flagOn
|
||||||
|
if now.Sub(o.splitFlipWindow) > flipWindow {
|
||||||
|
o.splitFlipWindow, o.splitFlips, o.splitFlaky = now, 0, false
|
||||||
|
}
|
||||||
|
o.splitFlips++
|
||||||
|
if o.splitFlips >= flipsToArm {
|
||||||
|
o.splitFlaky = true
|
||||||
|
}
|
||||||
|
} else if o.splitFlaky && now.Sub(o.splitFlipWindow) > flipWindow {
|
||||||
|
o.splitFlaky, o.splitFlips = false, 0 // stopped oscillating — drop the delay
|
||||||
|
}
|
||||||
|
splitRecentOn := o.splitFlaky && !o.lastSplitOnAt.IsZero() && now.Sub(o.lastSplitOnAt) < 6*time.Second
|
||||||
|
|
||||||
|
// A forced VFO replaces whatever the rig file reported, BEFORE anything is
|
||||||
|
// derived from it. Nothing downstream then has to know about the override.
|
||||||
|
vfo := s.Vfo
|
||||||
|
if o.ForceVFO != "" {
|
||||||
|
vfo = o.ForceVFO
|
||||||
|
}
|
||||||
|
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, vfo, splitRaw, splitRecentOn)
|
||||||
|
|
||||||
|
// Delayed readback after a Set (see SetFrequency): logged from HERE because
|
||||||
|
// this runs on the COM-owning goroutine. One line, once per set.
|
||||||
|
if !o.pendingReadback.IsZero() && time.Now().After(o.pendingReadback) {
|
||||||
|
o.pendingReadback = time.Time{}
|
||||||
|
debugLog.Printf("OmniRig.SetFrequency: readback +1.5s FreqA=%d FreqB=%d Freq=%d → shown %d",
|
||||||
|
freqA, freqB, freqMain, s.FreqHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so normal
|
||||||
|
// operation stays quiet but toggling split or SUB VFO on the radio is
|
||||||
|
// captured. It logs the RESOLVED tx/rx/split too: with the raw values alone a
|
||||||
|
// user's log showed what OmniRig said but not what OpsLog concluded, which is
|
||||||
|
// the half that was wrong.
|
||||||
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
|
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
|
||||||
o.lastSig = sig
|
o.lastSig = sig
|
||||||
debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d",
|
debugLog.Printf("OmniRig Rig%d raw: rig=%q Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X sticky=%v → tx=%d rx=%d split=%v",
|
||||||
o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 {
|
o.RigNum, o.rigType, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, splitRecentOn,
|
||||||
if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil {
|
s.FreqHz, s.RxFreqHz, s.Split)
|
||||||
return v.Val
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
// A genuine split: the rig explicitly flags PM_SPLITON, the two VFOs are
|
|
||||||
// distinct and non-zero, AND they're in the same band. The same-band test
|
|
||||||
// kills the common false positive where VFO B just holds a leftover from
|
|
||||||
// another band (a "28 MHz / 7 MHz split" is nonsensical), which on the
|
|
||||||
// FT-710 / TS-570 otherwise froze the main/TX freq on the wrong VFO.
|
|
||||||
genuineSplit := splitRaw == pmSplitOn &&
|
|
||||||
freqA != 0 && freqB != 0 && freqA != freqB &&
|
|
||||||
BandFromHz(freqA) == BandFromHz(freqB)
|
|
||||||
|
|
||||||
if genuineSplit {
|
|
||||||
// ADIF: FreqHz = TX, RxFreqHz = RX. Determine which VFO is RX from the
|
|
||||||
// ACTIVE frequency (OmniRig's generic Freq — the VFO you're listening on):
|
|
||||||
// RX = the active VFO, TX = the other one. This is far more reliable than
|
|
||||||
// trusting OmniRig's Vfo AB/BA enum, which several rigs (e.g. Yaesu FTDX10)
|
|
||||||
// report inverted — the split then showed TX/RX swapped.
|
|
||||||
s.Split = true
|
|
||||||
switch {
|
|
||||||
case freqMain != 0 && freqMain == freqA:
|
|
||||||
s.RxFreqHz, s.FreqHz = freqA, freqB // listening on A → TX on B
|
|
||||||
case freqMain != 0 && freqMain == freqB:
|
|
||||||
s.RxFreqHz, s.FreqHz = freqB, freqA // listening on B → TX on A
|
|
||||||
case s.Vfo == "BA":
|
|
||||||
s.FreqHz, s.RxFreqHz = freqA, freqB // fall back to the Vfo enum
|
|
||||||
default:
|
|
||||||
s.FreqHz, s.RxFreqHz = freqB, freqA
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Simplex: the operating frequency is OmniRig's generic Freq (the active
|
|
||||||
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
|
|
||||||
s.Split = false
|
|
||||||
s.RxFreqHz = 0
|
|
||||||
s.FreqHz = freqMain
|
|
||||||
if s.FreqHz == 0 {
|
|
||||||
if s.Vfo == "B" || s.Vfo == "BB" {
|
|
||||||
s.FreqHz = freqB
|
|
||||||
} else {
|
|
||||||
s.FreqHz = freqA
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveOmniRigVFOs turns OmniRig's four readings into the ADIF pair
|
||||||
|
// (FreqHz = TX, RxFreqHz = RX) plus a split flag.
|
||||||
|
//
|
||||||
|
// Pure and separate from ReadState because it encodes rig-specific rules that
|
||||||
|
// contradict each other — what fixes a Yaesu can break an Icom — and the only way
|
||||||
|
// to change it safely is with every known rig's behaviour pinned in a test. COM
|
||||||
|
// cannot be exercised from a test; this can.
|
||||||
|
func resolveOmniRigVFOs(rigType string, freqMain, freqA, freqB int64, vfo string, splitRaw int64, splitRecentOn bool) (txHz, rxHz int64, split bool) {
|
||||||
|
// PM_SPLITON is tested as a BIT, not by equality. OmniRig's Split is a flag
|
||||||
|
// word: an exact `== 0x8000` holds only for a rig whose ini sets that bit and
|
||||||
|
// nothing else, and silently reports "no split" for any rig reporting the bit
|
||||||
|
// alongside another. Requiring ON set and OFF clear keeps the two states apart
|
||||||
|
// (both flags are non-zero, so a bare `!= 0` would read OFF as split) while
|
||||||
|
// tolerating extra bits.
|
||||||
|
splitFlagged := (splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0) || splitRecentOn
|
||||||
|
|
||||||
|
// A genuine split also needs two distinct, non-zero VFOs in the SAME band. The
|
||||||
|
// band test kills the common false positive where VFO B merely holds a
|
||||||
|
// leftover from another band (a "28 MHz / 7 MHz split" is nonsensical), which
|
||||||
|
// on the FT-710 / TS-570 otherwise froze the TX freq on the wrong VFO.
|
||||||
|
if splitFlagged && freqA != 0 && freqB != 0 && freqA != freqB &&
|
||||||
|
BandFromHz(freqA) == BandFromHz(freqB) {
|
||||||
|
// RX is the VFO being listened on — identified from the generic Freq rather
|
||||||
|
// than from the Vfo AB/BA enum, which several rigs (Yaesu FTDX10) report
|
||||||
|
// inverted, showing TX and RX swapped.
|
||||||
|
switch {
|
||||||
|
case freqMain != 0 && freqMain == freqA:
|
||||||
|
return freqB, freqA, true // listening on A → TX on B
|
||||||
|
case freqMain != 0 && freqMain == freqB:
|
||||||
|
return freqA, freqB, true // listening on B → TX on A
|
||||||
|
case vfo == "BA":
|
||||||
|
return freqA, freqB, true // fall back to the Vfo enum
|
||||||
|
default:
|
||||||
|
return freqB, freqA, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplex. The VFO the rig says is ACTIVE comes first: preferring freqA
|
||||||
|
// unconditionally (as this did) meant the displayed frequency never left VFO
|
||||||
|
// A — press SUB VFO on an FTDX101D and the radio receives on B while OpsLog
|
||||||
|
// went on showing A, taking the band and the logged frequency with it.
|
||||||
|
//
|
||||||
|
// Only a VFO OmniRig explicitly names is honoured, so a rig that does not
|
||||||
|
// report the enum keeps exactly the previous fallback order. That matters for
|
||||||
|
// the IC-7610, whose stock ini reports the generic Freq as VFO B; and for the
|
||||||
|
// PM_FREQA rigs (Yaesu, Kenwood) versus the Icoms (IC-9100) that populate only
|
||||||
|
// the generic Freq.
|
||||||
|
// The PAIR enums name BOTH VFOs at once — first letter = the one being
|
||||||
|
// listened on, second = the one that transmits. Only the single-letter forms
|
||||||
|
// were honoured here, so a rig that reports nothing but pairs (the whole Yaesu
|
||||||
|
// family) always fell through to freqA and never followed the operator to SUB.
|
||||||
|
// Log4OM reads that first letter and logs the right frequency on the same
|
||||||
|
// rigs, which is what showed this was readable data and not a dead end.
|
||||||
|
//
|
||||||
|
// Checked BEFORE the Yaesu fallback below: an enum that names a VFO is the
|
||||||
|
// rig speaking, the fallback is only an inference.
|
||||||
|
switch {
|
||||||
|
case (vfo == "BA" || vfo == "BB") && freqB != 0:
|
||||||
|
return freqB, 0, false
|
||||||
|
case (vfo == "AA" || vfo == "AB") && freqA != 0:
|
||||||
|
return freqA, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yaesu fallback, for a rig file that names no VFO at all (the stock FTDX10
|
||||||
|
// one: it answers neither VS; nor FR; usably, verified on the air 2026-07-26).
|
||||||
|
// There the generic Freq is the last clue — matching FreqB and not FreqA means
|
||||||
|
// the operator is on SUB. Limited to Yaesu: the IC-7610's stock ini reports
|
||||||
|
// the generic Freq as VFO B permanently, where this would name the wrong VFO —
|
||||||
|
// that case is pinned in the test table.
|
||||||
|
if isYaesuRig(rigType) && freqMain != 0 && freqMain == freqB && freqB != freqA {
|
||||||
|
return freqB, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case (vfo == "B" || vfo == "BB") && freqB != 0:
|
||||||
|
return freqB, 0, false
|
||||||
|
case (vfo == "A" || vfo == "AA") && freqA != 0:
|
||||||
|
return freqA, 0, false
|
||||||
|
case freqA != 0:
|
||||||
|
return freqA, 0, false
|
||||||
|
case freqMain != 0:
|
||||||
|
return freqMain, 0, false
|
||||||
|
default:
|
||||||
|
return freqB, 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OmniRig) SetFrequency(hz int64) error {
|
func (o *OmniRig) SetFrequency(hz int64) error {
|
||||||
if o.rig == nil {
|
if o.rig == nil {
|
||||||
debugLog.Printf("OmniRig.SetFrequency(%d): NOT CONNECTED", hz)
|
debugLog.Printf("OmniRig.SetFrequency(%d): NOT CONNECTED", hz)
|
||||||
@@ -262,19 +493,48 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
|||||||
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
|
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
|
||||||
// where direct FreqA/FreqB writes are accepted but never move the radio.
|
// where direct FreqA/FreqB writes are accepted but never move the radio.
|
||||||
// Clearing split is the right thing when tuning to a spot anyway.
|
// Clearing split is the right thing when tuning to a spot anyway.
|
||||||
|
// …but ONLY when the operator is on the main VFO. SetSimplexMode means
|
||||||
|
// "receive and transmit here, simplex", and OmniRig implements that on the
|
||||||
|
// MAIN VFO — so on an FTDX101 listening on SUB, clicking a cluster spot
|
||||||
|
// dragged the radio back to MAIN (F4NBZ, 2026-07-29). The operator picked SUB
|
||||||
|
// deliberately; a spot click is a request to change frequency, not to change
|
||||||
|
// VFO. On SUB the direct FreqB write below does the whole job.
|
||||||
|
onSubVFO := vfo == "B" || vfo == "BB" || vfo == "BA"
|
||||||
|
simplexOK := false
|
||||||
|
switch {
|
||||||
|
case onSubVFO:
|
||||||
|
debugLog.Printf("OmniRig.SetFrequency: on VFO %q — skipping SetSimplexMode so the rig stays on SUB", vfo)
|
||||||
|
default:
|
||||||
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
|
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
|
||||||
|
simplexOK = true
|
||||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
|
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
|
||||||
} else {
|
} else {
|
||||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v) — using property writes", err)
|
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v)", err)
|
||||||
// Fallback: write the active VFO's property AND the generic Freq
|
}
|
||||||
// (always — some .ini honour only one, and split here is often misread).
|
}
|
||||||
|
|
||||||
|
// On Yaesu / Kenwood (and anything non-Icom), SetSimplexMode frequently returns
|
||||||
|
// OK but is a SILENT NO-OP — the rig never moves. Confirmed on an FT-891 over
|
||||||
|
// OmniRig: every spot click logged "SetSimplexMode OK" yet FreqA stayed put,
|
||||||
|
// while SetMode on the same .ini worked fine (so the CAT link is healthy).
|
||||||
|
// Writing the VFO frequency PROPERTY directly DOES move them, so also do that
|
||||||
|
// here. It is skipped on Icom, where the direct write is the unreliable one and
|
||||||
|
// could nudge the wrong Main/Sub VFO — there SetSimplexMode is authoritative.
|
||||||
|
isIcom := strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "IC")
|
||||||
|
if !isIcom || !simplexOK {
|
||||||
prop := "FreqA"
|
prop := "FreqA"
|
||||||
switch vfo {
|
if onSubVFO {
|
||||||
case "B", "BB", "BA":
|
|
||||||
prop = "FreqB"
|
prop = "FreqB"
|
||||||
}
|
}
|
||||||
okAny := false
|
okAny := false
|
||||||
for _, p := range []string{prop, "Freq"} {
|
// The generic "Freq" property is written too — but NOT on SUB, where it is
|
||||||
|
// the main VFO's frequency on several rig files and would move the VFO the
|
||||||
|
// operator is not using, or pull them back to it.
|
||||||
|
props := []string{prop, "Freq"}
|
||||||
|
if onSubVFO {
|
||||||
|
props = []string{prop}
|
||||||
|
}
|
||||||
|
for _, p := range props {
|
||||||
if _, e := oleutil.PutProperty(o.rig, p, hz32); e != nil {
|
if _, e := oleutil.PutProperty(o.rig, p, hz32); e != nil {
|
||||||
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", p, e)
|
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", p, e)
|
||||||
} else {
|
} else {
|
||||||
@@ -282,15 +542,22 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
|||||||
okAny = true
|
okAny = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !okAny {
|
if !simplexOK && !okAny {
|
||||||
return fmt.Errorf("OmniRig: no writable frequency property for this rig")
|
return fmt.Errorf("OmniRig: no writable frequency property for this rig")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read back all three immediately. OmniRig is async (the CAT command is
|
// Read back all three, then AGAIN a moment later. OmniRig is async — the CAT
|
||||||
// queued + sent over serial), so these may still show the OLD value for
|
// command is queued and sent over serial — so an immediate read always shows
|
||||||
// one poll cycle — but if they NEVER change in the next poll, the rig
|
// the old value and proves nothing. The delayed one is the useful half: it
|
||||||
// isn't honouring the write (wrong .ini WRITE command for this model).
|
// separates "the rig ignored the write" from "the rig moved but its .ini
|
||||||
|
// never re-reads the frequency", which look identical to an operator whose
|
||||||
|
// display stays on the old band until they nudge the VFO.
|
||||||
|
//
|
||||||
|
// Off the CAT goroutine so the poll loop is not held for a second. COM is
|
||||||
|
// thread-affine, so the delayed read goes through the manager's own command
|
||||||
|
// channel rather than touching o.rig from here.
|
||||||
|
logFreqs := func(when string) {
|
||||||
fa, fb, fg := int64(-1), int64(-1), int64(-1)
|
fa, fb, fg := int64(-1), int64(-1), int64(-1)
|
||||||
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
||||||
fa = v.Val
|
fa = v.Val
|
||||||
@@ -301,7 +568,10 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
|||||||
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||||
fg = v.Val
|
fg = v.Val
|
||||||
}
|
}
|
||||||
debugLog.Printf("OmniRig.SetFrequency: readback FreqA=%d FreqB=%d Freq=%d (target %d)", fa, fb, fg, hz)
|
debugLog.Printf("OmniRig.SetFrequency: readback %s FreqA=%d FreqB=%d Freq=%d (target %d)", when, fa, fb, fg, hz)
|
||||||
|
}
|
||||||
|
logFreqs("now")
|
||||||
|
o.pendingReadback = time.Now().Add(1500 * time.Millisecond)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +740,13 @@ func omniRigMode(m int64) string {
|
|||||||
|
|
||||||
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
|
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
|
||||||
// documented PM_VFO* constants.
|
// documented PM_VFO* constants.
|
||||||
|
// isYaesuRig recognises a Yaesu from OmniRig's RigType (the .ini title, e.g.
|
||||||
|
// "FTDX101D", "FT-891"). Only used to gate rules that are true for Yaesu and
|
||||||
|
// false for Icom, so a mis-titled ini simply keeps the generic behaviour.
|
||||||
|
func isYaesuRig(rigType string) bool {
|
||||||
|
return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "FT")
|
||||||
|
}
|
||||||
|
|
||||||
func omniRigVfo(v int64) string {
|
func omniRigVfo(v int64) string {
|
||||||
switch {
|
switch {
|
||||||
case v&0x40 != 0: // PM_VFOAA
|
case v&0x40 != 0: // PM_VFOAA
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
ole "github.com/go-ole/go-ole"
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Last-resort activation path for OmniRig, used only when the normal
|
||||||
|
// CreateObject fails.
|
||||||
|
//
|
||||||
|
// OmniRig is a 32-bit program and its installer splits its COM identity across
|
||||||
|
// registry views — on a working machine the ProgID sits in the 64-bit view while
|
||||||
|
// the CLSID and its LocalServer32 exist only under WOW6432Node. That LOOKS like
|
||||||
|
// it should break a 64-bit client, and it was my first theory for "OmniRig not
|
||||||
|
// found"; measuring it on a machine with exactly that layout disproved it. COM
|
||||||
|
// resolves an out-of-process server across views by itself, and the plain
|
||||||
|
// CreateObject succeeds.
|
||||||
|
//
|
||||||
|
// What this still covers is the case where the ProgID is not visible to us at all
|
||||||
|
// (a partial or 32-bit-only registration), where CreateObject has nothing to
|
||||||
|
// resolve. Here we read the CLSID from BOTH views ourselves and activate the
|
||||||
|
// 32-bit local server explicitly. CLSCTX_ACTIVATE_32_BIT_SERVER is the documented
|
||||||
|
// flag for that; go-ole hard-codes CLSCTX_SERVER and keeps CoCreateInstance
|
||||||
|
// unexported, hence the direct call.
|
||||||
|
//
|
||||||
|
// It costs nothing when the normal path works, and the reason it ran at all is
|
||||||
|
// logged — so if it ever rescues a real installation we will see it.
|
||||||
|
const clsctxActivate32BitServer = 0x40000
|
||||||
|
|
||||||
|
var (
|
||||||
|
modole32 = syscall.NewLazyDLL("ole32.dll")
|
||||||
|
procCoCreateInst32 = modole32.NewProc("CoCreateInstance")
|
||||||
|
)
|
||||||
|
|
||||||
|
// omnirigCLSIDFromRegistry reads OmniRig's CLSID from the ProgID key, looking in
|
||||||
|
// the 64-bit view first and then the 32-bit one. Returned as a GUID ready for
|
||||||
|
// CoCreateInstance.
|
||||||
|
func omnirigCLSIDFromRegistry(progID string) (*ole.GUID, error) {
|
||||||
|
for _, access := range []uint32{registry.QUERY_VALUE, registry.QUERY_VALUE | registry.WOW64_32KEY} {
|
||||||
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Classes\`+progID+`\CLSID`, access)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, _, err := k.GetStringValue("")
|
||||||
|
k.Close()
|
||||||
|
if err != nil || s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if g := ole.NewGUID(s); g != nil {
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no CLSID registered for %s in either registry view", progID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createOmniRig32 activates OmniRig's 32-bit out-of-process server explicitly,
|
||||||
|
// bypassing the 64-bit registry lookup that CoCreateInstance would otherwise do.
|
||||||
|
func createOmniRig32(progID string) (*ole.IDispatch, error) {
|
||||||
|
clsid, err := omnirigCLSIDFromRegistry(progID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var unk *ole.IUnknown
|
||||||
|
hr, _, _ := procCoCreateInst32.Call(
|
||||||
|
uintptr(unsafe.Pointer(clsid)),
|
||||||
|
0, // no aggregation
|
||||||
|
uintptr(ole.CLSCTX_LOCAL_SERVER|clsctxActivate32BitServer),
|
||||||
|
uintptr(unsafe.Pointer(ole.IID_IUnknown)),
|
||||||
|
uintptr(unsafe.Pointer(&unk)))
|
||||||
|
if hr != 0 {
|
||||||
|
return nil, fmt.Errorf("CoCreateInstance(32-bit local server) failed: %w", ole.NewError(hr))
|
||||||
|
}
|
||||||
|
disp, err := unk.QueryInterface(ole.IID_IDispatch)
|
||||||
|
unk.Release()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query IDispatch: %w", err)
|
||||||
|
}
|
||||||
|
return disp, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The COM refusal when OmniRig runs elevated and OpsLog does not (or the reverse)
|
||||||
|
// must be recognised and named. It reached a user as "OmniRig not found" while
|
||||||
|
// OmniRig sat visibly on screen with its settings window open — so the report was
|
||||||
|
// a driver hunt that could never succeed.
|
||||||
|
//
|
||||||
|
// Windows returns this text localised, so the match cannot rely on English alone;
|
||||||
|
// the HRESULT (0x800702E4 = ERROR_ELEVATION_REQUIRED) is accepted too.
|
||||||
|
func TestElevationHint(t *testing.T) {
|
||||||
|
recognised := []string{
|
||||||
|
"L’opération demandée nécessite une élévation.", // as logged, fr-FR
|
||||||
|
"The requested operation requires elevation.",
|
||||||
|
"Access denied",
|
||||||
|
"Accès refusé",
|
||||||
|
"CoCreateInstance failed: 0x800702E4",
|
||||||
|
}
|
||||||
|
for _, msg := range recognised {
|
||||||
|
if elevationHint(errors.New(msg)) == "" {
|
||||||
|
t.Errorf("not recognised as a privilege problem: %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unrelated failures must NOT be blamed on elevation, or the advice sends the
|
||||||
|
// operator to the wrong place just as surely.
|
||||||
|
for _, msg := range []string{
|
||||||
|
"Classe non enregistrée",
|
||||||
|
"REGDB_E_CLASSNOTREG",
|
||||||
|
"no CLSID registered for Omnirig.OmnirigX in either registry view",
|
||||||
|
"open COM3: Access is den", // truncated word must not match "access denied"
|
||||||
|
} {
|
||||||
|
if h := elevationHint(errors.New(msg)); h != "" {
|
||||||
|
t.Errorf("%q wrongly reported as a privilege problem: %s", msg, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if elevationHint(nil) != "" {
|
||||||
|
t.Error("nil error must yield no hint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reconnect loop runs every 5 s forever; a persistent failure must be logged
|
||||||
|
// once, not 1500 times an hour.
|
||||||
|
func TestLogConnFailureOnlyOncePerCause(t *testing.T) {
|
||||||
|
var lines []string
|
||||||
|
prev := LogSink
|
||||||
|
LogSink = func(format string, args ...any) { lines = append(lines, format) }
|
||||||
|
defer func() { LogSink = prev }()
|
||||||
|
|
||||||
|
o := &OmniRig{RigNum: 1}
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
o.logConnFailure("requires elevation")
|
||||||
|
}
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Errorf("logged %d times, want 1", len(lines))
|
||||||
|
}
|
||||||
|
// A DIFFERENT cause must still be reported.
|
||||||
|
o.logConnFailure("class not registered")
|
||||||
|
if len(lines) != 2 {
|
||||||
|
t.Errorf("a new cause was not logged: %d lines", len(lines))
|
||||||
|
}
|
||||||
|
// And after reconnecting, the same cause may be reported again.
|
||||||
|
o.connLogged = ""
|
||||||
|
o.logConnFailure("requires elevation")
|
||||||
|
if len(lines) != 3 {
|
||||||
|
t.Errorf("after a reconnect the cause should log again: %d lines", len(lines))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Every known rig's OmniRig behaviour, pinned. These rules genuinely contradict
|
||||||
|
// each other between models, so a change that fixes one rig must be shown not to
|
||||||
|
// break another — that is what this table is for.
|
||||||
|
func TestResolveOmniRigVFOs(t *testing.T) {
|
||||||
|
const (
|
||||||
|
a14200 = 14200000
|
||||||
|
b14205 = 14205000
|
||||||
|
b21000 = 21000000
|
||||||
|
)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rig string
|
||||||
|
main, fa, fb int64
|
||||||
|
vfo string
|
||||||
|
split int64
|
||||||
|
sticky bool
|
||||||
|
wantTX, wantRX int64
|
||||||
|
wantSplit bool
|
||||||
|
}{
|
||||||
|
// The reported failure: FTDX101D, SUB VFO pressed. OmniRig names VFO B and
|
||||||
|
// reports it as the generic Freq; freqA still holds the main VFO. Preferring
|
||||||
|
// freqA meant the display never followed the operator to B.
|
||||||
|
{"FTDX101D on SUB VFO", "FTDX101D", b14205, a14200, b14205, "B", pmSplitOff, false, b14205, 0, false},
|
||||||
|
{"FTDX101D on MAIN VFO", "FTDX101D", a14200, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
|
||||||
|
|
||||||
|
// Yaesu fallback for a rig file that names NO VFO at all (the stock FTDX10
|
||||||
|
// one, confirmed 2026-07-26): the generic Freq matching FreqB is then the
|
||||||
|
// only sign that the operator is on SUB. It applies ONLY when the enum is
|
||||||
|
// silent — when the enum names a VFO, the enum wins (pair cases below).
|
||||||
|
{"FTDX10, no enum, generic Freq is B", "FTDX10", b14205, a14200, b14205, "", pmSplitOff, false, b14205, 0, false},
|
||||||
|
// Same alternating ini: one poll says OFF while the rig IS in split. The
|
||||||
|
// latched ON flag has to survive the contradicting sample.
|
||||||
|
{"FTDX101D split, contradicting OFF sample", "FTDX101D", a14200, a14200, b14205, "AB", pmSplitOff, true, b14205, a14200, true},
|
||||||
|
// The latch must not manufacture a split out of a stale cross-band VFO B.
|
||||||
|
{"FTDX101D latch, VFOs on different bands", "FTDX101D", a14200, a14200, b21000, "AB", pmSplitOff, true, a14200, 0, false},
|
||||||
|
|
||||||
|
// Pair enums: first letter is the VFO being listened on. Reported by the
|
||||||
|
// whole Yaesu family; ignoring them pinned the display to VFO A (F4NBZ,
|
||||||
|
// 2026-07-26 — Log4OM follows SUB on the same rig through OmniRig).
|
||||||
|
{"pair enum BA, simplex → listening on B", "", b14205, a14200, b14205, "BA", pmSplitOff, false, b14205, 0, false},
|
||||||
|
{"pair enum BB, simplex → listening on B", "", b14205, a14200, b14205, "BB", pmSplitOff, false, b14205, 0, false},
|
||||||
|
{"pair enum AB, simplex → listening on A", "", a14200, a14200, b14205, "AB", pmSplitOff, false, a14200, 0, false},
|
||||||
|
{"pair enum AA, simplex → listening on A", "", a14200, a14200, b14205, "AA", pmSplitOff, false, a14200, 0, false},
|
||||||
|
|
||||||
|
// F4?? IC-7610 report, 2026-07-27: the operator's custom rig file declares
|
||||||
|
// VFO B permanently while they work on the MAIN VFO. OpsLog then wrote to A
|
||||||
|
// (SetSimplexMode acts on main) and read B — "the frequency never follows
|
||||||
|
// the knob". Honouring the enum is right in general, which is why the cure
|
||||||
|
// is an explicit override in the settings, applied before this function.
|
||||||
|
{"IC-7610 file wrongly says B — enum honoured, hence the override", "IC-7610", 0, 7150000, 14295000, "B", pmSplitOff, false, 14295000, 0, false},
|
||||||
|
{"same rig with the override forcing A", "IC-7610", 0, 7150000, 14295000, "A", pmSplitOff, false, 7150000, 0, false},
|
||||||
|
|
||||||
|
// Non-regression: a rig that does not report the VFO enum keeps the old
|
||||||
|
// order — freqA, then the generic Freq, then freqB.
|
||||||
|
{"no VFO enum, freqA populated (Yaesu/Kenwood)", "FT-891", a14200, a14200, 0, "", pmSplitOff, false, a14200, 0, false},
|
||||||
|
{"no VFO enum, only generic Freq (IC-9100)", "IC-9100", a14200, 0, 0, "", pmSplitOff, false, a14200, 0, false},
|
||||||
|
{"IC-7610: generic Freq reports B, enum says A", "IC-7610", b14205, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
|
||||||
|
|
||||||
|
// Split: PM_SPLITON must be read as a BIT. An exact == 0x8000 reported "no
|
||||||
|
// split" for any rig that sets the flag alongside another bit.
|
||||||
|
{"split, ON flag alone", "", a14200, a14200, b14205, "AB", pmSplitOn, false, b14205, a14200, true},
|
||||||
|
{"split, ON flag with extra bits set", "", a14200, a14200, b14205, "AB", pmSplitOn | 0x40, false, b14205, a14200, true},
|
||||||
|
{"listening on B → TX on A", "", b14205, a14200, b14205, "BA", pmSplitOn, false, a14200, b14205, true},
|
||||||
|
|
||||||
|
// Split must NOT be inferred when the rig says OFF, nor from a stale VFO B
|
||||||
|
// left on another band (the FT-710 / TS-570 false positive).
|
||||||
|
{"OFF flag, two distinct VFOs", "", a14200, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
|
||||||
|
{"ON flag but VFOs on different bands", "", a14200, a14200, b21000, "AB", pmSplitOn, false, a14200, 0, false},
|
||||||
|
{"ON flag but both VFOs identical", "", a14200, a14200, a14200, "AB", pmSplitOn, false, a14200, 0, false},
|
||||||
|
{"ON and OFF both set — ambiguous, treat as no split", "", a14200, a14200, b14205, "A", pmSplitOn | pmSplitOff, false, a14200, 0, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
tx, rx, split := resolveOmniRigVFOs(c.rig, c.main, c.fa, c.fb, c.vfo, c.split, c.sticky)
|
||||||
|
if tx != c.wantTX || rx != c.wantRX || split != c.wantSplit {
|
||||||
|
t.Errorf("%s:\n got TX=%d RX=%d split=%v\n want TX=%d RX=%d split=%v",
|
||||||
|
c.name, tx, rx, split, c.wantTX, c.wantRX, c.wantSplit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which VFO a frequency set writes to, and whether it may call SetSimplexMode.
|
||||||
|
//
|
||||||
|
// Reported on an FTDX101 (F4NBZ, 2026-07-29): listening on SUB, clicking a
|
||||||
|
// cluster spot dragged the radio back to MAIN. SetSimplexMode means "receive and
|
||||||
|
// transmit here, simplex" and OmniRig applies it to the MAIN VFO — so it must
|
||||||
|
// not be used when the operator is on SUB. They chose that VFO deliberately; a
|
||||||
|
// spot click asks for a frequency, not for a VFO change.
|
||||||
|
func TestOmniRigWriteTarget(t *testing.T) {
|
||||||
|
// Mirrors the decision made in SetFrequency.
|
||||||
|
target := func(vfo string) (prop string, simplexAllowed bool) {
|
||||||
|
onSub := vfo == "B" || vfo == "BB" || vfo == "BA"
|
||||||
|
if onSub {
|
||||||
|
return "FreqB", false
|
||||||
|
}
|
||||||
|
return "FreqA", true
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
vfo string
|
||||||
|
prop string
|
||||||
|
simplex bool
|
||||||
|
}{
|
||||||
|
// On the main VFO nothing changes: SetSimplexMode is what moves the Icoms
|
||||||
|
// whose direct writes are ignored.
|
||||||
|
{"", "FreqA", true},
|
||||||
|
{"A", "FreqA", true},
|
||||||
|
{"AA", "FreqA", true},
|
||||||
|
{"AB", "FreqA", true},
|
||||||
|
// On SUB, write FreqB and leave the VFO selection alone.
|
||||||
|
{"B", "FreqB", false},
|
||||||
|
{"BB", "FreqB", false},
|
||||||
|
{"BA", "FreqB", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
prop, simplex := target(c.vfo)
|
||||||
|
if prop != c.prop || simplex != c.simplex {
|
||||||
|
t.Errorf("VFO %q → write %s, simplex=%v; want %s, simplex=%v",
|
||||||
|
c.vfo, prop, simplex, c.prop, c.simplex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// Native Xiegu CAT (G90, X6100, X6200, X5105 and relatives) over the rig's
|
||||||
|
// serial/USB port.
|
||||||
|
//
|
||||||
|
// Xiegu speaks CI-V — Icom's bus protocol — with a REDUCED command set. Frames,
|
||||||
|
// BCD encoding, addressing and the opcodes for frequency (0x03/0x05), mode
|
||||||
|
// (0x04/0x06), PTT (0x1C 0x00), split (0x0F) and the meters (0x15) are the same
|
||||||
|
// as Icom's, which is why this backend reuses internal/cat/civ wholesale rather
|
||||||
|
// than re-deriving it.
|
||||||
|
//
|
||||||
|
// It is a SEPARATE backend rather than the Icom one with another address,
|
||||||
|
// because what the two rigs DON'T share is the important part. The Icom backend
|
||||||
|
// reads the spectrum scope, the DSP block, data-mode via 0x1A 0x06, the model id
|
||||||
|
// via 0x19 — none of which a Xiegu implements. Pointed at a G90 it would poll
|
||||||
|
// for answers that never come on every cycle, and its silence tolerance would
|
||||||
|
// spend itself on commands the radio was never going to support.
|
||||||
|
//
|
||||||
|
// Mode differences that matter: the Xiegu table lists LSB, USB, AM, CW and CWR
|
||||||
|
// only — no FM, no RTTY, and no data mode. A digital QSO therefore runs in USB
|
||||||
|
// (which is what the operator does on the radio anyway), and the mode is
|
||||||
|
// reported as the operator's configured digital mode when they select one, not
|
||||||
|
// invented from the rig.
|
||||||
|
//
|
||||||
|
// Verified on: nothing yet — written from the Xiegu CI-V command table. The
|
||||||
|
// command table published by Xiegu has rows that clearly slipped during
|
||||||
|
// typesetting (0x07 and 0x0F share a block), so where it contradicts itself the
|
||||||
|
// Icom meaning is used, since the rest of the table matches Icom exactly. Every
|
||||||
|
// unexpected reply is logged raw so a first on-air run settles it.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
|
||||||
|
"hamlog/internal/cat/civ"
|
||||||
|
)
|
||||||
|
|
||||||
|
// XieguDefaultAddr is the factory CI-V address of the G90/X6100 family.
|
||||||
|
const XieguDefaultAddr = 0x70
|
||||||
|
|
||||||
|
type Xiegu struct {
|
||||||
|
portName string
|
||||||
|
baud int
|
||||||
|
rigAddr byte
|
||||||
|
digital string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
port serial.Port
|
||||||
|
|
||||||
|
curFreq int64
|
||||||
|
// splitSupported is cleared when the rig ignores the split query, so we stop
|
||||||
|
// asking every cycle — a Xiegu that has no split must not cost a timeout per
|
||||||
|
// poll, which would slow the whole loop to a crawl.
|
||||||
|
splitSupported bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = 19200 // G90 factory default
|
||||||
|
}
|
||||||
|
if addr <= 0 || addr > 0xFF {
|
||||||
|
addr = XieguDefaultAddr
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(digital) == "" {
|
||||||
|
digital = "FT8"
|
||||||
|
}
|
||||||
|
return &Xiegu{
|
||||||
|
portName: strings.TrimSpace(portName), baud: baud,
|
||||||
|
rigAddr: byte(addr), digital: digital, splitSupported: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Xiegu) Name() string { return "xiegu" }
|
||||||
|
|
||||||
|
func (x *Xiegu) Connect() error {
|
||||||
|
x.mu.Lock()
|
||||||
|
defer x.mu.Unlock()
|
||||||
|
if x.portName == "" {
|
||||||
|
return fmt.Errorf("xiegu: no serial port configured")
|
||||||
|
}
|
||||||
|
p, err := serial.Open(x.portName, &serial.Mode{BaudRate: x.baud})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
|
||||||
|
}
|
||||||
|
p.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
x.port = p
|
||||||
|
x.splitSupported = true
|
||||||
|
|
||||||
|
// Prove the link before declaring success: an open COM port says nothing
|
||||||
|
// about a radio being on the other end, and a backend that reports
|
||||||
|
// "connected" to a powered-off rig sends the operator hunting for a fault in
|
||||||
|
// the wrong place.
|
||||||
|
if _, err := x.readFreq(); err != nil {
|
||||||
|
_ = p.Close()
|
||||||
|
x.port = nil
|
||||||
|
return fmt.Errorf("xiegu: no answer on %s @ %d baud (address 0x%02X): %w", x.portName, x.baud, x.rigAddr, err)
|
||||||
|
}
|
||||||
|
debugLog.Printf("xiegu: connected on %s @ %d baud, CI-V address 0x%02X", x.portName, x.baud, x.rigAddr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Xiegu) Disconnect() {
|
||||||
|
x.mu.Lock()
|
||||||
|
defer x.mu.Unlock()
|
||||||
|
if x.port != nil {
|
||||||
|
_ = x.port.Close()
|
||||||
|
x.port = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Xiegu) ReadState() (RigState, error) {
|
||||||
|
x.mu.Lock()
|
||||||
|
defer x.mu.Unlock()
|
||||||
|
if x.port == nil {
|
||||||
|
return RigState{}, fmt.Errorf("xiegu: not connected")
|
||||||
|
}
|
||||||
|
s := RigState{Backend: x.Name(), Connected: true, Rig: "Xiegu"}
|
||||||
|
|
||||||
|
hz, err := x.readFreq()
|
||||||
|
if err != nil {
|
||||||
|
return RigState{}, err // let the Manager reconnect
|
||||||
|
}
|
||||||
|
s.FreqHz = hz
|
||||||
|
x.curFreq = hz
|
||||||
|
|
||||||
|
if d, err := x.ask(civ.CmdReadMode); err == nil && len(d.Data) >= 1 {
|
||||||
|
s.Mode = civ.ModeToADIF(d.Data[0], false)
|
||||||
|
// The rig has no data mode, so it reports USB on the digital watering
|
||||||
|
// holes. Naming the operator's digital mode there is the frontend's job
|
||||||
|
// (it infers from frequency); reporting USB honestly is ours.
|
||||||
|
}
|
||||||
|
|
||||||
|
if x.splitSupported {
|
||||||
|
d, err := x.ask(civ.CmdSplit)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
debugLog.Printf("xiegu: split query got no answer (%v) — not asking again this session", err)
|
||||||
|
x.splitSupported = false
|
||||||
|
case len(d.Data) >= 1:
|
||||||
|
s.Split = d.Data[0] == 0x01
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Split TX frequency is deliberately NOT reported. Reading the unselected
|
||||||
|
// VFO needs 0x25, which the Xiegu table does not list — and a split flag with
|
||||||
|
// a wrong TX frequency is worse than a split flag alone, because it is the
|
||||||
|
// frequency that gets logged.
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Xiegu) SetFrequency(hz int64) error {
|
||||||
|
x.mu.Lock()
|
||||||
|
defer x.mu.Unlock()
|
||||||
|
if x.port == nil {
|
||||||
|
return fmt.Errorf("xiegu: not connected")
|
||||||
|
}
|
||||||
|
if hz <= 0 {
|
||||||
|
return fmt.Errorf("xiegu: invalid frequency %d", hz)
|
||||||
|
}
|
||||||
|
payload := append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)
|
||||||
|
return x.send(payload...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Xiegu) SetMode(mode string) error {
|
||||||
|
x.mu.Lock()
|
||||||
|
defer x.mu.Unlock()
|
||||||
|
if x.port == nil {
|
||||||
|
return fmt.Errorf("xiegu: not connected")
|
||||||
|
}
|
||||||
|
m, ok := xieguModeByte(mode, x.curFreq)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("xiegu: no CAT mode for %q", mode)
|
||||||
|
}
|
||||||
|
return x.send(civ.CmdSetMode, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Xiegu) SetPTT(on bool) error {
|
||||||
|
x.mu.Lock()
|
||||||
|
defer x.mu.Unlock()
|
||||||
|
if x.port == nil {
|
||||||
|
return fmt.Errorf("xiegu: not connected")
|
||||||
|
}
|
||||||
|
v := byte(0x00)
|
||||||
|
if on {
|
||||||
|
v = 0x01
|
||||||
|
}
|
||||||
|
return x.send(civ.CmdPTT, 0x00, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (x *Xiegu) send(payload ...byte) error {
|
||||||
|
if x.port == nil {
|
||||||
|
return fmt.Errorf("xiegu: not connected")
|
||||||
|
}
|
||||||
|
_, err := x.port.Write(civ.Frame(x.rigAddr, civ.AddrController, payload...))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask sends a query and returns the rig's answer frame.
|
||||||
|
//
|
||||||
|
// CI-V is a shared bus: the rig echoes back what we sent before answering, so
|
||||||
|
// our own frame has to be skipped. Matching on the SENDER (From == the rig)
|
||||||
|
// rather than on position is what makes this robust when an echo is dropped or
|
||||||
|
// an unsolicited frame arrives from the dial being turned.
|
||||||
|
func (x *Xiegu) ask(payload ...byte) (civ.Decoded, error) {
|
||||||
|
if err := x.send(payload...); err != nil {
|
||||||
|
return civ.Decoded{}, err
|
||||||
|
}
|
||||||
|
buf := make([]byte, 0, 64)
|
||||||
|
tmp := make([]byte, 64)
|
||||||
|
deadline := time.Now().Add(600 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := x.port.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return civ.Decoded{}, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf = append(buf, tmp[:n]...)
|
||||||
|
frames, consumed := civ.Scan(buf)
|
||||||
|
buf = buf[consumed:]
|
||||||
|
for _, f := range frames {
|
||||||
|
if f.From != x.rigAddr {
|
||||||
|
continue // our own echo on the bus
|
||||||
|
}
|
||||||
|
if f.Cmd == 0xFA {
|
||||||
|
return civ.Decoded{}, fmt.Errorf("xiegu: rig rejected command 0x%02X", payload[0])
|
||||||
|
}
|
||||||
|
if f.Cmd == payload[0] {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
// An unsolicited update (the operator turning the dial) — useful, but
|
||||||
|
// not the answer we asked for.
|
||||||
|
debugLog.Printf("xiegu: unsolicited frame cmd=0x%02X data=% X", f.Cmd, f.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return civ.Decoded{}, fmt.Errorf("xiegu: timeout answering 0x%02X", payload[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Xiegu) readFreq() (int64, error) {
|
||||||
|
d, err := x.ask(civ.CmdReadFreq)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
hz, ok := civ.BCDToFreq(d.Data)
|
||||||
|
if !ok || hz <= 0 {
|
||||||
|
return 0, fmt.Errorf("xiegu: unusable frequency % X", d.Data)
|
||||||
|
}
|
||||||
|
return hz, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// xieguModeByte maps an ADIF mode to the Xiegu's mode byte.
|
||||||
|
//
|
||||||
|
// The rig has LSB, USB, AM, CW and CWR — nothing else. A digital mode therefore
|
||||||
|
// becomes USB (or LSB below 10 MHz), which is what the operator selects on the
|
||||||
|
// radio; claiming a DATA mode it does not have would just be refused.
|
||||||
|
func xieguModeByte(mode string, freqHz int64) (byte, bool) {
|
||||||
|
lowBand := freqHz > 0 && freqHz < 10_000_000
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "":
|
||||||
|
return 0, false
|
||||||
|
case "LSB":
|
||||||
|
return 0x00, true
|
||||||
|
case "USB":
|
||||||
|
return 0x01, true
|
||||||
|
case "SSB":
|
||||||
|
if lowBand {
|
||||||
|
return 0x00, true
|
||||||
|
}
|
||||||
|
return 0x01, true
|
||||||
|
case "AM":
|
||||||
|
return 0x02, true
|
||||||
|
case "CW":
|
||||||
|
return 0x03, true
|
||||||
|
case "CWR", "CW-R":
|
||||||
|
return 0x07, true
|
||||||
|
default:
|
||||||
|
// Every digital sub-mode rides on plain sideband here.
|
||||||
|
if lowBand {
|
||||||
|
return 0x00, true
|
||||||
|
}
|
||||||
|
return 0x01, true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/cat/civ"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Xiegu mode table is SHORTER than Icom's: LSB, USB, AM, CW, CWR and
|
||||||
|
// nothing else. The interesting cases are the ones with no equivalent — a
|
||||||
|
// digital mode must land on plain sideband rather than be refused, because
|
||||||
|
// refusing it would leave the rig on whatever it was and silently log the wrong
|
||||||
|
// mode.
|
||||||
|
func TestXieguModeByte(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
mode string
|
||||||
|
hz int64
|
||||||
|
want byte
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"SSB", 7150000, 0x00, true}, // LSB below 10 MHz
|
||||||
|
{"SSB", 14250000, 0x01, true}, // USB above
|
||||||
|
{"LSB", 14250000, 0x00, true}, // explicit beats the convention
|
||||||
|
{"USB", 7150000, 0x01, true},
|
||||||
|
{"AM", 7150000, 0x02, true},
|
||||||
|
{"CW", 7030000, 0x03, true},
|
||||||
|
{"CWR", 7030000, 0x07, true},
|
||||||
|
{"FT8", 7074000, 0x00, true}, // no data mode on this rig → LSB
|
||||||
|
{"FT8", 14074000, 0x01, true}, // → USB
|
||||||
|
{"RTTY", 14080000, 0x01, true},
|
||||||
|
{"FM", 145000000, 0x01, true}, // no FM either; sideband is the honest fallback
|
||||||
|
{"", 14074000, 0x00, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := xieguModeByte(c.mode, c.hz)
|
||||||
|
if got != c.want || ok != c.ok {
|
||||||
|
t.Errorf("xieguModeByte(%q, %d) = 0x%02X,%v — want 0x%02X,%v", c.mode, c.hz, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Xiegu mode bytes must decode through the shared Icom table, since that is
|
||||||
|
// the whole reason this backend reuses internal/cat/civ instead of its own
|
||||||
|
// codec. If the two ever disagree, the radio would be set to one mode and read
|
||||||
|
// back as another.
|
||||||
|
func TestXieguModesRoundTripThroughCIV(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
mode string
|
||||||
|
adif string
|
||||||
|
}{
|
||||||
|
// ADIF has no LSB/USB distinction — both sidebands ARE the SSB mode, and
|
||||||
|
// that is what gets logged. The sideband still matters to the radio, which
|
||||||
|
// is why xieguModeByte picks it from the frequency.
|
||||||
|
{"LSB", "SSB"},
|
||||||
|
{"USB", "SSB"},
|
||||||
|
{"AM", "AM"},
|
||||||
|
{"CW", "CW"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
b, ok := xieguModeByte(c.mode, 14200000)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("xieguModeByte(%q) refused", c.mode)
|
||||||
|
}
|
||||||
|
if got := civ.ModeToADIF(b, false); got != c.adif {
|
||||||
|
t.Errorf("%s → 0x%02X → %q, want %q", c.mode, b, got, c.adif)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequency encoding is shared with Icom, and it is the one field where a byte
|
||||||
|
// out of place mistunes the radio silently. Pinned here at the boundaries a
|
||||||
|
// Xiegu actually covers (it is an HF rig, so the 5-byte BCD upper bytes are 0).
|
||||||
|
func TestXieguFrequencyEncoding(t *testing.T) {
|
||||||
|
for _, hz := range []int64{1840000, 7074000, 14074000, 28500000, 50313000} {
|
||||||
|
b := civ.FreqToBCD(hz)
|
||||||
|
if len(b) != 5 {
|
||||||
|
t.Fatalf("FreqToBCD(%d) produced %d bytes, want 5", hz, len(b))
|
||||||
|
}
|
||||||
|
got, ok := civ.BCDToFreq(b)
|
||||||
|
if !ok || got != hz {
|
||||||
|
t.Errorf("round trip %d → % X → %d (ok=%v)", hz, b, got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// Native Yaesu CAT over the rig's serial/USB port — no OmniRig.
|
||||||
|
//
|
||||||
|
// Why this exists: OmniRig sits between OpsLog and the radio and adds its own
|
||||||
|
// rig-description files, its own VFO/split interpretation and its own polling.
|
||||||
|
// Every Yaesu problem reported so far came from that layer disagreeing with the
|
||||||
|
// radio — a .ini that never exposes the VFO, a Freq property that means A on one
|
||||||
|
// model and B on another, a split flag that alternates. Talking to the rig
|
||||||
|
// directly removes the disagreement: what the radio answers is what we show.
|
||||||
|
//
|
||||||
|
// ── The protocol ──────────────────────────────────────────────────────────
|
||||||
|
// Modern Yaesu CAT is plain ASCII: a command, its arguments, and a ';'
|
||||||
|
// terminator. A query is the command with no argument; the rig echoes the same
|
||||||
|
// command with the value. It is the same shape as Kenwood's, which is why an
|
||||||
|
// FTDX10 answers a Kenwood-speaking logger for the basics.
|
||||||
|
//
|
||||||
|
// FA; → FA014074000; VFO A frequency, 9 digits, Hz
|
||||||
|
// FB; → FB014100000; VFO B frequency
|
||||||
|
// MD0;/MD1; → MD02; operating mode — 0 = main receiver, 1 = sub
|
||||||
|
// FR; → FR1; RECEIVE VFO (0=main/A, 1=sub/B)
|
||||||
|
// VS; → VS0; selected VFO, on models without FR
|
||||||
|
// ST; → ST1; split (FTDX10/FTDX101)
|
||||||
|
// FT; → FT1; TX VFO (FT-991A/FT-710/FT-891 family)
|
||||||
|
// TX1; / TX0; key / unkey
|
||||||
|
// ID; → ID0761; model identifier
|
||||||
|
//
|
||||||
|
// Two of these are genuinely uncertain across the family and are treated as
|
||||||
|
// such rather than guessed at: SPLIT is read through ST and, if the rig does not
|
||||||
|
// answer that, through FT — whichever replies wins, and the choice is
|
||||||
|
// remembered. Every unrecognised reply is logged raw, because that log is the
|
||||||
|
// only way to learn a model's real behaviour from an operator's shack.
|
||||||
|
//
|
||||||
|
// Verified on: FTDX10, 2026-07-29 — frequency, mode, VFO and split all correct
|
||||||
|
// against the radio. The other models are still inference from the same CAT
|
||||||
|
// reference; anything this file asserts about a rig it has not met should be
|
||||||
|
// read as a hypothesis with a log line attached.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// yaesuModels maps the ID reply to a display name. An unknown id is shown as
|
||||||
|
// itself rather than guessed — a wrong model name would be worse than a number,
|
||||||
|
// because it silently implies capabilities the rig may not have.
|
||||||
|
var yaesuModels = map[string]string{
|
||||||
|
"0761": "FTDX10",
|
||||||
|
"0681": "FTDX101D",
|
||||||
|
"0682": "FTDX101MP",
|
||||||
|
"0800": "FT-710",
|
||||||
|
"0570": "FT-991A",
|
||||||
|
"0650": "FT-891",
|
||||||
|
"0670": "FT-DX3000",
|
||||||
|
"0460": "FT-450D",
|
||||||
|
}
|
||||||
|
|
||||||
|
// yaesuModeToADIF maps the MD digit to an ADIF mode. The DATA and RTTY variants
|
||||||
|
// differ only by sideband, which ADIF does not record — they collapse to the
|
||||||
|
// operator's configured digital mode and to RTTY respectively.
|
||||||
|
var yaesuModeToADIF = map[byte]string{
|
||||||
|
'1': "LSB",
|
||||||
|
'2': "USB",
|
||||||
|
'3': "CW",
|
||||||
|
'4': "FM",
|
||||||
|
'5': "AM",
|
||||||
|
'6': "RTTY",
|
||||||
|
'7': "CW",
|
||||||
|
'8': "DATA",
|
||||||
|
'9': "RTTY",
|
||||||
|
'A': "FM",
|
||||||
|
'B': "FM",
|
||||||
|
'C': "DATA",
|
||||||
|
'D': "AM",
|
||||||
|
'E': "FM", // C4FM — digital voice, closest ADIF sense is FM
|
||||||
|
}
|
||||||
|
|
||||||
|
type Yaesu struct {
|
||||||
|
portName string
|
||||||
|
baud int
|
||||||
|
digital string // ADIF mode reported for DATA (FT8, RTTY…)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
port serial.Port
|
||||||
|
|
||||||
|
model string
|
||||||
|
// splitCmd is learned at connect: "ST" or "FT" depending on which the rig
|
||||||
|
// answers. Empty means the rig answered neither, and split is reported as
|
||||||
|
// off rather than invented.
|
||||||
|
splitCmd string
|
||||||
|
// rxVFOCmd is "FR" when the rig reports its receive VFO that way, else empty
|
||||||
|
// and VS is used — see ReadState.
|
||||||
|
rxVFOCmd string
|
||||||
|
|
||||||
|
curFreq int64
|
||||||
|
curRXFreq int64
|
||||||
|
curVFO string // "A" or "B"
|
||||||
|
|
||||||
|
// Control-panel state and its slow-beat counter — see yaesu_panel.go.
|
||||||
|
panel YaesuTXState
|
||||||
|
panelCycle int
|
||||||
|
panelLoaded bool
|
||||||
|
// Commands this rig answered "?;" to — asked once, then never again.
|
||||||
|
unsupported map[string]bool
|
||||||
|
// Needle inertia for the TX meters — see meterPeak.
|
||||||
|
powerPeak meterPeak
|
||||||
|
powerWPeak meterPeak
|
||||||
|
swrPeak meterPeak
|
||||||
|
// metersLogged counts the RM1..RM6 samples taken during transmission, so the
|
||||||
|
// survey follows a real carrier instead of catching one instant of it.
|
||||||
|
metersLogged int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewYaesu(portName string, baud int, digital string) *Yaesu {
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = 38400 // FTDX10/FTDX101 factory default
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(digital) == "" {
|
||||||
|
digital = "FT8"
|
||||||
|
}
|
||||||
|
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *Yaesu) Name() string { return "yaesu" }
|
||||||
|
|
||||||
|
func (y *Yaesu) Connect() error {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
if y.portName == "" {
|
||||||
|
return fmt.Errorf("yaesu: no serial port configured")
|
||||||
|
}
|
||||||
|
// Close any handle we still hold before opening another.
|
||||||
|
//
|
||||||
|
// Connect is called again on every reconnect, and it used to overwrite y.port
|
||||||
|
// with a fresh handle and leak the old one. Windows opens a serial port
|
||||||
|
// EXCLUSIVELY, so a leaked handle makes the next open fail with "Serial port
|
||||||
|
// busy" — seen in the field — and the retry loop then leaks one handle per
|
||||||
|
// attempt, once every few seconds, for as long as it keeps failing.
|
||||||
|
if y.port != nil {
|
||||||
|
_ = y.port.Close()
|
||||||
|
y.port = nil
|
||||||
|
}
|
||||||
|
p, err := serial.Open(y.portName, &serial.Mode{BaudRate: y.baud})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
|
||||||
|
}
|
||||||
|
p.SetReadTimeout(300 * time.Millisecond)
|
||||||
|
y.port = p
|
||||||
|
|
||||||
|
// Silence unsolicited status reports. The rig can push them on every knob
|
||||||
|
// movement (AI1), which interleaves with our request/response pairs and makes
|
||||||
|
// a reply impossible to attribute — we poll instead, so the traffic is ours.
|
||||||
|
_ = y.write("AI0;")
|
||||||
|
|
||||||
|
answered := false
|
||||||
|
if id, err := y.ask("ID;"); err == nil {
|
||||||
|
answered = true
|
||||||
|
code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
|
||||||
|
if name, ok := yaesuModels[code]; ok {
|
||||||
|
y.model = name
|
||||||
|
} else {
|
||||||
|
y.model = "Yaesu (" + code + ")"
|
||||||
|
debugLog.Printf("yaesu: unknown model id %q — add it to yaesuModels", code)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("yaesu: ID query failed (%v) — continuing, the model name is cosmetic", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which command carries split on THIS rig. Asking once at connect and
|
||||||
|
// remembering the answer keeps the poll loop from paying for two round trips
|
||||||
|
// per cycle, and makes "neither answered" an explicit, logged state instead
|
||||||
|
// of a silent assumption that split is off.
|
||||||
|
// Which command reports the RECEIVE VFO. FR is the right one where it exists;
|
||||||
|
// VS is a weaker substitute that an FTDX101 answers with the main VFO even
|
||||||
|
// when the operator has moved both RX and TX to sub.
|
||||||
|
if r, err := y.ask("FR;"); err == nil && strings.HasPrefix(r, "FR") {
|
||||||
|
answered = true
|
||||||
|
y.rxVFOCmd = "FR"
|
||||||
|
debugLog.Printf("yaesu: receive VFO is read through FR (answered %q)", r)
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("yaesu: no FR; — falling back to VS for the receive VFO")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which command carries split — and the ORDER matters.
|
||||||
|
//
|
||||||
|
// ST is a bare flag: it says "split", not which VFO transmits. On an FTDX101
|
||||||
|
// with RX and TX both moved to SUB, the rig reports ST1 even though that is
|
||||||
|
// plain simplex on the sub VFO, and OpsLog showed split with the main
|
||||||
|
// frequency as TX (F4NBZ, 2026-07-29). FT names the TRANSMIT VFO, so where the
|
||||||
|
// receive VFO is also known (FR), "split" can be derived from the two: it is
|
||||||
|
// on when they differ. That is a statement about the rig's actual state rather
|
||||||
|
// than a flag whose meaning varies by model, so FT is asked FIRST when FR
|
||||||
|
// answered, and ST remains the fallback for rigs without either.
|
||||||
|
splitProbes := []string{"ST", "FT"}
|
||||||
|
if y.rxVFOCmd != "" {
|
||||||
|
splitProbes = []string{"FT", "ST"}
|
||||||
|
}
|
||||||
|
for _, c := range splitProbes {
|
||||||
|
if r, err := y.ask(c + ";"); err == nil && strings.HasPrefix(r, c) {
|
||||||
|
answered = true
|
||||||
|
y.splitCmd = c
|
||||||
|
debugLog.Printf("yaesu: split is read through %s (answered %q)", c, r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if y.splitCmd == "" {
|
||||||
|
debugLog.Printf("yaesu: neither ST; nor FT; answered — split will be reported as OFF. Send this log if the rig does have split.")
|
||||||
|
}
|
||||||
|
// A port that opens is not a rig that is there.
|
||||||
|
//
|
||||||
|
// The log used to announce "connected … model=FTDX101D" after every probe had
|
||||||
|
// timed out, with the model left over from the previous session — an operator
|
||||||
|
// whose radio was simply switched OFF got a line saying it was connected, and
|
||||||
|
// the real cause took a log study to find. Say what actually happened.
|
||||||
|
if !answered {
|
||||||
|
y.model = ""
|
||||||
|
debugLog.Printf("yaesu: %s opens at %d baud but the rig answers nothing — is it powered on, and is its CAT rate %d?",
|
||||||
|
y.portName, y.baud, y.baud)
|
||||||
|
return fmt.Errorf("yaesu: %s opened but the rig is not answering — check that it is switched on", y.portName)
|
||||||
|
}
|
||||||
|
debugLog.Printf("yaesu: connected on %s @ %d baud, model=%q", y.portName, y.baud, y.model)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *Yaesu) Disconnect() {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
if y.port != nil {
|
||||||
|
_ = y.port.Close()
|
||||||
|
y.port = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *Yaesu) ReadState() (RigState, error) {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
if y.port == nil {
|
||||||
|
return RigState{}, fmt.Errorf("yaesu: not connected")
|
||||||
|
}
|
||||||
|
s := RigState{Backend: y.Name(), Connected: true, Rig: y.model}
|
||||||
|
|
||||||
|
faRaw, err := y.ask("FA;")
|
||||||
|
if errors.Is(err, errYaesuUnsupported) {
|
||||||
|
// A "?;" here is almost never about FA — the rig answers frequency queries
|
||||||
|
// perfectly well. It is a rejection left over from the PREVIOUS command
|
||||||
|
// that our read then attributed to this one. Retrying once costs a few
|
||||||
|
// milliseconds; treating it as "lost the rig" tore the CAT link down and
|
||||||
|
// reconnected it, which is what the operator saw on every CW macro.
|
||||||
|
debugLog.Printf("yaesu: FA; got a stray rejection — retrying once")
|
||||||
|
faRaw, err = y.ask("FA;")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return RigState{}, err // the rig stopped answering — let the Manager reconnect
|
||||||
|
}
|
||||||
|
freqA, ok := parseYaesuFreq(faRaw, "FA")
|
||||||
|
if !ok {
|
||||||
|
return RigState{}, fmt.Errorf("yaesu: unparsable FA reply %q", faRaw)
|
||||||
|
}
|
||||||
|
freqB := int64(0)
|
||||||
|
if r, err := y.ask("FB;"); err == nil {
|
||||||
|
freqB, _ = parseYaesuFreq(r, "FB")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which VFO the operator is LISTENING on.
|
||||||
|
//
|
||||||
|
// FR is the command that answers that — it selects the receive VFO — and VS
|
||||||
|
// does not: on an FTDX101 with both RX and TX moved to SUB, VS still reported
|
||||||
|
// the main VFO, so OpsLog displayed VFO A while the operator was entirely on
|
||||||
|
// B. Reported 2026-07-29. FR is asked first and VS is the fallback for models
|
||||||
|
// that do not implement it.
|
||||||
|
vfo := "A"
|
||||||
|
switch {
|
||||||
|
case y.rxVFOCmd != "":
|
||||||
|
if r, err := y.ask(y.rxVFOCmd + ";"); err == nil && yaesuStateDigit(r, y.rxVFOCmd) == '1' {
|
||||||
|
vfo = "B"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if r, err := y.ask("VS;"); err == nil && yaesuStateDigit(r, "VS") == '1' {
|
||||||
|
vfo = "B"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
y.curVFO = vfo
|
||||||
|
|
||||||
|
split := false
|
||||||
|
if y.splitCmd != "" {
|
||||||
|
if r, err := y.ask(y.splitCmd + ";"); err == nil {
|
||||||
|
split = yaesuSplitFromReply(r, y.splitCmd, vfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Vfo = vfo
|
||||||
|
s.FreqHz, s.RxFreqHz, s.Split = resolveYaesuVFOs(freqA, freqB, vfo, split)
|
||||||
|
y.curFreq = s.FreqHz
|
||||||
|
// The frequency being LISTENED to, which is what a split offset is measured
|
||||||
|
// from — under split that is RxFreqHz, not FreqHz.
|
||||||
|
y.curRXFreq = s.FreqHz
|
||||||
|
if s.Split && s.RxFreqHz > 0 {
|
||||||
|
y.curRXFreq = s.RxFreqHz
|
||||||
|
}
|
||||||
|
|
||||||
|
if r, err := y.ask("MD" + y.modeVFOSuffix() + ";"); err == nil && len(r) >= 4 {
|
||||||
|
// Keep the RAW mode too: ADIF folds CW-U/CW-L and DATA-U/DATA-L together,
|
||||||
|
// but the panel has to show which sideband the rig is actually on.
|
||||||
|
y.panel.RawMode = yaesuRawModeName(r[3])
|
||||||
|
if m, ok := yaesuModeToADIF[r[3]]; ok {
|
||||||
|
if m == "DATA" {
|
||||||
|
m = y.digital
|
||||||
|
}
|
||||||
|
s.Mode = m
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("yaesu: unknown mode reply %q", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// s.FreqHz is the TX frequency by the ADIF convention, so it IS the split
|
||||||
|
// transmit frequency when split is on.
|
||||||
|
y.readPanel(s.Mode, s.Split, s.FreqHz)
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *Yaesu) SetFrequency(hz int64) error {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
if y.port == nil {
|
||||||
|
return fmt.Errorf("yaesu: not connected")
|
||||||
|
}
|
||||||
|
if hz <= 0 || hz > 999_999_999 {
|
||||||
|
return fmt.Errorf("yaesu: frequency %d out of the 9-digit CAT range", hz)
|
||||||
|
}
|
||||||
|
// Write to the VFO the operator is ACTUALLY on. Always writing FA is what
|
||||||
|
// makes a display disagree with the radio when the operator is on B.
|
||||||
|
cmd := "FA"
|
||||||
|
if y.curVFO == "B" {
|
||||||
|
cmd = "FB"
|
||||||
|
}
|
||||||
|
return y.write(fmt.Sprintf("%s%09d;", cmd, hz))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *Yaesu) SetMode(mode string) error {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
if y.port == nil {
|
||||||
|
return fmt.Errorf("yaesu: not connected")
|
||||||
|
}
|
||||||
|
d := yaesuModeDigit(mode, y.curFreq)
|
||||||
|
if d == 0 {
|
||||||
|
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
|
||||||
|
}
|
||||||
|
return y.write(fmt.Sprintf("MD%s%c;", y.modeVFOSuffix(), d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeVFOSuffix picks which receiver the mode commands address: "0" is main,
|
||||||
|
// "1" is sub.
|
||||||
|
//
|
||||||
|
// Both the read and the write used 0 unconditionally. A spot click already tuned
|
||||||
|
// the right VFO — that part was fixed — but then set the MODE on main, so an
|
||||||
|
// operator working from the sub receiver had a spot change the mode of the VFO
|
||||||
|
// they were not using and leave the one they were on untouched (F4NBZ,
|
||||||
|
// 2026-07-29). The rig reads the same way, so the panel showed main's mode too.
|
||||||
|
//
|
||||||
|
// Rigs without a sub receiver simply never see "1": curVFO only becomes B on a
|
||||||
|
// rig that reports its receive VFO.
|
||||||
|
func (y *Yaesu) modeVFOSuffix() string {
|
||||||
|
if y.curVFO == "B" {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *Yaesu) SetPTT(on bool) error {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
if y.port == nil {
|
||||||
|
return fmt.Errorf("yaesu: not connected")
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
return y.write("TX1;")
|
||||||
|
}
|
||||||
|
return y.write("TX0;")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// write sends one command. The caller holds the mutex.
|
||||||
|
func (y *Yaesu) write(cmd string) error {
|
||||||
|
if y.port == nil {
|
||||||
|
return fmt.Errorf("yaesu: not connected")
|
||||||
|
}
|
||||||
|
_, err := y.port.Write([]byte(cmd))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask sends a query and reads the reply up to its ';'. The caller holds the
|
||||||
|
// mutex, so a command and its answer are never interleaved with another's.
|
||||||
|
func (y *Yaesu) ask(cmd string) (string, error) {
|
||||||
|
if err := y.write(cmd); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Match the reply to the COMMAND, and drop anything else.
|
||||||
|
//
|
||||||
|
// Returning the first ';'-terminated string whatever it was is what made a CW
|
||||||
|
// macro knock the CAT link over: KY produces no reply, so the next query —
|
||||||
|
// FA; from the poll loop — collected a leftover frame, failed to parse as a
|
||||||
|
// frequency, and the Manager treated that as "lost the rig" and reconnected.
|
||||||
|
// The operator saw the CAT drop for a few seconds on every macro click.
|
||||||
|
want := cmdPrefix(cmd)
|
||||||
|
buf := make([]byte, 0, 64)
|
||||||
|
tmp := make([]byte, 64)
|
||||||
|
deadline := time.Now().Add(600 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := y.port.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue // read timeout — the rig may still be composing its answer
|
||||||
|
}
|
||||||
|
buf = append(buf, tmp[:n]...)
|
||||||
|
for {
|
||||||
|
i := strings.IndexByte(string(buf), ';')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
frame := string(buf[:i+1])
|
||||||
|
buf = buf[i+1:]
|
||||||
|
// "?;" is the rig saying it does not know this command. Confirmed on an
|
||||||
|
// FTDX10, which answers it to KY; and MG;. Reporting it as such — rather
|
||||||
|
// than discarding it and waiting out the timeout — is what lets callers
|
||||||
|
// stop asking instead of paying 600 ms per poll for ever.
|
||||||
|
if strings.TrimSpace(frame) == "?;" {
|
||||||
|
return "", errYaesuUnsupported
|
||||||
|
}
|
||||||
|
if want == "" || strings.HasPrefix(strings.ToUpper(frame), want) {
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
debugLog.Printf("yaesu: discarding %q while waiting for %s (asked %q)", frame, want, cmd)
|
||||||
|
}
|
||||||
|
if len(buf) > 512 {
|
||||||
|
return "", fmt.Errorf("yaesu: no ';' in %d bytes answering %q", len(buf), cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("yaesu: timeout answering %q", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errYaesuUnsupported is returned when the rig answers "?;" — it does not know
|
||||||
|
// the command. Different models implement different subsets, and the only
|
||||||
|
// reliable way to learn which is to ask once and remember the refusal.
|
||||||
|
var errYaesuUnsupported = errors.New("yaesu: command not supported by this rig")
|
||||||
|
|
||||||
|
// cmdPrefix is the leading letters of a command — what its reply starts with.
|
||||||
|
// "FA;" → "FA", "MD0;" → "MD", "KY;" → "KY".
|
||||||
|
func cmdPrefix(cmd string) string {
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(cmd))
|
||||||
|
for i := 0; i < len(c); i++ {
|
||||||
|
if c[i] < 'A' || c[i] > 'Z' {
|
||||||
|
return c[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseYaesuFreq reads "FA014074000;" into Hz.
|
||||||
|
func parseYaesuFreq(reply, prefix string) (int64, bool) {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(r, prefix) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
|
||||||
|
if digits == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
hz, err := strconv.ParseInt(digits, 10, 64)
|
||||||
|
if err != nil || hz <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return hz, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// yaesuSplitFromReply turns the split reply into a yes or no.
|
||||||
|
//
|
||||||
|
// The two commands say DIFFERENT things and reading them alike is a real fault,
|
||||||
|
// reported on an FTDX101 (F4NBZ, 2026-07-29) where the panel showed split ON with
|
||||||
|
// the radio OFF and the reverse:
|
||||||
|
//
|
||||||
|
// ST is a split FLAG — ST1 means split, whatever VFO is in use.
|
||||||
|
// FT names the TX VFO — FT0 = transmit on A, FT1 = transmit on B.
|
||||||
|
//
|
||||||
|
// Split is on when the rig TRANSMITS on a different VFO from the one it is
|
||||||
|
// LISTENING to. Reading FT1 as "split" is therefore only right for an operator
|
||||||
|
// on VFO A: on SUB it is exactly inverted, which is why the same model behaved
|
||||||
|
// correctly for one operator and backwards for another — one was on MAIN, the
|
||||||
|
// other on SUB.
|
||||||
|
func yaesuSplitFromReply(reply, cmd, vfo string) bool {
|
||||||
|
d := yaesuStateDigit(reply, cmd)
|
||||||
|
if d == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if cmd == "ST" {
|
||||||
|
return d == '1'
|
||||||
|
}
|
||||||
|
// FT: compare the transmit VFO with the one being listened to.
|
||||||
|
txOnB := d == '1'
|
||||||
|
rxOnB := strings.HasPrefix(strings.ToUpper(vfo), "B")
|
||||||
|
return txOnB != rxOnB
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveYaesuVFOs turns the two frequencies plus the VFO and split flags into
|
||||||
|
// the ADIF pair: FreqHz is where we TRANSMIT, RxFreqHz only when split.
|
||||||
|
//
|
||||||
|
// Kept pure and separate from ReadState so the rules can be tested without a
|
||||||
|
// radio — the equivalent OmniRig function is where every Yaesu bug lived.
|
||||||
|
func resolveYaesuVFOs(freqA, freqB int64, vfo string, split bool) (tx, rx int64, isSplit bool) {
|
||||||
|
listening, transmitting := freqA, freqB
|
||||||
|
if vfo == "B" {
|
||||||
|
listening, transmitting = freqB, freqA
|
||||||
|
}
|
||||||
|
if !split {
|
||||||
|
return listening, 0, false
|
||||||
|
}
|
||||||
|
// Split with a missing or identical other VFO is not split: reporting it
|
||||||
|
// would put a wrong TX frequency in the log, which is worse than ignoring a
|
||||||
|
// flag the rig may have left set.
|
||||||
|
if transmitting <= 0 || transmitting == listening {
|
||||||
|
return listening, 0, false
|
||||||
|
}
|
||||||
|
return transmitting, listening, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// yaesuModeDigit maps an ADIF mode to the MD digit. SSB has no single digit —
|
||||||
|
// the sideband follows the worldwide convention (LSB below 10 MHz, USB above),
|
||||||
|
// which is why the current frequency is part of the decision.
|
||||||
|
func yaesuModeDigit(mode string, freqHz int64) byte {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "SSB":
|
||||||
|
if freqHz > 0 && freqHz < 10_000_000 {
|
||||||
|
return '1' // LSB
|
||||||
|
}
|
||||||
|
return '2' // USB
|
||||||
|
case "LSB":
|
||||||
|
return '1'
|
||||||
|
case "USB":
|
||||||
|
return '2'
|
||||||
|
case "CW":
|
||||||
|
return '3'
|
||||||
|
case "FM":
|
||||||
|
return '4'
|
||||||
|
case "AM":
|
||||||
|
return '5'
|
||||||
|
case "RTTY":
|
||||||
|
return '6'
|
||||||
|
case "":
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
// Everything else is a digital sub-mode (FT8, FT4, PSK31, JS8…). They all
|
||||||
|
// ride on the rig's DATA mode; the sideband follows the same convention.
|
||||||
|
if freqHz > 0 && freqHz < 10_000_000 {
|
||||||
|
return '8' // DATA-LSB
|
||||||
|
}
|
||||||
|
return 'C' // DATA-USB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yaesuStateDigit returns the STATE digit of a reply — the FIRST digit after
|
||||||
|
// the command — or 0 if the reply does not belong to this command.
|
||||||
|
//
|
||||||
|
// The parameter is not always one character: an FTDX101 answers "FR01;" where an
|
||||||
|
// FTDX10 answers "FR0;". The state is the FIRST digit in both; the second is a
|
||||||
|
// separate parameter. Reading the LAST digit inverted it — with RX and TX on
|
||||||
|
// MAIN the rig answered FR01, OpsLog concluded SUB, the main frequency stopped
|
||||||
|
// updating and a spot click tuned VFO B (F4NBZ, 2026-07-29). That was my own
|
||||||
|
// correction of the previous evening, made the wrong way round: the earlier
|
||||||
|
// "SUB shows MAIN" fault came from reading VS, not from this digit.
|
||||||
|
func yaesuStateDigit(reply, cmd string) byte {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(r, cmd) || len(r) <= len(cmd) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
d := r[len(cmd)]
|
||||||
|
if d < '0' || d > '9' {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// CW keying through the Yaesu's own keyer — the KY command.
|
||||||
|
//
|
||||||
|
// This is the fifth CW engine, alongside WinKeyer, the DTR/RTS line keyer, the
|
||||||
|
// Icom CI-V keyer and FlexRadio's CWX. The point is the same in each: no extra
|
||||||
|
// hardware, no second cable. The radio holds the text and keys it with its own
|
||||||
|
// timing, which is why the character spacing is perfect where a PC keying a line
|
||||||
|
// through USB latency is not.
|
||||||
|
//
|
||||||
|
// KY; → KY0; buffer has room / KY1; buffer full
|
||||||
|
// KY <text>; queue up to 24 characters
|
||||||
|
//
|
||||||
|
// The leading SPACE after KY is part of the command, not padding.
|
||||||
|
//
|
||||||
|
// CONFIRMED on an FTDX10 (2026-07-29): it refuses this outright, answering "?;"
|
||||||
|
// to both KY; and KY <text>; — including with PC KEYING on DAKY, the obvious
|
||||||
|
// candidate, which does NOT help. That model keys from a PC only through the DTR
|
||||||
|
// line of its second (standard) COM port, which is OpsLog's "serial" engine and
|
||||||
|
// is confirmed working.
|
||||||
|
//
|
||||||
|
// The code stays because KY is documented for the FTDX101 / FT-991A / FT-710
|
||||||
|
// family. On a rig that refuses it, the error now names the route that works.
|
||||||
|
//
|
||||||
|
// STOP is the other uncertain half: Yaesu documents no buffer-clear, see StopCW.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// yaesuCWChunk is the most characters one KY command accepts. Longer text is
|
||||||
|
// split and fed as the rig drains its buffer.
|
||||||
|
const yaesuCWChunk = 24
|
||||||
|
|
||||||
|
// cwStatusLogged makes the KY status reply appear in the log once per run: it
|
||||||
|
// is a line we have no verified sample of, so the first one is worth keeping.
|
||||||
|
var cwStatusLogged bool
|
||||||
|
|
||||||
|
// yaesuCWAllowed is what the rig's keyer can actually send. Anything else is
|
||||||
|
// dropped rather than passed through: an unsupported byte can abort the whole
|
||||||
|
// buffer, losing the rest of the message with no error anywhere.
|
||||||
|
const yaesuCWAllowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+:;()"
|
||||||
|
|
||||||
|
// SendCW queues a message on the rig's keyer.
|
||||||
|
//
|
||||||
|
// Text is filtered, upper-cased and fed in 24-character pieces, waiting for room
|
||||||
|
// between pieces. Without that wait a long macro would silently lose its tail:
|
||||||
|
// the rig drops what does not fit rather than reporting an error.
|
||||||
|
func (y *Yaesu) SendCW(text string) error {
|
||||||
|
msg := filterYaesuCW(text)
|
||||||
|
if msg == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for len(msg) > 0 {
|
||||||
|
n := yaesuCWChunk
|
||||||
|
if len(msg) < n {
|
||||||
|
n = len(msg)
|
||||||
|
}
|
||||||
|
chunk := msg[:n]
|
||||||
|
msg = msg[n:]
|
||||||
|
if err := y.waitCWBuffer(4 * time.Second); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
y.mu.Lock()
|
||||||
|
err := y.write("KY " + chunk + ";")
|
||||||
|
if err == nil {
|
||||||
|
// KY produces no reply when it is accepted — but a rig that REJECTS it
|
||||||
|
// answers "?;", and that frame then sat in the buffer until the poll
|
||||||
|
// loop's next query picked it up, failed, and the Manager tore the link
|
||||||
|
// down. That is the CAT dropping for a few seconds on every macro click.
|
||||||
|
//
|
||||||
|
// Reading here does two things: it keeps the stray frame off the poll
|
||||||
|
// loop, and it turns a silent non-transmission into a stated reason.
|
||||||
|
err = y.drainCWReply()
|
||||||
|
}
|
||||||
|
y.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// When the rig will not tell us how full its buffer is (an FTDX10 answers
|
||||||
|
// "?;" to KY;), pace the next piece by how long this one takes to key.
|
||||||
|
// Sending everything at once overruns the 24-character buffer and the rig
|
||||||
|
// silently drops the rest.
|
||||||
|
if len(msg) > 0 && y.cwStatusUnsupported() {
|
||||||
|
time.Sleep(yaesuCWDuration(chunk, y.keyerWPM()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainCWReply reads for a moment after a KY write.
|
||||||
|
//
|
||||||
|
// An accepted KY says nothing at all, so silence here is success. A rejection
|
||||||
|
// answers "?;" — and left unread, that frame was picked up by the poll loop's
|
||||||
|
// next query, which failed to parse it and took the whole CAT link down with it.
|
||||||
|
// Reading it here keeps the link up AND turns "nothing was transmitted, no idea
|
||||||
|
// why" into a stated reason.
|
||||||
|
//
|
||||||
|
// The caller holds the mutex.
|
||||||
|
func (y *Yaesu) drainCWReply() error {
|
||||||
|
if y.port == nil {
|
||||||
|
return fmt.Errorf("yaesu: not connected")
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(200 * time.Millisecond)
|
||||||
|
buf := make([]byte, 0, 32)
|
||||||
|
tmp := make([]byte, 32)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := y.port.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return nil // a read problem here is the poll loop's business, not ours
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf = append(buf, tmp[:n]...)
|
||||||
|
for {
|
||||||
|
i := strings.IndexByte(string(buf), ';')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
frame := strings.TrimSpace(string(buf[:i+1]))
|
||||||
|
buf = buf[i+1:]
|
||||||
|
if frame == "?;" {
|
||||||
|
// An FTDX10 refuses KY whatever PC KEYING is set to — DAKY included,
|
||||||
|
// tested on the radio 2026-07-29. The message therefore points
|
||||||
|
// straight at what works, rather than at a setting that will not
|
||||||
|
// help.
|
||||||
|
return fmt.Errorf("this radio does not accept CW over CAT (it answered \"?;\" to KY). " +
|
||||||
|
"Switch the keyer engine to \"Serial port (DTR=CW / RTS=PTT)\" on the rig's OTHER " +
|
||||||
|
"COM port — the standard one, CAT keeping the enhanced one — with PC KEYING set " +
|
||||||
|
"to DTR in the rig's CW menu")
|
||||||
|
}
|
||||||
|
debugLog.Printf("yaesu cw: rig answered %q to KY — ignoring", frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil // silence = accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
// cwStatusUnsupported reports whether this rig refused the KY status query.
|
||||||
|
func (y *Yaesu) cwStatusUnsupported() bool {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
return y.unsupported["KY;"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyerWPM is the speed the rig is keying at, for pacing. Falls back to a
|
||||||
|
// middling 20 wpm when the rig does not report it: too slow only wastes a
|
||||||
|
// moment, too fast overruns the buffer.
|
||||||
|
func (y *Yaesu) keyerWPM() int {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
if y.panel.KeySpeed >= 4 {
|
||||||
|
return y.panel.KeySpeed
|
||||||
|
}
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// yaesuCWDuration estimates how long a piece of text takes to key.
|
||||||
|
//
|
||||||
|
// PARIS timing: one word is 50 dit-lengths and a word is 5 characters, so a
|
||||||
|
// character averages 10 dits and a dit is 1.2/wpm seconds.
|
||||||
|
func yaesuCWDuration(text string, wpm int) time.Duration {
|
||||||
|
if wpm < 4 {
|
||||||
|
wpm = 20
|
||||||
|
}
|
||||||
|
ditMs := 1200.0 / float64(wpm)
|
||||||
|
return time.Duration(float64(len(text))*10*ditMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCW aborts the message being sent.
|
||||||
|
//
|
||||||
|
// Yaesu documents no buffer-clear, so this drops the transmitter instead: TX0
|
||||||
|
// takes the rig out of transmit, which is what an operator pressing Escape
|
||||||
|
// actually wants. Anything still queued is not keyed on the air.
|
||||||
|
//
|
||||||
|
// It deliberately does NOT send "KY0;" — a plausible-looking clear that the rig
|
||||||
|
// would read as the CHARACTER zero and dutifully send. A wrong guess here
|
||||||
|
// transmits, which is worse than an imperfect abort.
|
||||||
|
func (y *Yaesu) StopCW() error {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
if y.port == nil {
|
||||||
|
return fmt.Errorf("yaesu: not connected")
|
||||||
|
}
|
||||||
|
return y.write("TX0;")
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitCWBuffer blocks until the keyer reports room, or the deadline passes.
|
||||||
|
// A rig that never answers the status query is not a reason to refuse to send —
|
||||||
|
// we go ahead once, and the worst case is the truncation we were avoiding.
|
||||||
|
func (y *Yaesu) waitCWBuffer(within time.Duration) error {
|
||||||
|
deadline := time.Now().Add(within)
|
||||||
|
for {
|
||||||
|
y.mu.Lock()
|
||||||
|
if y.port == nil {
|
||||||
|
y.mu.Unlock()
|
||||||
|
return fmt.Errorf("yaesu: not connected")
|
||||||
|
}
|
||||||
|
if y.unsupported["KY;"] {
|
||||||
|
y.mu.Unlock()
|
||||||
|
return nil // this rig does not report buffer state — pacing covers it
|
||||||
|
}
|
||||||
|
r, err := y.ask("KY;")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errYaesuUnsupported) {
|
||||||
|
if y.unsupported == nil {
|
||||||
|
y.unsupported = map[string]bool{}
|
||||||
|
}
|
||||||
|
y.unsupported["KY;"] = true
|
||||||
|
debugLog.Printf("yaesu cw: this rig does not answer KY; — pacing the send by keying time instead")
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("yaesu cw: KY status query failed (%v) — sending anyway", err)
|
||||||
|
}
|
||||||
|
y.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
y.mu.Unlock()
|
||||||
|
// Only an explicit "full" holds us back.
|
||||||
|
//
|
||||||
|
// The first version required the reply to be exactly "KY0;" at byte 2, and
|
||||||
|
// on a real FTDX10 that refused to send at all: anything the rig phrases
|
||||||
|
// differently — a space before the digit, a longer status — read as "still
|
||||||
|
// full" and the send died after four seconds having keyed nothing. The rig
|
||||||
|
// is the one that knows; when its answer is not a clear "1", the right move
|
||||||
|
// is to go ahead rather than to sit and time out.
|
||||||
|
// Log the shape once per session: this is a reply we have no verified
|
||||||
|
// sample of, and the log is what turns the next surprise into a fact.
|
||||||
|
if !cwStatusLogged {
|
||||||
|
cwStatusLogged = true
|
||||||
|
debugLog.Printf("yaesu cw: KY status reply is %q (full=%v)", r, yaesuCWBufferFull(r))
|
||||||
|
}
|
||||||
|
if !yaesuCWBufferFull(r) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("yaesu: the keyer buffer stayed full for %s (last reply %q)", within, r)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yaesuCWBufferFull reads the KY status reply.
|
||||||
|
//
|
||||||
|
// Deliberately asymmetric: only a clear "1" means full. Anything else — an
|
||||||
|
// unexpected shape, a reply from another command that arrived first, an empty
|
||||||
|
// string — is treated as "go ahead". Refusing to send because a status line was
|
||||||
|
// phrased unexpectedly is the worse failure: the operator gets silence with no
|
||||||
|
// explanation, where at worst sending early truncates a long message.
|
||||||
|
func yaesuCWBufferFull(reply string) bool {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(strings.ToUpper(r), "KY") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range r[2:] {
|
||||||
|
switch c {
|
||||||
|
case '0':
|
||||||
|
return false
|
||||||
|
case '1':
|
||||||
|
return true
|
||||||
|
case ' ', ';':
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return false // not a status digit — don't block on it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterYaesuCW upper-cases and strips what the keyer cannot send.
|
||||||
|
func filterYaesuCW(text string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToUpper(text) {
|
||||||
|
// Any whitespace becomes a word gap FIRST. Dropping tabs and newlines as
|
||||||
|
// "not in the allowed set" glued the words either side together: a macro
|
||||||
|
// written on two lines went out as CQCQ.
|
||||||
|
if r == '\t' || r == '\n' || r == '\r' {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(yaesuCWAllowed, r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Runs of spaces become one: the rig sends each as a word gap, so three in a
|
||||||
|
// row is three gaps and the message crawls.
|
||||||
|
return strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What reaches the rig's keyer.
|
||||||
|
//
|
||||||
|
// An unsupported byte does not produce an error on a Yaesu — it can abort the
|
||||||
|
// whole buffer, so the rest of the message is lost silently. Filtering is
|
||||||
|
// therefore part of correctness, not tidiness.
|
||||||
|
func TestFilterYaesuCW(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"cq cq de f4bpo", "CQ CQ DE F4BPO"},
|
||||||
|
{"F4BPO/P", "F4BPO/P"},
|
||||||
|
{"599 tu 73", "599 TU 73"},
|
||||||
|
// Accents and symbols the keyer has no code for.
|
||||||
|
{"héllo", "HLLO"},
|
||||||
|
{"a*b#c", "ABC"},
|
||||||
|
// Runs of whitespace collapse: the rig sends each space as a word gap, so
|
||||||
|
// three in a row is three gaps and the message crawls.
|
||||||
|
{"cq cq", "CQ CQ"},
|
||||||
|
{" padded ", "PADDED"},
|
||||||
|
{"\tcq\ncq\t", "CQ CQ"},
|
||||||
|
{"", ""},
|
||||||
|
{" ", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := filterYaesuCW(c.in); got != c.want {
|
||||||
|
t.Errorf("filterYaesuCW(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long macros have to be split: one KY command carries 24 characters and the rig
|
||||||
|
// DROPS what does not fit rather than reporting an error, so a contest CQ would
|
||||||
|
// lose its tail with no sign anywhere.
|
||||||
|
func TestYaesuCWChunking(t *testing.T) {
|
||||||
|
msg := filterYaesuCW("cq cq cq de f4bpo f4bpo f4bpo pse k")
|
||||||
|
if len(msg) <= yaesuCWChunk {
|
||||||
|
t.Fatalf("test message is %d chars — too short to exercise chunking", len(msg))
|
||||||
|
}
|
||||||
|
var chunks []string
|
||||||
|
for rest := msg; len(rest) > 0; {
|
||||||
|
n := yaesuCWChunk
|
||||||
|
if len(rest) < n {
|
||||||
|
n = len(rest)
|
||||||
|
}
|
||||||
|
chunks = append(chunks, rest[:n])
|
||||||
|
rest = rest[n:]
|
||||||
|
}
|
||||||
|
for i, c := range chunks {
|
||||||
|
if len(c) > yaesuCWChunk {
|
||||||
|
t.Errorf("chunk %d is %d chars, over the %d limit", i, len(c), yaesuCWChunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nothing added, nothing lost: the message the operator typed is what the
|
||||||
|
// keyer receives.
|
||||||
|
if joined := strings.Join(chunks, ""); joined != msg {
|
||||||
|
t.Errorf("chunks rejoin to %q, want %q", joined, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading the KY status reply.
|
||||||
|
//
|
||||||
|
// The first version demanded exactly "KY0;" with the digit at byte 2, and on a
|
||||||
|
// real FTDX10 that refused to send at all: the reply was phrased differently,
|
||||||
|
// every poll read as "still full", and after four seconds the send gave up
|
||||||
|
// having keyed nothing. Hence the asymmetry — only a clear "1" blocks.
|
||||||
|
func TestYaesuCWBufferFull(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
reply string
|
||||||
|
full bool
|
||||||
|
}{
|
||||||
|
{"KY0;", false},
|
||||||
|
{"KY1;", true},
|
||||||
|
{"KY 0;", false}, // a space before the digit
|
||||||
|
{"KY 1;", true},
|
||||||
|
{"ky1;", true}, // lower case
|
||||||
|
{" KY0; ", false},
|
||||||
|
// Shapes we have no sample of must NOT block: silence with no explanation
|
||||||
|
// is a worse failure than sending a moment early.
|
||||||
|
{"KY;", false},
|
||||||
|
{"", false},
|
||||||
|
{"FA014074000;", false}, // another command's reply arriving first
|
||||||
|
{"KYX;", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := yaesuCWBufferFull(c.reply); got != c.full {
|
||||||
|
t.Errorf("yaesuCWBufferFull(%q) = %v, want %v", c.reply, got, c.full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pacing when the rig will not report its buffer.
|
||||||
|
//
|
||||||
|
// An FTDX10 answers "?;" to KY;, so there is nothing to wait on and the send has
|
||||||
|
// to be spaced by how long the text takes to key — otherwise the second piece
|
||||||
|
// overruns the 24-character buffer and the rig drops it silently.
|
||||||
|
func TestYaesuCWDuration(t *testing.T) {
|
||||||
|
// PARIS: at 20 wpm a dit is 60 ms and a character averages 10 dits, so 24
|
||||||
|
// characters take about 14.4 s.
|
||||||
|
got := yaesuCWDuration("ABCDEFGHIJKLMNOPQRSTUVWX", 20)
|
||||||
|
if got < 13*time.Second || got > 16*time.Second {
|
||||||
|
t.Errorf("24 chars at 20 wpm = %s, expected about 14.4s", got)
|
||||||
|
}
|
||||||
|
// Twice the speed, half the time.
|
||||||
|
fast := yaesuCWDuration("ABCDEFGHIJKLMNOPQRSTUVWX", 40)
|
||||||
|
if fast >= got || fast < got/3 {
|
||||||
|
t.Errorf("40 wpm = %s vs 20 wpm = %s — should be about half", fast, got)
|
||||||
|
}
|
||||||
|
// A nonsense speed must not produce a zero wait (send everything at once) nor
|
||||||
|
// an absurd one (the operator waiting minutes).
|
||||||
|
if d := yaesuCWDuration("TEST", 0); d <= 0 || d > 10*time.Second {
|
||||||
|
t.Errorf("4 chars at an unknown speed = %s, want a sane fallback", d)
|
||||||
|
}
|
||||||
|
if d := yaesuCWDuration("", 20); d != 0 {
|
||||||
|
t.Errorf("empty text = %s, want 0", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user