Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b5c195ab4 | ||
|
|
139b4675e3 | ||
|
|
2ad72b19fb | ||
|
|
678c8821c2 | ||
|
|
5394b55bb7 | ||
|
|
05d64024ef | ||
|
|
cb27aa5ebf | ||
|
|
aefb984974 | ||
|
|
91b5af1c7b | ||
|
|
4fd70f6a9d | ||
|
|
51e279887d | ||
|
|
7e08553e6e | ||
|
|
3564eecc36 |
@@ -0,0 +1,109 @@
|
||||
# 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.
|
||||
|
||||
**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.
|
||||
+4
-4
@@ -4,8 +4,8 @@
|
||||
<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 façon
|
||||
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
||||
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
|
||||
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
|
||||
@@ -32,7 +32,7 @@ Développé par **F4BPO**.
|
||||
|
||||
## 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
|
||||
**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
|
||||
@@ -91,7 +91,7 @@ Développé par **F4BPO**.
|
||||
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
|
||||
`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 →
|
||||
*Gestion des alertes*).
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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 — Log4OM-style entry, real-time CAT
|
||||
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**
|
||||
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
||||
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
|
||||
@@ -31,7 +31,7 @@ Developed by **F4BPO**.
|
||||
|
||||
## 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
|
||||
**flag** is shown large next to the RST fields.
|
||||
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
||||
@@ -81,7 +81,7 @@ Developed by **F4BPO**.
|
||||
**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`).
|
||||
- **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
|
||||
management*).
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"hamlog/internal/backup"
|
||||
"hamlog/internal/cabrillo"
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/catemu"
|
||||
"hamlog/internal/clublog"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/contest"
|
||||
@@ -53,6 +54,7 @@ import (
|
||||
"hamlog/internal/rotator/gs232"
|
||||
"hamlog/internal/rotator/pst"
|
||||
"hamlog/internal/rotgenius"
|
||||
"hamlog/internal/scp"
|
||||
"hamlog/internal/settings"
|
||||
"hamlog/internal/solar"
|
||||
"hamlog/internal/spe"
|
||||
@@ -234,6 +236,8 @@ const (
|
||||
|
||||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||||
|
||||
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
|
||||
|
||||
keyBackupEnabled = "backup.enabled"
|
||||
keyBackupFolder = "backup.folder"
|
||||
keyBackupRotation = "backup.rotation"
|
||||
@@ -276,6 +280,14 @@ const (
|
||||
keyExtEQSLAutoUpload = "extsvc.eqsl.auto_upload"
|
||||
keyExtEQSLUploadMode = "extsvc.eqsl.upload_mode"
|
||||
|
||||
// Cloudlog and its fork Wavelog are self-hosted, so the instance URL is part
|
||||
// of the configuration (there is no fixed endpoint).
|
||||
keyExtCloudlogURL = "extsvc.cloudlog.url"
|
||||
keyExtCloudlogAPIKey = "extsvc.cloudlog.api_key"
|
||||
keyExtCloudlogStationID = "extsvc.cloudlog.station_id"
|
||||
keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload"
|
||||
keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode"
|
||||
|
||||
keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync
|
||||
|
||||
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
||||
@@ -493,6 +505,7 @@ type App struct {
|
||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
||||
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
|
||||
scp *scp.Manager // Super Check Partial / N+1 callsign master list
|
||||
|
||||
// NET Control: persistent net definitions/rosters (global JSON) + the live
|
||||
// session (in-memory only — active stations currently in QSO).
|
||||
@@ -789,6 +802,14 @@ func (a *App) startup(ctx context.Context) {
|
||||
cat.LogSink = applog.Printf
|
||||
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
|
||||
extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis
|
||||
lookup.LogSink = applog.Printf // which call was queried, and why a portable lookup fell back
|
||||
db.LogSink = applog.Printf // which schema migrations ran, and how long they took
|
||||
// Version and executable FIRST, before anything else can fail. Diagnosing a
|
||||
// report means knowing which build produced the log, and that was previously
|
||||
// impossible: the copy someone is actually running is not always the one they
|
||||
// think they installed, and the version shown in the UI is the only clue.
|
||||
exe, _ := os.Executable()
|
||||
applog.Printf("startup: OpsLog %s — %s", appVersion, exe)
|
||||
applog.Printf("startup: data dir = %s", dataDir)
|
||||
// The local SQLite file ALWAYS holds per-operator configuration — settings,
|
||||
// station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award
|
||||
@@ -897,6 +918,10 @@ func (a *App) startup(ctx context.Context) {
|
||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||
}
|
||||
a.emitRadioUDP(s)
|
||||
// Feed the frequency to any amplifier we are pretending to be a radio
|
||||
// for. Just two atomic stores per amp — the reply itself is built when
|
||||
// the amp polls, so a fast-tuning VFO costs nothing here.
|
||||
a.feedAmpBandFollow(s)
|
||||
// Drive station relays by the current frequency/band (PstRotator-style
|
||||
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
||||
// feature is off; when on, run off this callback so a slow relay board never
|
||||
@@ -985,6 +1010,25 @@ func (a *App) startup(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Super Check Partial / N+1: load the cached MASTER.SCP; when the feature is
|
||||
// enabled, auto-(re)download it if missing or older than a week.
|
||||
a.scp = scp.NewManager(dataDir)
|
||||
go func() {
|
||||
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
|
||||
return
|
||||
}
|
||||
if a.scp.Count() == 0 || time.Since(a.scp.Updated()) > 7*24*time.Hour {
|
||||
if n, err := a.scp.Download(context.Background()); err == nil {
|
||||
applog.Printf("scp: auto-downloaded %d callsigns", n)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "scp:updated")
|
||||
}
|
||||
} else {
|
||||
applog.Printf("scp: auto-download failed: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
_ = a.clublog.EnsureLoaded()
|
||||
// Auto-refresh a missing/stale country file (ClubLog adds date-ranged
|
||||
@@ -1473,6 +1517,74 @@ type dbPointer struct {
|
||||
|
||||
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
|
||||
|
||||
// ── Portable paths ─────────────────────────────────────────────────────
|
||||
//
|
||||
// OpsLog is meant to be carried on a stick or copied between machines: the exe,
|
||||
// its data folder and the databases travel together. Storing "C:\OpsLog\data\
|
||||
// logbook.db" broke that — dropped into D:\OpsLog on another PC, the pointer
|
||||
// still named C:, and the app either lost the database or (worse) silently
|
||||
// created a NEW empty one at a C: path that happened to be creatable.
|
||||
//
|
||||
// So a path INSIDE the application folder is stored relative to it, and any
|
||||
// stored path is resolved against the CURRENT location at read time. Absolute
|
||||
// paths outside the folder (a deliberate choice — a synced folder, another
|
||||
// drive) keep working exactly as before: they are only re-rooted if they have
|
||||
// gone missing AND the same file exists in this install's data folder.
|
||||
|
||||
// appDir is the folder the running exe lives in — the anchor for relative paths.
|
||||
func appDir() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
|
||||
// portablePath prepares a path for STORAGE: relative to the app folder when it
|
||||
// sits inside it, unchanged otherwise. Forward slashes so the value survives a
|
||||
// round trip through a folder copied between machines.
|
||||
func portablePath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
base := appDir()
|
||||
if p == "" || base == "" || !filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
rel, err := filepath.Rel(base, p)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return p // outside the app folder — the user meant that exact place
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
// resolvePath turns a stored path back into an absolute one for THIS install.
|
||||
// dataDir is where the app's own data lives, used for the re-rooting rescue.
|
||||
func resolvePath(dataDir, p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(p) {
|
||||
if base := appDir(); base != "" {
|
||||
return filepath.Join(base, filepath.FromSlash(p))
|
||||
}
|
||||
return filepath.FromSlash(p)
|
||||
}
|
||||
if fileExists(p) {
|
||||
return p
|
||||
}
|
||||
// An absolute path from another machine. Rescue it ONLY if a file of the same
|
||||
// name is sitting in this install's data folder — that is the copied-folder
|
||||
// case. Anything else is left alone so a genuinely-missing database is
|
||||
// reported rather than silently replaced by an unrelated file.
|
||||
if dataDir != "" {
|
||||
if cand := filepath.Join(dataDir, filepath.Base(p)); fileExists(cand) {
|
||||
applog.Printf("path: %q not found — using %q from this install (folder moved?)", p, cand)
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ── Window geometry (window.json) ──────────────────────────────────────
|
||||
//
|
||||
// Remembered across restarts so the window reopens where and how you left it.
|
||||
@@ -1545,9 +1657,44 @@ func (a *App) restoreWindowPosition() {
|
||||
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
|
||||
return // corrupt / absurd — leave the default placement
|
||||
}
|
||||
// The SIZE was sanity-checked above but the POSITION never was, and that is
|
||||
// the one that makes OpsLog unusable: close it on a second monitor, unplug
|
||||
// that monitor (or dock elsewhere, or change the layout), and the saved
|
||||
// coordinates put the window somewhere no screen covers. It opens, invisibly,
|
||||
// forever — with no way back short of deleting window.json, which nobody
|
||||
// knows to do. Fall back to the default placement instead.
|
||||
if !onSomeMonitor(ws.X, ws.Y, ws.Width, ws.Height) {
|
||||
applog.Printf("window: saved position %d,%d (%dx%d) is off every monitor — opening at the default placement",
|
||||
ws.X, ws.Y, ws.Width, ws.Height)
|
||||
return
|
||||
}
|
||||
wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y)
|
||||
}
|
||||
|
||||
// onSomeMonitor reports whether a window at these coordinates would land on the
|
||||
// visible desktop. It demands a real slab of the title bar rather than a single
|
||||
// pixel: a window overlapping the screen edge by 2 px is, in practice, as lost
|
||||
// as one entirely outside it. When the desktop bounds can't be read, it says yes
|
||||
// — better to honour the operator's saved position than to second-guess it.
|
||||
func onSomeMonitor(x, y, w, h int) bool {
|
||||
vx, vy, vw, vh, ok := virtualScreenBounds()
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return overlapsEnough(x, y, w, h, vx, vy, vw, vh)
|
||||
}
|
||||
|
||||
// overlapsEnough is the geometry behind onSomeMonitor, split out so it can be
|
||||
// tested — the virtual desktop origin is NEGATIVE when a monitor sits left of or
|
||||
// above the primary one, which is precisely the layout that produces a lost
|
||||
// window and precisely where sign errors hide.
|
||||
func overlapsEnough(x, y, w, h, vx, vy, vw, vh int) bool {
|
||||
const grabW, grabH = 160, 32 // enough of the title bar to see and drag
|
||||
overlapW := min(x+w, vx+vw) - max(x, vx)
|
||||
overlapH := min(y+h, vy+vh) - max(y, vy)
|
||||
return overlapW >= grabW && overlapH >= grabH
|
||||
}
|
||||
|
||||
// readBootstrap returns the full bootstrap config (DB path + MySQL), or a zero
|
||||
// value if the file is missing/unreadable.
|
||||
func readBootstrap(dataDir string) dbPointer {
|
||||
@@ -1557,12 +1704,16 @@ func readBootstrap(dataDir string) dbPointer {
|
||||
return c
|
||||
}
|
||||
_ = json.Unmarshal(b, &c)
|
||||
c.DBPath = strings.TrimSpace(c.DBPath)
|
||||
// Stored relative when it lives inside the app folder, so the pointer follows
|
||||
// the folder from C:OpsLog to D:OpsLog or to a stick.
|
||||
c.DBPath = resolvePath(dataDir, c.DBPath)
|
||||
c.DeletePending = resolvePath(dataDir, c.DeletePending)
|
||||
return c
|
||||
}
|
||||
|
||||
func writeBootstrap(dataDir string, c dbPointer) error {
|
||||
c.DBPath = strings.TrimSpace(c.DBPath)
|
||||
c.DBPath = portablePath(c.DBPath)
|
||||
c.DeletePending = portablePath(c.DeletePending)
|
||||
b, _ := json.MarshalIndent(c, "", " ")
|
||||
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
|
||||
}
|
||||
@@ -1700,6 +1851,15 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
|
||||
if lp == "" {
|
||||
return a.db, "sqlite", nil
|
||||
}
|
||||
// Resolve against THIS install before opening. Without it, a profile carried
|
||||
// from C:\OpsLog to D:\OpsLog kept naming the C: path — and since db.Open
|
||||
// CREATES what is missing, the operator silently got a brand-new empty
|
||||
// logbook instead of an error. Their QSOs were still on disk, in the folder
|
||||
// they had just copied.
|
||||
if r := resolvePath(a.dataDir, lp); r != lp {
|
||||
applog.Printf("logbook: profile path %q resolved to %q", lp, r)
|
||||
lp = r
|
||||
}
|
||||
c, err := db.Open(lp)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
|
||||
@@ -2311,6 +2471,76 @@ func (a *App) DownloadLoTWUsers() (int, error) {
|
||||
return a.lotwUsers.Download(a.ctx)
|
||||
}
|
||||
|
||||
// ── Super Check Partial / N+1 ────────────────────────────────────────────────
|
||||
|
||||
// ScpStatus is the loaded-list summary for Settings + the widget gate.
|
||||
type ScpStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Count int `json:"count"`
|
||||
Updated string `json:"updated,omitempty"` // RFC3339, empty if never
|
||||
}
|
||||
|
||||
// GetScpStatus returns whether SCP is enabled and how many calls are loaded.
|
||||
func (a *App) GetScpStatus() ScpStatus {
|
||||
st := ScpStatus{}
|
||||
if a.settings != nil {
|
||||
v, _ := a.settings.Get(a.ctx, keyScpEnabled)
|
||||
st.Enabled = v == "1"
|
||||
}
|
||||
if a.scp != nil {
|
||||
st.Count = a.scp.Count()
|
||||
if u := a.scp.Updated(); !u.IsZero() {
|
||||
st.Updated = u.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// SetScpEnabled turns Super Check Partial on/off. Enabling triggers a background
|
||||
// download when the list is missing or stale.
|
||||
func (a *App) SetScpEnabled(on bool) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyScpEnabled, boolStr(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
if on && a.scp != nil && (a.scp.Count() == 0 || time.Since(a.scp.Updated()) > 7*24*time.Hour) {
|
||||
go func() {
|
||||
if n, err := a.scp.Download(context.Background()); err == nil {
|
||||
applog.Printf("scp: downloaded %d callsigns", n)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "scp:updated")
|
||||
}
|
||||
} else {
|
||||
applog.Printf("scp: download failed: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadScp fetches the MASTER.SCP master file and caches it. Returns the
|
||||
// number of callsigns loaded.
|
||||
func (a *App) DownloadScp() (int, error) {
|
||||
if a.scp == nil {
|
||||
return 0, fmt.Errorf("not initialized")
|
||||
}
|
||||
return a.scp.Download(a.ctx)
|
||||
}
|
||||
|
||||
// ScpLookup returns the Super Check Partial (substring) and N+1 (one-edit)
|
||||
// suggestions for a typed fragment. Empty when SCP is disabled.
|
||||
func (a *App) ScpLookup(fragment string) scp.Result {
|
||||
if a.scp == nil || a.settings == nil {
|
||||
return scp.Result{}
|
||||
}
|
||||
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
|
||||
return scp.Result{}
|
||||
}
|
||||
return a.scp.Lookup(fragment, 60)
|
||||
}
|
||||
|
||||
// StationInfoComputed bundles the data we resolve live from the
|
||||
// profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone,
|
||||
// lat/lon. Used by the Settings UI to show the "what will be stamped on
|
||||
@@ -5379,6 +5609,20 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
|
||||
if field == "freq" {
|
||||
return a.bulkSetFrequency(ids, value)
|
||||
}
|
||||
// Some ADIF fields have no promoted column and live in extras_json
|
||||
// (OWNER_CALLSIGN) — those take the JSON path so the rest of the extras on
|
||||
// each QSO survive the edit.
|
||||
if key := qso.BulkExtraKey(field); key != "" {
|
||||
n, err := a.qso.BulkSetExtra(a.ctx, ids, key, strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n > 0 {
|
||||
a.invalidateAwardStats()
|
||||
a.materializeAwardRefsForIDs(ids)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
col, ok := bulkFieldColumns[field]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown field %q", field)
|
||||
@@ -5983,12 +6227,23 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||||
if a.lookup == nil {
|
||||
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
||||
}
|
||||
// Bound the whole lookup: give the providers a couple of seconds, then let
|
||||
// Lookup fall through to cty.dat (country/zones). Without this a call that isn't
|
||||
// in QRZ.com — or a slow/unresponsive provider — left the "looking up" spinner
|
||||
// turning for 10 s+ before the cty.dat fallback showed. The providers respect
|
||||
// the context, so they're cancelled at the deadline and cty.dat answers instantly.
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 2*time.Second)
|
||||
// Bound the whole lookup, then let Lookup fall through to cty.dat
|
||||
// (country/zones). Without this a call that isn't in QRZ.com — or a slow
|
||||
// provider — left the "looking up" spinner turning for 10 s+ before the
|
||||
// cty.dat fallback showed. The providers respect the context, so they're
|
||||
// cancelled at the deadline and cty.dat answers instantly.
|
||||
//
|
||||
// A slashed/portable call needs a bigger slice, because it costs TWO provider
|
||||
// round trips rather than one: the full form is looked up first, and only when
|
||||
// that comes back not-found does the home call get tried (F4LYI/M → F4LYI).
|
||||
// Two seconds covered one request but not two, so /M and /P calls always ran
|
||||
// out of time on the second and fell back to cty.dat — even though the
|
||||
// operator's QRZ record was there and the plain call resolved fine.
|
||||
budget := 2 * time.Second
|
||||
if strings.Contains(callsign, "/") {
|
||||
budget = 6 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, budget)
|
||||
defer cancel()
|
||||
r, err := a.lookup.Lookup(ctx, callsign)
|
||||
if errors.Is(err, lookup.ErrNotFound) {
|
||||
@@ -7930,7 +8185,7 @@ func (a *App) pttKey(cfg AudioSettings) error {
|
||||
a.pttKeyedMethod = "cat"
|
||||
a.pttGen++
|
||||
a.pttMu.Unlock()
|
||||
applog.Printf("dvk: PTT keyed (CAT/OmniRig)")
|
||||
applog.Printf("dvk: PTT keyed (CAT via %s)", a.cat.State().Backend)
|
||||
return nil
|
||||
case "rts", "dtr":
|
||||
if strings.TrimSpace(cfg.PTTPort) == "" {
|
||||
@@ -8119,6 +8374,19 @@ func (a *App) GetLogFilePath() string {
|
||||
return applog.Path()
|
||||
}
|
||||
|
||||
// UILog lets the frontend write to the same diagnostic log as the backend.
|
||||
//
|
||||
// Frontend-only logic (entry-strip auto-fill, debounce ordering, which field the
|
||||
// operator has touched) was previously invisible in a bug report: console output
|
||||
// dies with the window, and these problems reproduce on other operators'
|
||||
// machines, not here. A line in opslog.log can simply be sent along with the
|
||||
// report. Callers prefix their own subsystem, e.g. "backfill: …".
|
||||
func (a *App) UILog(msg string) {
|
||||
if msg = strings.TrimSpace(msg); msg != "" {
|
||||
applog.Printf("ui: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ── QSL defaults ──────────────────────────────────────────────────────
|
||||
|
||||
// GetQSLDefaults returns the stored defaults — empty strings when the
|
||||
@@ -8338,7 +8606,9 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||||
keyExtLoTWUsername, keyExtLoTWWebPassword,
|
||||
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
|
||||
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode)
|
||||
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
|
||||
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
|
||||
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
@@ -8408,6 +8678,13 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
out.EQSL.Username = p.Callsign
|
||||
}
|
||||
}
|
||||
out.Cloudlog = extsvc.ServiceConfig{
|
||||
URL: m[keyExtCloudlogURL],
|
||||
APIKey: m[keyExtCloudlogAPIKey],
|
||||
StationID: m[keyExtCloudlogStationID],
|
||||
AutoUpload: m[keyExtCloudlogAutoUpload] == "1",
|
||||
UploadMode: extsvc.UploadMode(m[keyExtCloudlogUploadMode]),
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -8465,6 +8742,17 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
if cfg.EQSL.AutoUpload {
|
||||
eqAuto = "1"
|
||||
}
|
||||
// Cloudlog has no per-QSO upload-status column in the logbook, so the
|
||||
// on-close sweep (which selects by that column) cannot apply — force any
|
||||
// stored on_close back to immediate rather than silently doing nothing.
|
||||
clgMode := modeOf(cfg.Cloudlog.UploadMode)
|
||||
if clgMode == string(extsvc.ModeOnClose) {
|
||||
clgMode = string(extsvc.ModeImmediate)
|
||||
}
|
||||
clgAuto := "0"
|
||||
if cfg.Cloudlog.AutoUpload {
|
||||
clgAuto = "1"
|
||||
}
|
||||
scope := a.profileScope() // write under the active profile's prefix
|
||||
for k, v := range map[string]string{
|
||||
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
||||
@@ -8500,6 +8788,12 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
keyExtEQSLQTHNick: strings.TrimSpace(cfg.EQSL.QTHNickname),
|
||||
keyExtEQSLAutoUpload: eqAuto,
|
||||
keyExtEQSLUploadMode: eqMode,
|
||||
|
||||
keyExtCloudlogURL: strings.TrimSpace(cfg.Cloudlog.URL),
|
||||
keyExtCloudlogAPIKey: strings.TrimSpace(cfg.Cloudlog.APIKey),
|
||||
keyExtCloudlogStationID: strings.TrimSpace(cfg.Cloudlog.StationID),
|
||||
keyExtCloudlogAutoUpload: clgAuto,
|
||||
keyExtCloudlogUploadMode: clgMode,
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||||
return err
|
||||
@@ -8538,6 +8832,12 @@ func (a *App) TestEQSLUpload() (string, error) {
|
||||
return extsvc.TestEQSL(a.ctx, nil, a.loadExternalServices().EQSL)
|
||||
}
|
||||
|
||||
// TestCloudlogUpload checks the Cloudlog/Wavelog URL, API key and station id
|
||||
// against the user's own instance, without inserting anything.
|
||||
func (a *App) TestCloudlogUpload() (string, error) {
|
||||
return extsvc.TestCloudlog(a.ctx, nil, a.loadExternalServices().Cloudlog)
|
||||
}
|
||||
|
||||
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
||||
|
||||
// uploadColumnFor maps a service id to its QSO sent-status column.
|
||||
@@ -9825,6 +10125,12 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case extsvc.ServiceCloudlog:
|
||||
// No per-QSO status column for Cloudlog: it dedupes server-side (its API
|
||||
// checks each incoming record against the log), so re-sending is harmless
|
||||
// and every QSO is eligible. The cost is that a permanently failed upload
|
||||
// is not remembered — hence no on-close mode and no manual backlog.
|
||||
return true
|
||||
case extsvc.ServiceLoTW:
|
||||
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
|
||||
if strings.EqualFold(q.LOTWSent, f) {
|
||||
@@ -9860,6 +10166,9 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
||||
err = a.qso.MarkHRDLogUploaded(ctx, id, date)
|
||||
case extsvc.ServiceEQSL:
|
||||
err = a.qso.MarkEQSLSent(ctx, id, date)
|
||||
case extsvc.ServiceCloudlog:
|
||||
// Nothing to stamp — see extShouldUpload. Still logged and announced so
|
||||
// the upload is visible in the diagnostic log and the UI.
|
||||
}
|
||||
if err != nil {
|
||||
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
|
||||
@@ -11732,6 +12041,10 @@ func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {
|
||||
if a.profiles == nil {
|
||||
return profile.Profile{}, fmt.Errorf("profiles not initialized")
|
||||
}
|
||||
// Store a logbook that lives inside the app folder RELATIVE to it, so the
|
||||
// profile keeps working when the folder is copied to another machine or
|
||||
// another drive letter.
|
||||
p.DB.Path = portablePath(p.DB.Path)
|
||||
if err := a.profiles.Save(a.ctx, &p); err != nil {
|
||||
return profile.Profile{}, err
|
||||
}
|
||||
@@ -13120,6 +13433,18 @@ type AmpConfig struct {
|
||||
Port int `json:"port"`
|
||||
ComPort string `json:"com_port"`
|
||||
Baud int `json:"baud"`
|
||||
|
||||
// Band-follow: an ACOM is the MASTER on its CAT/AUX connector — it polls a
|
||||
// transceiver and changes band from the reply, so following OpsLog means
|
||||
// answering those polls on a SECOND serial port (independent of the one used
|
||||
// above for metering; both run at once). See internal/catemu.
|
||||
FreqOut bool `json:"freq_out"`
|
||||
FreqComPort string `json:"freq_com_port"`
|
||||
FreqBaud int `json:"freq_baud"`
|
||||
// FreqBroadcastMs > 0 also SENDS the frequency unprompted at that interval,
|
||||
// for an amplifier that listens to a transceiver's CAT stream instead of
|
||||
// polling it. 0 = answer polls only.
|
||||
FreqBroadcastMs int `json:"freq_broadcast_ms"`
|
||||
}
|
||||
|
||||
type ampInst struct {
|
||||
@@ -13127,6 +13452,7 @@ type ampInst struct {
|
||||
pgxl *powergenius.Client
|
||||
spe *spe.Client
|
||||
acom *acom.Client
|
||||
catemu *catemu.Server // Kenwood-format responder for band-follow (ACOM)
|
||||
}
|
||||
|
||||
func (i *ampInst) stopAll() {
|
||||
@@ -13139,6 +13465,9 @@ func (i *ampInst) stopAll() {
|
||||
if i.acom != nil {
|
||||
i.acom.Stop()
|
||||
}
|
||||
if i.catemu != nil {
|
||||
i.catemu.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ampTypeLabel is the default display name for an amp type.
|
||||
@@ -13268,12 +13597,61 @@ func (a *App) startAmps() {
|
||||
a.spe = inst.spe
|
||||
}
|
||||
}
|
||||
// Band-follow on a SECOND serial port, for any amp that takes its band from
|
||||
// a transceiver CAT link (ACOM and SPE both do). Independent of the control
|
||||
// transport above, so metering and band-follow run at the same time.
|
||||
if c.FreqOut && strings.TrimSpace(c.FreqComPort) != "" {
|
||||
inst.catemu = catemu.New(catemu.Config{
|
||||
ComPort: c.FreqComPort, Baud: c.FreqBaud,
|
||||
BroadcastMs: c.FreqBroadcastMs,
|
||||
}, applog.Printf)
|
||||
if st := a.cat.State(); st.Connected {
|
||||
inst.catemu.SetFrequency(st.FreqHz)
|
||||
inst.catemu.SetMode(st.Mode)
|
||||
}
|
||||
inst.catemu.Start()
|
||||
applog.Printf("amp %s: band-follow on %s at %d baud (Kenwood format, broadcast=%dms)",
|
||||
c.Name, c.FreqComPort, c.FreqBaud, c.FreqBroadcastMs)
|
||||
}
|
||||
a.ampsMu.Lock()
|
||||
a.ampInsts[c.ID] = inst
|
||||
a.ampsMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// feedAmpBandFollow pushes the rig's frequency/mode to every amplifier we are
|
||||
// emulating a transceiver for. Called on each CAT state change.
|
||||
//
|
||||
// The TX frequency is what the amp must follow: in split it has to be tuned
|
||||
// for where we transmit, not where we listen.
|
||||
func (a *App) feedAmpBandFollow(s cat.RigState) {
|
||||
if !s.Connected || s.FreqHz <= 0 {
|
||||
return
|
||||
}
|
||||
a.ampsMu.Lock()
|
||||
defer a.ampsMu.Unlock()
|
||||
for _, inst := range a.ampInsts {
|
||||
if inst.catemu != nil {
|
||||
inst.catemu.SetFrequency(s.FreqHz)
|
||||
inst.catemu.SetMode(s.Mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetAmpBandFollowStatus returns the band-follow link state per amplifier id,
|
||||
// so the settings panel can show whether the amp is actually polling us.
|
||||
func (a *App) GetAmpBandFollowStatus() map[string]catemu.Status {
|
||||
out := map[string]catemu.Status{}
|
||||
a.ampsMu.Lock()
|
||||
defer a.ampsMu.Unlock()
|
||||
for id, inst := range a.ampInsts {
|
||||
if inst.catemu != nil {
|
||||
out[id] = inst.catemu.GetStatus()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AmpStatus is one amp's live state for the UI poll — exactly one of the
|
||||
// per-family payloads is set, per the amp's type.
|
||||
type AmpStatus struct {
|
||||
|
||||
@@ -26,6 +26,7 @@ var sensitiveSettingKeys = map[string]bool{
|
||||
keyExtLoTWWebPassword: true,
|
||||
keyExtHRDLogCode: true,
|
||||
keyExtEQSLPassword: true,
|
||||
keyExtCloudlogAPIKey: true,
|
||||
}
|
||||
|
||||
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
||||
|
||||
+114
@@ -1,4 +1,118 @@
|
||||
[
|
||||
{
|
||||
"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",
|
||||
|
||||
+222
-14
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -22,9 +22,10 @@ import {
|
||||
RefreshCtyDat, DownloadAllReferenceLists,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||
GetDBConnectionInfo, GetLogbookRevision,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
||||
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
|
||||
GetScpStatus, ScpLookup,
|
||||
OpenExternalURL,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||
@@ -71,6 +72,7 @@ import { FlexPanel } from '@/components/FlexPanel';
|
||||
import { IcomPanel } from '@/components/IcomPanel';
|
||||
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
|
||||
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
|
||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||
import { StatsPanel } from '@/components/StatsPanel';
|
||||
@@ -93,7 +95,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { setGridPrefsProfile } from '@/lib/gridPrefs';
|
||||
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
|
||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -247,6 +249,13 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.——
|
||||
function shortCatError(err?: string): string {
|
||||
if (!err) return '';
|
||||
const e = err.toLowerCase();
|
||||
// Checked BEFORE the not-found cases: a privilege mismatch used to be condensed
|
||||
// into "OmniRig not found", which is actively misleading — OmniRig is running,
|
||||
// visibly, and the operator goes looking for a driver or COM-port fault. The
|
||||
// full explanation is in the tooltip and the log.
|
||||
if (e.includes('privilege level') || e.includes('elevation') || e.includes('élévation')) {
|
||||
return 'OmniRig: run as admin?';
|
||||
}
|
||||
if (e.includes('not registered') || e.includes('not available')) return 'OmniRig not found';
|
||||
if (e.includes('not connected')) return 'not connected';
|
||||
if (e.includes('coinitialize')) return 'COM error';
|
||||
@@ -473,6 +482,10 @@ export default function App() {
|
||||
const [agEnabled, setAgEnabled] = useState(false);
|
||||
const [tgStatus, setTgStatus] = useState<TGStatus>({ connected: false });
|
||||
const [tgEnabled, setTgEnabled] = useState(false);
|
||||
// Super Check Partial / N+1
|
||||
const [scpEnabled, setScpEnabled] = useState(false);
|
||||
const [scpCount, setScpCount] = useState(0);
|
||||
const [scpResult, setScpResult] = useState<ScpResult>({});
|
||||
// Per-port optimistic selection that the status poll must not revert until the
|
||||
// device confirms it (or it expires) — otherwise a stale poll right after a
|
||||
// click reverts the UI and the click looks like it did nothing.
|
||||
@@ -599,6 +612,10 @@ export default function App() {
|
||||
|
||||
const userEditedRef = useRef<Set<string>>(new Set());
|
||||
const lastLookedUpRef = useRef<string>('');
|
||||
// Callsign for which a PROVIDER (QRZ/HamQTH) grid is currently in the field.
|
||||
// A later cty.dat-only answer for the same call must not replace that precise
|
||||
// locator with the entity centroid — see the apply block in doLookup.
|
||||
const providerGridCallRef = useRef<string>('');
|
||||
// Tracks the call we last auto-switched to the Worked-before tab for, so we
|
||||
// don't keep yanking the tab on every wb refresh of the same callsign.
|
||||
const lastWbFocusRef = useRef<string>('');
|
||||
@@ -1442,6 +1459,21 @@ export default function App() {
|
||||
// re-populating a field the operator just cleared.
|
||||
const lookupGenRef = useRef(0);
|
||||
const [wb, setWb] = useState<WB | null>(null);
|
||||
// Live mirror of `wb` so the lookup fallback can read the latest worked-before
|
||||
// entries synchronously (the two run on separate debounce timers).
|
||||
const wbRef = useRef<WB | null>(null);
|
||||
useEffect(() => { wbRef.current = wb; }, [wb]);
|
||||
// Which callsign wbRef.current actually belongs to. Worked-before is replaced
|
||||
// only when its query RESOLVES, so between two calls the ref still holds the
|
||||
// previous station — and the backfill below would happily copy that station's
|
||||
// name, QTH and grid onto the new one.
|
||||
const wbCallRef = useRef('');
|
||||
// A backfill the lookup asked for before the history had arrived. Parked here
|
||||
// and replayed by runWorkedBefore: with no QRZ/HamQTH configured the lookup
|
||||
// answers from local cty.dat almost instantly, while the history query — a
|
||||
// round trip to a possibly remote MySQL logbook — is still in flight, so the
|
||||
// backfill found nothing and nothing ever ran it again.
|
||||
const pendingBackfillRef = useRef<{ call: string; r?: any } | null>(null);
|
||||
const [wbBusy, setWbBusy] = useState(false);
|
||||
|
||||
// Per-award columns for the Recent QSOs / Worked-before grids: load the award
|
||||
@@ -1551,6 +1583,7 @@ export default function App() {
|
||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
|
||||
const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0');
|
||||
const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0');
|
||||
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||
|
||||
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
||||
@@ -1782,6 +1815,31 @@ export default function App() {
|
||||
TunerGeniusActivate(ch).catch((e) => setError(String(e?.message ?? e)));
|
||||
};
|
||||
|
||||
// Super Check Partial: poll the enabled flag + list size so the widget shows up
|
||||
// once the operator turns SCP on and the master list has downloaded.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try { const s: any = await GetScpStatus(); if (alive && s) { setScpEnabled(!!s.enabled); setScpCount(s.count || 0); } } catch {}
|
||||
};
|
||||
load();
|
||||
const id = window.setInterval(load, 3000);
|
||||
const off = EventsOn('scp:updated', load);
|
||||
return () => { alive = false; window.clearInterval(id); off(); };
|
||||
}, []);
|
||||
// Query SCP/N+1 as the callsign changes (debounced). Skipped when disabled or
|
||||
// the widget is hidden, so we don't hit the backend for nothing.
|
||||
useEffect(() => {
|
||||
if (!scpEnabled || !showScp) { setScpResult({}); return; }
|
||||
const c = callsign.trim();
|
||||
if (c.length < 2) { setScpResult({}); return; }
|
||||
let alive = true;
|
||||
const id = window.setTimeout(async () => {
|
||||
try { const r: any = await ScpLookup(c); if (alive && r) setScpResult(r as ScpResult); } catch {}
|
||||
}, 120);
|
||||
return () => { alive = false; window.clearTimeout(id); };
|
||||
}, [callsign, scpEnabled, showScp]);
|
||||
|
||||
// RX band auto-follows the TX band (only differs for cross-band work).
|
||||
useEffect(() => { setBandRx(band); }, [band]);
|
||||
|
||||
@@ -2298,6 +2356,14 @@ export default function App() {
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// A column resized in the last seconds before quitting must still reach the
|
||||
// database: the DB write is debounced, so flush it on the way out.
|
||||
useEffect(() => {
|
||||
const flush = () => flushGridPrefs();
|
||||
window.addEventListener('beforeunload', flush);
|
||||
return () => { window.removeEventListener('beforeunload', flush); flush(); };
|
||||
}, []);
|
||||
|
||||
// Every setting is per-profile, so when the active profile changes the whole
|
||||
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
||||
// side reloads its managers; this keeps the React state in sync.
|
||||
@@ -2669,6 +2735,7 @@ export default function App() {
|
||||
|
||||
function resetAutoFill() {
|
||||
setName(''); setQth(''); setCountry(''); setGrid('');
|
||||
providerGridCallRef.current = ''; // the grid field is empty again
|
||||
// NOTE: don't clear `wb` here. It's owned by runWorkedBefore (fast 150 ms
|
||||
// pass) and the short-callsign guard in scheduleLookup. Clearing it inside
|
||||
// runLookup blanked the Worked-before table for the whole (possibly slow,
|
||||
@@ -2850,9 +2917,93 @@ export default function App() {
|
||||
|
||||
async function runWorkedBefore(call: string, dxccHint: number = 0) {
|
||||
setWbBusy(true);
|
||||
try { setWb(await WorkedBefore(call, dxccHint)); }
|
||||
catch { setWb(null); }
|
||||
finally { setWbBusy(false); }
|
||||
try {
|
||||
const w = await WorkedBefore(call, dxccHint);
|
||||
setWb(w);
|
||||
// Mirrored synchronously rather than through the effect above: a backfill
|
||||
// parked by the lookup has to read this on the very next line, not a
|
||||
// render later.
|
||||
wbRef.current = w;
|
||||
wbCallRef.current = call;
|
||||
// The lookup finished before this history did and parked its backfill —
|
||||
// run it now that we can answer "who did we work last?".
|
||||
const p = pendingBackfillRef.current;
|
||||
if (p && p.call === call) {
|
||||
pendingBackfillRef.current = null;
|
||||
fillFromLastQso(p.r, call);
|
||||
}
|
||||
} catch {
|
||||
setWb(null);
|
||||
wbRef.current = null;
|
||||
wbCallRef.current = '';
|
||||
} finally { setWbBusy(false); }
|
||||
}
|
||||
// fillFromLastQso enriches the entry from the LAST QSO we logged with this call
|
||||
// when the live lookup came up short — the callsign isn't on QRZ/HamQTH, or no
|
||||
// lookup service is configured (cty.dat then gives country/zones only). It fills
|
||||
// ONLY the fields the provider left empty and the operator hasn't edited, so a
|
||||
// real QRZ/HamQTH hit is never overridden. `r` is the provider result (omitted
|
||||
// on a lookup error, where every provider field counts as empty).
|
||||
function fillFromLastQso(r: any, call: string) {
|
||||
// Only ever fill from THIS callsign's history. If worked-before hasn't
|
||||
// resolved for it yet, park the request instead of reading whatever the ref
|
||||
// happens to hold — that would be the previously entered station.
|
||||
if (wbCallRef.current !== call) {
|
||||
pendingBackfillRef.current = { call, r };
|
||||
UILog(`backfill ${call}: history not in yet (have "${wbCallRef.current}") — parked`).catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Parked backfills can land late; drop it if the operator has moved on.
|
||||
if (call !== callsignValRef.current.trim().toUpperCase()) {
|
||||
UILog(`backfill ${call}: abandoned, entry now holds "${callsignValRef.current}"`).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const last: any = wbRef.current?.entries?.[0]; // entries are qso_date DESC → most recent
|
||||
if (!last) {
|
||||
UILog(`backfill ${call}: no prior QSO in the log (count=${wbRef.current?.count ?? 0}, entries=${wbRef.current?.entries?.length ?? 0})`).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const ue = userEditedRef.current;
|
||||
const empty = (v: any) => (v ?? '') === '';
|
||||
// One line saying what we found and what blocked each field, so this can be
|
||||
// diagnosed from another operator's log instead of guessed at.
|
||||
UILog(`backfill ${call}: last QSO ${last.qso_date ?? '?'} name="${last.name ?? ''}" qth="${last.qth ?? ''}" grid="${last.grid ?? ''}"`
|
||||
+ ` | provider name="${r?.name ?? ''}" grid="${r?.grid ?? ''}" src=${r?.source ?? 'none'}`
|
||||
+ ` | edited=[${[...ue].join(',')}]`).catch(() => {});
|
||||
if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name);
|
||||
if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth);
|
||||
if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country);
|
||||
// Grid: a REAL provider grid always wins. Otherwise the last QSO's precise
|
||||
// locator beats the coarse cty.dat entity centroid the provider block set — so
|
||||
// a French call resolves to its real JNxx, not the country's JN16 centroid.
|
||||
const adoptLastGrid = !ue.has('grid') && empty(r?.grid) && !!last.grid;
|
||||
if (adoptLastGrid) setGrid(last.grid);
|
||||
setDetails((d) => {
|
||||
// When we adopt the last QSO's locator, take its coordinates too (derive them
|
||||
// from the grid if that QSO didn't store any) so the map + saved record match.
|
||||
let lat = d.lat, lon = d.lon;
|
||||
if (adoptLastGrid) {
|
||||
if (last.lat != null && last.lon != null) { lat = last.lat; lon = last.lon; }
|
||||
else { const ll = gridToLatLon(last.grid); if (ll) { lat = ll.lat; lon = ll.lon; } }
|
||||
} else {
|
||||
lat = d.lat ?? (last.lat ?? undefined);
|
||||
lon = d.lon ?? (last.lon ?? undefined);
|
||||
}
|
||||
return {
|
||||
...d,
|
||||
address: d.address || last.address || '',
|
||||
state: d.state || last.state || '',
|
||||
cnty: d.cnty || last.cnty || '',
|
||||
lat, lon,
|
||||
dxcc: d.dxcc ?? (last.dxcc || undefined),
|
||||
cqz: d.cqz ?? (last.cqz || undefined),
|
||||
ituz: d.ituz ?? (last.ituz || undefined),
|
||||
cont: d.cont || last.cont || '',
|
||||
email: d.email || last.email || '',
|
||||
qsl_via: d.qsl_via || last.qsl_via || '',
|
||||
};
|
||||
});
|
||||
if (adoptLastGrid || last.grid || last.lat) setMapZoomSignal((n) => n + 1);
|
||||
}
|
||||
async function runLookup(call: string) {
|
||||
if (call !== lastLookedUpRef.current) resetAutoFill();
|
||||
@@ -2877,12 +3028,24 @@ export default function App() {
|
||||
if (!ue.has('name') && (r.name ?? '') !== '') setName(r.name ?? '');
|
||||
if (!ue.has('qth') && (r.qth ?? '') !== '') setQth(r.qth ?? '');
|
||||
if (!ue.has('grid')) {
|
||||
if ((r.grid ?? '') !== '') setGrid(r.grid ?? '');
|
||||
if ((r.grid ?? '') !== '') {
|
||||
setGrid(r.grid ?? '');
|
||||
providerGridCallRef.current = call;
|
||||
}
|
||||
// No provider grid (cty.dat-only / portable): derive a 4-char grid from
|
||||
// the entity centroid (e.g. Svalbard → JQ88) so the field — and the
|
||||
// bearing/map — aren't empty. 4 chars signals it's entity-level, not a
|
||||
// precise QTH (matches how Log4OM shows it).
|
||||
else if (r.lat || r.lon) setGrid(latLonToGrid(r.lat || 0, r.lon || 0, 4));
|
||||
//
|
||||
// Skipped when QRZ already gave a precise grid for THIS call: lookups run
|
||||
// more than once per QSO (debounced typing, then blur/Enter), and the
|
||||
// provider only has a 2-second budget — one slow answer fell back to
|
||||
// cty.dat and downgraded JN05JG to the France centroid JN16, while Name
|
||||
// and QTH stayed (they are only written when non-empty). Same rule as
|
||||
// those fields: a cty.dat answer never overwrites a richer one.
|
||||
else if ((r.lat || r.lon) && providerGridCallRef.current !== call) {
|
||||
setGrid(latLonToGrid(r.lat || 0, r.lon || 0, 4));
|
||||
}
|
||||
}
|
||||
// Country/zones are exactly what cty.dat IS authoritative for — set them
|
||||
// (only skipped if empty, so we never blank a known country).
|
||||
@@ -2901,6 +3064,9 @@ export default function App() {
|
||||
email: d.email || (r.email ?? ''),
|
||||
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
||||
}));
|
||||
// Backfill anything the provider didn't supply from the last time we worked
|
||||
// this call (call not found on QRZ/HamQTH, or lookup off → cty.dat only).
|
||||
fillFromLastQso(r, call);
|
||||
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
|
||||
// The DX location is now known (grid set above) — force the world map to
|
||||
// auto-zoom right away, so it doesn't lag behind the resolved QSO.
|
||||
@@ -2920,6 +3086,8 @@ export default function App() {
|
||||
if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
|
||||
setLookupResult(null);
|
||||
setLookupError(String(e?.message ?? e));
|
||||
// Lookup failed outright — still borrow from the last logged QSO.
|
||||
fillFromLastQso(undefined, call);
|
||||
}
|
||||
} finally {
|
||||
// Only clear the spinner if we're still the current lookup — a newer one
|
||||
@@ -2934,6 +3102,9 @@ export default function App() {
|
||||
const call = value.trim().toUpperCase();
|
||||
if (call.length < 3) {
|
||||
setLookupResult(null); setWb(null);
|
||||
// Drop the history and any backfill waiting on it, so clearing the field
|
||||
// can't let a late one repopulate the next callsign typed.
|
||||
wbRef.current = null; wbCallRef.current = ''; pendingBackfillRef.current = null;
|
||||
if (lastLookedUpRef.current !== '') resetAutoFill();
|
||||
return;
|
||||
}
|
||||
@@ -3984,7 +4155,7 @@ export default function App() {
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
<div className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||
<ClusterGrid rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
|
||||
<ClusterGrid key={`clg-${activeProfileId ?? 'x'}`} rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
|
||||
</div>
|
||||
{clusterShowFilters && renderClusterFilters()}
|
||||
</div>
|
||||
@@ -3993,9 +4164,11 @@ export default function App() {
|
||||
case 'worked':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
</div>
|
||||
);
|
||||
case 'flex':
|
||||
@@ -4027,7 +4200,13 @@ export default function App() {
|
||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||
<RecentQSOsGrid
|
||||
key={`rqg-${activeProfileId ?? 'x'}`}
|
||||
// Its OWN layout, separate from the full-width Recent QSOs tab.
|
||||
// This pane is roughly half as wide, so it wants fewer columns and
|
||||
// narrower ones; sharing one key meant whichever was opened last
|
||||
// rewrote the other's widths.
|
||||
storageKey="mainpane"
|
||||
rows={qsosWithAwards as any}
|
||||
myGrid={station.my_grid}
|
||||
total={total}
|
||||
awardCols={awardCols}
|
||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
@@ -4275,6 +4454,20 @@ export default function App() {
|
||||
{showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
|
||||
</button>
|
||||
)}
|
||||
{scpEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { const v = !showScp; setShowScp(v); writeUiPref('opslog.showScp', v ? '1' : '0'); }}
|
||||
title={showScp ? 'Super Check Partial — shown · click to hide' : 'Super Check Partial · click to show'}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||
showScp ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||
: 'border-border text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<SpellCheck className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
{chatAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -4746,7 +4939,7 @@ export default function App() {
|
||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||
otherwise it shows the QRZ profile photo. */}
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
||||
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
||||
// the entry strip and each widget fills that height, scrolling inside.
|
||||
@@ -4845,6 +5038,17 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showScp && scpEnabled && (
|
||||
<div className="w-[240px] shrink-0 min-h-0">
|
||||
<ScpPanel
|
||||
result={scpResult}
|
||||
currentCall={callsign}
|
||||
count={scpCount}
|
||||
onPick={(c) => onCallsignInput(c, { force: true })}
|
||||
onClose={() => { setShowScp(false); writeUiPref('opslog.showScp', '0'); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{dvkEnabled && (
|
||||
<div className="w-[320px] shrink-0 min-h-0">
|
||||
<DvkPanel
|
||||
@@ -5136,6 +5340,7 @@ export default function App() {
|
||||
<RecentQSOsGrid
|
||||
key={`rqg2-${activeProfileId ?? 'x'}`}
|
||||
rows={qsosWithAwards as any}
|
||||
myGrid={station.my_grid}
|
||||
total={total}
|
||||
awardCols={awardCols}
|
||||
onFilteredCountChange={setGridFilteredCount}
|
||||
@@ -5293,6 +5498,7 @@ export default function App() {
|
||||
}
|
||||
return (
|
||||
<ClusterGrid
|
||||
key={`clg2-${activeProfileId ?? 'x'}`}
|
||||
rows={rendered as any}
|
||||
spotStatus={spotStatus}
|
||||
onSpotClick={handleSpotClick}
|
||||
@@ -5377,9 +5583,11 @@ export default function App() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Opened on demand from Tools → QSL Manager; closable via the
|
||||
|
||||
@@ -70,6 +70,13 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
const { t } = useI18n();
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
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 [emailTo, setEmailTo] = 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]);
|
||||
|
||||
// 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 toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
|
||||
if (!d) return d;
|
||||
@@ -92,14 +105,14 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
async function save() {
|
||||
if (!draft) 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)); }
|
||||
}
|
||||
async function del() {
|
||||
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;
|
||||
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)); }
|
||||
}
|
||||
|
||||
@@ -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="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>
|
||||
<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 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.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',
|
||||
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')} />
|
||||
@@ -187,8 +200,11 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
<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"
|
||||
placeholder={'DL1ABC\nIW3*\n*/P'}
|
||||
value={(draft.calls ?? []).join('\n')}
|
||||
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
|
||||
value={callsText}
|
||||
onChange={(e) => {
|
||||
setCallsText(e.target.value);
|
||||
patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) });
|
||||
}} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.countries')}</Label>
|
||||
|
||||
@@ -377,7 +377,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
|
||||
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
||||
{/* 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="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
@@ -422,8 +422,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right: tabbed editor for selected award */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Right: tabbed editor for selected award.
|
||||
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>}
|
||||
{/* 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
|
||||
@@ -459,7 +465,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
{!cur ? (
|
||||
<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">
|
||||
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
|
||||
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||
@@ -729,7 +735,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</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="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||
|
||||
@@ -34,6 +34,8 @@ const FIELDS: FieldDef[] = [
|
||||
// My station / operator
|
||||
{ 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 },
|
||||
// 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_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
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';
|
||||
|
||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
@@ -371,10 +371,28 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
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;
|
||||
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>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
@@ -394,17 +412,20 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
gridRef.current?.api?.refreshCells({ force: true });
|
||||
}, [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);
|
||||
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) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
});
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) saveState(COL_STATE_KEY, state);
|
||||
}, []);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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:
|
||||
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
||||
function fmtVFO(hz?: number): string {
|
||||
@@ -731,12 +749,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
{/* Band buttons + antenna selection. */}
|
||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||
<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(() => {})}
|
||||
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}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Row label={t('icmp.antenna')}>
|
||||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||
|
||||
@@ -427,6 +427,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
// 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">
|
||||
<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}
|
||||
total={paperRows.length}
|
||||
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">
|
||||
<RecentQSOsGrid
|
||||
storageKey="qslmgr.upload"
|
||||
rows={rows as any}
|
||||
total={rows.length}
|
||||
selectAllSignal={uploadSelAllSig}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
||||
|
||||
// Register every Community feature once. v32+ requires explicit registration;
|
||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||
@@ -27,6 +28,9 @@ const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34
|
||||
type Props = {
|
||||
rows: QSOForm[];
|
||||
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
|
||||
// in the QSL Manager, where the default is "all selected").
|
||||
selectAllSignal?: number;
|
||||
@@ -111,7 +115,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.
|
||||
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 ──
|
||||
{ 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) },
|
||||
@@ -146,6 +169,11 @@ 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.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' },
|
||||
// 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.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||
|
||||
@@ -263,7 +291,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||
|
||||
export function RecentQSOsGrid({ rows, 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) {
|
||||
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 gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -275,7 +303,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
||||
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
||||
|
||||
// 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.
|
||||
@@ -493,7 +521,13 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [awardCols, awardShown]);
|
||||
// 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>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||
ChevronDown, ChevronRight,
|
||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
@@ -35,10 +35,11 @@ import {
|
||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||
GetScpStatus, SetScpEnabled, DownloadScp,
|
||||
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
||||
ComputeStationInfo,
|
||||
GetUIPref, SetUIPref,
|
||||
@@ -626,7 +627,10 @@ function ADIFMonitorPanel() {
|
||||
}
|
||||
|
||||
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
|
||||
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number };
|
||||
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number;
|
||||
// Band-follow (ACOM): a SECOND serial port on which OpsLog answers the amp's
|
||||
// frequency polls, pretending to be a Kenwood-format transceiver.
|
||||
freq_out?: boolean; freq_com_port?: string; freq_baud?: number; freq_broadcast_ms?: number };
|
||||
|
||||
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
||||
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
||||
@@ -1177,7 +1181,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||||
});
|
||||
const [emailMsg, setEmailMsg] = useState('');
|
||||
const [showSmtpPass, setShowSmtpPass] = useState(false);
|
||||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||||
// eQSL card e-mail (subject/body templates + auto-send on log).
|
||||
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
|
||||
@@ -1222,20 +1225,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
type ExtServiceCfg = {
|
||||
api_key: string; email: string; username: string; password: string; callsign: string;
|
||||
code: string; qth_nickname: string;
|
||||
url: string; station_id: string; // Cloudlog/Wavelog: own instance + station profile
|
||||
force_station_callsign: string;
|
||||
tqsl_path: string; station_location: string; key_password: string;
|
||||
upload_flags: string[]; write_log: boolean;
|
||||
auto_upload: boolean; upload_mode: string;
|
||||
};
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg };
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg };
|
||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||
api_key: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||
upload_flags: ['N', 'R'], write_log: false,
|
||||
auto_upload: false, upload_mode: 'immediate',
|
||||
});
|
||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(),
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(),
|
||||
});
|
||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [qrzTesting, setQrzTesting] = useState(false);
|
||||
@@ -1253,6 +1257,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
|
||||
finally { setLotwUsersBusy(false); }
|
||||
};
|
||||
// Super Check Partial / N+1 callsign helper: enabled flag + master-list status.
|
||||
const [scp, setScp] = useState<{ enabled: boolean; count: number; updated?: string }>({ enabled: false, count: 0 });
|
||||
const [scpBusy, setScpBusy] = useState(false);
|
||||
useEffect(() => { GetScpStatus().then((s) => setScp(s as any)).catch(() => {}); }, []);
|
||||
const toggleScp = async (on: boolean) => {
|
||||
setScp((s) => ({ ...s, enabled: on }));
|
||||
setScpBusy(true);
|
||||
try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||||
finally { setScpBusy(false); }
|
||||
};
|
||||
const downloadScp = async () => {
|
||||
setScpBusy(true);
|
||||
try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||||
finally { setScpBusy(false); }
|
||||
};
|
||||
// US Counties (offline FCC ULS) — download progress arrives via events.
|
||||
const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string }>({ count: 0 });
|
||||
const [ulsBusy, setUlsBusy] = useState(false);
|
||||
@@ -1283,12 +1302,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
};
|
||||
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||||
const [cloudlogTest, setCloudlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [cloudlogTesting, setCloudlogTesting] = useState(false);
|
||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [eqslTesting, setEqslTesting] = useState(false);
|
||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||
// Active tab in the External Services panel — lifted here because
|
||||
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'pota'>('qrz');
|
||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'pota'>('qrz');
|
||||
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||||
const [potaToken, setPotaToken] = useState('');
|
||||
const [potaBusy, setPotaBusy] = useState(false);
|
||||
@@ -2793,6 +2814,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
});
|
||||
const addAmp = () => setAmps((l) => [...l, {
|
||||
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
|
||||
freq_out: false, freq_com_port: '', freq_baud: 9600, freq_broadcast_ms: 0,
|
||||
}]);
|
||||
return (
|
||||
<>
|
||||
@@ -2909,6 +2931,62 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Band-follow, for any amp that takes its band from a transceiver
|
||||
CAT link (ACOM and SPE both do) — never PowerGenius, which is
|
||||
driven over its network protocol. A SECOND serial port,
|
||||
separate from the metering one above. */}
|
||||
{!isPGXL && (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!amp.freq_out}
|
||||
onCheckedChange={(c) => patchAmp(i, { freq_out: !!c })}
|
||||
/>
|
||||
{t('amp.freqOut')}
|
||||
</label>
|
||||
{amp.freq_out && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>{t('amp.freqPort')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={amp.freq_com_port || '_'} onValueChange={(v) => patchAmp(i, { freq_com_port: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Baud</Label>
|
||||
<Input type="number" min={1200} value={amp.freq_baud ?? 9600}
|
||||
onChange={(e) => patchAmp(i, { freq_baud: parseInt(e.target.value) || 9600 })} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>{t('amp.freqBroadcast')}</Label>
|
||||
<Select value={String(amp.freq_broadcast_ms ?? 0)} onValueChange={(v) => patchAmp(i, { freq_broadcast_ms: parseInt(v) })}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">{t('amp.freqPollOnly')}</SelectItem>
|
||||
<SelectItem value="500">{t('amp.freqEvery', { ms: 500 })}</SelectItem>
|
||||
<SelectItem value="1000">{t('amp.freqEvery', { ms: 1000 })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t('amp.freqHint')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
|
||||
{!isPGXL && !isACOM && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
@@ -3740,6 +3818,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
{ k: 'hrdlog', label: 'HRDLOG.NET', ready: true },
|
||||
{ k: 'eqsl', label: 'EQSL', ready: true },
|
||||
{ k: 'lotw', label: 'LOTW', ready: true },
|
||||
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true },
|
||||
{ k: 'pota', label: 'POTA', ready: true },
|
||||
];
|
||||
|
||||
@@ -3809,6 +3888,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}
|
||||
}
|
||||
|
||||
const cloudlog = extSvc.cloudlog;
|
||||
const setCloudlog = (patch: Partial<ExtServiceCfg>) =>
|
||||
setExtSvc((s) => ({ ...s, cloudlog: { ...s.cloudlog, ...patch } }));
|
||||
|
||||
async function testCloudlog() {
|
||||
setCloudlogTesting(true);
|
||||
setCloudlogTest(null);
|
||||
try {
|
||||
// Save first: the backend test reads the stored config, so it must see
|
||||
// what was just typed (same as the other services' Test buttons).
|
||||
await SaveExternalServices(extSvc as any);
|
||||
const msg = await TestCloudlogUpload();
|
||||
setCloudlogTest({ ok: true, msg });
|
||||
} catch (e: any) {
|
||||
setCloudlogTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally {
|
||||
setCloudlogTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const eqsl = extSvc.eqsl;
|
||||
const setEqsl = (patch: Partial<ExtServiceCfg>) =>
|
||||
setExtSvc((s) => ({ ...s, eqsl: { ...s.eqsl, ...patch } }));
|
||||
@@ -4061,6 +4160,74 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : extSvcTab === 'cloudlog' ? (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">{t('es.cloudlogUrl')}</Label>
|
||||
<Input
|
||||
value={cloudlog.url}
|
||||
onChange={(e) => setCloudlog({ url: e.target.value })}
|
||||
placeholder={t('es.cloudlogUrlPh')}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Label className="text-sm">{t('es.apiKey')}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={cloudlog.api_key}
|
||||
onChange={(e) => setCloudlog({ api_key: e.target.value })}
|
||||
placeholder={t('es.cloudlogKeyPh')}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label className="text-sm">{t('es.cloudlogStationId')}</Label>
|
||||
<Input
|
||||
value={cloudlog.station_id}
|
||||
onChange={(e) => setCloudlog({ station_id: e.target.value })}
|
||||
placeholder="1"
|
||||
className="font-mono text-xs w-24"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground -mt-1">
|
||||
{t('es.cloudlogHint')}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={cloudlog.auto_upload}
|
||||
onCheckedChange={(c) => setCloudlog({ auto_upload: !!c })}
|
||||
/>
|
||||
{t('es.autoUpload')}
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||||
<Select
|
||||
value={cloudlog.upload_mode === 'delayed' ? 'delayed' : 'immediate'}
|
||||
onValueChange={(v) => setCloudlog({ upload_mode: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
|
||||
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* No "on close" option: unlike the other services, Cloudlog has no
|
||||
per-QSO status column in the logbook, so there is nothing to
|
||||
select a backlog from at shutdown. */}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={testCloudlog} disabled={cloudlogTesting}>
|
||||
<UploadCloud className="size-3.5" /> {cloudlogTesting ? t('es.testing') : t('es.testConn')}
|
||||
</Button>
|
||||
{cloudlogTest && (
|
||||
<span className={cn('text-xs', cloudlogTest.ok ? 'text-success' : 'text-danger')}>
|
||||
{cloudlogTest.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : extSvcTab === 'eqsl' ? (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
@@ -4512,6 +4679,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}
|
||||
|
||||
function AudioPanel() {
|
||||
// Names the CAT backend the operator has configured, so the PTT dropdown can
|
||||
// say "CAT — Icom CI-V (USB)" rather than the flatly wrong "CAT (OmniRig)".
|
||||
const catBackendLabel = ({
|
||||
omnirig: t('cat.optOmnirig'),
|
||||
flex: t('cat.optFlex'),
|
||||
icom: t('cat.optIcom'),
|
||||
'icom-net': t('cat.optIcomNet'),
|
||||
tci: t('cat.optTci'),
|
||||
} as Record<string, string>)[catCfg.backend] ?? '';
|
||||
|
||||
const deviceSelect = (
|
||||
field: keyof AudioSettings,
|
||||
devices: AudioDev[],
|
||||
@@ -4523,9 +4700,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder={placeholder} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">— none / system default —</SelectItem>
|
||||
<SelectItem value="_">{t('aud.noneDefault')}</SelectItem>
|
||||
{devices.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' (default)' : ''}</SelectItem>
|
||||
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' ' + t('aud.defaultTag') : ''}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -4536,24 +4713,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<SectionHeader
|
||||
title={t('hw.audioVoice')}/>
|
||||
<Button variant="outline" size="sm" className="h-7 text-[11px] shrink-0" onClick={reloadAudioDevices}>
|
||||
Refresh devices
|
||||
{t('aud.refreshDevices')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">From Radio (RX in)</Label>
|
||||
{deviceSelect('from_radio', audioInputs, 'Rig audio output → soundcard input')}
|
||||
<Label className="text-sm">To Radio (TX out)</Label>
|
||||
{deviceSelect('to_radio', audioOutputs, 'Soundcard output → rig mic/data in')}
|
||||
<Label className="text-sm">Recording mic</Label>
|
||||
{deviceSelect('recording_device', audioInputs, 'Your microphone (record DVK messages)')}
|
||||
<Label className="text-sm">Listening (preview)</Label>
|
||||
{deviceSelect('listening_device', audioOutputs, 'Local speakers for preview')}
|
||||
<Label className="text-sm">{t('aud.fromRadio')}</Label>
|
||||
{deviceSelect('from_radio', audioInputs, t('aud.phFromRadio'))}
|
||||
<Label className="text-sm">{t('aud.toRadio')}</Label>
|
||||
{deviceSelect('to_radio', audioOutputs, t('aud.phToRadio'))}
|
||||
<Label className="text-sm">{t('aud.recMic')}</Label>
|
||||
{deviceSelect('recording_device', audioInputs, t('aud.phRecMic'))}
|
||||
<Label className="text-sm">{t('aud.listening')}</Label>
|
||||
{deviceSelect('listening_device', audioOutputs, t('aud.phListening'))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
|
||||
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
|
||||
<strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '}
|
||||
<strong>{t('aud.toRadioShort')}</strong> {t('aud.explainTo')}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
@@ -4562,14 +4739,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
className="h-8"
|
||||
onClick={toggleMonitor}
|
||||
disabled={!monitorOn && !audioCfg.from_radio}
|
||||
title="Hear the rig's RX audio (From Radio) through your Listening device"
|
||||
title={t('aud.monitorTitle')}
|
||||
>
|
||||
{monitorOn ? '■ Stop listening' : '▶ Listen to radio'}
|
||||
{monitorOn ? t('aud.stopListening') : t('aud.listenRadio')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{monitorOn
|
||||
? 'RX monitor running — From Radio → Listening device.'
|
||||
: 'Live-monitor the rig here (USB codec now; network audio later).'}
|
||||
{monitorOn ? t('aud.monitorOn') : t('aud.monitorHint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -4579,101 +4754,106 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
className="h-8"
|
||||
onClick={toggleTX}
|
||||
disabled={!txOn && !audioCfg.to_radio}
|
||||
title="Key PTT and pipe your live mic into the rig (To Radio device)"
|
||||
title={t('aud.txTitle')}
|
||||
>
|
||||
{txOn ? '■ Stop talking (TX)' : '🎙 Talk to radio (TX)'}
|
||||
{txOn ? t('aud.stopTalk') : t('aud.talkRadio')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{txOn
|
||||
? 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.'
|
||||
: 'Live mic → rig with PTT (USB now; network TX later).'}
|
||||
{txOn ? t('aud.txOn') : t('aud.txHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
|
||||
<h4 className="text-sm font-semibold text-foreground">QSO recorder</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('aud.recorder')}</h4>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} />
|
||||
Record every QSO to an audio file (From Radio + your mic)
|
||||
{t('aud.recordEvery')}
|
||||
</label>
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">Recordings folder</Label>
|
||||
<Label className="text-sm">{t('aud.recFolder')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={audioCfg.qso_dir} onChange={(e) => setAudioField({ qso_dir: e.target.value })}
|
||||
placeholder="C:\…\OpsLog\Recordings" className="h-8 font-mono text-xs" />
|
||||
<Button variant="outline" size="sm" className="h-8 shrink-0"
|
||||
onClick={() => PickAudioFolder().then((d) => { if (d) setAudioField({ qso_dir: d }); }).catch(() => {})}>
|
||||
Browse…
|
||||
{t('aud.browse')}
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-sm">Pre-roll (seconds)</Label>
|
||||
<Label className="text-sm">{t('aud.preroll')}</Label>
|
||||
<Input type="number" min={0} max={60} value={audioCfg.preroll_seconds}
|
||||
onChange={(e) => setAudioField({ preroll_seconds: Math.max(0, Math.min(60, parseInt(e.target.value, 10) || 0)) })}
|
||||
className="h-8 w-24 font-mono" />
|
||||
<Label className="text-sm">File format</Label>
|
||||
<Label className="text-sm">{t('aud.format')}</Label>
|
||||
<Select value={audioCfg.format} onValueChange={(v) => setAudioField({ format: v as any })}>
|
||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="wav">WAV (lossless, larger)</SelectItem>
|
||||
<SelectItem value="mp3">MP3 (compressed, small)</SelectItem>
|
||||
<SelectItem value="wav">{t('aud.wav')}</SelectItem>
|
||||
<SelectItem value="mp3">{t('aud.mp3')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="text-sm">From Radio level</Label>
|
||||
<Label className="text-sm">{t('aud.fromLevel')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
|
||||
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||||
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
|
||||
</div>
|
||||
<Label className="text-sm">Mic level</Label>
|
||||
<Label className="text-sm">{t('aud.micLevel')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
|
||||
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||||
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">If your voice is louder than the station, lower Mic level.</p>
|
||||
<p className="text-xs text-muted-foreground">{t('aud.levelHint')}</p>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={emailCfg.auto_send} onCheckedChange={(c) => setEmailField({ auto_send: !!c })} />
|
||||
Auto-send the recording to the station by e-mail when I log a QSO
|
||||
{t('aud.autoSend')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-2 max-w-2xl">
|
||||
<h4 className="text-sm font-semibold text-foreground">Voice keyer messages (F1–F6)</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('aud.dvkTitle')}</h4>
|
||||
<div className="rounded-md border border-border/60 p-2.5 space-y-2">
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2 items-center">
|
||||
<Label className="text-sm">PTT method</Label>
|
||||
<Label className="text-sm">{t('aud.pttMethod')}</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={audioCfg.ptt_method} onValueChange={(v) => setAudioField({ ptt_method: v as any })}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None (VOX)</SelectItem>
|
||||
<SelectItem value="cat">CAT (OmniRig)</SelectItem>
|
||||
<SelectItem value="rts">Serial RTS</SelectItem>
|
||||
<SelectItem value="dtr">Serial DTR</SelectItem>
|
||||
<SelectItem value="none">{t('aud.pttNone')}</SelectItem>
|
||||
{/* This has ALWAYS driven whichever CAT backend is active —
|
||||
it calls the generic manager, and every backend (OmniRig,
|
||||
FlexRadio, Icom CI-V over USB and over the network, TCI)
|
||||
implements SetPTT. The label said "CAT (OmniRig)", so
|
||||
operators on a CI-V rig concluded there was no CAT PTT for
|
||||
them and reached for RTS/DTR or VOX instead. Name the
|
||||
backend actually configured. */}
|
||||
<SelectItem value="cat">{t('aud.pttCat')}{catBackendLabel ? ` — ${catBackendLabel}` : ''}</SelectItem>
|
||||
<SelectItem value="rts">{t('aud.pttRts')}</SelectItem>
|
||||
<SelectItem value="dtr">{t('aud.pttDtr')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{audioCfg.ptt_method !== 'none' && (
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT(audioCfg as any).catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
|
||||
Test PTT
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT(audioCfg as any).catch((e: any) => setDvkErr(t('aud.errPttTest') + String(e?.message ?? e))); }}>
|
||||
{t('aud.testPtt')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
|
||||
<>
|
||||
<Label className="text-sm">PTT COM port</Label>
|
||||
<Label className="text-sm">{t('aud.pttPort')}</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={audioCfg.ptt_port || '_'} onValueChange={(v) => setAudioField({ ptt_port: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue placeholder="Pick a COM port" /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue placeholder={t('aud.pickPort')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">— select —</SelectItem>
|
||||
<SelectItem value="_">{t('aud.selectPort')}</SelectItem>
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-[11px]"
|
||||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
Refresh
|
||||
{t('aud.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -4690,7 +4870,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<span className="w-7 font-mono text-xs font-bold text-muted-foreground">F{m.slot}</span>
|
||||
<Input
|
||||
className="h-8 flex-1"
|
||||
placeholder={`Message ${m.slot} label (CQ, report, 73…)`}
|
||||
placeholder={t('aud.msgPlaceholder', { n: m.slot })}
|
||||
value={m.label}
|
||||
onChange={(e) => setDvkMsgs((ms) => ms.map((x) => x.slot === m.slot ? { ...x, label: e.target.value } : x))}
|
||||
onBlur={(e) => SetDVKLabel(m.slot, e.target.value).catch(() => {})}
|
||||
@@ -4706,21 +4886,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
setDvkErr('');
|
||||
DVKStartRecord(m.slot).catch((err) => setDvkErr('Record: ' + String(err?.message ?? err)));
|
||||
DVKStartRecord(m.slot).catch((err) => setDvkErr(t('aud.errRecord') + String(err?.message ?? err)));
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr('Save: ' + String(err?.message ?? err)));
|
||||
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr(t('aud.errSave') + String(err?.message ?? err)));
|
||||
}}
|
||||
>
|
||||
{recHere ? '● Recording…' : '● Hold to rec'}
|
||||
{recHere ? t('aud.recordingNow') : t('aud.holdRec')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline" size="sm" className="h-8 w-20 shrink-0"
|
||||
disabled={!m.has_audio || dvkStat.recording}
|
||||
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr('Play: ' + String(err?.message ?? err))))}
|
||||
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr(t('aud.errPlay') + String(err?.message ?? err))))}
|
||||
>
|
||||
{dvkStat.playing ? '■ Stop' : '▶ Play'}
|
||||
{dvkStat.playing ? t('aud.stop') : t('aud.play')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -4761,6 +4941,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Checkbox checked={showBeamMap} onCheckedChange={(c) => { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }} />
|
||||
{t('gen.showBeam')}
|
||||
</label>
|
||||
|
||||
{/* Super Check Partial / N+1 — downloads the community MASTER.SCP list and
|
||||
shows a two-column callsign helper (partial matches + one-edit calls). */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
|
||||
{t('scp.enable')}
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground">{t('scp.settingsHint')}</p>
|
||||
{scp.enabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
|
||||
<ArrowDown className="size-3.5" /> {scpBusy ? t('scp.downloading') : t('scp.download')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{scp.count > 0
|
||||
? t('scp.loaded', { n: scp.count.toLocaleString(), date: scp.updated ? new Date(scp.updated).toLocaleDateString() : '?' })
|
||||
: t('scp.notLoaded')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
|
||||
{t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span>
|
||||
@@ -4941,15 +5143,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Label className="text-sm">{t('em.username')}</Label>
|
||||
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
|
||||
<Label className="text-sm">{t('es.password')}</Label>
|
||||
<div className="relative">
|
||||
<Input type={showSmtpPass ? 'text' : 'password'} className="h-8 pr-9" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||
<button type="button" tabIndex={-1} onClick={() => setShowSmtpPass((v) => !v)}
|
||||
title={showSmtpPass ? t('es.hidePass') : t('es.showPass')}
|
||||
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
disabled={!emailCfg.auth}>
|
||||
{showSmtpPass ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{/* No reveal button: the settings window is often open while the
|
||||
screen is shared or shown to someone in the shack. */}
|
||||
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||
<Label className="text-sm">{t('em.fromAddr')}</Label>
|
||||
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
||||
<Label className="text-sm">{t('em.replyTo')}</Label>
|
||||
|
||||
@@ -415,7 +415,11 @@ 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) {
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
}
|
||||
@@ -423,9 +427,9 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
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} /> });
|
||||
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} /> });
|
||||
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) });
|
||||
|
||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||
@@ -470,7 +474,12 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
|
||||
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
|
||||
{ordered.map((w) => (
|
||||
// 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Gauge, Radio, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
@@ -66,9 +66,42 @@ function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: {
|
||||
}
|
||||
|
||||
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 vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
const fwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
|
||||
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 ?? {};
|
||||
@@ -115,8 +148,11 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
|
||||
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
|
||||
</div>
|
||||
|
||||
{/* PWR + SWR meters — same grid as the Flex/amp meters so they match size. */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{/* 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')} />
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||
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';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
@@ -25,6 +25,8 @@ const hamlogTheme = hamlogGridTheme;
|
||||
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||||
|
||||
type Props = {
|
||||
// Operator's CURRENT locator — fallback for the distance column (see catalog).
|
||||
myGrid?: string;
|
||||
wb: WorkedBeforeView | null;
|
||||
busy: boolean;
|
||||
currentCall: string;
|
||||
@@ -35,6 +37,10 @@ type Props = {
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (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;
|
||||
// One column per defined award (cell = the reference this QSO counts for).
|
||||
awardCols?: { code: string; name: string }[];
|
||||
@@ -50,14 +56,14 @@ function fmtDate(s: any): string {
|
||||
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 gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog (shared with the Recent QSOs grid).
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
@@ -107,15 +113,18 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
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);
|
||||
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) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
});
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
||||
@@ -131,7 +140,9 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
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 {
|
||||
const col = gridRef.current?.api?.getColumn(colId);
|
||||
@@ -247,6 +258,11 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
</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
|
||||
menu={menu}
|
||||
onClose={() => setMenu(null)}
|
||||
@@ -256,6 +272,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
onSendTo={onSendTo}
|
||||
onSendRecording={onSendRecording}
|
||||
onSendEQSL={onSendEQSL}
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportSelectedFields={onExportSelectedFields}
|
||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
|
||||
@@ -19,6 +19,26 @@ let lsScope = '';
|
||||
// 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
|
||||
@@ -40,22 +60,66 @@ export function loadLocal(key: string): any[] | null {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const v = await GetUIPref(key);
|
||||
const parsed = v ? JSON.parse(v) : null;
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} 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). 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[]) {
|
||||
const json = JSON.stringify(state);
|
||||
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
|
||||
|
||||
+47
-11
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// 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).
|
||||
export const APP_VERSION = '0.21.1';
|
||||
export const APP_VERSION = '0.21.5';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+16
@@ -6,6 +6,7 @@ import {main} from '../models';
|
||||
import {cat} from '../models';
|
||||
import {profile} from '../models';
|
||||
import {acom} from '../models';
|
||||
import {catemu} from '../models';
|
||||
import {antgenius} from '../models';
|
||||
import {award} from '../models';
|
||||
import {awardref} from '../models';
|
||||
@@ -24,6 +25,7 @@ import {udp} from '../models';
|
||||
import {lotwusers} from '../models';
|
||||
import {lookup} from '../models';
|
||||
import {netctl} from '../models';
|
||||
import {scp} from '../models';
|
||||
|
||||
export function ACOMSetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
@@ -173,6 +175,8 @@ export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Prom
|
||||
|
||||
export function DownloadLoTWUsers():Promise<number>;
|
||||
|
||||
export function DownloadScp():Promise<number>;
|
||||
|
||||
export function DownloadULSCounties():Promise<void>;
|
||||
|
||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||
@@ -341,6 +345,8 @@ export function GetActiveProfile():Promise<profile.Profile>;
|
||||
|
||||
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>>;
|
||||
@@ -459,6 +465,8 @@ 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 GetSlotStats():Promise<qso.SlotStats>;
|
||||
@@ -855,6 +863,8 @@ export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):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 SendChatMessage(arg1:string):Promise<main.ChatMessage>;
|
||||
@@ -889,6 +899,8 @@ export function SetDVKLabel(arg1:number,arg2: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 SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
@@ -905,6 +917,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
|
||||
export function TestCloudlogUpload():Promise<string>;
|
||||
|
||||
export function TestClublogUpload():Promise<string>;
|
||||
|
||||
export function TestEQSLUpload():Promise<string>;
|
||||
@@ -937,6 +951,8 @@ 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 UltrabeamRetract():Promise<void>;
|
||||
|
||||
@@ -298,6 +298,10 @@ export function DownloadLoTWUsers() {
|
||||
return window['go']['main']['App']['DownloadLoTWUsers']();
|
||||
}
|
||||
|
||||
export function DownloadScp() {
|
||||
return window['go']['main']['App']['DownloadScp']();
|
||||
}
|
||||
|
||||
export function DownloadULSCounties() {
|
||||
return window['go']['main']['App']['DownloadULSCounties']();
|
||||
}
|
||||
@@ -634,6 +638,10 @@ export function 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']();
|
||||
}
|
||||
@@ -870,6 +878,10 @@ export function GetSPEStatus() {
|
||||
return window['go']['main']['App']['GetSPEStatus']();
|
||||
}
|
||||
|
||||
export function GetScpStatus() {
|
||||
return window['go']['main']['App']['GetScpStatus']();
|
||||
}
|
||||
|
||||
export function GetSecretStatus() {
|
||||
return window['go']['main']['App']['GetSecretStatus']();
|
||||
}
|
||||
@@ -1662,6 +1674,10 @@ export function 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) {
|
||||
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -1730,6 +1746,10 @@ export function SetPassphrase(arg1) {
|
||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||
}
|
||||
|
||||
export function SetScpEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetTelemetryEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
|
||||
}
|
||||
@@ -1762,6 +1782,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
|
||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function TestCloudlogUpload() {
|
||||
return window['go']['main']['App']['TestCloudlogUpload']();
|
||||
}
|
||||
|
||||
export function TestClublogUpload() {
|
||||
return window['go']['main']['App']['TestClublogUpload']();
|
||||
}
|
||||
@@ -1826,6 +1850,10 @@ 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() {
|
||||
return window['go']['main']['App']['ULSStatus']();
|
||||
}
|
||||
|
||||
@@ -1184,6 +1184,8 @@ export namespace extsvc {
|
||||
|
||||
export class ServiceConfig {
|
||||
api_key: string;
|
||||
url: string;
|
||||
station_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -1206,6 +1208,8 @@ export namespace extsvc {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.api_key = source["api_key"];
|
||||
this.url = source["url"];
|
||||
this.station_id = source["station_id"];
|
||||
this.email = source["email"];
|
||||
this.username = source["username"];
|
||||
this.password = source["password"];
|
||||
@@ -1228,6 +1232,7 @@ export namespace extsvc {
|
||||
lotw: ServiceConfig;
|
||||
hrdlog: ServiceConfig;
|
||||
eqsl: ServiceConfig;
|
||||
cloudlog: ServiceConfig;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ExternalServices(source);
|
||||
@@ -1240,6 +1245,7 @@ export namespace extsvc {
|
||||
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
|
||||
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
||||
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -1436,6 +1442,10 @@ export namespace main {
|
||||
port: number;
|
||||
com_port: string;
|
||||
baud: number;
|
||||
freq_out: boolean;
|
||||
freq_com_port: string;
|
||||
freq_baud: number;
|
||||
freq_broadcast_ms: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AmpConfig(source);
|
||||
@@ -1452,6 +1462,10 @@ export namespace main {
|
||||
this.port = source["port"];
|
||||
this.com_port = source["com_port"];
|
||||
this.baud = source["baud"];
|
||||
this.freq_out = source["freq_out"];
|
||||
this.freq_com_port = source["freq_com_port"];
|
||||
this.freq_baud = source["freq_baud"];
|
||||
this.freq_broadcast_ms = source["freq_broadcast_ms"];
|
||||
}
|
||||
}
|
||||
export class AmpStatus {
|
||||
@@ -2720,6 +2734,22 @@ export namespace main {
|
||||
this.com_port = source["com_port"];
|
||||
}
|
||||
}
|
||||
export class ScpStatus {
|
||||
enabled: boolean;
|
||||
count: number;
|
||||
updated?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ScpStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.count = source["count"];
|
||||
this.updated = source["updated"];
|
||||
}
|
||||
}
|
||||
export class SecretStatus {
|
||||
has_passphrase: boolean;
|
||||
unlocked: boolean;
|
||||
@@ -4323,6 +4353,25 @@ export namespace qso {
|
||||
|
||||
}
|
||||
|
||||
export namespace scp {
|
||||
|
||||
export class Result {
|
||||
partial: string[];
|
||||
nplus1: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Result(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.partial = source["partial"];
|
||||
this.nplus1 = source["nplus1"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace solar {
|
||||
|
||||
export class Data {
|
||||
|
||||
@@ -649,15 +649,29 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
||||
const reconnectEvery = 5 * time.Second
|
||||
connected := false
|
||||
var lastAttempt time.Time
|
||||
var lastConnErr string // last connect failure logged, so the retry loop says it once
|
||||
tryConnect := func() {
|
||||
if connected || time.Since(lastAttempt) < reconnectEvery {
|
||||
return
|
||||
}
|
||||
lastAttempt = time.Now()
|
||||
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()})
|
||||
return
|
||||
}
|
||||
if lastConnErr != "" {
|
||||
debugLog.Printf("%s connected (after: %s)", b.Name(), lastConnErr)
|
||||
lastConnErr = ""
|
||||
}
|
||||
connected = true
|
||||
}
|
||||
tryConnect()
|
||||
|
||||
+27
-6
@@ -307,22 +307,43 @@ func ModeToADIF(m byte, data bool) string {
|
||||
|
||||
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
|
||||
// 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 {
|
||||
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:
|
||||
return "IC-7300"
|
||||
case 0x98:
|
||||
return "IC-7610"
|
||||
case 0x7C:
|
||||
return "IC-9100"
|
||||
case 0xA2:
|
||||
return "IC-9700"
|
||||
case 0xA4:
|
||||
return "IC-705"
|
||||
case 0x88:
|
||||
return "IC-7700"
|
||||
case 0x80:
|
||||
return "IC-7800"
|
||||
}
|
||||
return fmt.Sprintf("Icom (0x%02X)", addr)
|
||||
}
|
||||
|
||||
@@ -130,3 +130,35 @@ func TestModelName(t *testing.T) {
|
||||
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",
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +518,14 @@ func (f *Flex) handleStatus(payload string) {
|
||||
alreadyBound := f.boundClientID != ""
|
||||
f.mu.Unlock()
|
||||
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 {
|
||||
f.mu.Lock()
|
||||
f.boundClientID = clientID
|
||||
|
||||
@@ -374,10 +374,54 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
if !b.dspLoaded {
|
||||
b.readDSP()
|
||||
b.dspLoaded = true
|
||||
} else {
|
||||
b.refreshFrontPanel()
|
||||
}
|
||||
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 {
|
||||
if hz <= 0 {
|
||||
return fmt.Errorf("invalid frequency")
|
||||
|
||||
+19
-4
@@ -4,6 +4,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// LogSink, when set by the host app at startup, receives every CAT debug
|
||||
@@ -15,13 +16,21 @@ var LogSink func(format string, args ...any)
|
||||
// catLogger forwards Printf either to the host LogSink (preferred) or to a
|
||||
// local file/stderr fallback. Keeps the call sites (debugLog.Printf(...))
|
||||
// unchanged.
|
||||
type catLogger struct{ fallback *log.Logger }
|
||||
type catLogger struct {
|
||||
once sync.Once
|
||||
fallback *log.Logger
|
||||
}
|
||||
|
||||
func (c *catLogger) Printf(format string, args ...any) {
|
||||
if LogSink != nil {
|
||||
LogSink("cat: "+format, args...)
|
||||
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 {
|
||||
c.fallback.Printf(format, args...)
|
||||
}
|
||||
@@ -30,7 +39,7 @@ func (c *catLogger) Printf(format string, args ...any) {
|
||||
// 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
|
||||
// main opslog.log.
|
||||
var debugLog = &catLogger{fallback: openFallbackLog()}
|
||||
var debugLog = &catLogger{}
|
||||
|
||||
func openFallbackLog() *log.Logger {
|
||||
base, err := os.UserConfigDir()
|
||||
@@ -49,9 +58,15 @@ func openFallbackLog() *log.Logger {
|
||||
return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
|
||||
}
|
||||
|
||||
// DebugLogPath returns where the fallback cat.log lives, for surfacing in the
|
||||
// UI / docs. When LogSink is wired, CAT lines are in the main app log instead.
|
||||
// DebugLogPath returns where the fallback cat.log lives, or "" when CAT lines are
|
||||
// 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 {
|
||||
if LogSink != nil {
|
||||
return "" // lines go to the unified app log; there is no separate cat.log
|
||||
}
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
|
||||
+262
-61
@@ -34,6 +34,11 @@ type OmniRig struct {
|
||||
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.
|
||||
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
||||
// async Freq property, which still reports the OLD band for a poll or two
|
||||
@@ -41,6 +46,19 @@ type OmniRig struct {
|
||||
// the sideband (freq moved, but mode read the old band → wrong sideband).
|
||||
lastSetFreq int64
|
||||
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
|
||||
}
|
||||
|
||||
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
||||
@@ -53,8 +71,55 @@ func NewOmniRig(rigNum int) *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 {
|
||||
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 {
|
||||
// 0x1 = S_FALSE → COM already initialised on this thread, fine.
|
||||
if oerr, ok := err.(*ole.OleError); !ok || oerr.Code() != 0x00000001 {
|
||||
@@ -62,15 +127,43 @@ func (o *OmniRig) Connect() error {
|
||||
}
|
||||
}
|
||||
|
||||
unk, err := oleutil.CreateObject("Omnirig.OmnirigX")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Omnirig.OmnirigX not available — is OmniRig installed and running?: %w", err)
|
||||
}
|
||||
omnirig, err := unk.QueryInterface(ole.IID_IDispatch)
|
||||
const progID = "Omnirig.OmnirigX"
|
||||
var omnirig *ole.IDispatch
|
||||
unk, err := oleutil.CreateObject(progID)
|
||||
if err == nil {
|
||||
omnirig, err = unk.QueryInterface(ole.IID_IDispatch)
|
||||
unk.Release()
|
||||
if err != nil {
|
||||
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))
|
||||
if err != nil {
|
||||
@@ -80,10 +173,26 @@ func (o *OmniRig) Connect() error {
|
||||
o.omnirig = omnirig
|
||||
o.rig = rigVar.ToIDispatch()
|
||||
|
||||
// Log WHICH OmniRig answered. There are two incompatible products called
|
||||
// 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 to Rig%d type=%q", o.RigNum, o.rigType)
|
||||
}
|
||||
debugLog.Printf("OmniRig connected: Rig%d type=%q (OmniRig interface=%d software=%d)",
|
||||
o.RigNum, o.rigType, iv, sv)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -155,66 +264,151 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
||||
splitRaw = v.Val
|
||||
}
|
||||
|
||||
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so
|
||||
// normal operation stays quiet but toggling split on the radio is captured —
|
||||
// needed to pin down this rig's PM_SPLITON value.
|
||||
// FTDX101D field capture: OmniRig alternates between two contradictory
|
||||
// readings on consecutive polls — "Vfo=AB Split=0x10000(OFF)" then
|
||||
// "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
|
||||
|
||||
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, s.Vfo, splitRaw, splitRecentOn)
|
||||
|
||||
// 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 {
|
||||
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",
|
||||
o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 {
|
||||
if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil {
|
||||
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: read VFO A first, fall back to the generic Freq — exactly like
|
||||
// DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
|
||||
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
|
||||
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
|
||||
// stock ini), so keying off FreqA gives the operator the VFO they expect.
|
||||
s.Split = false
|
||||
s.RxFreqHz = 0
|
||||
switch {
|
||||
case freqA != 0:
|
||||
s.FreqHz = freqA
|
||||
case freqMain != 0:
|
||||
s.FreqHz = freqMain
|
||||
default:
|
||||
s.FreqHz = freqB
|
||||
}
|
||||
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, o.rigType, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, splitRecentOn,
|
||||
s.FreqHz, s.RxFreqHz, s.Split)
|
||||
}
|
||||
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 {
|
||||
if o.rig == nil {
|
||||
debugLog.Printf("OmniRig.SetFrequency(%d): NOT CONNECTED", hz)
|
||||
@@ -486,6 +680,13 @@ func omniRigMode(m int64) string {
|
||||
|
||||
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
|
||||
// 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 {
|
||||
switch {
|
||||
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,77 @@
|
||||
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},
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// Package catemu emulates a transceiver on a serial port so a device that
|
||||
// POLLS a radio for its frequency can follow OpsLog instead.
|
||||
//
|
||||
// This exists for the ACOM amplifiers: on their CAT/AUX connector the amp is
|
||||
// the master — it polls the transceiver every few hundred milliseconds and
|
||||
// changes band only when it gets a valid reply. There is no way to push a
|
||||
// frequency to it, so following OpsLog means answering its polls.
|
||||
//
|
||||
// The dialect is ACOM "command set 5" (Kenwood / Elecraft RS-232, also what
|
||||
// Flex and SunSDR users select): plain ASCII commands terminated by ';'. It is
|
||||
// the simplest of the five sets by a wide margin, which is why the SDC utility
|
||||
// uses it to steer an ACOM with no physical radio attached.
|
||||
//
|
||||
// Only the handful of commands an amp actually asks for are implemented:
|
||||
//
|
||||
// FA; → FA00014025000; TX frequency, 11 digits, Hz
|
||||
// FB; → same (sub VFO — amps poll it on some firmware)
|
||||
// IF; → the 38-character TS-2000 status frame
|
||||
// ID; → ID019; (TS-2000 — a known model keeps the amp from timing out)
|
||||
//
|
||||
// Anything else is ignored rather than answered: a wrong-length reply is worse
|
||||
// than none, because it desynchronises the amp's parser for the next poll.
|
||||
package catemu
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// Config is the serial port the amplifier's CAT/AUX cable is wired to. This is
|
||||
// a SECOND port, independent of the one used for the amp's own remote/metering
|
||||
// protocol — both run at the same time on an ACOM.
|
||||
type Config struct {
|
||||
ComPort string
|
||||
Baud int
|
||||
|
||||
// BroadcastMs > 0 also sends the frequency UNPROMPTED every that many
|
||||
// milliseconds. Some amplifiers do not poll at all: they sit in parallel on
|
||||
// the radio↔PC CAT line and read whatever goes past. Such an amp would never
|
||||
// hear us, since answering polls means speaking only when spoken to.
|
||||
// 0 = answer polls only.
|
||||
BroadcastMs int
|
||||
}
|
||||
|
||||
// Status is what the settings panel shows about the link.
|
||||
type Status struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Connected bool `json:"connected"`
|
||||
Port string `json:"port"`
|
||||
Polls int64 `json:"polls"` // replies sent since start
|
||||
LastCmd string `json:"last_cmd"` // last command received, e.g. "FA;"
|
||||
LastAt string `json:"last_at"` // RFC3339 of the last poll, "" if none
|
||||
FreqHz int64 `json:"freq_hz"` // what we are currently answering
|
||||
Error string `json:"error"` // last open/IO failure
|
||||
}
|
||||
|
||||
// Server answers a polling amplifier on one serial port.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
status Status
|
||||
|
||||
freqHz atomic.Int64
|
||||
mode atomic.Value // string, ADIF-ish ("CW", "USB"…)
|
||||
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
|
||||
logf func(string, ...any)
|
||||
}
|
||||
|
||||
// New builds a server. Nothing is opened until Start.
|
||||
func New(cfg Config, logf func(string, ...any)) *Server {
|
||||
if cfg.Baud <= 0 {
|
||||
cfg.Baud = 9600
|
||||
}
|
||||
s := &Server{cfg: cfg, logf: logf}
|
||||
s.mode.Store("")
|
||||
s.status.Port = cfg.ComPort
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) log(format string, args ...any) {
|
||||
if s.logf != nil {
|
||||
s.logf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// SetFrequency updates the frequency reported to the amplifier. Called from the
|
||||
// CAT state callback; safe from any goroutine and never blocks — the serve loop
|
||||
// reads the value when a poll arrives, so a fast-tuning VFO costs nothing.
|
||||
func (s *Server) SetFrequency(hz int64) { s.freqHz.Store(hz) }
|
||||
|
||||
// SetMode updates the mode digit in the IF frame. Optional: the amp only cares
|
||||
// about the frequency, but a coherent frame avoids odd firmware behaviour.
|
||||
func (s *Server) SetMode(mode string) { s.mode.Store(strings.ToUpper(strings.TrimSpace(mode))) }
|
||||
|
||||
// Start opens the port and serves polls until Stop. It returns immediately;
|
||||
// a port that is missing or busy is retried every 5 s, because the amplifier is
|
||||
// often powered on after the software.
|
||||
func (s *Server) Start() {
|
||||
s.stop = make(chan struct{})
|
||||
s.done = make(chan struct{})
|
||||
go s.run()
|
||||
}
|
||||
|
||||
// Stop closes the port and waits for the loop to end.
|
||||
func (s *Server) Stop() {
|
||||
if s.stop == nil {
|
||||
return
|
||||
}
|
||||
close(s.stop)
|
||||
s.mu.Lock()
|
||||
if s.port != nil {
|
||||
_ = s.port.Close()
|
||||
s.port = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
<-s.done
|
||||
s.stop = nil
|
||||
}
|
||||
|
||||
// GetStatus returns a snapshot for the UI.
|
||||
func (s *Server) GetStatus() Status {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.status
|
||||
st.Enabled = true
|
||||
st.FreqHz = s.freqHz.Load()
|
||||
return st
|
||||
}
|
||||
|
||||
func (s *Server) setErr(msg string) {
|
||||
s.mu.Lock()
|
||||
s.status.Error = msg
|
||||
s.status.Connected = false
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) run() {
|
||||
defer close(s.done)
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err := s.open(); err != nil {
|
||||
s.setErr(err.Error())
|
||||
s.log("catemu: %s open failed: %v (retry in 5s)", s.cfg.ComPort, err)
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.serve()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) open() error {
|
||||
if strings.TrimSpace(s.cfg.ComPort) == "" {
|
||||
return fmt.Errorf("no COM port configured")
|
||||
}
|
||||
p, err := serial.Open(s.cfg.ComPort, &serial.Mode{
|
||||
BaudRate: s.cfg.Baud,
|
||||
DataBits: 8,
|
||||
Parity: serial.NoParity,
|
||||
StopBits: serial.OneStopBit,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A short read timeout keeps the loop responsive to Stop while idle: the amp
|
||||
// may poll only every few hundred ms, and a blocking read would hold the
|
||||
// port open past shutdown.
|
||||
_ = p.SetReadTimeout(200 * time.Millisecond)
|
||||
s.mu.Lock()
|
||||
s.port = p
|
||||
s.status.Connected = true
|
||||
s.status.Error = ""
|
||||
s.mu.Unlock()
|
||||
s.log("catemu: serving Kenwood-format polls on %s at %d baud", s.cfg.ComPort, s.cfg.Baud)
|
||||
return nil
|
||||
}
|
||||
|
||||
// broadcast sends an unsolicited FA frame at the configured interval, for an
|
||||
// amplifier that listens to the CAT line rather than polling it. It stops when
|
||||
// the port is closed or Stop is called.
|
||||
func (s *Server) broadcast(stopServe <-chan struct{}) {
|
||||
if s.cfg.BroadcastMs <= 0 {
|
||||
return
|
||||
}
|
||||
// Below ~100 ms this is pure noise on the wire; the band only ever changes
|
||||
// at human speed.
|
||||
every := time.Duration(s.cfg.BroadcastMs) * time.Millisecond
|
||||
if every < 100*time.Millisecond {
|
||||
every = 100 * time.Millisecond
|
||||
}
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-stopServe:
|
||||
return
|
||||
case <-t.C:
|
||||
hz := s.freqHz.Load()
|
||||
if hz <= 0 {
|
||||
continue // nothing known yet — say nothing rather than "0 Hz"
|
||||
}
|
||||
s.mu.Lock()
|
||||
p := s.port
|
||||
s.mu.Unlock()
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if _, err := p.Write([]byte(fmt.Sprintf("FA%011d;", hz))); err != nil {
|
||||
s.setErr(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serve reads commands until the port fails or Stop is called.
|
||||
func (s *Server) serve() {
|
||||
// The broadcaster shares this port and must die with it, or it would write
|
||||
// into a closed handle after a reopen.
|
||||
stopServe := make(chan struct{})
|
||||
defer close(stopServe)
|
||||
go s.broadcast(stopServe)
|
||||
|
||||
buf := make([]byte, 64)
|
||||
var acc []byte
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
s.mu.Lock()
|
||||
p := s.port
|
||||
s.mu.Unlock()
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
n, err := p.Read(buf)
|
||||
if err != nil {
|
||||
s.setErr(err.Error())
|
||||
s.log("catemu: %s read failed: %v — reopening", s.cfg.ComPort, err)
|
||||
s.mu.Lock()
|
||||
if s.port != nil {
|
||||
_ = s.port.Close()
|
||||
s.port = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
acc = append(acc, buf[:n]...)
|
||||
// Commands are ';'-terminated; handle every complete one in the buffer.
|
||||
for {
|
||||
i := indexByte(acc, ';')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
cmd := strings.ToUpper(strings.TrimSpace(string(acc[:i])))
|
||||
acc = acc[i+1:]
|
||||
s.handle(p, cmd)
|
||||
}
|
||||
// A runaway buffer means we are seeing something that is not this
|
||||
// protocol (wrong baud, or the amp's other port); drop it rather than
|
||||
// grow without bound.
|
||||
if len(acc) > 512 {
|
||||
acc = acc[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func indexByte(b []byte, c byte) int {
|
||||
for i := range b {
|
||||
if b[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// handle answers one command. cmd has no trailing ';'.
|
||||
func (s *Server) handle(p serial.Port, cmd string) {
|
||||
hz := s.freqHz.Load()
|
||||
var reply string
|
||||
switch {
|
||||
case cmd == "FA" || cmd == "FB":
|
||||
reply = fmt.Sprintf("%s%011d;", cmd, hz)
|
||||
case cmd == "IF":
|
||||
reply = s.ifFrame(hz)
|
||||
case cmd == "ID":
|
||||
reply = "ID019;" // TS-2000
|
||||
default:
|
||||
// Unknown or a SET command (FA00014025000;) — silently ignored: an amp
|
||||
// never sets our frequency, and answering the wrong length would break
|
||||
// its parser for the following poll.
|
||||
return
|
||||
}
|
||||
if _, err := p.Write([]byte(reply)); err != nil {
|
||||
s.setErr(err.Error())
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.status.Polls++
|
||||
s.status.LastCmd = cmd + ";"
|
||||
s.status.LastAt = time.Now().Format(time.RFC3339)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// modeDigit maps our mode name to the Kenwood mode digit used in IF.
|
||||
func (s *Server) modeDigit() byte {
|
||||
m, _ := s.mode.Load().(string)
|
||||
switch {
|
||||
case strings.HasPrefix(m, "CW"):
|
||||
return '3'
|
||||
case strings.HasPrefix(m, "LSB"):
|
||||
return '1'
|
||||
case strings.HasPrefix(m, "USB"), strings.HasPrefix(m, "SSB"):
|
||||
return '2'
|
||||
case strings.HasPrefix(m, "FM"):
|
||||
return '4'
|
||||
case strings.HasPrefix(m, "AM"):
|
||||
return '5'
|
||||
case m == "RTTY", strings.HasPrefix(m, "FSK"):
|
||||
return '6'
|
||||
case m == "":
|
||||
return '2'
|
||||
default:
|
||||
// Data modes (FT8, PSK…) ride on SSB as far as an amplifier cares.
|
||||
return '2'
|
||||
}
|
||||
}
|
||||
|
||||
// ifFrame builds the 38-character TS-2000 IF status frame:
|
||||
//
|
||||
// IF | freq(11) | step(4) | RIT(6) | RIT/XIT/…(3) | memch(2) | rx/tx | mode |
|
||||
// FR | scan | split | tone | tone#(2) | shift | ;
|
||||
//
|
||||
// Only the frequency and mode carry meaning here; the rest is a valid, inert
|
||||
// state (no RIT, receiving, simplex) so the amp's parser is satisfied.
|
||||
func (s *Server) ifFrame(hz int64) string {
|
||||
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%01d%01d%01d%01d%02d%01d;",
|
||||
hz, // P1 frequency, Hz
|
||||
0, // P2 frequency step
|
||||
0, // P3 RIT/XIT offset, signed 5 digits
|
||||
0, // P4-P6 RIT off, XIT off, channel-bank
|
||||
0, // P7 memory channel
|
||||
0, // P8 0 = RX
|
||||
s.modeDigit(), // P9 mode
|
||||
0, // P10 VFO A
|
||||
0, // P11 scan off
|
||||
0, // P12 split off
|
||||
0, // P13 tone off
|
||||
0, // P14 tone number
|
||||
0, // P15 shift
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package catemu
|
||||
|
||||
import "testing"
|
||||
|
||||
// The reply LENGTHS are the contract: an amplifier parses these frames by
|
||||
// fixed offsets, so a frame one character short desynchronises its parser for
|
||||
// every following poll. Pin them.
|
||||
func TestReplyShapes(t *testing.T) {
|
||||
s := New(Config{ComPort: "COM99", Baud: 9600}, nil)
|
||||
s.SetFrequency(14025000)
|
||||
s.SetMode("CW")
|
||||
|
||||
if got := s.ifFrame(14025000); len(got) != 38 {
|
||||
t.Errorf("IF frame is %d chars, want 38: %q", len(got), got)
|
||||
}
|
||||
// TS-2000 IF: "IF" then the 11-digit frequency in Hz.
|
||||
if got := s.ifFrame(14025000); got[:13] != "IF00014025000" {
|
||||
t.Errorf("IF frame frequency field = %q", got[:13])
|
||||
}
|
||||
if got := s.ifFrame(14025000); got[len(got)-1] != ';' {
|
||||
t.Errorf("IF frame not terminated by ';': %q", got)
|
||||
}
|
||||
// Mode digit sits at P9, right after freq(11)+step(4)+rit(6)+3+2+1 — index 29 in the frame.
|
||||
if got := s.ifFrame(14025000)[29]; got != '3' {
|
||||
t.Errorf("CW mode digit = %q, want '3'", got)
|
||||
}
|
||||
s.SetMode("FT8") // data rides on SSB as far as an amp cares
|
||||
if got := s.ifFrame(14074000)[29]; got != '2' {
|
||||
t.Errorf("FT8 mode digit = %q, want '2'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeDigit(t *testing.T) {
|
||||
cases := map[string]byte{
|
||||
"CW": '3', "CW-R": '3', "LSB": '1', "USB": '2', "SSB": '2',
|
||||
"FM": '4', "AM": '5', "RTTY": '6', "": '2', "FT8": '2',
|
||||
}
|
||||
for mode, want := range cases {
|
||||
s := New(Config{}, nil)
|
||||
s.SetMode(mode)
|
||||
if got := s.modeDigit(); got != want {
|
||||
t.Errorf("mode %q → %q, want %q", mode, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
-3
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -143,7 +144,6 @@ func SetDialect(d string) {
|
||||
// same INSERT/UPDATE works on both backends.
|
||||
func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") }
|
||||
|
||||
|
||||
// Open opens (and creates if needed) the SQLite database at the given path,
|
||||
// enables performance PRAGMAs, and applies embedded migrations.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
@@ -161,18 +161,77 @@ func Open(path string) (*sql.DB, error) {
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
Dialect = "sqlite"
|
||||
if err := migrate(conn, nil); err != nil {
|
||||
if err := migrate(conn, nil, path); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// LogSink receives this package's diagnostic lines. The app points it at
|
||||
// applog.Printf at startup (same pattern as cat / audio / extsvc); left nil in
|
||||
// tests and in the CLI tools under cmd/, where it is simply discarded.
|
||||
var LogSink func(format string, args ...any)
|
||||
|
||||
// logMigration records a migration that has just been applied, and how long it
|
||||
// took — the only trace an operator has that a data-rewriting migration ran.
|
||||
func logMigration(name string, start time.Time) {
|
||||
logf("db: migration %s applied in %s", name, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
func logf(format string, args ...any) {
|
||||
if LogSink != nil {
|
||||
LogSink(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// dataRewriteMarker flags a migration that rewrites existing user rows rather
|
||||
// than only altering the schema. Put it on its own line in the .sql file, and a
|
||||
// safety copy of the logbook is taken before it runs.
|
||||
const dataRewriteMarker = "-- opslog:rewrites-data"
|
||||
|
||||
// backupBeforeRewrite takes a one-off copy of the logbook before a migration
|
||||
// that rewrites user rows.
|
||||
//
|
||||
// It exists because the auto-updater gives the operator no say: the new build
|
||||
// relaunches and migrates before the changelog explaining it is ever shown, so
|
||||
// "back up first" is advice nobody can act on. The app takes the copy instead.
|
||||
//
|
||||
// Skipped when there is nothing to protect — a shared MySQL (no file to copy;
|
||||
// that server is the admin's to back up), the settings database, and a
|
||||
// freshly-created empty logbook all have no QSOs at stake.
|
||||
func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
|
||||
if dbPath == "" {
|
||||
return
|
||||
}
|
||||
var n int
|
||||
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n == 0 {
|
||||
return
|
||||
}
|
||||
dest := dbPath + ".pre-" + strings.TrimSuffix(migration, ".sql") + ".bak"
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return // a copy from an earlier attempt is already there — never overwrite it
|
||||
}
|
||||
// VACUUM INTO rather than copying the file: it writes a consistent,
|
||||
// self-contained snapshot even with WAL pages still outstanding, which a
|
||||
// plain file copy would silently miss. It cannot run inside a transaction,
|
||||
// so it happens here, before the migration opens one. The destination is
|
||||
// spliced (VACUUM INTO takes no bound parameter), with quotes doubled.
|
||||
start := time.Now()
|
||||
if _, err := conn.Exec(`VACUUM INTO '` + strings.ReplaceAll(dest, "'", "''") + `'`); err != nil {
|
||||
// Not fatal: the migration itself is a single atomic transaction, so
|
||||
// failing to take a belt-and-braces copy is no reason to block the update.
|
||||
logf("db: could not back up before %s: %v — continuing (the migration is atomic)", migration, err)
|
||||
return
|
||||
}
|
||||
logf("db: backed up %d QSO(s) to %s in %s before %s", n, dest, time.Since(start).Round(time.Millisecond), migration)
|
||||
}
|
||||
|
||||
// migrate applies all embedded *.sql migrations in alphabetical order,
|
||||
// skipping those already applied. Intentionally minimal in-house system
|
||||
// (no external dependency). translate, when non-nil, rewrites each statement
|
||||
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
|
||||
func migrate(conn *sql.DB, translate func(string) string) error {
|
||||
func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
|
||||
// A non-nil translator means this is the MySQL connection (use the
|
||||
// per-statement, FK-aware path); nil means a SQLite connection. This is
|
||||
// determined by the caller's argument, NOT the global Dialect, so the
|
||||
@@ -219,12 +278,22 @@ func migrate(conn *sql.DB, translate func(string) string) error {
|
||||
if applied[name] {
|
||||
continue // already applied
|
||||
}
|
||||
// Timed, and logged only once it has actually succeeded (below). Most
|
||||
// migrations are instant DDL, but some rewrite user rows — 0024 upper-cases
|
||||
// every callsign — and on a large logbook that is exactly what an operator
|
||||
// wants confirmed afterwards: that it ran, once, and what it cost.
|
||||
start := time.Now()
|
||||
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
sqlText := translate(string(content))
|
||||
|
||||
// A migration that rewrites user rows gets a safety copy taken first.
|
||||
if strings.Contains(string(content), dataRewriteMarker) {
|
||||
backupBeforeRewrite(conn, dbPath, name)
|
||||
}
|
||||
|
||||
// MySQL implicitly commits each DDL statement, so a wrapping transaction
|
||||
// gives no atomicity — a mid-file failure would leave columns/tables
|
||||
// behind, unrecorded, and every restart would re-run and choke on
|
||||
@@ -238,6 +307,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
|
||||
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", name, err)
|
||||
}
|
||||
logMigration(name, start)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -257,6 +327,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
logMigration(name, start)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const mig0024 = "0024_normalise_callsign.sql"
|
||||
|
||||
// openWithUnappliedRewrite builds a logbook that already holds QSOs and has not
|
||||
// yet had the callsign-normalising migration applied — i.e. exactly what an
|
||||
// existing installation looks like the moment the auto-updater relaunches it.
|
||||
func openWithUnappliedRewrite(t *testing.T, rows [][2]string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "logbook.db")
|
||||
conn, err := Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if _, err := conn.Exec(
|
||||
`INSERT INTO qso (callsign, qso_date, band, mode) VALUES (?, ?, '20m', 'SSB')`, r[0], r[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Rewind so the migration runs against real data on the next Open.
|
||||
if _, err := conn.Exec(`DELETE FROM schema_migrations WHERE name = ?`, mig0024); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func callsigns(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
conn, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
rows, err := conn.Query(`SELECT callsign FROM qso ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The auto-updater migrates before the operator ever sees the changelog, so the
|
||||
// app has to take the safety copy itself. The copy must hold the data as it was
|
||||
// BEFORE the rewrite — a copy of the already-migrated rows would be worthless.
|
||||
func TestRewriteMigrationBacksUpOriginalData(t *testing.T) {
|
||||
p := openWithUnappliedRewrite(t, [][2]string{
|
||||
{"f5lit", "2026-07-01"},
|
||||
{" Pa3Eyf ", "2026-07-02"},
|
||||
{"F4BPO", "2026-07-03"},
|
||||
})
|
||||
|
||||
var logged []string
|
||||
LogSink = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
|
||||
defer func() { LogSink = nil }()
|
||||
|
||||
conn, err := Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
// The live logbook is normalised.
|
||||
if got, want := callsigns(t, p), []string{"F5LIT", "PA3EYF", "F4BPO"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("logbook callsigns = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// The backup exists, next to the logbook, named after the migration.
|
||||
backup := p + ".pre-0024_normalise_callsign.bak"
|
||||
if _, err := os.Stat(backup); err != nil {
|
||||
t.Fatalf("no safety copy at %s: %v\nlogged:\n%s", backup, err, strings.Join(logged, "\n"))
|
||||
}
|
||||
// …and it holds the ORIGINAL rows, untouched.
|
||||
if got, want := callsigns(t, backup), []string{"f5lit", " Pa3Eyf ", "F4BPO"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("backup callsigns = %v, want the pre-migration values %v", got, want)
|
||||
}
|
||||
|
||||
var sawBackup bool
|
||||
for _, l := range logged {
|
||||
if strings.Contains(l, "backed up 3 QSO(s)") {
|
||||
sawBackup = true
|
||||
}
|
||||
}
|
||||
if !sawBackup {
|
||||
t.Errorf("the backup was not logged; got:\n%s", strings.Join(logged, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// No QSOs, nothing to protect: the settings database and a freshly created
|
||||
// logbook must not litter the folder with pointless copies.
|
||||
func TestRewriteMigrationSkipsBackupWhenNoQSOs(t *testing.T) {
|
||||
p := openWithUnappliedRewrite(t, nil)
|
||||
conn, err := Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
if _, err := os.Stat(p + ".pre-0024_normalise_callsign.bak"); err == nil {
|
||||
t.Error("an empty database should not be backed up")
|
||||
}
|
||||
}
|
||||
|
||||
// schema_migrations is what makes "runs once" a guarantee rather than a promise:
|
||||
// a second launch must neither re-run the rewrite nor take a second copy.
|
||||
func TestRewriteMigrationRunsOnce(t *testing.T) {
|
||||
p := openWithUnappliedRewrite(t, [][2]string{{"f5lit", "2026-07-01"}})
|
||||
conn, err := Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
backup := p + ".pre-0024_normalise_callsign.bak"
|
||||
first, err := os.Stat(backup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var logged []string
|
||||
LogSink = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
|
||||
defer func() { LogSink = nil }()
|
||||
|
||||
conn2, err := Open(p) // second launch
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn2.Close()
|
||||
|
||||
for _, l := range logged {
|
||||
if strings.Contains(l, mig0024) {
|
||||
t.Errorf("migration ran again on the second launch: %s", l)
|
||||
}
|
||||
}
|
||||
second, err := os.Stat(backup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !first.ModTime().Equal(second.ModTime()) {
|
||||
t.Error("the existing safety copy was overwritten on the second launch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
-- opslog:rewrites-data
|
||||
-- Normalise stored callsigns so lookups can use idx_qso_callsign.
|
||||
--
|
||||
-- WorkedBefore matched rows with `upper(trim(callsign)) = ?`. Wrapping the
|
||||
-- column in functions makes the predicate non-sargable: SQLite cannot use the
|
||||
-- index and falls back to scanning. Measured on a 190 000-row logbook, the
|
||||
-- COUNT went from 0.3 ms (SEARCH ... USING INDEX) to 20.8 ms (SCAN), and the
|
||||
-- entries query — which needs every column, so not even a covering index helps
|
||||
-- — scans the whole table. That runs on every keystroke of a callsign, and it
|
||||
-- made the entry strip's history arrive too late to auto-fill the name and
|
||||
-- locator from the previous QSO. Small logbooks never showed it.
|
||||
--
|
||||
-- Add, bulk insert and Update have always upper-cased and trimmed the callsign,
|
||||
-- so this only rewrites rows left by older versions or foreign imports, and the
|
||||
-- queries can then compare the column directly.
|
||||
--
|
||||
-- SQLite compares case-sensitively, so the WHERE finds exactly the rows that
|
||||
-- need it. MySQL's default collation is case- and trailing-space-insensitive:
|
||||
-- there the UPDATE is largely a no-op and equally unnecessary, because `=`
|
||||
-- already matches those rows through the index.
|
||||
UPDATE qso SET callsign = upper(trim(callsign))
|
||||
WHERE callsign <> upper(trim(callsign));
|
||||
@@ -200,7 +200,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
||||
err = applyMySQLBaseline(conn)
|
||||
} else {
|
||||
// Existing database: apply only the migrations it's missing.
|
||||
err = migrate(conn, mysqlDDL)
|
||||
err = migrate(conn, mysqlDDL, "")
|
||||
}
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
@@ -287,7 +287,7 @@ func applyMySQLBaseline(conn *sql.DB) error {
|
||||
return fmt.Errorf("open baseline sqlite: %w", err)
|
||||
}
|
||||
defer mem.Close()
|
||||
if err := migrate(mem, nil); err != nil {
|
||||
if err := migrate(mem, nil, ""); err != nil {
|
||||
return fmt.Errorf("build baseline schema: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cloudlog (and its fork Wavelog) are self-hosted logbooks, so there is no
|
||||
// fixed endpoint: the user gives the base URL of their own instance and we
|
||||
// append the API path. Both expose the SAME contract — an ADIF record wrapped
|
||||
// in JSON — which is why one uploader serves both.
|
||||
//
|
||||
// POST <base>/index.php/api/qso
|
||||
// {"key":"…","station_profile_id":"1","type":"adif","string":"<call:4>… <eor>"}
|
||||
//
|
||||
// The station profile id is NOT optional: Cloudlog files the QSO under one of
|
||||
// the account's station locations, and a wrong id silently lands the contact in
|
||||
// someone else's log slot.
|
||||
const cloudlogAPIPath = "index.php/api/qso"
|
||||
|
||||
// cloudlogEndpoint builds the API URL from whatever the user pasted. People
|
||||
// paste the dashboard URL, the API URL, with or without a trailing slash or
|
||||
// index.php, so normalise all of it rather than make them guess the exact form.
|
||||
func cloudlogEndpoint(base string) (string, error) {
|
||||
u := strings.TrimSpace(base)
|
||||
if u == "" {
|
||||
return "", fmt.Errorf("cloudlog: URL not set")
|
||||
}
|
||||
// A bare host or IP is almost always meant as http:// on a LAN instance;
|
||||
// requiring the scheme just produces a confusing "unsupported protocol".
|
||||
if !strings.HasPrefix(strings.ToLower(u), "http://") && !strings.HasPrefix(strings.ToLower(u), "https://") {
|
||||
u = "http://" + u
|
||||
}
|
||||
u = strings.TrimRight(u, "/")
|
||||
// Trim anything the user copied past the site root, so both
|
||||
// "https://log.f4bpo.fr" and "https://log.f4bpo.fr/index.php/api/qso" work.
|
||||
for _, suffix := range []string{"/index.php/api/qso", "/api/qso", "/index.php"} {
|
||||
if strings.HasSuffix(strings.ToLower(u), suffix) {
|
||||
u = u[:len(u)-len(suffix)]
|
||||
u = strings.TrimRight(u, "/")
|
||||
}
|
||||
}
|
||||
return u + "/" + cloudlogAPIPath, nil
|
||||
}
|
||||
|
||||
// cloudlogRequest is the JSON body both Cloudlog and Wavelog expect.
|
||||
type cloudlogRequest struct {
|
||||
Key string `json:"key"`
|
||||
StationID string `json:"station_profile_id"`
|
||||
Type string `json:"type"`
|
||||
String string `json:"string"`
|
||||
}
|
||||
|
||||
// cloudlogReply covers the documented failure shape
|
||||
// ({"status":"failed","reason":"missing api key"}); success replies vary
|
||||
// between versions, so success is judged on the HTTP status plus the ABSENCE
|
||||
// of a failure marker rather than on a field that may not be there.
|
||||
type cloudlogReply struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
Type string `json:"type"`
|
||||
String string `json:"string"`
|
||||
}
|
||||
|
||||
// cloudlogPost sends one JSON body and returns the trimmed response.
|
||||
func cloudlogPost(ctx context.Context, client *http.Client, endpoint string, body cloudlogRequest) (string, int, error) {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("cloudlog: encode request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("cloudlog: build request: %w", err)
|
||||
}
|
||||
// Content-Type is what most "wrong JSON" reports come down to: without it
|
||||
// Cloudlog falls back to form-decoding and never sees the fields.
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("cloudlog: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
return strings.TrimSpace(string(raw)), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// cloudlogReason turns a reply into a human-readable failure reason, or ""
|
||||
// when the reply looks like a success.
|
||||
func cloudlogReason(body string, status int) string {
|
||||
var r cloudlogReply
|
||||
if json.Unmarshal([]byte(body), &r) == nil && strings.EqualFold(r.Status, "failed") {
|
||||
if r.Reason != "" {
|
||||
return r.Reason
|
||||
}
|
||||
return "rejected"
|
||||
}
|
||||
switch {
|
||||
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||
// The documented 401 body is {"status":"failed","reason":"missing api key"},
|
||||
// but a reverse proxy in front of the instance can swallow it.
|
||||
return "API key refused"
|
||||
case status == http.StatusNotFound:
|
||||
return "API not found at this URL — check the address of your Cloudlog/Wavelog instance"
|
||||
case status >= 400:
|
||||
msg := body
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200]
|
||||
}
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("HTTP %d", status)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UploadCloudlog pushes one ADIF record to a Cloudlog or Wavelog instance.
|
||||
//
|
||||
// Duplicates are handled by the server (Cloudlog dedupes on the fly), so a
|
||||
// retry of an already-accepted QSO is harmless — the upload stays idempotent
|
||||
// without OpsLog having to track it.
|
||||
func UploadCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||
endpoint, err := cloudlogEndpoint(cfg.URL)
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
key := strings.TrimSpace(cfg.APIKey)
|
||||
station := strings.TrimSpace(cfg.StationID)
|
||||
if key == "" {
|
||||
return UploadResult{}, fmt.Errorf("cloudlog: API key not set")
|
||||
}
|
||||
if station == "" {
|
||||
return UploadResult{}, fmt.Errorf("cloudlog: station ID not set")
|
||||
}
|
||||
if strings.TrimSpace(adifRecord) == "" {
|
||||
return UploadResult{}, fmt.Errorf("cloudlog: empty adif record")
|
||||
}
|
||||
|
||||
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
|
||||
Key: key, StationID: station, Type: "adif", String: adifRecord,
|
||||
})
|
||||
if err != nil {
|
||||
return UploadResult{OK: false, Message: body}, err
|
||||
}
|
||||
// The endpoint is echoed in both outcomes: a self-hosted instance means the
|
||||
// URL itself is a prime suspect, and a log line naming what was actually
|
||||
// called settles it without the operator having to guess how we normalised
|
||||
// what they typed.
|
||||
if reason := cloudlogReason(body, status); reason != "" {
|
||||
return UploadResult{OK: false, Message: reason},
|
||||
fmt.Errorf("cloudlog: POST %s → HTTP %d: %s", endpoint, status, reason)
|
||||
}
|
||||
return UploadResult{OK: true, Message: fmt.Sprintf("uploaded to %s (HTTP %d)", endpoint, status)}, nil
|
||||
}
|
||||
|
||||
// TestCloudlog validates URL, API key and station ID with a REAL request.
|
||||
//
|
||||
// It posts a well-formed body with an EMPTY ADIF string: the credentials are
|
||||
// checked by the server before the record is parsed, so a bad key or id fails
|
||||
// exactly as it would for a real QSO, while nothing is inserted.
|
||||
func TestCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||
endpoint, err := cloudlogEndpoint(cfg.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := strings.TrimSpace(cfg.APIKey)
|
||||
station := strings.TrimSpace(cfg.StationID)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("cloudlog: API key not set")
|
||||
}
|
||||
if station == "" {
|
||||
return "", fmt.Errorf("cloudlog: station ID not set")
|
||||
}
|
||||
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
|
||||
Key: key, StationID: station, Type: "adif", String: "",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if reason := cloudlogReason(body, status); reason != "" {
|
||||
return "", fmt.Errorf("cloudlog: %s", reason)
|
||||
}
|
||||
return fmt.Sprintf("Connected — station profile %s", station), nil
|
||||
}
|
||||
@@ -33,6 +33,9 @@ const (
|
||||
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
||||
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
|
||||
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
|
||||
// ServiceCloudlog covers Cloudlog AND its fork Wavelog: same API contract,
|
||||
// only the instance URL differs, so one service handles both.
|
||||
ServiceCloudlog Service = "cloudlog"
|
||||
)
|
||||
|
||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||
@@ -63,6 +66,8 @@ const (
|
||||
// user can run e.g. Club Log immediate and QRZ delayed).
|
||||
type ServiceConfig struct {
|
||||
APIKey string `json:"api_key"`
|
||||
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
|
||||
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
|
||||
Email string `json:"email"` // Club Log account email
|
||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||
Password string `json:"password"` // Club Log account / LoTW website password
|
||||
@@ -83,6 +88,8 @@ type ServiceConfig struct {
|
||||
// mode (defaults to immediate).
|
||||
func (c ServiceConfig) normalised() ServiceConfig {
|
||||
c.APIKey = strings.TrimSpace(c.APIKey)
|
||||
c.URL = strings.TrimSpace(c.URL)
|
||||
c.StationID = strings.TrimSpace(c.StationID)
|
||||
c.Email = strings.TrimSpace(c.Email)
|
||||
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
||||
c.Code = strings.TrimSpace(c.Code)
|
||||
@@ -120,6 +127,7 @@ type ExternalServices struct {
|
||||
LoTW ServiceConfig `json:"lotw"`
|
||||
HRDLog ServiceConfig `json:"hrdlog"`
|
||||
EQSL ServiceConfig `json:"eqsl"`
|
||||
Cloudlog ServiceConfig `json:"cloudlog"`
|
||||
}
|
||||
|
||||
// UploadResult is the outcome of a single upload attempt.
|
||||
|
||||
+24
-3
@@ -65,11 +65,32 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("lotw: http %d", resp.StatusCode)
|
||||
}
|
||||
// LoTW returns a plain-text error (not ADIF) on bad login.
|
||||
// Not ADIF. Two very different failures land here, and telling them apart is
|
||||
// the difference between a fixable message and a wall of markup.
|
||||
if !strings.Contains(strings.ToUpper(text), "<EOH>") && !strings.Contains(strings.ToLower(text), "<eor>") {
|
||||
msg := strings.TrimSpace(text)
|
||||
trimmed := strings.TrimSpace(text)
|
||||
// Keep the whole thing in the log — that is where a real diagnosis happens,
|
||||
// and a 200-character excerpt of an HTML page tells nobody anything.
|
||||
snippet := trimmed
|
||||
if len(snippet) > 2000 {
|
||||
snippet = snippet[:2000]
|
||||
}
|
||||
LogSink("lotw: expected ADIF, got %d bytes of non-ADIF; first 2000: %s", len(text), snippet)
|
||||
|
||||
// LoTW answers a REJECTED LOGIN with its ordinary web page rather than an
|
||||
// error string, so an HTML body here means the credentials were not
|
||||
// accepted — not that the download is broken.
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") || strings.Contains(lower, "logbook of the world</title>") {
|
||||
return "", fmt.Errorf("LoTW returned its web page instead of a log, which is how it answers a login it did not accept. " +
|
||||
"Check the username and password in Settings → External services: LoTW wants your lotw.arrl.org WEBSITE login, " +
|
||||
"not your callsign certificate or your ARRL member number")
|
||||
}
|
||||
|
||||
// Anything else: a plain-text complaint from LoTW, or a maintenance notice.
|
||||
msg := trimmed
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200]
|
||||
msg = msg[:200] + "…"
|
||||
}
|
||||
return "", fmt.Errorf("lotw: unexpected response: %s", msg)
|
||||
}
|
||||
|
||||
@@ -138,7 +138,30 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
||||
cfg.LoTW = cfg.LoTW.normalised()
|
||||
cfg.HRDLog = cfg.HRDLog.normalised()
|
||||
cfg.EQSL = cfg.EQSL.normalised()
|
||||
cfg.Cloudlog = cfg.Cloudlog.normalised()
|
||||
m.cfg = cfg
|
||||
|
||||
// Summary of what is armed, written at startup and on every settings save.
|
||||
// It answers "is the service even switched on for this profile?" — the
|
||||
// settings are per-profile, so a service configured under another profile
|
||||
// looks enabled in the UI of the one and silent in the other.
|
||||
var on []string
|
||||
for _, s := range []struct {
|
||||
name string
|
||||
cfg ServiceConfig
|
||||
}{
|
||||
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
|
||||
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
|
||||
} {
|
||||
if s.cfg.AutoUpload {
|
||||
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
|
||||
}
|
||||
}
|
||||
if len(on) == 0 {
|
||||
m.logf("extsvc: auto-upload disabled for every service")
|
||||
} else {
|
||||
m.logf("extsvc: auto-upload armed for %s", strings.Join(on, " "))
|
||||
}
|
||||
}
|
||||
|
||||
// Config returns the current snapshot.
|
||||
@@ -182,6 +205,28 @@ func (m *Manager) OnQSOLogged(id int64) {
|
||||
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
|
||||
m.route(ServiceEQSL, id, e)
|
||||
}
|
||||
// Cloudlog / Wavelog — the instance URL, an API key and the station id.
|
||||
if c := cfg.Cloudlog; c.AutoUpload {
|
||||
// Say WHY nothing happens when the toggle is on but a field is missing.
|
||||
// Without this the whole path was silent — the operator saw no upload and
|
||||
// no log line, with no way to tell "disabled" from "broken".
|
||||
var missing []string
|
||||
if c.URL == "" {
|
||||
missing = append(missing, "URL")
|
||||
}
|
||||
if c.APIKey == "" {
|
||||
missing = append(missing, "API key")
|
||||
}
|
||||
if c.StationID == "" {
|
||||
missing = append(missing, "station ID")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
m.logf("extsvc: cloudlog auto-upload is ON but not configured — missing %s (QSO %d not sent)",
|
||||
strings.Join(missing, ", "), id)
|
||||
} else {
|
||||
m.route(ServiceCloudlog, id, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// route sends a logged QSO down the configured timing path: queue it for the
|
||||
@@ -229,6 +274,9 @@ func (m *Manager) onCloseServices() []Service {
|
||||
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
|
||||
out = append(out, ServiceEQSL)
|
||||
}
|
||||
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
|
||||
out = append(out, ServiceCloudlog)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -288,6 +336,12 @@ func (m *Manager) FlushOnClose() int {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
case ServiceCloudlog:
|
||||
for _, id := range ids {
|
||||
if ok, _ := m.upload(svc, id, cfg.Cloudlog); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return uploaded
|
||||
@@ -376,6 +430,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
||||
}
|
||||
}
|
||||
|
||||
// One line per attempt, BEFORE the request: a failure that never returns
|
||||
// (hung TCP connect to a self-hosted instance) otherwise leaves no trace at
|
||||
// all, and "did it even try?" is the first question when an upload is missing.
|
||||
m.logf("extsvc: %s uploading QSO %d…", svc, id)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -426,6 +485,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
||||
return false, false
|
||||
}
|
||||
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
||||
case ServiceCloudlog:
|
||||
// Cloudlog/Wavelog file the QSO under a station profile chosen by id,
|
||||
// so the ADIF keeps the QSO's own station call (no override).
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return false, false
|
||||
}
|
||||
res, err = UploadCloudlog(ctx, m.deps.Client, cfg, record)
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
@@ -441,7 +509,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
||||
return false, true // transient (rate-limit / network) → worth a retry
|
||||
}
|
||||
|
||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q) %s", svc, id, res.LogID, res.Message)
|
||||
if m.deps.MarkUploaded != nil {
|
||||
m.deps.MarkUploaded(svc, id, res.LogID)
|
||||
}
|
||||
|
||||
@@ -19,3 +19,44 @@ func TestHomeCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile and maritime-mobile suffixes: /M is the case that surfaced in the field
|
||||
// (F4LYI/M resolved only to cty.dat while F4LYI resolved on QRZ), so pin the
|
||||
// whole suffix family — the home call is what the provider record is filed under.
|
||||
func TestHomeCallSuffixes(t *testing.T) {
|
||||
for call, want := range map[string]string{
|
||||
"F4LYI/M": "F4LYI",
|
||||
"F4LYI/MM": "F4LYI",
|
||||
"F4LYI/AM": "F4LYI",
|
||||
"F4LYI/QRP": "F4LYI",
|
||||
"F4LYI/A": "F4LYI",
|
||||
"F4LYI/B": "F4LYI",
|
||||
} {
|
||||
if got := homeCall(call); got != want {
|
||||
t.Errorf("homeCall(%q) = %q, want %q", call, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Operational suffixes must resolve to the bare call WITHOUT a provider query on
|
||||
// the slashed form; entity- and area-changing forms must not be stripped.
|
||||
func TestStripOpSuffix(t *testing.T) {
|
||||
strip := map[string]string{
|
||||
"F4LYI/M": "F4LYI", "F4LYI/MM": "F4LYI", "F4LYI/AM": "F4LYI",
|
||||
"F4LYI/P": "F4LYI", "F4LYI/QRP": "F4LYI", "F4LYI/p": "F4LYI",
|
||||
"F4BPO/M/P": "F4BPO",
|
||||
}
|
||||
for call, want := range strip {
|
||||
got, ok := stripOpSuffix(call)
|
||||
if !ok || got != want {
|
||||
t.Errorf("stripOpSuffix(%q) = %q,%v — want %q,true", call, got, ok, want)
|
||||
}
|
||||
}
|
||||
// These change the entity or the call area: they are real, separately
|
||||
// registered forms and must be queried exactly as entered.
|
||||
for _, call := range []string{"JW/OR1A", "VP8/F4BPO", "F4BPO/8", "DL/F4NIE", "OH2BH", "F4BPO/W6"} {
|
||||
if got, ok := stripOpSuffix(call); ok {
|
||||
t.Errorf("stripOpSuffix(%q) stripped to %q — it changes entity/area and must be kept", call, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,13 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
// An operational suffix (/M, /P, …) is never registered as such: skip the
|
||||
// futile query on the slashed form and let the home-call pass below do the one
|
||||
// request that can actually answer.
|
||||
_, opOnly := stripOpSuffix(call)
|
||||
if opOnly {
|
||||
LogSink("lookup: %s carries only an operational suffix — querying the bare call", call)
|
||||
} else {
|
||||
for _, p := range providers {
|
||||
r, err := p.Lookup(ctx, call)
|
||||
if err == nil {
|
||||
@@ -126,6 +133,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
}
|
||||
lastErr = fmt.Errorf("%s: %w", p.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Portable / slashed call not found under its full form: the operator's
|
||||
// record lives under the HOME call (JW/OR1A → OR1A, DL/F4NIE → F4NIE). Look
|
||||
@@ -135,6 +143,10 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
for _, p := range providers {
|
||||
r, err := p.Lookup(ctx, home)
|
||||
if err != nil {
|
||||
// Logged, because this is where a portable lookup silently dies: the
|
||||
// error is swallowed to try the next provider, and the operator only
|
||||
// ever sees the cty.dat fallback with no clue why.
|
||||
LogSink("lookup: %s → home call %s failed on %s: %v", call, home, p.Name(), err)
|
||||
continue
|
||||
}
|
||||
r.Callsign = call
|
||||
@@ -177,6 +189,46 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
return Result{}, lastErr
|
||||
}
|
||||
|
||||
// LogSink receives this package's diagnostic lines (which call was actually
|
||||
// queried, and why a lookup fell back). Set to applog.Printf by the app.
|
||||
var LogSink = func(string, ...any) {}
|
||||
|
||||
// opSuffixes are OPERATIONAL suffixes: they describe how the operator is working
|
||||
// — mobile, maritime, aeronautical, portable, low power — not who they are or
|
||||
// where. No provider has a record filed under "F4LYI/M", so querying that form
|
||||
// is a round trip that cannot succeed.
|
||||
//
|
||||
// It was worse than merely wasted: it spent the lookup's time budget, so the
|
||||
// home-call retry that followed ran out of time and the entry fell back to
|
||||
// cty.dat for every /M and /P call, even though the operator was on QRZ. These
|
||||
// go straight to the bare callsign instead.
|
||||
//
|
||||
// Everything else after a slash is NOT this: JW/, VP8/ change the DXCC entity,
|
||||
// and /8 or /W6 change the call area. Those forms can be registered in their own
|
||||
// right and must be looked up exactly as entered.
|
||||
var opSuffixes = map[string]bool{"M": true, "MM": true, "AM": true, "P": true, "QRP": true}
|
||||
|
||||
// stripOpSuffix returns the bare callsign when call carries nothing but
|
||||
// operational suffixes ("F4LYI/M" → "F4LYI", true). Reports false for anything
|
||||
// that changes entity or area ("JW/OR1A", "F4BPO/8"), and for a call whose base
|
||||
// part isn't callsign-shaped.
|
||||
func stripOpSuffix(call string) (string, bool) {
|
||||
if !strings.ContainsRune(call, '/') {
|
||||
return call, false
|
||||
}
|
||||
parts := strings.Split(call, "/")
|
||||
base := strings.TrimSpace(parts[0])
|
||||
if len(base) < 3 || !strings.ContainsAny(base, "0123456789") {
|
||||
return call, false // "JW/OR1A": the first part is a prefix, not the callsign
|
||||
}
|
||||
for _, p := range parts[1:] {
|
||||
if !opSuffixes[strings.ToUpper(strings.TrimSpace(p))] {
|
||||
return call, false
|
||||
}
|
||||
}
|
||||
return base, true
|
||||
}
|
||||
|
||||
// homeCall extracts the operator's home callsign from a slashed/portable call
|
||||
// so its provider record (name/QTH/QSL) can be fetched when the full form isn't
|
||||
// registered: JW/OR1A → OR1A, DL/F4NIE → F4NIE, F4BPO/P → F4BPO, VP8/F4BPO →
|
||||
@@ -266,12 +318,30 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||
return false
|
||||
}
|
||||
filled := false
|
||||
if country != "" { r.Country = country; filled = true }
|
||||
if cont != "" { r.Continent = cont; filled = true }
|
||||
if cqz != 0 { r.CQZ = cqz; filled = true }
|
||||
if ituz != 0 { r.ITUZ = ituz; filled = true }
|
||||
if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true }
|
||||
if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true }
|
||||
if country != "" {
|
||||
r.Country = country
|
||||
filled = true
|
||||
}
|
||||
if cont != "" {
|
||||
r.Continent = cont
|
||||
filled = true
|
||||
}
|
||||
if cqz != 0 {
|
||||
r.CQZ = cqz
|
||||
filled = true
|
||||
}
|
||||
if ituz != 0 {
|
||||
r.ITUZ = ituz
|
||||
filled = true
|
||||
}
|
||||
if lat != 0 && r.Lat == 0 {
|
||||
r.Lat = lat
|
||||
filled = true
|
||||
}
|
||||
if lon != 0 && r.Lon == 0 {
|
||||
r.Lon = lon
|
||||
filled = true
|
||||
}
|
||||
// cty.dat is authoritative for the *operating* entity: it strips benign
|
||||
// suffixes (/P /M /MM /QRP /A …) and honours real prefixes (DL/F4NIE).
|
||||
// Use its DXCC# when known — this overrides the provider's home-call
|
||||
@@ -279,7 +349,10 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||
// France's 227). Only when cty.dat can't map a slashed call do we drop
|
||||
// the provider's number rather than mislabel.
|
||||
if dxccNum != 0 {
|
||||
if r.DXCC != dxccNum { r.DXCC = dxccNum; filled = true }
|
||||
if r.DXCC != dxccNum {
|
||||
r.DXCC = dxccNum
|
||||
filled = true
|
||||
}
|
||||
} else if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
|
||||
r.DXCC = 0
|
||||
filled = true
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package qso
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// openBulkTestDB builds the minimum of the qso table this needs. It does not go
|
||||
// through db.Open (that would pull the whole migration set and an import cycle);
|
||||
// the columns BulkSetExtra touches are extras_json and updated_at.
|
||||
func openBulkTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
conn, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
if _, err := conn.Exec(`CREATE TABLE qso (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
callsign TEXT NOT NULL,
|
||||
extras_json TEXT,
|
||||
updated_at TEXT
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
func extras(t *testing.T, conn *sql.DB, id int64) map[string]any {
|
||||
t.Helper()
|
||||
var raw sql.NullString
|
||||
if err := conn.QueryRow(`SELECT extras_json FROM qso WHERE id = ?`, id).Scan(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !raw.Valid || raw.String == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(raw.String), &m); err != nil {
|
||||
t.Fatalf("extras_json is not valid JSON (%q): %v", raw.String, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// OWNER_CALLSIGN has no promoted column, so bulk-editing it means merging a key
|
||||
// into extras_json. The thing that must not happen is collateral damage: the
|
||||
// other ADIF extras on the same QSO have to survive.
|
||||
func TestBulkSetExtraPreservesOtherExtras(t *testing.T) {
|
||||
conn := openBulkTestDB(t)
|
||||
r := &Repo{db: conn}
|
||||
ctx := context.Background()
|
||||
|
||||
// Two QSOs with existing extras, one with none at all (NULL column).
|
||||
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F5LIT', '{"SILENT_KEY":"Y","ANT_PATH":"S"}')`)
|
||||
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('PA3EYF', '{"ANT_PATH":"L"}')`)
|
||||
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F4BPO', NULL)`)
|
||||
|
||||
n, err := r.BulkSetExtra(ctx, []int64{1, 2, 3}, "OWNER_CALLSIGN", "TM2Q")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Errorf("updated %d rows, want 3", n)
|
||||
}
|
||||
|
||||
e1 := extras(t, conn, 1)
|
||||
if e1["OWNER_CALLSIGN"] != "TM2Q" {
|
||||
t.Errorf("row 1 OWNER_CALLSIGN = %v, want TM2Q", e1["OWNER_CALLSIGN"])
|
||||
}
|
||||
if e1["SILENT_KEY"] != "Y" || e1["ANT_PATH"] != "S" {
|
||||
t.Errorf("row 1 lost its other extras: %v", e1)
|
||||
}
|
||||
// A NULL extras_json must become a valid object, not stay null or hold "null".
|
||||
if e3 := extras(t, conn, 3); e3["OWNER_CALLSIGN"] != "TM2Q" {
|
||||
t.Errorf("row 3 (extras_json was NULL) = %v, want OWNER_CALLSIGN=TM2Q", e3)
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing the field must REMOVE the key: a blank extra would otherwise be
|
||||
// carried into every ADIF export from then on.
|
||||
func TestBulkSetExtraEmptyRemovesKey(t *testing.T) {
|
||||
conn := openBulkTestDB(t)
|
||||
r := &Repo{db: conn}
|
||||
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F5LIT', '{"OWNER_CALLSIGN":"TM2Q","ANT_PATH":"S"}')`)
|
||||
|
||||
if _, err := r.BulkSetExtra(context.Background(), []int64{1}, "OWNER_CALLSIGN", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := extras(t, conn, 1)
|
||||
if _, present := e["OWNER_CALLSIGN"]; present {
|
||||
t.Errorf("OWNER_CALLSIGN should be gone, got %v", e)
|
||||
}
|
||||
if e["ANT_PATH"] != "S" {
|
||||
t.Errorf("clearing one extra removed another: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// The frontend field id must resolve to the ADIF key, and nothing else must slip
|
||||
// through — this map is the whitelist guarding a spliced JSON path.
|
||||
func TestBulkExtraKeyWhitelist(t *testing.T) {
|
||||
if got := BulkExtraKey("owner_callsign"); got != "OWNER_CALLSIGN" {
|
||||
t.Errorf(`BulkExtraKey("owner_callsign") = %q, want "OWNER_CALLSIGN"`, got)
|
||||
}
|
||||
for _, bad := range []string{"", "callsign", "notes", "OWNER_CALLSIGN", "owner_callsign'"} {
|
||||
if got := BulkExtraKey(bad); got != "" {
|
||||
t.Errorf("BulkExtraKey(%q) = %q, want empty", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
-6
@@ -828,6 +828,56 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// bulkEditableExtras whitelists ADIF fields that are bulk-editable but live in
|
||||
// extras_json rather than in a promoted column. Key = the frontend's field id,
|
||||
// value = the uppercase ADIF key inside the JSON object.
|
||||
//
|
||||
// OWNER_CALLSIGN is the case that prompted this: it was already filterable (see
|
||||
// filterableExtras) but could not be bulk-edited, because BulkSetField writes a
|
||||
// column and there is no owner_callsign column.
|
||||
var bulkEditableExtras = map[string]string{
|
||||
"owner_callsign": "OWNER_CALLSIGN",
|
||||
}
|
||||
|
||||
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
|
||||
func BulkExtraKey(field string) string { return bulkEditableExtras[field] }
|
||||
|
||||
// BulkSetExtra sets one whitelisted extras_json field on every listed QSO,
|
||||
// leaving the other extras untouched. An empty value REMOVES the key rather than
|
||||
// storing a blank — an empty extra would otherwise be carried into every export.
|
||||
//
|
||||
// json_set / json_remove exist under those names in both SQLite and MySQL and
|
||||
// take the same '$.KEY' path syntax, so one statement serves both backends.
|
||||
func (r *Repo) BulkSetExtra(ctx context.Context, ids []int64, adifKey, value string) (int64, error) {
|
||||
if adifKey == "" {
|
||||
return 0, fmt.Errorf("empty extras key")
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ph := make([]string, len(ids))
|
||||
args := make([]any, 0, len(ids)+2)
|
||||
expr := `json_set(COALESCE(extras_json, '{}'), '$.` + adifKey + `', ?)`
|
||||
if value == "" {
|
||||
expr = `json_remove(COALESCE(extras_json, '{}'), '$.` + adifKey + `')`
|
||||
} else {
|
||||
args = append(args, value)
|
||||
}
|
||||
args = append(args, db.NowISO())
|
||||
for i, id := range ids {
|
||||
ph[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||
args...)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bulk set extra %s: %w", adifKey, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// BulkSetFrequency sets freq_hz AND band together on every listed QSO. Kept
|
||||
// separate from BulkSetField because frequency is numeric and must keep the band
|
||||
// consistent — the main use is fixing a batch that was logged on a stale/default
|
||||
@@ -1478,14 +1528,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
|
||||
// ---- Per-callsign stats ----
|
||||
if err := r.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
|
||||
`SELECT COUNT(*) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
|
||||
return wb, fmt.Errorf("count worked: %w", err)
|
||||
}
|
||||
if wb.Count > 0 {
|
||||
// Pull the full QSO records (same columns as the Recent QSOs list) so
|
||||
// the Worked-before grid can offer the same rich column picker.
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+`
|
||||
FROM qso WHERE upper(trim(callsign)) = ?
|
||||
FROM qso WHERE callsign = ?
|
||||
ORDER BY qso_date DESC, id DESC
|
||||
LIMIT ?`, wb.Callsign, maxWorkedEntries)
|
||||
if err != nil {
|
||||
@@ -1520,7 +1570,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
if wb.Count > maxWorkedEntries {
|
||||
var firstStr sql.NullString
|
||||
_ = r.db.QueryRowContext(ctx,
|
||||
`SELECT MIN(qso_date) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&firstStr)
|
||||
`SELECT MIN(qso_date) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&firstStr)
|
||||
if firstStr.Valid {
|
||||
wb.First = parseTimeLoose(firstStr.String)
|
||||
}
|
||||
@@ -1545,7 +1595,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
var d sql.NullInt64
|
||||
_ = r.db.QueryRowContext(ctx, `
|
||||
SELECT dxcc FROM qso
|
||||
WHERE upper(trim(callsign)) = ? AND dxcc IS NOT NULL
|
||||
WHERE callsign = ? AND dxcc IS NOT NULL
|
||||
ORDER BY qso_date DESC LIMIT 1`, wb.Callsign).Scan(&d)
|
||||
if d.Valid {
|
||||
dxcc = int(d.Int64)
|
||||
@@ -1614,8 +1664,8 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
// WorkedBefore call, blanking the matrix in the UI.
|
||||
statusRows, err := r.db.QueryContext(ctx, `
|
||||
SELECT band, mode,
|
||||
MAX(CASE WHEN upper(trim(callsign)) = ? THEN 1 ELSE 0 END),
|
||||
MAX(CASE WHEN upper(trim(callsign)) = ?
|
||||
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
|
||||
MAX(CASE WHEN callsign = ?
|
||||
AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y')
|
||||
THEN 1 ELSE 0 END),
|
||||
MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// Package scp provides Super Check Partial (SCP) and N+1 callsign suggestions
|
||||
// from the community MASTER.SCP master file (supercheckpartial.com) — the same
|
||||
// aid contest loggers (N1MM, DXLog, Log4OM) show to catch/correct a busted call.
|
||||
//
|
||||
// - Partial (SCP): every master call that CONTAINS what you've typed, so a
|
||||
// mistyped or half-copied call surfaces the real ones.
|
||||
// - N+1: master calls exactly one edit away (one char substituted, added or
|
||||
// removed) from the full call you typed — the classic "did I bust it?" check.
|
||||
//
|
||||
// The list is downloaded once and cached on disk so it survives restarts.
|
||||
package scp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// masterURL is the community Super Check Partial master file: one callsign per
|
||||
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
|
||||
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP"
|
||||
|
||||
const cacheFile = "MASTER.SCP"
|
||||
|
||||
// Result is the two suggestion lists for a typed fragment.
|
||||
type Result struct {
|
||||
Partial []string `json:"partial"` // master calls containing the fragment
|
||||
NPlus1 []string `json:"nplus1"` // master calls one edit away from the full call
|
||||
}
|
||||
|
||||
// Manager holds the parsed call list + cache location.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
calls []string // UPPER, de-duplicated, sorted
|
||||
updated time.Time // when the cache was last refreshed
|
||||
dir string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewManager loads any on-disk cache and returns a ready manager.
|
||||
func NewManager(dataDir string) *Manager {
|
||||
m := &Manager{
|
||||
dir: dataDir,
|
||||
client: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
m.loadCache()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
|
||||
|
||||
func (m *Manager) loadCache() {
|
||||
data, err := os.ReadFile(m.path())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.parse(data)
|
||||
if fi, e := os.Stat(m.path()); e == nil {
|
||||
m.mu.Lock()
|
||||
m.updated = fi.ModTime()
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Download fetches the latest MASTER.SCP, caches it and replaces the in-memory
|
||||
// list. Returns the number of callsigns loaded.
|
||||
func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, masterURL, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "OpsLog")
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scp: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("scp: http %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scp: read: %w", err)
|
||||
}
|
||||
n := m.parse(body)
|
||||
if n == 0 {
|
||||
return 0, fmt.Errorf("scp: file parsed to 0 callsigns")
|
||||
}
|
||||
_ = os.WriteFile(m.path(), body, 0o644) // best-effort cache
|
||||
m.mu.Lock()
|
||||
m.updated = time.Now()
|
||||
m.mu.Unlock()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// parse loads the SCP bytes into the sorted call slice and returns the count.
|
||||
func (m *Manager) parse(data []byte) int {
|
||||
seen := make(map[string]struct{}, 1<<17)
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
line := strings.ToUpper(strings.TrimSpace(sc.Text()))
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue // blank / header comment
|
||||
}
|
||||
// A call token only (the master file is one call per line, but guard
|
||||
// against stray trailing fields).
|
||||
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
if !plausibleCall(line) {
|
||||
continue
|
||||
}
|
||||
seen[line] = struct{}{}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return 0
|
||||
}
|
||||
calls := make([]string, 0, len(seen))
|
||||
for c := range seen {
|
||||
calls = append(calls, c)
|
||||
}
|
||||
sort.Strings(calls)
|
||||
m.mu.Lock()
|
||||
m.calls = calls
|
||||
m.mu.Unlock()
|
||||
return len(calls)
|
||||
}
|
||||
|
||||
// plausibleCall keeps a token that looks like a callsign: length 3–12, at least
|
||||
// one digit and one letter, only A–Z/0–9//.
|
||||
func plausibleCall(s string) bool {
|
||||
if len(s) < 3 || len(s) > 12 {
|
||||
return false
|
||||
}
|
||||
hasDigit, hasAlpha := false, false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
hasDigit = true
|
||||
case c >= 'A' && c <= 'Z':
|
||||
hasAlpha = true
|
||||
case c == '/':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasDigit && hasAlpha
|
||||
}
|
||||
|
||||
// Lookup returns the Partial (substring) and N+1 (one-edit) suggestions for the
|
||||
// typed fragment. limit caps EACH list. Partial needs ≥2 chars; N+1 needs ≥3
|
||||
// (a plausible whole call) and is skipped otherwise.
|
||||
func (m *Manager) Lookup(fragment string, limit int) Result {
|
||||
q := strings.ToUpper(strings.TrimSpace(fragment))
|
||||
if len(q) < 2 {
|
||||
return Result{}
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
m.mu.RLock()
|
||||
calls := m.calls
|
||||
m.mu.RUnlock()
|
||||
|
||||
wantN1 := len(q) >= 3
|
||||
type pm struct {
|
||||
call string
|
||||
prefix bool
|
||||
}
|
||||
var partial []pm
|
||||
var nplus1 []string
|
||||
for _, c := range calls {
|
||||
if c == q {
|
||||
partial = append(partial, pm{c, true}) // exact = strongest match
|
||||
continue
|
||||
}
|
||||
if idx := strings.Index(c, q); idx >= 0 {
|
||||
partial = append(partial, pm{c, idx == 0})
|
||||
}
|
||||
if wantN1 && len(nplus1) < limit*2 && editDistanceOne(q, c) {
|
||||
nplus1 = append(nplus1, c)
|
||||
}
|
||||
}
|
||||
// Prefix matches first, then the rest (both already alphabetical since `calls`
|
||||
// is sorted and we scanned in order).
|
||||
sort.SliceStable(partial, func(i, j int) bool {
|
||||
if partial[i].prefix != partial[j].prefix {
|
||||
return partial[i].prefix
|
||||
}
|
||||
return false
|
||||
})
|
||||
out := Result{Partial: []string{}, NPlus1: []string{}}
|
||||
for _, p := range partial {
|
||||
if len(out.Partial) >= limit {
|
||||
break
|
||||
}
|
||||
out.Partial = append(out.Partial, p.call)
|
||||
}
|
||||
if len(nplus1) > limit {
|
||||
nplus1 = nplus1[:limit]
|
||||
}
|
||||
out.NPlus1 = append(out.NPlus1, nplus1...)
|
||||
return out
|
||||
}
|
||||
|
||||
// editDistanceOne reports whether a and b are exactly one edit apart (one
|
||||
// substitution, insertion or deletion) — never equal (distance 0 returns false).
|
||||
func editDistanceOne(a, b string) bool {
|
||||
la, lb := len(a), len(b)
|
||||
if la == lb {
|
||||
diff := 0
|
||||
for i := 0; i < la; i++ {
|
||||
if a[i] != b[i] {
|
||||
diff++
|
||||
if diff > 1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return diff == 1
|
||||
}
|
||||
// Ensure a is the shorter one; lengths must differ by exactly 1.
|
||||
if la > lb {
|
||||
a, b = b, a
|
||||
la, lb = lb, la
|
||||
}
|
||||
if lb-la != 1 {
|
||||
return false
|
||||
}
|
||||
// b is a with one extra char: walk both, allowing a single skip in b.
|
||||
i, j, skipped := 0, 0, false
|
||||
for i < la && j < lb {
|
||||
if a[i] == b[j] {
|
||||
i++
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if skipped {
|
||||
return false
|
||||
}
|
||||
skipped = true
|
||||
j++ // skip the extra char in the longer string
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Count returns how many callsigns are loaded.
|
||||
func (m *Manager) Count() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.calls)
|
||||
}
|
||||
|
||||
// Updated returns when the list was last refreshed (zero if never).
|
||||
func (m *Manager) Updated() time.Time {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.updated
|
||||
}
|
||||
+109
-8
@@ -25,6 +25,7 @@ package steppir
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -35,6 +36,10 @@ import (
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// errBadFrame marks a reply that isn't a well-formed status frame. It means
|
||||
// "ignore this poll", not "the link is down".
|
||||
var errBadFrame = errors.New("steppir: malformed status frame")
|
||||
|
||||
// Direction values, matching the app-wide convention (also used by Ultrabeam):
|
||||
// 0 normal, 1 reverse (180°), 2 bidirectional.
|
||||
const (
|
||||
@@ -50,6 +55,15 @@ const (
|
||||
wireBi = 0x80
|
||||
)
|
||||
|
||||
// pendingDirTTL is how long a commanded direction is trusted over the
|
||||
// controller's own report. The elements physically re-tune to swap director and
|
||||
// reflector, and the SDA only reports the new pattern once it starts that move,
|
||||
// so a few seconds is not enough — 4 s (the original value) had the UI snapping
|
||||
// back to "normal" while the antenna was on its way to 180°. Long enough to
|
||||
// cover a real move, short enough that a command the controller never received
|
||||
// self-corrects instead of lying forever.
|
||||
const pendingDirTTL = 45 * time.Second
|
||||
|
||||
// Transport says how to reach the controller.
|
||||
type Transport struct {
|
||||
Mode string // "tcp" | "serial"
|
||||
@@ -94,6 +108,11 @@ type Client struct {
|
||||
// A just-commanded direction is held until the controller's poll reports it —
|
||||
// the motors take a second or two, and a stale poll would otherwise snap the
|
||||
// UI back. Same trick as the Ultrabeam client.
|
||||
//
|
||||
// The hold is deliberately long (pendingDirTTL). It is not just a UI nicety:
|
||||
// the follow loop re-tunes with the direction it reads back from this status,
|
||||
// so a single stale poll reading "normal" would make OpsLog command the
|
||||
// antenna out of 180° all by itself.
|
||||
pendingDir int
|
||||
pendingDirAt time.Time
|
||||
pendingDirSet bool
|
||||
@@ -189,6 +208,12 @@ func (c *Client) pollLoop() {
|
||||
c.connMu.Unlock()
|
||||
|
||||
st, err := c.queryStatus()
|
||||
if errors.Is(err, errBadFrame) {
|
||||
// Framing glitch, not a dead link: skip this tick and keep the
|
||||
// previous status. Dropping the connection here would blink the
|
||||
// UI to "disconnected" over one garbled reply.
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("steppir: status query failed, reconnecting: %v", err)
|
||||
c.closeConn()
|
||||
@@ -197,19 +222,32 @@ func (c *Client) pollLoop() {
|
||||
}
|
||||
st.Connected = true
|
||||
c.statusMu.Lock()
|
||||
if c.pendingDirSet {
|
||||
if time.Since(c.pendingDirAt) > 4*time.Second || st.Direction == c.pendingDir {
|
||||
c.pendingDirSet = false
|
||||
} else {
|
||||
st.Direction = c.pendingDir
|
||||
}
|
||||
}
|
||||
c.applyPendingDir(st)
|
||||
c.lastStatus = st
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyPendingDir replaces a freshly polled direction with the one the operator
|
||||
// last commanded, until the controller confirms it (or the hold expires). The
|
||||
// caller holds statusMu.
|
||||
func (c *Client) applyPendingDir(st *Status) {
|
||||
if !c.pendingDirSet {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case st.Direction == c.pendingDir:
|
||||
c.pendingDirSet = false // confirmed — trust the controller's reports again
|
||||
case time.Since(c.pendingDirAt) > pendingDirTTL:
|
||||
c.pendingDirSet = false
|
||||
log.Printf("steppir: controller never confirmed direction %d (still reports %d) — dropping the hold",
|
||||
c.pendingDir, st.Direction)
|
||||
default:
|
||||
st.Direction = c.pendingDir
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) setDisconnected() {
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{Connected: false}
|
||||
@@ -232,6 +270,56 @@ func setDeadline(conn io.ReadWriteCloser, d time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// setReadTimeout bounds a single read on either transport, so a drain can tell
|
||||
// "nothing more queued" from "still arriving" without blocking.
|
||||
func setReadTimeout(conn io.ReadWriteCloser, d time.Duration) {
|
||||
switch t := conn.(type) {
|
||||
case net.Conn:
|
||||
_ = t.SetReadDeadline(time.Now().Add(d))
|
||||
case serial.Port:
|
||||
_ = t.SetReadTimeout(d)
|
||||
}
|
||||
}
|
||||
|
||||
// restoreTimeouts puts the normal exchange timeouts back after a drain shortened
|
||||
// them.
|
||||
func restoreTimeouts(conn io.ReadWriteCloser) {
|
||||
setDeadline(conn, 3*time.Second) // TCP: read + write
|
||||
setReadTimeout(conn, 2*time.Second)
|
||||
}
|
||||
|
||||
// drain throws away everything already sitting in the input buffer and returns
|
||||
// how many bytes it discarded.
|
||||
//
|
||||
// This is the fix for the antenna's state appearing tens of seconds out of date.
|
||||
// The SDA controller does not only answer "?A" — it also pushes status frames on
|
||||
// its own (front-panel changes, autotrack moves, each command it processes). We
|
||||
// consume exactly one frame per poll, so every unsolicited frame adds one to a
|
||||
// backlog that only ever grows: reading 11 bytes then returns a frame from
|
||||
// minutes ago. The field log showed it plainly — two consecutive polls 4 s apart
|
||||
// reporting 28280 kHz then 14200 kHz, a frequency last used hours earlier, and a
|
||||
// 180° command not showing up in the status for ~40 s (long after the UI had
|
||||
// given up waiting and snapped the button back to "normal"). Emptying the buffer
|
||||
// immediately before each query means the frame we then read is the answer to
|
||||
// THIS query.
|
||||
func drain(conn io.ReadWriteCloser) int {
|
||||
buf := make([]byte, 512)
|
||||
total := 0
|
||||
// Bounded so a controller that streams continuously can't hold the poll
|
||||
// goroutine here forever. 32 × 512 B is ~1500 frames — far more backlog than
|
||||
// any real link builds up, and it only costs one 30 ms timeout when the
|
||||
// buffer is already empty (reads return immediately while data is queued).
|
||||
for i := 0; i < 32; i++ {
|
||||
setReadTimeout(conn, 30*time.Millisecond)
|
||||
n, err := conn.Read(buf)
|
||||
total += n
|
||||
if err != nil || n == 0 { // timeout / nothing left
|
||||
break
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (c *Client) queryStatus() (*Status, error) {
|
||||
c.connMu.Lock()
|
||||
conn := c.conn
|
||||
@@ -241,7 +329,12 @@ func (c *Client) queryStatus() (*Status, error) {
|
||||
}
|
||||
c.ioMu.Lock()
|
||||
defer c.ioMu.Unlock()
|
||||
setDeadline(conn, 3*time.Second)
|
||||
// Discard any frame the controller pushed on its own since the last poll, so
|
||||
// what we read below is this query's answer and not a stale backlog entry.
|
||||
if n := drain(conn); n > 0 {
|
||||
log.Printf("steppir: discarded %d stale byte(s) queued by the controller before polling", n)
|
||||
}
|
||||
restoreTimeouts(conn)
|
||||
if _, err := conn.Write([]byte("?A\r")); err != nil {
|
||||
return nil, fmt.Errorf("write status cmd: %w", err)
|
||||
}
|
||||
@@ -249,6 +342,14 @@ func (c *Client) queryStatus() (*Status, error) {
|
||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||
return nil, fmt.Errorf("read status: %w", err)
|
||||
}
|
||||
// Reject anything that isn't a framed reply rather than decoding garbage into
|
||||
// a frequency and a direction the app would then act on.
|
||||
if buf[0] != '@' || buf[1] != 'A' || buf[10] != 0x0D {
|
||||
log.Printf("steppir: ignoring malformed status frame % X", buf)
|
||||
drain(conn) // resync: drop the rest of whatever we landed mid-way through
|
||||
restoreTimeouts(conn)
|
||||
return nil, errBadFrame
|
||||
}
|
||||
st, err := parseStatus(buf)
|
||||
// Log the raw frame + decode whenever it changes. The motor byte (buf[6]) is
|
||||
// what decides st.MotorsMoving, and that in turn drives the app's "block TX
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package steppir
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The exact bytes are the correctness checksum. If buildSet ever drifts from the
|
||||
@@ -104,3 +108,120 @@ func TestParseStatus(t *testing.T) {
|
||||
t.Error("active-motors 0xFF (command received) must not read as moving")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeConn stands in for the controller link. rx holds bytes the "controller"
|
||||
// has already sent (what the client will read), tx collects what the client
|
||||
// wrote, and a "?A" query queues `reply` into rx the way the SDA answers.
|
||||
//
|
||||
// Reads never block: an empty rx returns (0, nil), which is exactly how
|
||||
// go.bug.st/serial reports a read timeout, so drain() sees the same
|
||||
// nothing-left signal it gets from real hardware.
|
||||
type fakeConn struct {
|
||||
mu sync.Mutex
|
||||
rx bytes.Buffer
|
||||
tx bytes.Buffer
|
||||
reply []byte
|
||||
}
|
||||
|
||||
func (f *fakeConn) Read(p []byte) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.rx.Len() == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return f.rx.Read(p)
|
||||
}
|
||||
|
||||
func (f *fakeConn) Write(p []byte) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.tx.Write(p)
|
||||
if bytes.Contains(p, []byte("?A")) {
|
||||
f.rx.Write(f.reply)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (f *fakeConn) Close() error { return nil }
|
||||
|
||||
var (
|
||||
// 50.150 MHz, normal — the "stuck at 6 m" frame that kept turning up in
|
||||
// F4BPO's friend's log long after the rig had left the band.
|
||||
frame6mNormal = []byte{0x40, 0x41, 0x00, 0x4C, 0x85, 0xD8, 0x00, 0x07, 0x30, 0x38, 0x0D}
|
||||
// 14.250 MHz, 180° — what the controller actually reports right now.
|
||||
frame20m180 = []byte{0x40, 0x41, 0x00, 0x15, 0xBE, 0x68, 0x00, 0x47, 0x30, 0x38, 0x0D}
|
||||
)
|
||||
|
||||
// The controller pushes status frames unsolicited, so they pile up between polls.
|
||||
// Reading one frame per poll then returns state from minutes ago — which is how a
|
||||
// 180° command could take ~40 s to show up in the UI, and how two polls 4 s apart
|
||||
// reported 28 MHz then 14 MHz. queryStatus must empty the backlog first.
|
||||
func TestQueryStatusDiscardsQueuedFrames(t *testing.T) {
|
||||
fc := &fakeConn{reply: frame20m180}
|
||||
fc.rx.Write(frame6mNormal) // two frames the controller pushed on its own
|
||||
fc.rx.Write(frame6mNormal)
|
||||
|
||||
c := &Client{conn: fc}
|
||||
st, err := c.queryStatus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Frequency != 14250 {
|
||||
t.Errorf("freq = %d kHz, want 14250 — a stale queued frame was read instead of this poll's reply", st.Frequency)
|
||||
}
|
||||
if st.Direction != Dir180 {
|
||||
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
|
||||
}
|
||||
}
|
||||
|
||||
// A reply we land on mid-frame must be rejected, not decoded into a bogus
|
||||
// frequency and direction the follow loop would then act on — and it must not
|
||||
// look like a dead link either (errBadFrame keeps the connection).
|
||||
func TestQueryStatusRejectsMalformedFrame(t *testing.T) {
|
||||
shifted := append(append([]byte{}, frame20m180[3:]...), 0x40, 0x41, 0x00) // 11 bytes, wrong header
|
||||
c := &Client{conn: &fakeConn{reply: shifted}}
|
||||
if _, err := c.queryStatus(); !errors.Is(err, errBadFrame) {
|
||||
t.Fatalf("err = %v, want errBadFrame", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The direction the operator just commanded is shown until the controller
|
||||
// confirms it. The hold used to be 4 s — two polls — so the button snapped back
|
||||
// to Normal while the elements were still swapping over to 180°.
|
||||
func TestApplyPendingDirHold(t *testing.T) {
|
||||
c := &Client{pendingDir: Dir180, pendingDirAt: time.Now(), pendingDirSet: true}
|
||||
|
||||
// Controller still reports the old pattern: keep showing what was commanded.
|
||||
st := &Status{Direction: DirNormal}
|
||||
c.applyPendingDir(st)
|
||||
if st.Direction != Dir180 {
|
||||
t.Fatalf("direction = %d, want %d while the move is pending", st.Direction, Dir180)
|
||||
}
|
||||
if !c.pendingDirSet {
|
||||
t.Fatal("hold released before the controller confirmed")
|
||||
}
|
||||
|
||||
// Still holding well past the old 4 s window — a SteppIR takes longer than
|
||||
// that to report a pattern change.
|
||||
c.pendingDirAt = time.Now().Add(-10 * time.Second)
|
||||
st = &Status{Direction: DirNormal}
|
||||
c.applyPendingDir(st)
|
||||
if st.Direction != Dir180 {
|
||||
t.Fatalf("direction = %d after 10 s, want %d — the hold expired too early", st.Direction, Dir180)
|
||||
}
|
||||
|
||||
// Controller confirms: release the hold and trust its reports again.
|
||||
st = &Status{Direction: Dir180}
|
||||
c.applyPendingDir(st)
|
||||
if c.pendingDirSet {
|
||||
t.Fatal("hold should be released once the controller reports the commanded direction")
|
||||
}
|
||||
|
||||
// A command the controller never acted on must not lie forever.
|
||||
c.pendingDir, c.pendingDirAt, c.pendingDirSet = DirBi, time.Now().Add(-pendingDirTTL-time.Second), true
|
||||
st = &Status{Direction: DirNormal}
|
||||
c.applyPendingDir(st)
|
||||
if st.Direction != DirNormal || c.pendingDirSet {
|
||||
t.Fatalf("expired hold should fall back to the controller: direction = %d, pending = %v", st.Direction, c.pendingDirSet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package tunergenius
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// captureLog redirects the stream applog.Printf always writes to, and returns a
|
||||
// stop function yielding what was logged. Reading only after stop keeps the
|
||||
// collector goroutine and the test off the same slice.
|
||||
func captureLog() func() []string {
|
||||
orig := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stderr = w
|
||||
var lines []string
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
sc := bufio.NewScanner(r)
|
||||
for sc.Scan() {
|
||||
lines = append(lines, sc.Text())
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
return func() []string {
|
||||
os.Stderr = orig
|
||||
w.Close()
|
||||
<-done
|
||||
r.Close()
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
||||
// A tuner that answers, then vanishes mid-session. A drop used to leave no trace
|
||||
// at all, so "did the tuner disconnect, or is the meter just reading a gap
|
||||
// between syllables?" was unanswerable from the log. It must now log — and log
|
||||
// ONCE, not on every 400 ms retry.
|
||||
func TestLinkDownLoggedOnceNotPerRetry(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
die := make(chan struct{})
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Write([]byte("V1.2.11\n"))
|
||||
buf := make([]byte, 256)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(strings.SplitN(string(buf[:n]), "|", 2)[0], "C")
|
||||
conn.Write([]byte("R" + id + "|0|status state=1 fwd=60.7 swr=-25 active=1\n"))
|
||||
select {
|
||||
case <-die:
|
||||
conn.Close()
|
||||
ln.Close()
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
stop := captureLog()
|
||||
c := New("127.0.0.1", port, "")
|
||||
if err := c.Start(); err != nil {
|
||||
stop()
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
connected := c.GetStatus().Connected
|
||||
close(die)
|
||||
time.Sleep(2 * time.Second) // ~5 poll cycles with the tuner gone
|
||||
c.Stop()
|
||||
lines := stop()
|
||||
|
||||
if !connected {
|
||||
t.Fatal("the client never reached the fake tuner — the rest of the test is meaningless")
|
||||
}
|
||||
down := 0
|
||||
for _, l := range lines {
|
||||
if strings.Contains(l, "link DOWN") {
|
||||
down++
|
||||
}
|
||||
}
|
||||
t.Logf("logged:\n%s", strings.Join(lines, "\n"))
|
||||
if down != 1 {
|
||||
t.Errorf("got %d 'link DOWN' lines, want exactly 1 — the poll retries many times in a 3 s outage and must not repeat itself", down)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,6 @@ const (
|
||||
// ride the real-time Flex UDP stream; the tuner is a synchronous TCP poll, so
|
||||
// a slow interval made its SWR/power lag noticeably behind).
|
||||
pollEvery = 400 * time.Millisecond
|
||||
reconnectDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// Channel is the live state of one of the tuner's two RF channels (A / B). The
|
||||
@@ -215,6 +214,17 @@ func (c *Client) Activate(ch int) error {
|
||||
func (c *Client) pollLoop() {
|
||||
t := time.NewTicker(pollEvery)
|
||||
defer t.Stop()
|
||||
// Outage bookkeeping. A dropped link used to be entirely silent: the poll just
|
||||
// set Connected=false and retried, so "did the tuner disconnect, or is the
|
||||
// meter simply reading a gap between syllables?" could not be answered from
|
||||
// the log. Now an outage logs once when it starts and once when it ends, with
|
||||
// how long it lasted.
|
||||
//
|
||||
// Once, not every retry: the poll comes round every 400 ms, and a tuner that
|
||||
// is switched off would otherwise bury the log. These are local to the one
|
||||
// goroutine that polls — ensureConnected has no other caller — so they need no
|
||||
// locking.
|
||||
var downSince time.Time
|
||||
for {
|
||||
select {
|
||||
case <-c.stop:
|
||||
@@ -223,16 +233,28 @@ func (c *Client) pollLoop() {
|
||||
fresh := false
|
||||
if c.needConnect() {
|
||||
if err := c.ensureConnected(); err != nil {
|
||||
if downSince.IsZero() {
|
||||
downSince = time.Now()
|
||||
applog.Printf("tunergenius: link DOWN — cannot reach %s:%d: %v (retrying)", c.host, c.port, err)
|
||||
}
|
||||
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
|
||||
continue
|
||||
}
|
||||
fresh = true
|
||||
if !downSince.IsZero() {
|
||||
applog.Printf("tunergenius: link RESTORED after %s down", time.Since(downSince).Round(time.Second))
|
||||
downSince = time.Time{}
|
||||
}
|
||||
}
|
||||
// One-shot on a fresh link: learn the hardware variant (3-way vs SO2R).
|
||||
if fresh {
|
||||
_, _ = c.command("info")
|
||||
}
|
||||
if _, err := c.command("status"); err != nil {
|
||||
if downSince.IsZero() {
|
||||
downSince = time.Now()
|
||||
applog.Printf("tunergenius: link DOWN — poll failed: %v", err)
|
||||
}
|
||||
c.dropConn()
|
||||
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = err.Error() })
|
||||
}
|
||||
|
||||
+145
-2
@@ -27,10 +27,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -46,6 +49,14 @@ var errFCCMaintenance = errors.New("fcc uls under maintenance")
|
||||
const (
|
||||
fccAmateurURL = "https://data.fcc.gov/download/pub/uls/complete/l_amat.zip"
|
||||
geoNamesURL = "https://download.geonames.org/export/zip/US.zip"
|
||||
|
||||
// fccULSDir is the parent listing the weekly files live under. It is browsed
|
||||
// to recover from the FCC moving the file — see resolveFCCAmateurURL.
|
||||
fccULSDir = "https://data.fcc.gov/download/pub/uls/"
|
||||
|
||||
// fccAmateurFile is the weekly full-database filename, constant across the
|
||||
// FCC's directory reshuffles.
|
||||
fccAmateurFile = "l_amat.zip"
|
||||
)
|
||||
|
||||
// Location is a resolved callsign's home county + grid.
|
||||
@@ -170,10 +181,19 @@ func (s *Store) Import(ctx context.Context, tmpDir string, prog Progress) error
|
||||
return fmt.Errorf("GeoNames crosswalk is empty")
|
||||
}
|
||||
|
||||
// 2) FCC ULS full amateur database (large).
|
||||
// 2) FCC ULS full amateur database (large). The address is resolved rather
|
||||
// than assumed — the FCC moves this file between directories.
|
||||
prog("Locating FCC ULS database", 0)
|
||||
amatURL, err := resolveFCCAmateurURL(ctx)
|
||||
if err != nil {
|
||||
return err // already a user-facing explanation
|
||||
}
|
||||
if amatURL != fccAmateurURL {
|
||||
log.Printf("uls: FCC weekly file not at its usual address, using %s", amatURL)
|
||||
}
|
||||
prog("Downloading FCC ULS database", 0)
|
||||
amatPath := filepath.Join(tmpDir, "opslog_l_amat.zip")
|
||||
if err := download(ctx, fccAmateurURL, amatPath, func(pct int) { prog("Downloading FCC ULS database", pct) }); err != nil {
|
||||
if err := download(ctx, amatURL, amatPath, func(pct int) { prog("Downloading FCC ULS database", pct) }); err != nil {
|
||||
return fmt.Errorf("download FCC ULS: %w", err)
|
||||
}
|
||||
defer os.Remove(amatPath)
|
||||
@@ -323,6 +343,129 @@ func parseGeoNames(zipPath string) (map[string]zipRow, error) {
|
||||
return out, sc.Err()
|
||||
}
|
||||
|
||||
// resolveFCCAmateurURL returns a URL that actually serves the weekly amateur
|
||||
// database, working around the FCC relocating it.
|
||||
//
|
||||
// The canonical path is .../uls/complete/l_amat.zip and it is tried first. On
|
||||
// 2026-07-24 the FCC renamed that whole directory to "complete.07242026" and
|
||||
// left an empty "complete" behind, so every weekly file for every radio service
|
||||
// (not just amateur) started redirecting to a generic fcc.gov help page — the
|
||||
// county database became un-downloadable for everyone. The file itself was
|
||||
// intact the whole time, one directory across.
|
||||
//
|
||||
// Rather than hard-code that dated directory — it looks like a pre-migration
|
||||
// snapshot, and pinning it would break again the moment the FCC restores or
|
||||
// re-snapshots — we browse the parent listing and pick the most recent
|
||||
// "complete*" directory that actually holds the file. That survives the
|
||||
// canonical path coming back (tried first, so it wins), a differently-dated
|
||||
// snapshot next time, and anything else short of the file being withdrawn.
|
||||
func resolveFCCAmateurURL(ctx context.Context) (string, error) {
|
||||
if ok, _ := servesFile(ctx, fccAmateurURL); ok {
|
||||
return fccAmateurURL, nil
|
||||
}
|
||||
dirs, err := listFCCCompleteDirs(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the FCC weekly download moved and the directory listing could not be read (%w) — try again later, or check %s", err, fccULSDir)
|
||||
}
|
||||
// Newest first: the listing is alphabetical, and the dated names sort in an
|
||||
// arbitrary order (MMDDYYYY), so try them all rather than trusting the order.
|
||||
for _, d := range dirs {
|
||||
u := fccULSDir + d + fccAmateurFile
|
||||
if ok, _ := servesFile(ctx, u); ok {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("the FCC no longer serves %s at its usual address, and no alternate directory under %s has it either — the FCC has changed its downloads; please report this", fccAmateurFile, fccULSDir)
|
||||
}
|
||||
|
||||
// servesFile reports whether url returns a real file rather than a redirect to
|
||||
// an HTML error page. The FCC answers a missing file with 302 → a help page, so
|
||||
// a plain status check on the final response is not enough: we must refuse to
|
||||
// follow the bounce and insist on a non-HTML body.
|
||||
func servesFile(ctx context.Context, url string) (bool, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); strings.Contains(strings.ToLower(ct), "html") {
|
||||
return false, fmt.Errorf("served HTML, not a file")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// listFCCCompleteDirs returns the "complete*/" subdirectory names in the ULS
|
||||
// download area (e.g. "complete/", "complete.07242026/"), newest-looking last.
|
||||
func listFCCCompleteDirs(ctx context.Context) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fccULSDir, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseCompleteDirs(string(body)), nil
|
||||
}
|
||||
|
||||
// completeDirRe matches an Apache-style listing link to a "complete…" directory.
|
||||
var completeDirRe = regexp.MustCompile(`(?i)href="(complete[^"/]*/)"`)
|
||||
|
||||
// parseCompleteDirs pulls the candidate directory names out of a listing page.
|
||||
// Split out from the fetch so it can be tested against a captured listing.
|
||||
func parseCompleteDirs(html string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, m := range completeDirRe.FindAllStringSubmatch(html, -1) {
|
||||
d := m[1]
|
||||
if d == "complete/" || seen[d] { // canonical path was already tried
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
// Dated snapshots (complete.MMDDYYYY) — prefer the most recent by date, so a
|
||||
// stale older snapshot is never picked over a fresh one.
|
||||
sort.Slice(out, func(i, j int) bool { return snapshotDate(out[i]).After(snapshotDate(out[j])) })
|
||||
return out
|
||||
}
|
||||
|
||||
var snapshotDateRe = regexp.MustCompile(`(\d{8})`)
|
||||
|
||||
// snapshotDate extracts the MMDDYYYY stamp from "complete.07242026/"; a name
|
||||
// without one sorts as the zero time (tried last).
|
||||
func snapshotDate(dir string) time.Time {
|
||||
m := snapshotDateRe.FindStringSubmatch(dir)
|
||||
if m == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
t, err := time.Parse("01022006", m[1])
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// download streams url to dest, reporting percent when the content length is
|
||||
// known and prog is non-nil.
|
||||
func download(ctx context.Context, url, dest string, prog func(pct int)) error {
|
||||
|
||||
@@ -40,3 +40,57 @@ func TestParseGeoNames(t *testing.T) {
|
||||
t.Errorf("ZIP 20500 = %+v (ok=%v)", r, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// The real listing captured from data.fcc.gov on 2026-07-25, the day after the
|
||||
// FCC renamed complete/ to complete.07242026/ and left an empty complete/
|
||||
// behind — which broke the county-database download for every user.
|
||||
const fccListing2026 = `<html><head><title>Index of /download/pub/uls</title></head><body>
|
||||
<h1>Index of /download/pub/uls</h1>
|
||||
<table><tr><th>Name</th><th>Last modified</th><th>Size</th></tr>
|
||||
<tr><td><a href="/download/pub/">Parent Directory</a></td><td> </td><td>-</td></tr>
|
||||
<tr><td><a href="UAT/">UAT/</a></td><td>2025-04-08 19:30</td><td>-</td></tr>
|
||||
<tr><td><a href="complete.07242026/">complete.07242026/</a></td><td>2026-07-24 20:15</td><td>-</td></tr>
|
||||
<tr><td><a href="complete/">complete/</a></td><td>2026-07-25 21:15</td><td>-</td></tr>
|
||||
<tr><td><a href="daily/">daily/</a></td><td>2026-07-25 21:15</td><td>-</td></tr>
|
||||
</table></body></html>`
|
||||
|
||||
func TestParseCompleteDirs(t *testing.T) {
|
||||
got := parseCompleteDirs(fccListing2026)
|
||||
if len(got) != 1 || got[0] != "complete.07242026/" {
|
||||
t.Fatalf("got %v, want [complete.07242026/]", got)
|
||||
}
|
||||
// "complete/" is deliberately absent: it is the canonical URL, already tried
|
||||
// before the listing is consulted, and on this date it was empty.
|
||||
for _, d := range got {
|
||||
if d == "complete/" {
|
||||
t.Error("canonical complete/ should not be offered as a fallback")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Several snapshots must be tried newest-first, so a stale one is never
|
||||
// preferred over a fresh one.
|
||||
func TestParseCompleteDirsPrefersNewestSnapshot(t *testing.T) {
|
||||
html := `<a href="complete/">x</a><a href="complete.01052026/">x</a>` +
|
||||
`<a href="complete.07242026/">x</a><a href="complete.11302025/">x</a>`
|
||||
got := parseCompleteDirs(html)
|
||||
want := []string{"complete.07242026/", "complete.01052026/", "complete.11302025/"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotDate(t *testing.T) {
|
||||
if d := snapshotDate("complete.07242026/"); d.Format("2006-01-02") != "2026-07-24" {
|
||||
t.Errorf("complete.07242026/ → %s, want 2026-07-24", d.Format("2006-01-02"))
|
||||
}
|
||||
// An undated name must sort last rather than crash.
|
||||
if !snapshotDate("complete.backup/").IsZero() {
|
||||
t.Error("an undated directory should yield the zero time")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The portable-folder contract: a database that lives inside the application
|
||||
// folder must be stored relative to it, so copying C:\OpsLog to D:\OpsLog (or to
|
||||
// a USB stick) keeps working. A path the operator deliberately put elsewhere
|
||||
// must be left exactly as it is.
|
||||
func TestPortablePath(t *testing.T) {
|
||||
base := appDir()
|
||||
if base == "" {
|
||||
t.Skip("no executable dir available")
|
||||
}
|
||||
|
||||
inside := filepath.Join(base, "data", "logbook.db")
|
||||
if got := portablePath(inside); got != "data/logbook.db" {
|
||||
t.Errorf("inside the app folder: got %q, want %q", got, "data/logbook.db")
|
||||
}
|
||||
|
||||
// Outside: an absolute path on another drive / a synced folder is a
|
||||
// deliberate choice and must survive untouched.
|
||||
outside := filepath.FromSlash("Z:/Sync/ham/logbook.db")
|
||||
if got := portablePath(outside); got != outside {
|
||||
t.Errorf("outside the app folder: got %q, want it unchanged", got)
|
||||
}
|
||||
|
||||
if got := portablePath(""); got != "" {
|
||||
t.Errorf("empty path: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePath(t *testing.T) {
|
||||
base := appDir()
|
||||
if base == "" {
|
||||
t.Skip("no executable dir available")
|
||||
}
|
||||
|
||||
// A relative path is anchored to the CURRENT install, whatever drive it is on.
|
||||
want := filepath.Join(base, "data", "logbook.db")
|
||||
if got := resolvePath("", "data/logbook.db"); got != want {
|
||||
t.Errorf("relative: got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// An absolute path that still exists is honoured as-is.
|
||||
dir := t.TempDir()
|
||||
real := filepath.Join(dir, "logbook.db")
|
||||
if err := os.WriteFile(real, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := resolvePath(dir, real); got != real {
|
||||
t.Errorf("existing absolute: got %q, want %q", got, real)
|
||||
}
|
||||
|
||||
// The rescue: a path from ANOTHER machine, with the same file present in this
|
||||
// install's data folder — the copied-folder case.
|
||||
stale := filepath.FromSlash("C:/OldPC/OpsLog/data/logbook.db")
|
||||
if got := resolvePath(dir, stale); got != real {
|
||||
t.Errorf("stale absolute with a local twin: got %q, want %q", got, real)
|
||||
}
|
||||
|
||||
// No local twin: leave it alone so the failure is REPORTED rather than
|
||||
// silently replaced by an unrelated database.
|
||||
missing := filepath.FromSlash("C:/OldPC/OpsLog/data/other.db")
|
||||
if got := resolvePath(dir, missing); got != missing {
|
||||
t.Errorf("stale absolute with no twin: got %q, want it unchanged", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
// virtualScreenBounds is Windows-only; elsewhere we cannot tell, and the caller
|
||||
// treats "cannot tell" as "trust the saved position".
|
||||
func virtualScreenBounds() (x, y, w, h int, ok bool) { return 0, 0, 0, 0, false }
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// A window restored onto a monitor that is no longer attached opens invisibly
|
||||
// and stays that way — there is no way back short of deleting window.json, which
|
||||
// no operator knows to do. These cases decide whether the saved position is
|
||||
// honoured or quietly dropped for the default placement.
|
||||
func TestOverlapsEnough(t *testing.T) {
|
||||
// Two 1920x1080 monitors, the SECOND one to the LEFT of the primary: the
|
||||
// virtual desktop then starts at a negative x. This is the layout that
|
||||
// produces lost windows, and where a sign error would go unnoticed.
|
||||
const vx, vy, vw, vh = -1920, 0, 3840, 1080
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
x, y, w, h int
|
||||
wantRestorabl bool
|
||||
}{
|
||||
{"centred on the primary monitor", 300, 200, 1400, 900, true},
|
||||
{"on the left-hand monitor (negative x)", -1500, 100, 1400, 900, true},
|
||||
{"just inside the far left edge", -1900, 0, 1400, 900, true},
|
||||
{"hard against the right edge, title bar still grabbable", 1920 - 200, 100, 1400, 900, true},
|
||||
|
||||
// The failures this exists to catch.
|
||||
{"entirely past the left edge", -3400, 100, 1400, 900, false},
|
||||
{"below every monitor", 300, 2000, 1400, 900, false},
|
||||
{"barely clipping the right edge (10 px)", 1910, 100, 1400, 900, false},
|
||||
{"only a sliver of height on screen (8 px)", 300, 1072, 1400, 900, false},
|
||||
{"absurd coordinates from a corrupt file", 999999, 999999, 1400, 900, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := overlapsEnough(c.x, c.y, c.w, c.h, vx, vy, vw, vh); got != c.wantRestorabl {
|
||||
t.Errorf("%s: overlapsEnough(%d,%d,%dx%d) = %v, want %v",
|
||||
c.name, c.x, c.y, c.w, c.h, got, c.wantRestorabl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The actual field scenario: OpsLog was closed on a second monitor, that monitor
|
||||
// is gone, and the desktop is now the primary screen alone. The saved position
|
||||
// must be dropped — this is the case that leaves the window invisible.
|
||||
func TestOverlapsEnoughAfterMonitorUnplugged(t *testing.T) {
|
||||
// Single 1920x1080 primary; the left-hand monitor no longer exists.
|
||||
const vx, vy, vw, vh = 0, 0, 1920, 1080
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
x, y, w, h int
|
||||
want bool
|
||||
}{
|
||||
{"saved on the monitor that is now gone", -1500, 100, 1400, 900, false},
|
||||
{"saved just off the left edge", -1400, 100, 1400, 900, false},
|
||||
{"saved on the surviving monitor", 200, 100, 1400, 900, true},
|
||||
} {
|
||||
if got := overlapsEnough(c.x, c.y, c.w, c.h, vx, vy, vw, vh); got != c.want {
|
||||
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With the desktop bounds unavailable (non-Windows, or the call failing) the
|
||||
// saved position must be honoured rather than second-guessed.
|
||||
func TestOnSomeMonitorTrustsSavedPositionWhenBoundsUnknown(t *testing.T) {
|
||||
if _, _, _, _, ok := virtualScreenBounds(); !ok {
|
||||
if !onSomeMonitor(999999, 999999, 1400, 900) {
|
||||
t.Error("with unknown desktop bounds the saved position must be kept")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "syscall"
|
||||
|
||||
// GetSystemMetrics indices for the virtual desktop — the rectangle spanning
|
||||
// every attached monitor. Wails' ScreenGetAll reports each monitor's size but
|
||||
// not its offset, so it cannot answer "is this coordinate on any screen?"; the
|
||||
// Win32 metrics can.
|
||||
const (
|
||||
smXVirtualScreen = 76
|
||||
smYVirtualScreen = 77
|
||||
smCXVirtualScreen = 78
|
||||
smCYVirtualScreen = 79
|
||||
)
|
||||
|
||||
var (
|
||||
user32Dll = syscall.NewLazyDLL("user32.dll")
|
||||
procGetSystemMetrics = user32Dll.NewProc("GetSystemMetrics")
|
||||
)
|
||||
|
||||
func systemMetric(index int) int {
|
||||
r, _, _ := procGetSystemMetrics.Call(uintptr(index))
|
||||
return int(int32(r)) // signed: the virtual desktop origin is negative with a monitor to the left
|
||||
}
|
||||
|
||||
// virtualScreenBounds returns the rectangle covering all monitors, and whether
|
||||
// it could be determined at all.
|
||||
func virtualScreenBounds() (x, y, w, h int, ok bool) {
|
||||
w, h = systemMetric(smCXVirtualScreen), systemMetric(smCYVirtualScreen)
|
||||
if w <= 0 || h <= 0 {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
return systemMetric(smXVirtualScreen), systemMetric(smYVirtualScreen), w, h, true
|
||||
}
|
||||
+23
-5
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.21.1"
|
||||
appVersion = "0.21.5"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
@@ -88,12 +88,30 @@ func (a *App) sendTelemetryHeartbeat() {
|
||||
// distinct_id identifies the USER. Prefer the station callsign — it's stable
|
||||
// across every machine/reinstall the same operator runs, so one op counts as
|
||||
// one user (a callsign is public in amateur radio). The random per-install ID
|
||||
// is only a fallback until a callsign is configured; without it the same op on
|
||||
// a laptop + desktop + Maestro showed up as three "users". install_id rides
|
||||
// along as a property so multiple machines under one callsign stay visible.
|
||||
// is only a fallback; without it the same op on a laptop + desktop + Maestro
|
||||
// showed up as three "users". install_id rides along as a property so multiple
|
||||
// machines under one callsign stay visible.
|
||||
//
|
||||
// On a FRESH INSTALL the callsign isn't configured yet at launch, so sending
|
||||
// straight away would record the machine's random UUID (and the once-a-day lock
|
||||
// would then keep it that way even after the op enters their call). Instead wait
|
||||
// a while for the callsign to appear (Settings → Station Information) and only
|
||||
// fall back to the UUID if none shows up within the grace window — so a genuine
|
||||
// no-callsign install is still counted, but the normal case gets the callsign.
|
||||
call := a.activeCallsign()
|
||||
for i := 0; call == "" && i < 60; i++ { // up to ~10 min (60 × 10 s)
|
||||
time.Sleep(10 * time.Second)
|
||||
if a.settings == nil || !a.GetTelemetryEnabled() {
|
||||
return // disabled meanwhile
|
||||
}
|
||||
if last, _ := a.settings.GetGlobal(a.ctx, keyTelemetryLastSent); strings.TrimSpace(last) == today {
|
||||
return // sent by another path in the meantime
|
||||
}
|
||||
call = a.activeCallsign()
|
||||
}
|
||||
installID := a.telemetryInstallID()
|
||||
distinctID := installID
|
||||
if call := a.activeCallsign(); call != "" {
|
||||
if call != "" {
|
||||
distinctID = call
|
||||
}
|
||||
payload := map[string]any{
|
||||
|
||||
Reference in New Issue
Block a user