85 Commits
Author SHA1 Message Date
rouggy d38c783dcc chore: release v0.20.0 2026-07-19 02:34:23 +02:00
rouggy c825caa7a8 feat: relay auto-control by frequency / band (PstRotator-style)
Automatically switches the Station Control relay boards from the rig's
current frequency / band. Each relay carries one rule: off (manual), a
frequency window (ON inside [lo,hi] kHz, OFF outside), or a set of bands
(ON on those bands, OFF elsewhere). Evaluated on every CAT frequency/band
change; a relay is only switched when its desired state actually changed,
so tuning within a range doesn't hammer the board.

A cached atomic flag keeps the CAT hot path a no-op when the feature is off
(important during FT8 slice churn). Saving re-applies from the live
frequency so a changed rule takes effect immediately.

New Settings → Hardware → Relay auto-control section: master enable plus a
per-relay mode (Off / Frequency / Band) with kHz range inputs or band
chips, per configured relay board. i18n EN + FR. Azimuth/Time modes (the
other two PstRotator tabs) are left for later.
2026-07-19 01:57:56 +02:00
rouggy 215652570c fix: blank ADIF monitor settings panel (hooks in a called-as-function panel)
PANELS[selected]() invokes each settings panel as a plain function, so a panel
must not call hooks. ADIFMonitorPanel uses useState/useEffect/useI18n, which
broke the Rules of Hooks and blanked the section. Render it through JSX
(() => <ADIFMonitorPanel />) so it mounts as a real component with its own
hook context — same pattern as the Flex panel.
2026-07-19 01:51:21 +02:00
rouggy 79552bfae1 fix: Club Log status R->Y->R flicker on UDP auto-log
A UDP auto-logged QSO shows its Club Log (and other) upload status flick
R->Y->R even though the upload succeeds. Two refreshes race: the immediate
one after the UDP log reads the QSO at "R", the debounced one after
extsvc:uploaded reads it at "Y"; variable MySQL latency lets the older (R)
result resolve last and clobber the newer (Y) data.

Guard refresh() with a monotonic sequence number so only the most recently
issued refresh may apply its result — an older in-flight refresh is dropped.
Fixes the same class of stale-clobber for every confirmation column.
2026-07-19 01:47:39 +02:00
rouggy 8fc04563e1 feat: ADIF monitor — auto-import QSOs from watched external ADIF files
Watches a configurable list of external ADIF files (fldigi RTTY logbook,
N1MM, VarAC…) and imports newly appended QSOs automatically. Each record
goes through the existing UDP-log path, so it gets full enrichment, ±2-minute
dedup (shared udpLogMu, can't race the UDP auto-log) and automatic upload to
the configured external services — no per-file toggles like Log4OM.

A newly added file starts at its current size (offset -1 sentinel → size on
first scan), so the QSOs already in it are NOT bulk-imported; only contacts
logged after it was added come in. Reads only up to the last complete <eor>
so a half-written record waits. Handles truncation/rotation (re-reads from 0,
dedup protects) and persists per-file offsets without clobbering a concurrent
UI edit of the file list.

New Settings → ADIF monitor section: master enable + add/remove/toggle files.
Backend emits adifmon:imported → the grid refreshes and a toast reports the
count. i18n EN + FR.
2026-07-19 01:35:00 +02:00
rouggy 19993bafc1 feat: header rate/propagation colours + CW macro word space
Header: colour the propagation indices semantically (SFI/SSN green when
strong; A/K green quiet → yellow unsettled → red storm) and glow the QSO-rate
numbers the brand accent when active, dim to muted when idle.

WinKeyer/Icom CW: append a trailing word space to each macro send so two
macros fired back-to-back don't run together in the keyer buffer ("CQ"+"TEST"
→ "CQTEST"). The keyer keys the space at the current speed, so it scales with
WPM. Only the macro path is affected — send-on-type stays per-character.
2026-07-19 00:59:05 +02:00
rouggy da1793a902 fix: clearer Club Log / FCC ULS upload-download diagnostics
Club Log: on a failed batch, log the callsign#id of every QSO in it so a
per-record rejection (e.g. a field value nginx's WAF blocks with a 403) can
actually be located instead of hiding behind "batch FAILED".

FCC ULS: catch the maintenance bounce before following it. data.fcc.gov
redirects to www.fcc.gov/system-maintenance during maintenance windows, and
that page then HTTP/2-stream-errors — which surfaced as a cryptic
INTERNAL_ERROR. Detect the redirect and return "try again later".
2026-07-19 00:58:55 +02:00
rouggy 14c87f7fa9 chore: regenerate Wails bindings for GetQSORate / QSORate 2026-07-18 21:53:44 +02:00
rouggy 9d4ccb9254 feat: QSO rate meter (10/60 min) in the header
Opt-in via Settings→General (portable pref opslog.showQsoRate). Shows the
contest-style QSO rate in QSOs/hour, projected from the trailing 10-minute
(count ×6) and 60-minute windows, between the widget icons and propagation.

Backend: qso.RecentRate counts QSOs whose start time falls in each trailing
window, scanning only the last 400 rows (cheap on a large log); App.GetQSORate
exposes the 10/60-min counts. Frontend refreshes on qso:logged and a 30s tick.

The meter shares the propagation grid cell — the header is a fixed 6-column
grid, so adding it as its own child pushed profile/band-map/compact onto a
second row. i18n EN + FR.
2026-07-18 21:51:51 +02:00
rouggy d30b305ff2 chore: release v0.19.9 2026-07-18 19:32:17 +02:00
rouggyandClaude Opus 4.8 5abe4bd0c3 fix: restore theme on restart + consistent spot pills + red DVK TX
theme: self-heal the persisted theme after mount. The synchronous boot
read only looks at localStorage, which is empty when the WebView cleared
its storage or when syncPortablePrefs ran before the backend wired its
settings store (GetUIPref returned "" with no error) — so a restart could
silently land on the default light theme. ThemeProvider now re-reads the
portable pref from the DB once the backend is up (retrying briefly to ride
out a slow startup) and applies it, without clobbering a manual pick.

ClusterGrid: the Call cell now uses a danger pill for NEW DXCC, consistent
with the NEW BAND / NEW MODE pills, instead of a full-cell red fill. cellChip
inherits the column's font size (no fixed 9px / height) so a pill around a
callsign stays the same size as the plain callsigns beside it.

DvkPanel: the voice-keyer TX indicator (LED + label) is red while
transmitting, not orange.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 19:28:55 +02:00
rouggyandClaude Opus 4.8 3ed9f29d9a feat: never drop cluster spots + theme-aware spot badges + FR settings
Backend: replace the bounded cluster-event channel (which dropped spots
under RBN bursts once enrichment/UI fell behind) with an unbounded
clusterQueue (slice + sync.Cond). The socket-read goroutine still never
blocks, but a burst now grows the backlog and drains instead of losing
spots. Head compaction keeps a sustained burst from growing the backing
array without bound.

ClusterGrid: drop all hard-coded light-theme hex colours (Time, Country,
Spotter, Comment, POTA, band/mode fills) for semantic CSS-var tokens so
they read correctly on every theme. Replace the heavy full-cell band/mode
fills with small rounded pills (cellChip). Only NEW MODE pills the mode
cell — NEW SLOT (band and mode each worked, just not together) no longer
highlights the mode cell, which wrongly implied the mode was new.

App: stage incoming spots ~50ms and resolve their slot status before
inserting, so a row paints with its badge already on instead of flashing
plain text then flipping to a pill. Self-spot toast still fires per spot.

i18n: translate the General settings that were still English — telemetry
and live-status toggles, the Main view pane pickers and their options.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 18:20:06 +02:00
rouggyandClaude Opus 4.8 04eaa91bd8 chore: drop the station-owner note under the BY OPERATOR chart
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 17:12:59 +02:00
rouggyandClaude Opus 4.8 443698b507 feat: per-operator filter in the statistics dashboard
New Operator picker next to the contest picker, populated with the operators
actually present in the log. Selecting one recomputes every statistic for that
operator — QSO/DXCC totals, top countries, continent split, by band/mode, and
the rate/best-60. "— Station owner —" buckets QSOs logged with no OPERATOR set.
Shown only when the log has more than one operator.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 17:11:52 +02:00
rouggyandClaude Opus 4.8 db2908b3ef fix: RIT Ctrl+arrow tuning also works while the QSO entry field has focus
The radio-style Ctrl+left/right RIT shortcut skipped any focused input/textarea
(to preserve word navigation), but that's exactly where the operator's focus is
mid-QSO. Now only the RIT offset row itself is skipped; text-field word nav is
overridden only while RIT is engaged.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 14:33:55 +02:00
rouggyandClaude Opus 4.8 0838c2ec89 fix: Update from QRZ now fills the full address (and email/QSL-via/lat-lon), matching the edit-window Fetch
The right-click "Update from QRZ" only mapped QTH (city), name, state, county,
grid and zones — the street address and a few other fields QRZ returns were
dropped, so it looked half-updated next to the edit window's Fetch button.
Aligned it to fill the same set: address, email, qsl_via and lat/lon too.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 12:26:23 +02:00
rouggy 80c5fdc095 feat: Optimization of spots not to miss any 2026-07-18 12:17:22 +02:00
rouggy a1be0dfe68 feat: Added color per band in statistics as well as number of qso 2026-07-18 04:48:26 +02:00
rouggy ac4039393d feat: RIT back to 0 once a qso is logged 2026-07-18 04:06:43 +02:00
rouggy bb53085c21 fix: wider space for country names in statistics 2026-07-18 03:52:14 +02:00
rouggy 031dfa8f46 feat: Adding show password for SMTP server 2026-07-17 20:50:05 +02:00
rouggy 6322a425d8 feat: Added columns selection in the Net Control 2026-07-17 20:21:29 +02:00
rouggy 9f08df1c39 fix: fix a bug adjusting the length of Ultrabeam 2026-07-17 20:19:54 +02:00
rouggy aeeb658269 fix: Improved status badges in cluster 2026-07-17 19:35:30 +02:00
rouggy 8e088576c7 fix: Better matching of the counties for the USA-CA 2026-07-17 16:28:32 +02:00
rouggy eb2ff8ed59 feat: New badges in cluster view for new Counties and new POTA 2026-07-17 15:28:36 +02:00
rouggy dd3b51a2ae feat: Added support for US Counties in OpsLog / Extra feature with DXHunter 2026-07-17 13:23:35 +02:00
rouggy 1a155e3627 fix: bugs 2026-07-17 11:55:40 +02:00
rouggy cd13921322 fix: QSL Manager properly calculating new slot (Digi modes are grouped together) 2026-07-16 23:26:04 +02:00
rouggy 46e3619a38 fix: relocated Lotw and Alerts 2026-07-16 22:56:21 +02:00
rouggy dcf006905c fix: QTH field is wider to see the full city names 2026-07-16 22:47:24 +02:00
rouggy 7cf2dfeaf9 feat: For steppir & Ultrabeam,possibility to change each element length 2026-07-16 22:32:29 +02:00
rouggy 69229964f4 fix: bug 2026-07-16 22:13:21 +02:00
rouggy 09848adddc feat: Added Ultrabeam/Steppir to Station Control with function to retract elements. 2026-07-16 22:10:36 +02:00
rouggy f853dd479e chore: release v0.19.8 2026-07-16 22:02:37 +02:00
rouggy 829c236d6c feat: New Station Control, allow to control Webswitch 1216H or KMTronic 2026-07-16 22:01:07 +02:00
rouggy c9fd1379e1 feat: While using Flex & Steppir or Ultrabeam can prevent transmit when antenna is moving 2026-07-16 19:51:25 +02:00
rouggy 28b6f04ea4 feat: When a call is cleared from MSHV/WSJTx/JTDX it also clears the call field of OpsLog 2026-07-16 11:52:42 +02:00
rouggy e6744c1d0a fix: Renamed Operator Name into My Name to match the ADIF field 2026-07-16 09:31:46 +02:00
rouggy 5152366717 fix: MY_NAME adif field was not filled 2026-07-16 08:56:18 +02:00
rouggy e8691a43ba fix: bug correction on Awards columns persistence after relaunch 2026-07-16 08:48:12 +02:00
rouggy b83a4f4455 fix: correcting proper bit to see when Steppir is moving 2026-07-16 00:48:10 +02:00
rouggy 9d7091b1b8 fix: bug on steppir control 2026-07-16 00:42:16 +02:00
rouggy f650183936 feat: Added persistence on cluster filters and last tab selected 2026-07-15 22:35:05 +02:00
rouggy 33a6342a07 chore: release v0.19.7 2026-07-15 22:22:44 +02:00
rouggy 75548812d0 fix: bug keeping location and size of the window 2026-07-15 22:22:14 +02:00
rouggy 5b96f53930 feat: While closing OpsLog will keep the same size and position for next launch 2026-07-15 22:03:42 +02:00
rouggy d354709939 feat: type freq in RX freq will send the radio for Icom and Flex 2026-07-15 13:07:51 +02:00
rouggy 3cd80ead81 fix: wrong scale on the PGXL meter 2026-07-15 12:01:12 +02:00
rouggy 9e2ffdb758 feat: Adding XIT RIT in the Flex panel 2026-07-14 23:54:06 +02:00
rouggy 9718b8a78f feat: Added DME (Diploma Municipios de Espana) award 2026-07-14 20:15:25 +02:00
rouggy ee844564de fix: changed colors on qsl info tab 2026-07-14 20:02:52 +02:00
rouggy 04b6431726 fix: Improved the code for awards 2026-07-14 16:42:48 +02:00
rouggy 0c6f8e2d68 feat: Added a test tab in awards to test the matching 2026-07-14 16:32:49 +02:00
rouggy 4fe0405432 feat: Allow updation of award catalog 2026-07-14 16:22:54 +02:00
rouggy 1f0f75baf8 feat: versioning in awards definition 2026-07-14 15:37:42 +02:00
rouggy 7f95a71426 feat: added Worked All Italian Provinces award and FFMA 2026-07-14 00:03:10 +02:00
rouggy 08f4b61523 feat: added FFMA award support 2026-07-13 22:18:25 +02:00
rouggy f5ffe81c72 chore: release v0.19.6 2026-07-13 18:04:08 +02:00
rouggy 656e238a59 feat: awards folder in the data folder, anybody can create its own awards. 2026-07-13 18:03:06 +02:00
rouggy c170d6091e feat: Reworked the awards logic so it is easy to add new ones. 2026-07-13 17:38:18 +02:00
rouggy ae60d58893 feat: added extra stats for contests 2026-07-13 16:53:37 +02:00
rouggy 68982e9a85 feat: added command sent in cluster panel 2026-07-13 13:14:34 +02:00
rouggy eb9e2db41a deat: Added statistics on your log 2026-07-13 01:29:54 +02:00
rouggy b59c6856bd feat: When in MySQL if loosing db connection contacts can still be saved
they will be synchronized with the db once connected again.
2026-07-13 00:16:58 +02:00
rouggy a00817b93e fix: while connected to MySQL if internet was lost no qso would be logged anymore 2026-07-12 18:01:03 +02:00
rouggy 7398261c50 chore: release v0.19.5 2026-07-11 10:35:54 +02:00
rouggy 73f3ec51f7 fix: Flex with multiple slices opened was always showing slice A s-meter. 2026-07-10 23:13:06 +02:00
rouggy 6c39204301 chore: release v0.19.4 2026-07-10 17:16:32 +02:00
rouggy 0c3089344b fix: Awards additional bugs 2026-07-10 16:36:20 +02:00
rouggy 6ab606fa54 fix: Bug showing split for flex while in different mode on same band 2026-07-10 15:59:32 +02:00
rouggy 96390110f8 fix: Improved awards matches 2026-07-10 15:28:01 +02:00
rouggy 6f2f9236b0 fix: bug correction in awards where a reference regex was not checked 2026-07-10 10:58:33 +02:00
rouggy efb61107fe fix: Auto download Lotw user list at launch 2026-07-09 21:13:34 +02:00
rouggy 16e780c2df fix: Added LOTW badge for Lotw users colored depending on their last upload 2026-07-09 19:32:32 +02:00
rouggy f3bf0b2f5c fix: different bugs on eQSL 2026-07-09 18:36:24 +02:00
rouggy 1f74e4d234 chore: release v0.19.3 2026-07-09 17:32:13 +02:00
rouggy 19c2de5f61 chore: release v0.19.2 2026-07-09 16:51:57 +02:00
rouggy 5ae2bad549 fix: Antenna Genius show only available antennas per band 2026-07-09 15:52:04 +02:00
rouggy e487aa78f3 fix: Awards columns persistence bug 2026-07-09 15:36:37 +02:00
rouggy b9079fe4e2 fix: bug where scope is now showing on IC7300 2026-07-09 15:30:24 +02:00
rouggy 206a6bff09 feat: Implemented the download of eQSL 2026-07-09 13:16:04 +02:00
rouggy 6ee31ae45d fix: increase width for edit qso window in French to avoid scroll bar 2026-07-09 12:10:16 +02:00
rouggy 521f8266cf feat: added full support in USB (local) & ethernet (local or remote) of audio for Icom 2026-07-09 11:30:06 +02:00
rouggy 1f5e5759cc fix: CAT PTT not working for DVK 2026-07-09 08:18:49 +02:00
134 changed files with 91330 additions and 974 deletions
+251
View File
@@ -0,0 +1,251 @@
package main
// ADIF monitor: watches a configurable list of external ADIF files (fldigi's
// RTTY logbook, N1MM, VarAC…) and imports newly appended QSOs into OpsLog as if
// they had been logged here — same enrichment, dedup and automatic upload to
// external services (QRZ, Club Log…). Deliberately simpler than Log4OM's monitor:
// no per-file "upload" / "delete after load" toggles — importing + auto-upload is
// just what happens.
//
// Design notes:
// - A newly added file starts at its CURRENT size (Offset = -1 sentinel → set to
// size on first scan) so the QSOs already in it are NOT bulk-imported; only
// contacts appended AFTER you add the file come in.
// - Reads only up to the last complete <eor>, so a half-written record waits for
// the next poll instead of importing a truncated QSO.
// - Each record is fed through LogUDPLoggedADIF, which already does the full
// enrichment + ±2-minute dedup (shared udpLogMu, so it can't race the UDP
// auto-log) + automatic external-service upload.
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/applog"
)
const keyADIFMonitor = "adifmon.config"
// ADIFWatchFile is one monitored ADIF file.
type ADIFWatchFile struct {
Path string `json:"path"`
Enabled bool `json:"enabled"`
// Offset is the number of bytes already consumed. -1 means "not yet
// initialised": the first scan sets it to the file's current size so existing
// history is skipped.
Offset int64 `json:"offset"`
}
// ADIFMonitorConfig is the whole monitor setup: a master switch + the file list.
type ADIFMonitorConfig struct {
Enabled bool `json:"enabled"`
Files []ADIFWatchFile `json:"files"`
}
const eorTag = "<eor>"
func (a *App) loadADIFMonitorLocked() ADIFMonitorConfig {
var cfg ADIFMonitorConfig
if a.settings == nil {
return cfg
}
s, _ := a.settings.GetGlobal(a.ctx, keyADIFMonitor)
if strings.TrimSpace(s) != "" {
_ = json.Unmarshal([]byte(s), &cfg)
}
return cfg
}
func (a *App) saveADIFMonitorLocked(cfg ADIFMonitorConfig) {
b, _ := json.Marshal(cfg)
a.setSettingGlobal(keyADIFMonitor, string(b))
}
// GetADIFMonitor returns the monitor configuration for the settings UI.
func (a *App) GetADIFMonitor() ADIFMonitorConfig {
a.adifMonMu.Lock()
defer a.adifMonMu.Unlock()
return a.loadADIFMonitorLocked()
}
// SaveADIFMonitor persists the monitor configuration. A file the user just added
// gets Offset = -1 so its existing content is skipped (only QSOs logged AFTER it
// was added import); a file already present keeps its current read position.
func (a *App) SaveADIFMonitor(cfg ADIFMonitorConfig) error {
a.adifMonMu.Lock()
defer a.adifMonMu.Unlock()
old := a.loadADIFMonitorLocked()
oldOff := make(map[string]int64, len(old.Files))
for _, f := range old.Files {
oldOff[strings.TrimSpace(f.Path)] = f.Offset
}
for i := range cfg.Files {
cfg.Files[i].Path = strings.TrimSpace(cfg.Files[i].Path)
if off, ok := oldOff[cfg.Files[i].Path]; ok {
cfg.Files[i].Offset = off // keep the read position of an existing file
} else {
cfg.Files[i].Offset = -1 // new file → skip its existing history
}
}
a.saveADIFMonitorLocked(cfg)
return nil
}
// PickADIFMonitorFile opens a file dialog to choose an ADIF file to monitor.
func (a *App) PickADIFMonitorFile() (string, error) {
if a.ctx == nil {
return "", fmt.Errorf("no app context")
}
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Choose an ADIF file to monitor",
Filters: []wruntime.FileFilter{
{DisplayName: "ADIF (*.adi;*.adif)", Pattern: "*.adi;*.adif"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
}
// adifMonitorLoop polls the enabled ADIF files every few seconds and imports any
// newly appended QSOs. Runs for the app's lifetime on its own goroutine.
func (a *App) adifMonitorLoop() {
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
for range tick.C {
if a.ctx == nil || a.qso == nil {
continue
}
a.scanADIFMonitors()
}
}
// scanADIFMonitors walks the enabled files once, importing new records and
// persisting advanced offsets.
func (a *App) scanADIFMonitors() {
a.adifMonMu.Lock()
cfg := a.loadADIFMonitorLocked()
a.adifMonMu.Unlock()
if !cfg.Enabled || len(cfg.Files) == 0 {
return
}
// path → new offset, for the files we advanced this pass.
advanced := map[string]int64{}
for i := range cfg.Files {
f := &cfg.Files[i]
if !f.Enabled || strings.TrimSpace(f.Path) == "" {
continue
}
fi, err := os.Stat(f.Path)
if err != nil {
continue // not present (yet) — try again next tick
}
size := fi.Size()
if f.Offset < 0 {
// First sight of this file → skip whatever history it already holds.
advanced[f.Path] = size
continue
}
if size < f.Offset {
f.Offset = 0 // truncated / rotated → re-read from the start (dedup protects us)
}
if size == f.Offset {
continue // nothing new
}
newOff, n := a.importADIFAppend(f.Path, f.Offset, size)
if newOff != f.Offset {
advanced[f.Path] = newOff
}
if n > 0 {
applog.Printf("adif monitor: imported %d QSO(s) from %s", n, f.Path)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "adifmon:imported", map[string]any{"file": f.Path, "count": n})
}
}
}
if len(advanced) == 0 {
return
}
// Persist the advanced offsets WITHOUT clobbering a concurrent UI save of the
// file list: re-read the stored config and only patch offsets for paths that
// still exist there.
a.adifMonMu.Lock()
cur := a.loadADIFMonitorLocked()
for i := range cur.Files {
if off, ok := advanced[strings.TrimSpace(cur.Files[i].Path)]; ok {
cur.Files[i].Offset = off
}
}
a.saveADIFMonitorLocked(cur)
a.adifMonMu.Unlock()
}
// importADIFAppend reads bytes [from,to) of an ADIF file, imports every COMPLETE
// record found (up to the last <eor>) and returns the new offset (just past that
// last <eor>) plus how many QSOs were actually imported (duplicates excluded).
func (a *App) importADIFAppend(path string, from, to int64) (int64, int) {
fh, err := os.Open(path)
if err != nil {
return from, 0
}
defer fh.Close()
if _, err := fh.Seek(from, io.SeekStart); err != nil {
return from, 0
}
buf := make([]byte, to-from)
nRead, err := io.ReadFull(fh, buf)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return from, 0
}
buf = buf[:nRead]
// Only consume up to the last complete <eor>; a half-written trailing record
// waits for the next poll.
lower := bytes.ToLower(buf)
last := bytes.LastIndex(lower, []byte(eorTag))
if last < 0 {
return from, 0 // no complete record yet
}
end := last + len(eorTag)
chunk := buf[:end]
count := 0
for _, rec := range splitADIFRecords(chunk) {
if strings.TrimSpace(rec) == "" {
continue
}
if _, err := a.LogUDPLoggedADIF(rec); err == nil {
count++
}
// A duplicate (already in the log within ±2 min) returns an error and is
// simply not counted — expected when the same QSO is also logged in OpsLog.
}
return from + int64(end), count
}
// splitADIFRecords cuts an ADIF byte slice into individual record texts, each
// ending at its <eor> (case-insensitive). Any leading file header (up to the
// first record's <eor>) rides along with the first record — LogUDPLoggedADIF
// parses past an <EOH> header fine, and prepends one when there is none.
func splitADIFRecords(b []byte) []string {
lower := bytes.ToLower(b)
var out []string
start := 0
for {
rel := bytes.Index(lower[start:], []byte(eorTag))
if rel < 0 {
break
}
end := start + rel + len(eorTag)
out = append(out, string(b[start:end]))
start = end
}
return out
}
+2742 -328
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"testing"
"hamlog/internal/award"
)
// The catalog is the channel through which a shipped award is both DELIVERED and
// CORRECTED. These tests pin the three rules that make that safe:
// - a new catalog award reaches an operator who already has awards stored;
// - a fixed one (higher Version) replaces the stored copy;
// - unless the operator has edited it, in which case their work wins.
func catalogDef(t *testing.T, code string) award.Def {
t.Helper()
for _, d := range award.Defaults() {
if d.Code == code {
return d
}
}
t.Fatalf("%s is not in the catalog", code)
return award.Def{}
}
func findDef(defs []award.Def, code string) (award.Def, bool) {
for _, d := range defs {
if d.Code == code {
return d, true
}
}
return award.Def{}, false
}
func TestMergeCatalogAddsMissingAward(t *testing.T) {
stored := []award.Def{{Code: "MYOWN", Name: "Mine", Valid: true}}
got, updated, changed := mergeCatalog(stored)
if !changed {
t.Fatal("merge reported no change, but every catalog award was missing")
}
if len(updated) != 0 {
t.Errorf("updated = %v, want none: an ADDED award is not an UPDATED one", updated)
}
if _, ok := findDef(got, "FFMA"); !ok {
t.Error("FFMA was not added — a newly shipped award never reaches an existing install")
}
if _, ok := findDef(got, "MYOWN"); !ok {
t.Error("the operator's own award was dropped by the merge")
}
}
func TestMergeCatalogUpdatesOlderVersion(t *testing.T) {
// The stored copy is an older revision of a shipped award, with a broken rule.
old := catalogDef(t, "FFMA")
old.Version = catalogDef(t, "FFMA").Version - 1
old.Field = "wrong"
old.Valid = false // the operator disabled it — a preference, not a definition
got, updated, changed := mergeCatalog([]award.Def{old})
if !changed || len(updated) == 0 {
t.Fatal("a higher catalog version did not replace the stored definition — a shipped award could never be FIXED")
}
d, _ := findDef(got, "FFMA")
if d.Field != "grid4" {
t.Errorf("FFMA field = %q, want the catalog's %q", d.Field, "grid4")
}
if d.Valid {
t.Error("the update re-enabled an award the operator had switched off; that is their choice to make, not ours")
}
}
func TestMergeCatalogSkipsUserEdited(t *testing.T) {
old := catalogDef(t, "FFMA")
old.Version = catalogDef(t, "FFMA").Version - 1
old.Field = "mine"
old.UserEdited = true
got, updated, _ := mergeCatalog([]award.Def{old})
if len(updated) != 0 {
t.Fatalf("updated = %v: an award the operator has edited must never be overwritten", updated)
}
if d, _ := findDef(got, "FFMA"); d.Field != "mine" {
t.Errorf("field = %q, want the operator's %q", d.Field, "mine")
}
}
func TestMarkUserEditedOnlyOnRealChange(t *testing.T) {
prev := []award.Def{
{Code: "A", Name: "A", Field: "state", Valid: true, Version: 2},
{Code: "B", Name: "B", Field: "cqz", Valid: true, Version: 2},
}
next := []award.Def{
{Code: "A", Name: "A", Field: "state", Valid: true}, // untouched
{Code: "B", Name: "B", Field: "county", Valid: true}, // changed
{Code: "C", Name: "C", Field: "note", Valid: true}, // brand new
}
markUserEdited(next, prev)
if next[0].UserEdited {
t.Error("A was flagged as edited although nothing about it changed — every save would freeze every award out of future updates")
}
if !next[1].UserEdited {
t.Error("B changed field and was not flagged; a catalog update would overwrite the operator's work")
}
if next[2].UserEdited {
t.Error("C is a brand-new award; there is no shipped version to protect it from")
}
// A save must not pretend to be a new shipped revision.
if next[0].Version != 2 || next[1].Version != 2 {
t.Errorf("versions = %d/%d, want both 2: saving is not shipping", next[0].Version, next[1].Version)
}
}
+100
View File
@@ -0,0 +1,100 @@
package main
// One-shot generator: reads the FIPS county CSV and emits
// internal/awardref/uscounties_gen.go. Not part of the build.
import (
"bufio"
"fmt"
"os"
"sort"
"strings"
"hamlog/internal/award"
)
// dxccForState maps the two US "states" that are separate DXCC entities.
func dxccForState(st string) int {
switch st {
case "AK":
return 6
case "HI":
return 110
default:
return 291
}
}
// territories we exclude entirely — plus DC, which USA-CA does not count as a
// county.
var skipState = map[string]bool{"PR": true, "GU": true, "VI": true, "AS": true, "MP": true, "NA": true, "DC": true}
// excludedUSACA reports county-equivalents that the CQ USA-CA award does NOT
// count as counties: the independent cities of Virginia and Carson City (NV).
// (Baltimore MD and St. Louis MO ARE counted, so they are kept.) A contact in
// one of these counts toward a bordering county under the award rules.
func excludedUSACA(state, name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
switch state {
case "VA":
return strings.HasSuffix(n, " city")
case "NV":
return n == "carson city"
}
return false
}
func main() {
f, err := os.Open(os.Args[1])
if err != nil {
panic(err)
}
defer f.Close()
type row struct{ code, name string; dxcc int }
var rows []row
seen := map[string]bool{}
sc := bufio.NewScanner(f)
sc.Scan() // header
for sc.Scan() {
line := sc.Text()
parts := strings.SplitN(line, ",", 3)
if len(parts) < 3 {
continue
}
name := strings.TrimSpace(parts[1])
st := strings.TrimSpace(parts[2])
if skipState[st] || len(st) != 2 || strings.EqualFold(name, "UNITED STATES") {
continue
}
if excludedUSACA(st, name) {
continue
}
// State header rows have an all-caps state NAME and state code "NA"
// (already skipped). County rows have a 2-letter state code.
code := award.USCountyKey(st, name)
if code == "" || seen[code] {
continue
}
seen[code] = true
rows = append(rows, row{code: code, name: name + ", " + st, dxcc: dxccForState(st)})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].code < rows[j].code })
var b strings.Builder
b.WriteString("// Code generated by cmd/cntygen from FIPS county data. DO NOT EDIT.\n")
b.WriteString("package awardref\n\n")
b.WriteString("// usCounties is the US Counties (USA-CA) reference list: one entry per county\n")
b.WriteString("// in the 50 states, keyed by the canonical \"STATE,COUNTY\" match code that\n")
b.WriteString("// award.usCountyKey produces from a QSO's state + cnty fields.\n")
fmt.Fprintf(&b, "func usCounties() []Ref {\n\treturn []Ref{\n")
for _, r := range rows {
fmt.Fprintf(&b, "\t\tref(%q, %q, %d),\n", r.code, r.name, r.dxcc)
}
b.WriteString("\t}\n}\n")
if err := os.WriteFile(os.Args[2], []byte(b.String()), 0o644); err != nil {
panic(err)
}
fmt.Printf("wrote %d counties to %s\n", len(rows), os.Args[2])
}
+609 -74
View File
File diff suppressed because it is too large Load Diff
+40 -4
View File
@@ -1,14 +1,30 @@
import { Antenna, X } from 'lucide-react';
import { useState } from 'react';
import { Antenna, X, ListFilter } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
export type AGAntenna = { index: number; name: string };
export type AGAntenna = { index: number; name: string; bands?: number };
export type AGStatus = {
connected: boolean; host?: string; last_error?: string;
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
antennas: AGAntenna[];
};
// AG_BAND_BITS maps a band name to its bit in the 4O3A Antenna Genius band
// bitmask (standard HF+6m order, bit 0 = 160m). Used to show only the antennas
// configured for the current band, like the native app. If the mapping ever
// proves off on real hardware, the debug log (antgenius: antenna raw …) shows the
// device's actual mask so it can be corrected.
const AG_BAND_BITS: Record<string, number> = {
'160m': 1 << 0, '80m': 1 << 1, '60m': 0, '40m': 1 << 2, '30m': 1 << 3,
'20m': 1 << 4, '17m': 1 << 5, '15m': 1 << 6, '12m': 1 << 7, '10m': 1 << 8, '6m': 1 << 9,
};
function bandBit(band?: string): number {
if (!band) return 0;
return AG_BAND_BITS[band.trim().toLowerCase()] ?? 0;
}
// Format an antenna name: first letter uppercase, the rest lowercase
// (e.g. "DX COMMANDER" → "Dx commander").
function pretty(name: string): string {
@@ -22,13 +38,28 @@ function pretty(name: string): string {
// a port-A button (left) and port-B button (right). Colours: green = selected on
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
// Clicking an already-selected port deselects it (port → None).
export function AntGeniusPanel({ status, onActivate, onClose }: {
export function AntGeniusPanel({ status, onActivate, onClose, band }: {
status: AGStatus;
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
onClose: () => void;
band?: string; // current operating band (e.g. "20m") for the band filter
}) {
const { t } = useI18n();
const list = status.antennas ?? [];
const allAntennas = status.antennas ?? [];
// Band filter (persisted, default ON): show only antennas whose band mask
// includes the current band — matching the native 4O3A app. Antennas with an
// unknown mask (0) always show, and if the filter would hide EVERYTHING (e.g. a
// band we can't map, or masks the device didn't send) we fall back to the full
// list so the panel is never mysteriously empty.
const [bandFilter, setBandFilter] = useState(() => localStorage.getItem('opslog.agBandFilter') !== '0');
const toggleBandFilter = () => setBandFilter((v) => { const n = !v; localStorage.setItem('opslog.agBandFilter', n ? '1' : '0'); return n; });
const bit = bandBit(band);
let list = allAntennas;
if (bandFilter && bit !== 0) {
const filtered = allAntennas.filter((a) => !a.bands || (a.bands & bit) !== 0);
if (filtered.length > 0) list = filtered;
}
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
const letter = port === 1 ? 'A' : 'B';
@@ -57,6 +88,11 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
<Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
<span className="flex-1" />
<button type="button" onClick={toggleBandFilter}
title={bandFilter ? t('agp.filterOnHint', { band: band || '' }) : t('agp.filterOffHint')}
className={cn('transition-colors', bandFilter ? 'text-primary' : 'text-muted-foreground/50 hover:text-muted-foreground')}>
<ListFilter className="size-3.5" />
</button>
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
+370 -41
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -18,7 +18,8 @@ import {
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
ListCountries, DXCCForCountry, DXCCName,
PopulateBuiltinReferences, HasBuiltinReferences,
ExportAwards, ImportAwards,
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
} from '../../wailsjs/go/main/App';
// Above this many references the editor stops loading the whole list and
@@ -32,7 +33,7 @@ export type AwardDef = {
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
ref_display?: string; // grid column shows: ref | name | both
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
leading_str?: string; trailing_str?: string; dynamic?: boolean;
or_rules?: AwardOrRule[];
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
@@ -177,7 +178,90 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
loadMeta();
}, [open]);
// Codes present in the SHIPPED catalog. Anything in the database that isn't in
// here exists only on this machine — it is yours alone, and a reinstall loses it
// unless it has been exported. The list flags those.
const [catalogCodes, setCatalogCodes] = useState<string[]>([]);
useEffect(() => {
if (!open) return;
GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {});
}, [open]);
// Shipped fixes we did NOT apply, because this award carries the operator's own
// changes and we will not overwrite those behind their back. Offered, not forced.
type AwardUpdate = { code: string; name: string; from: number; to: number };
const [updates, setUpdates] = useState<AwardUpdate[]>([]);
const loadUpdates = useCallback(() => {
GetAwardUpdates().then((u: any) => setUpdates(Array.isArray(u) ? u : [])).catch(() => {});
}, []);
useEffect(() => { if (open) loadUpdates(); }, [open, loadUpdates]);
// Pending import awaiting the operator's decision on the awards that collide.
type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean };
type ImportPreview = { path: string; awards: ImportEntry[] };
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
const [decisions, setDecisions] = useState<Record<string, string>>({});
useEffect(() => {
if (!importPreview) return;
// Default to the SAFE choice: keep what you have. An import must never destroy
// an award because the operator clicked through a dialog without reading it.
const d: Record<string, string> = {};
for (const e of importPreview.awards) d[e.code] = e.exists ? 'skip' : 'replace';
setDecisions(d);
}, [importPreview]);
const cur = defs[sel];
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
// ── Award tester: run the award's rules against a real QSO and show every step.
type Rejected = { candidate: string; reason: string };
type Step = {
rule: string; field: string; match_by?: string; exact?: boolean; pattern?: string;
field_value?: string; candidates?: string[]; kept?: string[]; rejected?: Rejected[];
skipped?: boolean; error?: string;
};
type Explanation = {
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
};
type TestRow = { qso: any; explanation: Explanation };
const [testCall, setTestCall] = useState('');
const [testRows, setTestRows] = useState<TestRow[] | null>(null);
const [testErr, setTestErr] = useState('');
const [testing, setTesting] = useState(false);
const runTest = async () => {
if (!cur) return;
setTesting(true); setTestErr(''); setTestRows(null);
try {
const r = await ExplainAward(cur.code, testCall);
setTestRows((Array.isArray(r) ? r : []) as TestRow[]);
} catch (e: any) {
setTestErr(String(e?.message ?? e));
} finally {
setTesting(false);
}
};
// The tester reads the SAVED award, not the unsaved edits in this dialog — so say
// so, rather than let the operator test a rule they only think they applied.
useEffect(() => { setTestRows(null); setTestErr(''); }, [sel]);
// Several QSOs with the same station usually trace IDENTICALLY, and twenty copies
// of the same trace is noise. But they don't always: scope is judged per QSO
// (band, mode, date — FFMA's 1983 cut-off), a manual override lives ON a QSO, and
// two contacts can even hold different QTHs. That divergence is often the actual
// answer ("why does my 2019 QSO count and my 2024 one not?"), so we group by
// trace instead of dropping it: one card per distinct outcome, with its count.
const testGroups = useMemo(() => {
if (!testRows) return null;
const groups: { key: string; rows: TestRow[] }[] = [];
for (const row of testRows) {
const key = JSON.stringify(row.explanation);
const g = groups.find((x) => x.key === key);
if (g) g.rows.push(row);
else groups.push({ key, rows: [row] });
}
return groups;
}, [testRows]);
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
const toggleIn = (key: keyof AwardDef, v: string) => {
const arr = ((cur?.[key] as string[]) ?? []);
@@ -215,17 +299,39 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
if (p) setErr(t('awed.exportedTo', { path: p }));
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Import an award bundle: definitions are upserted by code, reference lists
// replaced. Reloads the editor afterwards.
// Import: LOOK FIRST, then ask.
//
// This used to merge by code with "imported wins", silently — import a WAPC
// someone shared and YOUR WAPC (its province list, its city regexes) was
// destroyed without a word. Sharing awards is exactly what we want people to do,
// so it must not be a data-loss trap.
async function importAwards() {
setErr('');
try {
const r: any = await ImportAwards();
if (!r || (!r.awards && !r.references)) return; // cancelled
const p: any = await InspectAwardImport();
if (!p?.path) return; // cancelled
const clashes = (p.awards ?? []).filter((e: any) => e.exists);
if (clashes.length === 0) {
// Nothing collides — nothing to ask. This is the common case.
const dec: Record<string, string> = {};
for (const e of p.awards) dec[e.code] = 'replace';
await applyImport(p.path, dec);
return;
}
setImportPreview(p); // → the collision dialog decides
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function applyImport(path: string, decisions: Record<string, string>) {
try {
const r: any = await ApplyAwardImport(path, decisions);
setImportPreview(null);
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
setDefs((d ?? []) as any); setSel(0);
onSaved();
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
if (r?.awards || r?.references) {
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
}
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function updateList(code: string) {
@@ -261,15 +367,37 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</div>
</div>
<div className="flex-1 overflow-auto">
{filtered.map(({ d, i }) => (
<button key={i} onClick={() => setSel(i)}
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
<span className="font-mono font-semibold shrink-0">{d.code}</span>
<span className="text-muted-foreground truncate">{d.name}</span>
</button>
))}
{filtered.map(({ d, i }) => {
// An award that is NOT in the shipped catalog exists only in THIS
// database: nobody else has it, and a reinstall loses it unless it
// has been exported. That deserves to be visible at a glance, not
// discovered the hard way.
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
// A pending update is only reachable from the award's own banner, so
// the list has to say which award to open — otherwise the fix waits
// behind a click nobody knows to make.
const hasUpdate = updates.some((u) => (u.code ?? '').toUpperCase() === (d.code ?? '').toUpperCase());
return (
<button key={i} onClick={() => setSel(i)}
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
<span className="font-mono font-semibold shrink-0">{d.code}</span>
<span className="text-muted-foreground truncate">{d.name}</span>
{hasUpdate && (
<span className="ml-auto shrink-0" title={t('awed.updateAvailable')}>
<ArrowUpCircle className="size-3.5 text-info" />
</span>
)}
{onlyHere && (
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
title={t('awed.onlyHereTip')}>
{t('awed.onlyHere')}
</span>
)}
</button>
);
})}
</div>
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
@@ -279,6 +407,37 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
{/* Right: tabbed editor for selected award */}
<div className="flex flex-col 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
plainly what accepting costs. */}
{cur && selUpdate && (
<div className="mx-4 mt-3 rounded border border-info/40 bg-info/10 px-3 py-2 text-xs">
<div className="flex items-center gap-2">
<ArrowUpCircle className="size-4 text-info shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium">{t('awed.updateAvailable')}</div>
<div className="text-muted-foreground">{t('awed.updateOverwrites')}</div>
</div>
<Button size="sm" className="h-7 px-2 text-[11px]"
onClick={async () => {
try {
await ApplyAwardUpdate(cur.code);
// The backend rewrote this award (and its references) — pull the
// new state back, or the editor would keep showing, and on the
// next Save re-persist, the definition we just replaced.
setDefs(((await GetAwardDefs()) ?? []) as any);
loadMeta();
loadUpdates();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}}>{t('awed.updateApply')}</Button>
<Button size="sm" variant="ghost" className="h-7 px-2 text-[11px]"
onClick={async () => {
try { await DismissAwardUpdate(cur.code); loadUpdates(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}}>{t('awed.updateKeepMine')}</Button>
</div>
</div>
)}
{!cur ? (
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
) : (
@@ -288,6 +447,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
<TabsTrigger value="test">{t('awed.tabTest')}</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-auto p-4">
@@ -297,6 +457,15 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
{/* No "Built-in" checkbox: an award OpsLog ships IS built-in, and
the catalog derives that on load. Asking the author to tick a
box to declare it would be one more step nobody can guess —
forget it and the award silently misses every future catalog
correction. "Protected" stays: whether an award can be deleted
IS a real choice. */}
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
</label>
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
</div>
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
@@ -338,7 +507,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
}</SelectContent>
</Select>
</Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> {t('awed.allowMultiple')}</label>
{/* No "allow multiple references" switch: a QSO always counts for
every reference its field holds (an n-fer POTA activation, a
VUCC grid-line contact). The old checkbox was read by nothing. */}
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
<div className="border-t pt-2.5 mt-1 space-y-2.5">
@@ -365,8 +536,10 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
</div>
{/* Additional OR searches: a QSO earns a reference if the
primary rule OR any of these match. */}
{/* Fallback searches: tried in order, only while nothing
has matched yet — the first that hits wins (short-circuit),
so a value already resolved by the primary rule isn't
re-derived differently by a later one. */}
<div className="border-t pt-2.5 space-y-2">
<div className="flex items-center justify-between">
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
@@ -423,8 +596,105 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
))}
</div>
</div>
<Field2 label={t('awed.grantCodes')}><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> {t('awed.exportCreditGranted')}</label>
{/* "Grant codes" and "export credit_granted" used to live here. No
ADIF export has ever written CREDIT_GRANTED, so both controls
did nothing at all. The stored values are kept (see award.Def);
put the controls back when the export is actually wired up. */}
</TabsContent>
{/* ── Test ──
Runs the SAVED award against a real QSO and shows every rule:
what it scanned, what it produced, and why each candidate was
rejected. An award that matches nothing used to be a black box. */}
<TabsContent value="test" className="mt-0 space-y-3">
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">{t('awed.testCallsign')}</Label>
<Input className="h-8 w-40 font-mono uppercase" value={testCall} placeholder="I2IFT"
onChange={(e) => setTestCall(e.target.value.toUpperCase())}
onKeyDown={(e) => { if (e.key === 'Enter') runTest(); }} />
</div>
<Button size="sm" className="h-8" onClick={runTest} disabled={testing || !testCall.trim()}>
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Search className="size-3.5 mr-1" />}
{t('awed.testRun')}
</Button>
<span className="text-[11px] text-muted-foreground pb-1">{t('awed.testSavedOnly')}</span>
</div>
{testErr && <div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{testErr}</div>}
{testGroups?.map((g, ri) => {
const row = g.rows[0];
const ex = row.explanation;
const matched = (ex.result ?? []).length > 0;
const qsoLabel = (q: any) => `${String(q?.qso_date ?? '').slice(0, 10)} · ${q?.band} · ${q?.mode}`;
return (
<div key={ri} className="rounded border border-border overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs border-b border-border">
<span className="font-mono font-semibold">{row.qso?.callsign}</span>
<span className="text-muted-foreground font-mono">{qsoLabel(row.qso)}</span>
{g.rows.length > 1 && (
<span className="text-muted-foreground" title={g.rows.slice(1).map((r) => qsoLabel(r.qso)).join('\n')}>
{t('awed.testSameAs', { n: String(g.rows.length - 1) })}
</span>
)}
<span className={cn('ml-auto px-1.5 rounded text-[10px] font-semibold uppercase tracking-wide',
matched ? 'bg-success/15 text-success' : 'bg-muted-foreground/15 text-muted-foreground')}>
{matched ? (ex.result ?? []).join(', ') : t('awed.testNoMatch')}
</span>
</div>
{!ex.in_scope ? (
<div className="px-3 py-2 text-xs">
<span className="font-medium">{t('awed.testOutOfScope')}</span>{' '}
<span className="text-muted-foreground">{ex.scope_error}</span>
</div>
) : (
<div className="divide-y divide-border/50">
{(ex.steps ?? []).map((s, si) => (
<div key={si} className={cn('px-3 py-2 text-xs', s.skipped && 'opacity-50')}>
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-semibold uppercase text-[10px] tracking-wide">{s.rule}</span>
<span className="text-muted-foreground">
{s.field}{s.match_by ? ` / ${s.match_by}` : ''}{s.exact ? ` / ${t('awed.exact')}` : ''}
</span>
{s.skipped && <span className="text-muted-foreground italic"> {t('awed.testSkipped')}</span>}
{s.error && <span className="text-destructive"> {s.error}</span>}
</div>
{!s.skipped && !s.error && (
<div className="mt-1 space-y-0.5 pl-3 border-l-2 border-border">
<div>
<span className="text-muted-foreground">{t('awed.testFieldValue')}: </span>
{s.field_value
? <span className="font-mono">{s.field_value}</span>
: <span className="italic text-muted-foreground">{t('awed.testEmptyField')}</span>}
</div>
{(s.kept ?? []).map((k) => (
<div key={k} className="text-success font-mono"> {k}</div>
))}
{(s.rejected ?? []).map((r, i2) => (
<div key={i2} className="font-mono text-muted-foreground">
{r.candidate} <span className="font-sans"> {r.reason}</span>
</div>
))}
{!(s.kept ?? []).length && !(s.rejected ?? []).length && s.field_value && (
<div className="italic text-muted-foreground">{t('awed.testNoCandidate')}</div>
)}
</div>
)}
</div>
))}
{(ex.manual ?? []).length > 0 && (
<div className="px-3 py-2 text-xs">
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
<span className="font-mono text-success">{(ex.manual ?? []).join(', ')}</span>
</div>
)}
</div>
)}
</div>
);
})}
</TabsContent>
{/* ── References ── */}
@@ -449,11 +719,78 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
</Button>
{/* The drop folder: put an award JSON here and it installs on restart —
no rebuild, nobody to ask. Opening it beats printing a path someone
then has to retype. */}
<Button variant="ghost" onClick={() => OpenAwardsFolder().catch((e: any) => setErr(String(e?.message ?? e)))}
title={t('awed.awardsFolderTip')}>
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
</Button>
<div className="flex-1" />
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
</DialogFooter>
</DialogContent>
{/* Collision dialog. The import cannot silently replace an award you already
have — importing a shared WAPC used to destroy yours (province list, city
regexes, band scope) without a word. You decide, per award. */}
{importPreview && (
<Dialog open onOpenChange={() => setImportPreview(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('awed.importCollisionTitle')}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground -mt-2">{t('awed.importCollisionHint')}</p>
<div className="max-h-[50vh] overflow-auto flex flex-col gap-2 mt-2">
{importPreview.awards.map((e) => (
<div key={e.code} className="rounded-md border border-border p-2.5">
<div className="flex items-baseline gap-2 min-w-0">
<span className="font-mono font-semibold text-sm">{e.code}</span>
<span className="text-xs text-muted-foreground truncate">{e.name}</span>
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground tabular-nums">
{t('awed.importRefs', { n: e.references })}
</span>
</div>
{!e.exists ? (
<p className="mt-1 text-[11px] text-success-muted-foreground">{t('awed.importNew')}</p>
) : (
<>
<p className="mt-1 text-[11px] text-warning-muted-foreground">
{t('awed.importExists', { name: e.mine_name || e.code, n: e.mine_refs })}
{e.protected && ` · ${t('awed.importProtected')}`}
</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{([
['skip', t('awed.importKeepMine')],
['replace', t('awed.importReplace')],
['copy', t('awed.importCopy', { code: e.code })],
] as [string, string][]).map(([v, label]) => (
<button key={v} type="button"
onClick={() => setDecisions((d) => ({ ...d, [e.code]: v }))}
className={cn('px-2 h-7 rounded-md border text-xs',
decisions[e.code] === v
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-muted')}>
{label}
</button>
))}
</div>
</>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setImportPreview(null)}>{t('awed.cancel')}</Button>
<Button onClick={() => applyImport(importPreview.path, decisions)}>
<Upload className="size-3.5 mr-1" /> {t('awed.import2')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</Dialog>
);
}
@@ -606,26 +943,18 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
</div>
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
<div className="grid grid-cols-2 gap-3">
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
</div>
{/* One per row: side by side, each half spends 120px of its width on
the label column and the input is left too narrow to read a group
name ("Basilicata" → "Basili"). */}
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">{t('awed.score')}</Label>
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} />
</div>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">{t('awed.bonus')}</Label>
<Input type="number" className="h-8 font-mono w-full" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} />
</div>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">{t('awed.grid')}</Label>
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
</div>
</div>
{/* Score / Bonus were here. Nothing computes an award score, so both
boxes were pure decoration. The columns stay in the database — a
third-party list may carry the values — but they are not offered
for editing until something actually reads them. */}
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
</div>
)}
+7 -1
View File
@@ -49,6 +49,10 @@ const FIELDS: FieldDef[] = [
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
{ id: 'my_name', label: 'bulk.fMyName', group: 'My station', kind: 'text' },
{ id: 'my_arrl_sect', label: 'bulk.fMyArrlSect', group: 'My station', kind: 'text', upper: true },
{ id: 'my_darc_dok', label: 'bulk.fMyDarcDok', group: 'My station', kind: 'text', upper: true },
{ id: 'my_vucc_grids', label: 'bulk.fMyVuccGrids', group: 'My station', kind: 'text', upper: true },
// Contest
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
@@ -60,7 +64,9 @@ const FIELDS: FieldDef[] = [
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
// Contacted station (activation refs / SIG)
// Contacted station (location / activation refs / SIG)
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
+91 -50
View File
@@ -51,6 +51,8 @@ export type SpotStatusEntry = {
country?: string;
continent?: string;
worked_call?: boolean;
new_county?: boolean;
new_pota?: boolean;
};
type Props = {
@@ -101,13 +103,43 @@ function statusFor(p: any): SpotStatusEntry | undefined {
// Status column, using the same colours as the per-cell fills (NEW DXCC =
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
// there's nothing notable to show.
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
type Badge = { text: string; fg: string; bg: string; bd: string };
// tok builds a badge from a semantic status token (success/warning/caution/
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
function tok(name: string, text: string): Badge {
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
}
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
// Status badges) when the slot is notable, instead of flooding the whole cell with
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
// null → plain text, no pill.
function cellChip(value: any, name: string | null): any {
const txt = value === undefined || value === null || value === '' ? '' : String(value);
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
// Inherit the column's font size (no fixed 9px / height) so a pill around a
// callsign stays the same size as the plain callsigns next to it — just tinted
// and rounded, not shrunk.
return (
<span style={{
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
border: `1px solid var(--${name}-border)`, fontWeight: 700,
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
}}>{txt}</span>
);
}
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
switch (s?.status) {
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null;
case 'new': return tok('danger', t('clg2.newDxcc'));
case 'new-band': return tok('warning', t('clg2.newBand'));
case 'new-mode': return tok('caution', t('clg2.newMode'));
case 'new-slot': return tok('caution', t('clg2.newSlot'));
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
}
}
@@ -117,22 +149,22 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
defaultVisible: true,
sort: 'desc',
cellStyle: { color: '#7a6b50' },
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
defaultVisible: true,
cellClass: 'font-mono',
// Only STATUS calls get a colour: new DXCC entity → filled cell (no padded
// pill, so calls stay aligned), worked-call → blue. A plain spot inherits the
// theme's normal text colour (var(--foreground)) so callsigns blend in with
// the rest of the row across every theme instead of always shouting orange.
cellStyle: (p: any): any => {
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
// a plain spot inherits the theme's normal text colour so callsigns blend in
// with the rest of the row instead of always shouting a colour.
cellRenderer: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 };
if (s?.worked_call) return { color: '#0369a1', fontWeight: 700 };
return { fontWeight: 700 };
if (s?.status === 'new') return cellChip(p.value, 'danger');
const color = s?.worked_call ? 'var(--info)' : undefined;
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
},
tooltipValueGetter: (p: any) => {
const s = statusFor(p);
@@ -141,26 +173,42 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
},
{
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
headerName: t('clg2.c.status'), width: 96, sortable: true,
headerName: t('clg2.c.status'), width: 120, sortable: true,
defaultVisible: true,
// Spells out the slot status as a text badge so NEW SLOT (and the others)
// is obvious at the row level, not just a single coloured cell.
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
// and NEW POTA are orthogonal, so they stack as extra badges.
valueGetter: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return t('clg2.newDxcc');
if (s?.status === 'new-band') return t('clg2.newBand');
if (s?.status === 'new-mode') return t('clg2.newMode');
if (s?.status === 'new-slot') return t('clg2.newSlot');
return s?.worked_call ? t('clg2.wkdCall') : '';
const parts: string[] = [];
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
if (s?.new_county) parts.push(t('clg2.newCounty'));
if (s?.new_pota) parts.push(t('clg2.newPota'));
return parts.join(' ');
},
cellRenderer: (p: any) => {
const b = statusBadge(t, statusFor(p));
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}></span>;
const s = statusFor(p);
const badges: Badge[] = [];
const b = statusBadge(t, s);
if (b) badges.push(b);
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return (
<span style={{
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{b.text}</span>
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
{badges.map((bd, i) => (
<span key={i} style={{
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
fontWeight: 700, fontSize: 9,
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{bd.text}</span>
))}
</span>
);
},
tooltipValueGetter: (p: any) => {
@@ -176,7 +224,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
defaultVisible: true,
cellStyle: { color: '#166534' },
cellStyle: { color: 'var(--success)' },
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
},
{
@@ -191,10 +239,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
defaultVisible: true,
cellClass: 'font-mono',
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
: undefined),
// NEW BAND for this entity → small warning pill around the band text.
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
},
{
@@ -203,16 +249,11 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
defaultVisible: true,
cellClass: 'font-mono',
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
cellStyle: (p: any) => {
const st = statusFor(p)?.status;
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
// Status badge text tells them apart.
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
return undefined;
},
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}></span>,
// Only NEW MODE pills the mode cell — there the mode itself is genuinely new
// for the entity. NEW SLOT means band AND mode were each worked before (just
// not together), so highlighting the mode cell would wrongly imply "CW is new";
// that case is signalled by the Status badge alone.
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
tooltipValueGetter: (p: any) => {
const st = statusFor(p)?.status;
if (st === 'new-mode') return t('clg2.tipNewMode');
@@ -224,7 +265,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
cellStyle: { color: '#7a6b50' },
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
@@ -261,7 +302,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
]?.country ?? '',
cellStyle: { color: '#7a6b50' },
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
@@ -270,31 +311,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
]?.continent ?? '',
cellStyle: { color: '#7a6b50', fontSize: 10 },
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
},
{
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
defaultVisible: true,
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
cellStyle: { color: '#7a6b50' },
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
defaultVisible: true,
cellStyle: { color: '#9a8870', fontSize: 10 },
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
},
{
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
cellStyle: { color: '#7a6b50' },
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
defaultVisible: true,
cellStyle: { color: '#7a6b50' },
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
+2 -2
View File
@@ -25,8 +25,8 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
<Mic className="size-3.5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
<span className={cn('size-2 rounded-full', status.playing ? 'bg-warning animate-pulse' : 'bg-success')} />
{status.playing && <span className="text-[10px] text-warning font-medium">tx...</span>}
<span className={cn('size-2 rounded-full', status.playing ? 'bg-danger animate-pulse' : 'bg-success')} />
{status.playing && <span className="text-[10px] text-danger font-medium">TX</span>}
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
<Square className="size-3" /> {t('dvkp.stop')}
+8 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
import { writeUiPref } from '@/lib/uiPref';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
@@ -134,6 +134,12 @@ interface Props {
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
const { t } = useI18n();
// Field dropdown sorted alphabetically by translated label (the FIELDS array is
// grouped by category for maintenance; the operator wants it A→Z to find things).
const sortedFields = useMemo(
() => FIELDS.map((f) => ({ f, txt: t(f.label) })).sort((a, b) => a.txt.localeCompare(b.txt)),
[t],
);
const [conditions, setConditions] = useState<FilterCondition[]>([]);
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
@@ -237,7 +243,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
}}>
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{t(f.label)}</SelectItem>)}
{sortedFields.map(({ f, txt }) => <SelectItem key={f.value} value={f.value}>{txt}</SelectItem>)}
</SelectContent>
</Select>
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
+275 -70
View File
@@ -6,10 +6,13 @@ import {
FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
} from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst';
@@ -24,7 +27,10 @@ type FlexState = {
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
wnb: boolean; wnb_level: number;
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
@@ -34,14 +40,16 @@ type FlexState = {
};
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
type Meter = { id: number; src?: string; name?: string; unit?: string; slice?: number; value: number; lo: number; hi: number };
const ZERO: FlexState = {
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
rit: false, rit_freq: 0, xit: false, xit_freq: 0,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
wnb: false, wnb_level: 0, tx_filter_low: 0, tx_filter_high: 0,
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
amp_available: false, amp_operate: false,
@@ -139,31 +147,93 @@ function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, slide
);
}
// OffsetRow — RIT / XIT: a switch plus one signed offset field you scrub with the
// wheel (or ± , or the arrow keys once focused; hold Ctrl/Shift for 100 Hz steps).
// Deliberately the same control as the Icom panel's ShiftRow, so the two rigs are
// driven identically.
//
// The offset is NOT cleared when the switch goes off: SmartSDR keeps it, so
// flipping RIT back on returns you exactly where you were, like the radio's own
// knob. Zeroing it is a separate, deliberate act — that is what the 0 button is for.
const OFFSET_MAX = 99999; // SmartSDR's limit
function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
label: string; on: boolean; onToggle: () => void; hz: number; onHz: (v: number) => void;
disabled?: boolean; title?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const step = useRef((_d: number) => {});
step.current = (d: number) => onHz(Math.max(-OFFSET_MAX, Math.min(OFFSET_MAX, (hz || 0) + d)));
// React's onWheel is passive, so preventDefault() there is ignored and the panel
// scrolls under the cursor instead of the value changing. Bind it natively.
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
step.current(e.deltaY < 0 ? mag : -mag);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
const onKey = (e: React.KeyboardEvent) => {
const up = e.key === 'ArrowUp' || e.key === 'ArrowRight';
const dn = e.key === 'ArrowDown' || e.key === 'ArrowLeft';
if (!up && !dn) return;
e.preventDefault();
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
step.current(up ? mag : -mag);
};
return (
<div className="flex items-center gap-2" title={title}>
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent="cyan" />
<div ref={ref} data-offsetrow tabIndex={disabled || !on ? -1 : 0} onKeyDown={onKey}
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none',
'focus:outline-none focus:ring-2 focus:ring-info/50',
on && !disabled ? 'border-border bg-muted/40 cursor-ns-resize' : 'border-border/60 bg-muted/20 opacity-60')}>
<button type="button" disabled={disabled || !on} onClick={() => step.current(-10)}
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground"></button>
<span className={cn('text-sm font-mono font-bold tabular-nums', on && hz ? 'text-info' : 'text-muted-foreground')}>
{hz > 0 ? '+' : hz < 0 ? '' : ''}{Math.abs(hz || 0)} Hz
</span>
<button type="button" disabled={disabled || !on} onClick={() => step.current(10)}
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground">+</button>
</div>
<button type="button" disabled={disabled || !hz} onClick={() => onHz(0)}
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted disabled:opacity-30">0</button>
</div>
);
}
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
// `display` overrides the numeric readout; `segColor` colours segments by their
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
const METER_SEGMENTS = 26;
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title }: {
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
segColor?: (frac: number) => string; onClick?: () => void; title?: string;
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
}) {
const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS);
return (
<div onClick={onClick} title={title}
className={cn('rounded-lg border border-border/70 px-2.5 py-2 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
compact ? 'px-2 py-1' : 'px-2.5 py-2',
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
<div className="flex items-baseline justify-between gap-1 mb-1.5">
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
<span className="text-sm font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90">
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
{display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)}
</span>
</div>
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
<div className="flex gap-[2px] h-3 items-stretch rounded-[3px] bg-black/10 p-[2px]">
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit;
const frac = i / METER_SEGMENTS;
@@ -176,7 +246,7 @@ function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, displ
);
})}
</div>
{extra && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
</div>
);
}
@@ -248,6 +318,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
const off = !st.available;
const rxOff = off || !st.rx_avail;
// Radio-style global RIT tuning: Ctrl+←/→ nudges the RIT offset (Ctrl+Shift =
// ±100 Hz) from ANYWHERE — including while the callsign/RST entry field has
// focus, which is where the operator is while working a station. Only the RIT
// offset row itself (data-offsetrow) is skipped, since it handles its own
// arrows. Text-field word navigation via Ctrl+← / → is overridden only while
// RIT is actually engaged (guarded below), which is fine mid-QSO. Requires RIT
// on and the RX available.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey)) return;
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
const ae = document.activeElement as HTMLElement | null;
if (ae && ae.hasAttribute('data-offsetrow')) return;
if (!st.rit || rxOff) return;
e.preventDefault();
const mag = e.shiftKey ? 100 : 10;
const next = (st.rit_freq || 0) + (e.key === 'ArrowRight' ? mag : -mag);
change('rit_freq', next, () => FlexSetRITFreq(next));
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [st.rit, st.rit_freq, rxOff]);
// Clear the RIT offset back to 0 whenever a QSO is logged (any path: Log
// button, keyer macro, WSJT-X), so the next station starts centred. Only acts
// when there's actually a non-zero offset to clear.
useEffect(() => {
const off = EventsOn('qso:logged', () => {
if (st.rit && (st.rit_freq || 0) !== 0) change('rit_freq', 0, () => FlexSetRITFreq(0));
});
return () => off();
}, [st.rit, st.rit_freq]);
const isCW = (st.mode || '').toUpperCase().includes('CW');
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
@@ -262,6 +365,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
: -1;
// Compact header readouts for PA voltage + temperature (numbers, not bars) — the
// user wanted these in the header to save vertical space in the Meters card.
const hdrMeters = st.meters || [];
const hdrFind = (name: string) => hdrMeters.find((m) => (m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const voltM = hdrFind('13.8') || hdrFind('VOLT');
const patempM = hdrFind('PATEMP') || hdrFind('PA TEMP');
return (
<div className="h-full min-h-0 overflow-auto bg-background">
<div className="max-w-5xl mx-auto p-3 space-y-3">
@@ -274,6 +384,23 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span>
</div>
<div className="flex-1" />
{/* Compact PA voltage + temperature readouts (numbers) to save space. */}
{!off && (voltM || patempM) && (
<div className="flex items-center gap-4 mr-1 font-mono tabular-nums">
{voltM && (
<span className="flex flex-col items-end leading-none">
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.voltage')}</span>
<span className="text-sm font-bold">{voltM.value.toFixed(1)}<span className="text-[9px] text-slate-400 ml-0.5">V</span></span>
</span>
)}
{patempM && (
<span className="flex flex-col items-end leading-none">
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.paTemp')}</span>
<span className={cn('text-sm font-bold', patempM.value >= 70 ? 'text-red-400' : patempM.value >= 55 ? 'text-amber-400' : 'text-slate-100')}>{patempM.value.toFixed(0)}<span className="text-[9px] text-slate-400 ml-0.5">°C</span></span>
</span>
)}
</div>
)}
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
!st.available ? 'bg-muted text-muted-foreground'
: st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}>
@@ -315,6 +442,66 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</div>
)}
{/* Live meters (UDP VITA-49 stream) — placed right under the slices. */}
<Card icon={Gauge} title={t('flxp.meters')}>
{(() => {
const meters = st.meters || [];
if (off || meters.length === 0) {
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
}
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
// Radio meters (exclude the amplifier's, which we show separately).
const radio = (name: string) => meters.find((m) =>
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
// Per-slice (SLC) meters — S-meter — exist once PER SLICE, so pick the
// one for the ACTIVE slice; otherwise we'd always show slice A's level.
const activeSlice = (st.slices || []).find((s) => s.active)?.index ?? -1;
const sliceMeter = (name: string) => {
const m = meters.filter((x) => (x.name || '').toUpperCase().includes(name) && !(x.src || '').toUpperCase().includes('AMP'));
if (m.length === 0) return undefined;
return m.find((x) => (x.src || '').toUpperCase().includes('SLC') && x.slice === activeSlice) || m[0];
};
const sig = sliceMeter('LEVEL') || sliceMeter('SIGNAL');
const fwd = radio('FWDPWR');
const swr = radio('SWR');
// Mic input level + speech-compression (voltage & PA temp live in the
// header now). All already in the meter stream.
const mic = meters.find((m) => /MIC/i.test(m.name || '') && !(m.src || '').toUpperCase().includes('AMP'));
const comp = radio('COMPPEAK') || radio('COMP');
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
const sUnit = (dbm: number) => {
const sv = (dbm + 127) / 6; // S0 = -127 dBm
if (sv >= 9) {
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
}
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
};
const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive).
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a"
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
// Speech compression (dB of gain reduction).
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={(comp.hi && comp.hi > 0) ? comp.hi : 25} accent="#0891b2" />,
].filter(Boolean);
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
);
})()}
</Card>
{off && (
<div className="text-center text-sm text-muted-foreground py-6">
{t('flxp.waiting')}
@@ -365,6 +552,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
)}
</div>
{!isCW ? (
<div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2">
@@ -391,6 +579,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
</div>
{/* TX audio bandwidth — low/high cut (Hz). Typed locally, committed on
blur/Enter so we don't spam a command per keystroke; the hold guard
keeps the poll from clobbering the field mid-edit. */}
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.txFilter')}</span>
{(() => {
const editTX = (patch: Partial<FlexState>) => { hold.current['tx_filter_low'] = Date.now() + 8000; hold.current['tx_filter_high'] = Date.now() + 8000; setSt((p) => ({ ...p, ...patch })); };
const commitTX = () => { hold.current['tx_filter_low'] = Date.now() + 900; hold.current['tx_filter_high'] = Date.now() + 900; FlexSetTXFilter(st.tx_filter_low || 0, st.tx_filter_high || 0).catch(() => {}); };
const inp = 'w-[70px] h-8 rounded-md border border-input bg-background px-2 text-xs font-mono tabular-nums disabled:opacity-40';
return (<>
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_low || 0} className={inp}
onChange={(e) => editTX({ tx_filter_low: parseInt(e.target.value, 10) || 0 })}
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
<span className="text-muted-foreground text-xs"></span>
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_high || 0} className={inp}
onChange={(e) => editTX({ tx_filter_high: parseInt(e.target.value, 10) || 0 })}
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
</>);
})()}
</div>
{/* Mic profile — SmartSDR named mic-EQ/processor presets. */}
{!!st.mic_profiles && st.mic_profiles.length > 0 && (
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.micProfile')}</span>
<select disabled={off} value={st.mic_profile || ''}
onChange={(e) => { const v = e.target.value; hold.current['mic_profile'] = Date.now() + 2000; setSt((p) => ({ ...p, mic_profile: v })); FlexSetMicProfile(v).catch(() => {}); }}
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-2 text-xs">
{!st.mic_profiles.includes(st.mic_profile || '') && <option value=""></option>}
{st.mic_profiles.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
)}
</div>
) : (
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
@@ -438,6 +659,18 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</div>
</div>
)}
{/* RIT / XIT — on the RECEIVE side: RIT is what you reach for while
listening (chasing a station that drifts off your transmit frequency),
so it belongs with the other things you touch mid-QSO. Both act on the
ACTIVE slice and follow slice focus like every control here. */}
<div className="space-y-1.5 pb-3 border-b border-border/60">
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
</div>
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
@@ -464,6 +697,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
@@ -560,71 +797,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
)}
</div>
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
and shown compact so they sit with the amp controls. */}
{(() => {
const meters = st.meters || [];
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
if (off || amp.length === 0) return null;
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
}
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
// Drain current (ID): the PGXL reports a full-scale far too small
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
// value itself is fine — only the bar's range was wrong. Give it a
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
// only override an unusable reported range, not a sane one.
let lo = m.lo, hi = m.hi;
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
lo = 0;
hi = m.hi >= 25 ? m.hi : 25;
}
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
})}
</div>
);
})()}
</Card>
)}
{/* Live meters (UDP VITA-49 stream) */}
<Card icon={Gauge} title={t('flxp.meters')}>
{(() => {
const meters = st.meters || [];
if (off || meters.length === 0) {
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
}
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
// Radio meters (exclude the amplifier's, which we show separately).
const radio = (name: string) => meters.find((m) =>
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const sig = radio('LEVEL') || radio('SIGNAL');
const fwd = radio('FWDPWR');
const swr = radio('SWR');
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
: /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c'
: /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
const sUnit = (dbm: number) => {
const sv = (dbm + 127) / 6; // S0 = -127 dBm
if (sv >= 9) {
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
}
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
};
const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
].filter(Boolean);
return (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
{amp.length > 0 && (
<div className="border-t border-border/60 pt-2 mt-1">
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">{t('flxp.amplifierHdr')}</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
}
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
})}
</div>
</div>
)}
</>
);
})()}
</Card>
</div>
</div>
);
+33 -12
View File
@@ -58,6 +58,21 @@ const BANDS: { l: string; hz: number }[] = [
// SSB by frequency and the rig's data variant for digital modes.
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
// dB stepped attenuator; the IC-7300/705/7100 have a single 20 dB attenuator; the
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
function attOptions(model?: string): { v: string; l: string }[] {
const m = (model ?? '').toUpperCase();
const OFF = { v: '0', l: 'OFF' };
if (/(7610|7700|7800|7850|7851)/.test(m)) {
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
}
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
}
// 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 {
@@ -534,7 +549,7 @@ function ScopePanadapter() {
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) {
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
const { t } = useI18n();
const [st, setSt] = useState<IcomState>(ZERO);
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
@@ -637,16 +652,22 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
</div>
<div className="flex items-center gap-1.5">
{/* Radio power ON / OFF. Manual by design the app never wakes the rig
on connect; ON sends the wake preamble then the rig boots ~15 s. */}
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
<Power className="size-3.5" /> ON
</button>
<button type="button" onClick={() => IcomSetPower(false).catch(() => {})} title={t('icmp.powerOffHint')}
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
<Power className="size-3.5" /> OFF
</button>
{/* Radio power ON / OFF NETWORK only. Over USB the CI-V interface is
unpowered while the rig is off, so power-ON can't reach it (OFF works
but ON doesn't); hiding both avoids a dead button. On the network the
rig's LAN server stays alive in standby, so both work. */}
{isNetwork && (
<>
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
<Power className="size-3.5" /> ON
</button>
<button type="button" onClick={() => IcomSetPower(false).catch(() => {})} title={t('icmp.powerOffHint')}
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
<Power className="size-3.5" /> OFF
</button>
</>
)}
</div>
</div>
@@ -804,7 +825,7 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
</Row>
<Row label="Att">
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
<Segmented value={String(st.att)} options={attOptions(cat?.rig)}
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
</Row>
<Row label={t('icmp.filter')}>
+39 -13
View File
@@ -64,6 +64,24 @@ function loadBasemap(): BasemapKey {
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
}
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
// down: satellite loads TWO tile layers, so its tile count — and the composited
// layers WebView2 has to hold — is double every other basemap's.
function addBasemap(
m: L.Map,
key: BasemapKey,
base: React.MutableRefObject<L.TileLayer | null>,
labels: React.MutableRefObject<L.TileLayer | null>,
) {
if (base.current) { m.removeLayer(base.current); base.current = null; }
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
const bm = BASEMAPS[key];
const opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 };
base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
}
function dot(color: string): L.DivIcon {
return L.divIcon({
className: '',
@@ -103,11 +121,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
// One-time map creation.
useEffect(() => {
if (worldRef.current && !worldMap.current) {
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
// composited paths re-rasterised on every pan, zoom and redraw, which is
// enough to blow WebView2's raster budget and leave the window painting in
// patches. On canvas it is a single layer.
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
.setView([20, 0], 1);
const bm = BASEMAPS[basemap];
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
addBasemap(m, basemap, baseLayer, labelsLayer);
worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m;
const sv = loadMapView();
@@ -115,7 +136,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
}
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
return () => window.clearTimeout(t);
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
// every overlay redraw (as before) forced a full repaint of the map each time
// the rotor moved a degree.
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
if (worldRef.current) ro.observe(worldRef.current);
return () => { window.clearTimeout(t); ro.disconnect(); };
}, []);
// Swap the basemap (and its optional place-name overlay) when the operator
@@ -123,12 +149,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
// overlayPane, always above any tile layer, so nothing to re-stack there.
useEffect(() => {
const m = worldMap.current;
if (!m) return;
if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; }
if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; }
const bm = BASEMAPS[basemap];
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
}, [basemap]);
// Redraw overlays whenever the operator/DX grids (or beam) change.
@@ -224,9 +245,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
wm.setView([from.lat, from.lon], 3);
}
}
setTimeout(() => { wm.invalidateSize(); }, 0);
// No invalidateSize() here — a ResizeObserver handles the only case that needs
// it. Forcing a full map repaint on every redraw is what made a moving rotor
// thrash the compositor.
// Headings are rounded in the deps: a rotor reports a jittering float, and a
// tenth of a degree is invisible on a 5500 km lobe but rebuilds every polyline.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]);
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth,
boomAzimuth == null ? null : Math.round(boomAzimuth), autoZoom, zoomSignal]);
const path = pathBetween(fromGrid, toGrid);
+18 -54
View File
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { QSOEditModal } from '@/components/QSOEditModal';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { useI18n } from '@/lib/i18n';
import type { QSOForm } from '@/types';
import {
@@ -59,13 +60,13 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
// Add/edit-contact dialog.
const [contactOpen, setContactOpen] = useState(false);
const [contact, setContact] = useState<Station>(emptyStation());
const [looking, setLooking] = useState(false);
const activeGrid = useRef<any>(null);
const rosterGrid = useRef<any>(null);
// Worked-before for the clicked station (see if/when we contacted it before).
@@ -230,18 +231,6 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
} catch (e: any) { setError(String(e?.message ?? e)); }
}
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
{ headerName: t('ncp.colMode'), field: 'mode', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
], [t]);
const rosterCols = useMemo<ColDef<Station>[]>(() => [
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
@@ -292,26 +281,20 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.onAirActive')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<QSOForm>
ref={activeGrid}
theme={hamlogTheme}
rowData={active}
columnDefs={activeCols}
defaultColDef={defaultColDef}
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
animateRows={false}
getRowId={(p) => String((p.data as any).id)}
/>
</div>
<div className="flex flex-col min-h-0 flex-1">
<RecentQSOsGrid
rows={active}
total={active.length}
storageKey="net.onair"
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
onRowDoubleClicked={(q) => setEditingDraft(q)}
onRowSelected={setSelectedActiveIds}
/>
</div>
{isOpen && active.length > 0 && (
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
</Button>
</div>
@@ -335,7 +318,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
</span>
)}
</div>
<div className="flex-1 min-h-0 overflow-auto">
<div className="flex-1 min-h-0 flex flex-col">
{!wbCall ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
) : wbBusy ? (
@@ -343,30 +326,11 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
) : !wb || wb.count === 0 ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-muted/30 text-muted-foreground">
<tr className="text-left">
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th>
<th className="px-2 py-1 font-semibold">{t('ncp.colTimeOn')}</th>
<th className="px-2 py-1 font-semibold">{t('ncp.colBand')}</th>
<th className="px-2 py-1 font-semibold">{t('ncp.colMode')}</th>
<th className="px-2 py-1 font-semibold">RST</th>
<th className="px-2 py-1 font-semibold">{t('ncp.colComment')}</th>
</tr>
</thead>
<tbody>
{((wb.entries ?? []) as any[]).map((q, i) => (
<tr key={q.id ?? i} className="border-t border-border/20 hover:bg-muted/30">
<td className="px-3 py-1 font-mono">{String(q.qso_date ?? '').slice(0, 10)}</td>
<td className="px-2 py-1 font-mono">{String(q.time_on ?? '').slice(0, 5)}</td>
<td className="px-2 py-1">{q.band}</td>
<td className="px-2 py-1">{q.mode}</td>
<td className="px-2 py-1 font-mono">{(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}</td>
<td className="px-2 py-1 truncate max-w-[360px]">{q.comment}</td>
</tr>
))}
</tbody>
</table>
<RecentQSOsGrid
rows={(wb.entries ?? []) as any}
total={wb.count}
storageKey="net.wb"
/>
)}
</div>
</div>
+32 -4
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -32,7 +32,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
type Confirmation = {
callsign: string; qso_date: string; band: string; mode: string; country: string;
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
};
const SERVICES = [
@@ -208,18 +208,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [busy, setBusy] = useState(false);
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
// Worked/confirmed tallies. Slots are counted by mode CLASS (PH/CW/DIGI) — the
// raw-mode granularity (RTTY ≠ FT8) stays only in the cluster/matrix new-slot flag.
const [stats, setStats] = useState<any>(
{ slots_worked: 0, slots_confirmed: 0, dxcc_worked: 0, dxcc_confirmed: 0, ph_worked: 0, ph_confirmed: 0, cw_worked: 0, cw_confirmed: 0, dig_worked: 0, dig_confirmed: 0 });
const refreshCounts = useCallback(() => { GetSlotStats().then((c: any) => setStats(c)).catch(() => {}); }, []);
useEffect(() => { refreshCounts(); }, [refreshCounts]);
useEffect(() => {
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
const offDone = EventsOn('qslmgr:done', (d: any) => {
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0}`]);
setBusy(false);
refreshCounts(); // a download may have added confirmations
});
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
setConfirmations((list ?? []) as Confirmation[]);
setViewMode('confirmations');
refreshCounts();
});
return () => { offLog(); offDone(); offConf(); };
}, []);
}, [refreshCounts]);
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
@@ -231,9 +240,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const shownConfs = useMemo(() => confirmations.filter((c) => {
switch (confFilter) {
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
case 'new': return c.new_dxcc || c.new_band || c.new_mode || c.new_slot;
case 'dxcc': return c.new_dxcc;
case 'band': return c.new_band;
case 'mode': return c.new_mode;
case 'slot': return c.new_slot;
default: return true;
}
@@ -353,6 +363,22 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
</>
)}
<div className="flex-1" />
{/* Worked / confirmed tallies. Slots by mode class (PH/CW/DIGI), with the
per-class breakdown so the totals are checkable. */}
<div className="self-center flex items-center gap-3 text-[11px] font-mono" title={t('qslm.countsTip')}>
<span className="flex items-center gap-1">
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">{t('qslm.slots')}</span>
<span className="font-semibold">{stats.slots_worked}</span>
<span className="text-muted-foreground">/</span>
<span className="text-success font-semibold">{stats.slots_confirmed}</span>
</span>
<span className="flex items-center gap-1">
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">DXCC</span>
<span className="font-semibold">{stats.dxcc_worked}</span>
<span className="text-muted-foreground">/</span>
<span className="text-success font-semibold">{stats.dxcc_confirmed}</span>
</span>
</div>
{service === 'pota' && potaRes && (
<span className="text-xs text-muted-foreground">
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
@@ -371,6 +397,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
</SelectContent>
</Select>
@@ -491,6 +518,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<td className="py-1 px-2">
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
: c.new_mode ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-info-muted text-info-muted-foreground border border-info-border">{t('qslm.newMode')}</span>
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
: <span className="text-muted-foreground/50"></span>}
</td>
+36 -12
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2 } from 'lucide-react';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
import { applyAwardRefs } from '@/lib/awardRefs';
@@ -80,11 +81,23 @@ function StatusCell({ value }: { value?: string }) {
if (v === '') {
return <span className="block text-center text-[11px] text-muted-foreground"></span>;
}
// One colour per state, and each colour means something:
// Yes green — confirmed, the thing you wanted
// Requested blue — in flight, waiting on the other end. Not a problem.
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
// This is the ONLY state asking for action, so it gets the
// only alarming colour.
// No neutral — nothing done yet. Every freshly logged QSO is "No" on
// every row; painting that orange (as it used to be, in the
// same orange as Requested) made the table shout about a
// non-problem and told you nothing apart.
// Ignore dashed — deliberately excluded, on purpose.
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
const cls = v === 'Y' ? 'bg-success text-success-foreground'
: v === 'R' ? 'bg-warning text-warning-foreground'
: v === 'I' ? 'bg-muted-foreground text-background'
: 'bg-warning text-warning-foreground';
const cls = v === 'Y' ? 'bg-success text-success-foreground border border-success'
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
: 'bg-muted text-muted-foreground border border-border';
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
}
@@ -163,6 +176,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return base;
}, [modes, qso.mode]);
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
// Per-mode RST dropdown choices, loaded from the same settings as the entry form.
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
useEffect(() => {
GetListsSettings()
.then((l: any) => setRstLists({ phone: l?.rst_phone ?? [], cw: l?.rst_cw ?? [], digital: l?.rst_digital ?? [] }))
.catch(() => {});
}, []);
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
const splitHz = (hz?: number) => hz
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
@@ -351,7 +371,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="flex-row items-baseline gap-2">
<DialogTitle>{t('qedit.title')}</DialogTitle>
<span className="font-mono text-xs text-muted-foreground">#{draft.id} {draft.callsign}</span>
@@ -392,9 +412,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
</div>
<div className="flex flex-col w-20"><Label>S</Label>
<Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div>
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_sent', v)} /></div>
<div className="flex flex-col w-20"><Label>R</Label>
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
title={t('qedit.fetchTitle')}>
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
@@ -571,9 +591,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</p>
</div>
{/* Right: live status grid for every channel */}
<div className="w-72 shrink-0">
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
{/* Right: live status grid for every channel.
Sized by its content, not pinned to a width: a fixed 288px box
left the label column too narrow, so "QSL (paper)" wrapped onto
two lines and padded out the whole row. There is spare width to
the right spend it on the label. */}
<div className="shrink-0">
<table className="border-separate" style={{ borderSpacing: 4 }}>
<thead>
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="text-left font-semibold">{t('qedit.thType')}</th>
@@ -584,7 +608,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<tbody>
{CONFIRMATIONS.map((c) => (
<tr key={c.key} className="text-xs">
<td className="font-medium pr-2 py-0.5">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td>
</tr>
+91 -13
View File
@@ -30,7 +30,12 @@ type Props = {
// 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;
// storageKey scopes the persisted column layout/visibility. Omit for the main
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
// reuse this grid elsewhere with its OWN independent column config.
storageKey?: string;
onRowDoubleClicked?: (q: QSOForm) => void;
onRowClicked?: (q: QSOForm) => void;
onRowSelected?: (ids: number[]) => void;
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
@@ -49,7 +54,7 @@ type Props = {
awardCols?: { code: string; name: string }[];
};
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
function fmtMhzDots(hz?: number): string {
if (!hz) return '';
@@ -203,6 +208,13 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
{ group: 'My station', label: t('rqg.c.my_name'), colId: 'my_name', headerName: t('rqg.c.my_name'), field: 'my_name' as any, width: 120 },
{ group: 'My station', label: t('rqg.c.my_wwff'), colId: 'my_wwff_ref', headerName: t('rqg.c.my_wwff'), field: 'my_wwff_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'My station', label: t('rqg.c.my_sig'), colId: 'my_sig', headerName: t('rqg.c.my_sig'), field: 'my_sig' as any, width: 90 },
{ group: 'My station', label: t('rqg.c.my_sig_info'), colId: 'my_sig_info', headerName: t('rqg.c.my_sig_info'), field: 'my_sig_info' as any, width: 120 },
{ group: 'My station', label: t('rqg.c.my_arrl_sect'), colId: 'my_arrl_sect', headerName: t('rqg.c.my_arrl_sect'), field: 'my_arrl_sect' as any, width: 90, cellClass: 'font-mono' },
{ group: 'My station', label: t('rqg.c.my_darc_dok'), colId: 'my_darc_dok', headerName: t('rqg.c.my_darc_dok'), field: 'my_darc_dok' as any, width: 90, cellClass: 'font-mono' },
{ group: 'My station', label: t('rqg.c.my_vucc_grids'),colId: 'my_vucc_grids', headerName: t('rqg.c.my_vucc_grids'), field: 'my_vucc_grids' as any, width: 130, cellClass: 'font-mono' },
// ── Misc ──
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
@@ -226,10 +238,20 @@ const GRP_KEYS: Record<string, string> = {
};
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
// Award columns are governed SOLELY by the awardShown code-set, never by AG
// Grid's saved column state. Stripping them here (on both save and restore)
// stops a stale saved state from re-hiding a shown award column on every
// awardCols rebuild — the desync that made award columns vanish mid-session.
const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
// Column-layout persistence keys — scoped by storageKey so a reused instance
// (e.g. the Net panel) keeps its own layout independent of the main log.
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
const [menu, setMenu] = useState<QSOMenuState>(null);
// Localized column catalog — rebuilt when the language changes.
@@ -262,6 +284,30 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
// auto-save during that window. Set in the memo (runs at render, before the
// column events) and cleared by the re-apply effect below.
const restoringRef = useRef(true);
// Award-column visibility is stored as an explicit set of award CODES, NOT via
// AG Grid's column-state round-trip. Those columns load asynchronously and
// race the state restore, and AG Grid preserves an existing column's visibility
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly
// why previously-shown award columns kept vanishing on reopen. A dedicated set
// is deterministic: it drives `hide` directly and is re-enforced below.
const AWARD_SHOWN_KEY = storageKey ? `hamlog.awardColsShown.${storageKey}` : 'hamlog.awardColsShown';
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
useEffect(() => {
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
if (loadLocal(AWARD_SHOWN_KEY)) return;
loadRemote(AWARD_SHOWN_KEY).then((remote) => {
if (remote && remote.length) {
seedLocal(AWARD_SHOWN_KEY, remote);
setAwardShown(new Set(remote.map((s: any) => String(s).toUpperCase())));
}
});
}, []);
const persistAwardShown = useCallback((next: Set<string>) => {
setAwardShown(next);
saveState(AWARD_SHOWN_KEY, [...next]);
}, []);
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => {
@@ -274,10 +320,26 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
headerTooltip: t('rqg.awardTip', { name: a.name }),
width: 110,
cellClass: 'text-[11px]',
// Visibility comes from the persisted award-code set, so a column the user
// showed reappears on reopen and one they didn't stays hidden.
hide: !awardShown.has(a.code.toUpperCase()),
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
}));
return [...base, ...awards];
}, [awardCols, COL_CATALOG, t]);
}, [awardCols, COL_CATALOG, t, awardShown]);
// Enforce award-column visibility via the API after the columns (re)appear —
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
// so we set it explicitly whenever the award set or the loaded columns change.
useEffect(() => {
const api = gridRef.current?.api;
if (!api || !awardCols?.length) return;
for (const a of awardCols) {
const want = awardShown.has(a.code.toUpperCase());
const col = api.getColumn(`award_${a.code}`);
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
}
}, [awardCols, awardShown]);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true,
@@ -287,21 +349,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
}), []);
function onGridReady(e: GridReadyEvent) {
const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const local = loadLocal(colStateKey);
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
// Fall back to the portable DB copy when the local cache is empty
// (fresh machine / after a reinstall), then re-seed the cache.
loadRemote(COL_STATE_KEY).then((remote) => {
loadRemote(colStateKey).then((remote) => {
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
seedLocal(colStateKey, 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);
if (state) saveState(colStateKey, stripAwardCols(state));
}, []);
// The award columns load asynchronously; when they arrive (or change) the
@@ -311,8 +373,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
// every rebuild so the user's choices win. No-op before the grid is ready.
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const local = loadLocal(colStateKey);
if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
// 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);
@@ -336,10 +398,22 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
// state for "which columns are visible" — AG Grid's column state is the
// source of truth, and saveColumnState persists it.
function isColVisible(colId: string): boolean {
// Award columns: the persisted set is the source of truth (see awardShown).
if (colId.startsWith('award_')) return awardShown.has(colId.slice('award_'.length).toUpperCase());
const col = gridRef.current?.api?.getColumn(colId);
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
}
function setColVisible(colId: string, visible: boolean) {
// Award columns are driven by the persisted code set — update it and let the
// enforce effect apply visibility. This is what makes them stick across reopen.
if (colId.startsWith('award_')) {
const code = colId.slice('award_'.length).toUpperCase();
const next = new Set(awardShown);
if (visible) next.add(code); else next.delete(code);
persistAwardShown(next);
gridRef.current?.api?.setColumnsVisible([colId], visible);
return;
}
const api = gridRef.current?.api;
if (!api) return;
api.setColumnsVisible([colId], visible);
@@ -367,6 +441,9 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
api.setColumnsVisible(visible, true);
api.setColumnsVisible(hidden, false);
saveColumnState();
// Award columns are opt-in: reset hides them all.
persistAwardShown(new Set());
(awardCols ?? []).forEach((a) => api.setColumnsVisible([`award_${a.code}`], false));
}
return (
@@ -396,6 +473,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
onColumnVisible={saveColumnState}
onSortChanged={saveColumnState}
onRowDoubleClicked={handleRowDoubleClicked}
onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
onSelectionChanged={onSelectionChanged}
onCellContextMenu={onCellContextMenu}
preventDefaultOnContextMenu
@@ -463,8 +541,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('rqg.all')}</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('rqg.none')}</button>
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { persistAwardShown(new Set(awardCols.map((a) => a.code.toUpperCase()))); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], true)); }}>{t('rqg.all')}</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { persistAwardShown(new Set()); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], false)); }}>{t('rqg.none')}</button>
</div>
</div>
<div className="flex flex-col gap-1">
+595 -67
View File
@@ -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,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff,
} from 'lucide-react';
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -21,6 +21,8 @@ import {
GetEmailSettings, SaveEmailSettings, TestEmail,
QSLGetEmailTemplates, QSLSaveEmailTemplates,
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
AudioStartMonitor, AudioStopMonitor, AudioMonitorActive,
AudioStartTX, AudioStopTX, AudioTXActive,
ListClusterServers, SaveClusterServer, DeleteClusterServer,
GetClusterAutoConnect, SetClusterAutoConnect,
ConnectClusterServer, DisconnectClusterServer,
@@ -35,9 +37,13 @@ import {
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus,
DownloadULSCounties, ULSStatus, BackfillUSCounties,
ComputeStationInfo,
GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
GetRelayAuto, SaveRelayAuto, GetStationDevices,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -165,6 +171,7 @@ type SectionId =
| 'confirmations'
| 'external-services'
| 'udp'
| 'adifmon'
| 'lookup'
| 'lists-bands'
| 'lists-modes'
@@ -172,6 +179,7 @@ type SectionId =
| 'backup'
| 'database'
| 'autostart'
| 'uscounties'
| 'awards'
| 'cat'
| 'rotator'
@@ -180,6 +188,7 @@ type SectionId =
| 'antgenius'
| 'pgxl'
| 'flex'
| 'relayauto'
| 'audio';
type TreeNode =
@@ -197,6 +206,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
{ kind: 'item', label: t('sec.audio'), id: 'audio' },
];
return [
@@ -220,6 +230,8 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
]},
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
{ kind: 'item', label: t('sec.database'), id: 'database' },
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
],
@@ -235,8 +247,11 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
adifmon: 'sec.adifmon',
uscounties: 'sec.uscounties',
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
relayauto: 'sec.relayauto',
};
// Map section id → friendly name (used in breadcrumb / placeholders).
@@ -254,14 +269,16 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
database: 'Database',
autostart: 'Autostart',
udp: 'UDP integrations',
adifmon: 'ADIF monitor',
awards: 'Awards',
cat: 'CAT interface',
rotator: 'PstRotator',
rotator: 'Rotator',
winkeyer: 'CW Keyer',
antenna: 'UltraBeam',
antenna: 'Ultrabeam / Steppir',
antgenius: 'Antenna Genius',
pgxl: 'Power Genius',
flex: 'FlexRadio',
relayauto: 'Relay auto-control',
audio: 'Audio devices',
};
@@ -341,6 +358,9 @@ function TreeNodeView({
// Select and slamming the open dropdown shut on any ambient re-render.
const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = {
'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' },
'light-sage': { bg: '#eef1ec', card: '#f8faf6', accent: '#2f855a' },
'dim-slate': { bg: '#343b47', card: '#3d4552', accent: '#fb923c' },
'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' },
'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' },
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
@@ -530,6 +550,7 @@ function AutostartPanelComponent() {
// (a random install ID + version + OS, sent once a day). Real component so it
// can own its state; embedded inside GeneralPanel.
function TelemetryToggle() {
const { t } = useI18n();
const [on, setOn] = useState(true);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
@@ -539,8 +560,8 @@ function TelemetryToggle() {
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={on} disabled={!loaded}
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
Send anonymous usage statistics
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day no callsign or QSO data)</span>
{t('settings.telemetry')}
<span className="text-xs text-muted-foreground">({t('settings.telemetryHint')})</span>
</label>
);
}
@@ -550,6 +571,7 @@ function TelemetryToggle() {
// events — a small web script on your server renders it for the QRZ page. Only
// useful on a MySQL logbook. Self-contained component (owns its async state).
function LiveStatusToggle() {
const { t } = useI18n();
const [on, setOn] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
@@ -559,34 +581,195 @@ function LiveStatusToggle() {
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={on} disabled={!loaded}
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
Publish live operator status <span className="text-xs text-muted-foreground">(multi-op on shared MySQL feeds a QRZ live page)</span>
{t('settings.liveStatus')} <span className="text-xs text-muted-foreground">({t('settings.liveStatusHint')})</span>
</label>
);
}
// ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM,
// VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a
// contact that arrives is imported and uploaded automatically like any log entry.
type ADIFWatchFileUI = { path: string; enabled: boolean; offset: number };
type ADIFMonitorCfgUI = { enabled: boolean; files: ADIFWatchFileUI[] };
function ADIFMonitorPanel() {
const { t } = useI18n();
const [cfg, setCfg] = useState<ADIFMonitorCfgUI>({ enabled: false, files: [] });
const [loaded, setLoaded] = useState(false);
useEffect(() => {
GetADIFMonitor()
.then((c: any) => { if (c) setCfg({ enabled: !!c.enabled, files: (c.files ?? []) as ADIFWatchFileUI[] }); })
.catch(() => {})
.finally(() => setLoaded(true));
}, []);
// Offsets are managed backend-side; SaveADIFMonitor ignores the ones we send and
// keeps each existing file's read position (a new file starts at end-of-file).
const persist = (next: ADIFMonitorCfgUI) => { setCfg(next); SaveADIFMonitor(next as any).catch(() => {}); };
const addFile = async () => {
try {
const p = await PickADIFMonitorFile();
if (!p || cfg.files.some((f) => f.path === p)) return;
persist({ ...cfg, files: [...cfg.files, { path: p, enabled: true, offset: -1 }] });
} catch { /* dialog cancelled */ }
};
return (
<div className="space-y-4 max-w-2xl">
<p className="text-xs text-muted-foreground">{t('adifmon.hint')}</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.enabled} disabled={!loaded} onCheckedChange={(c) => persist({ ...cfg, enabled: !!c })} />
{t('adifmon.enable')}
</label>
<div className="space-y-1.5">
{cfg.files.length === 0 && <p className="text-xs text-muted-foreground italic">{t('adifmon.empty')}</p>}
{cfg.files.map((f, i) => (
<div key={i} className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-2 py-1.5">
<Checkbox checked={f.enabled}
onCheckedChange={(c) => persist({ ...cfg, files: cfg.files.map((x, idx) => idx === i ? { ...x, enabled: !!c } : x) })} />
<span className="flex-1 font-mono text-xs truncate" title={f.path}>{f.path}</span>
<button type="button" title={t('adifmon.remove')}
className="text-muted-foreground hover:text-destructive shrink-0"
onClick={() => persist({ ...cfg, files: cfg.files.filter((_, idx) => idx !== i) })}>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
<Button variant="outline" size="sm" onClick={addFile}>
<FolderOpen className="size-3.5 mr-1" /> {t('adifmon.add')}
</Button>
<p className="text-[11px] text-muted-foreground">{t('adifmon.note')}</p>
</div>
);
}
// RelayAutoPanel configures automatic control of the Station Control relay boards
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
// off, a frequency window (kHz), or a set of bands.
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
const relayCountUI = (type: string) => (type === 'kmtronic' ? 8 : 5);
function RelayAutoPanel() {
const { t } = useI18n();
const [enabled, setEnabled] = useState(false);
const [rules, setRules] = useState<RelayRuleUI[]>([]);
const [devices, setDevices] = useState<StationDevUI[]>([]);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
Promise.all([GetRelayAuto(), GetStationDevices()])
.then(([cfg, devs]: any[]) => {
setEnabled(!!cfg?.enabled);
setRules((cfg?.rules ?? []) as RelayRuleUI[]);
setDevices((devs ?? []) as StationDevUI[]);
})
.catch(() => {})
.finally(() => setLoaded(true));
}, []);
const save = (en: boolean, rs: RelayRuleUI[]) => { SaveRelayAuto({ enabled: en, rules: rs } as any).catch(() => {}); };
const ruleFor = (dev: string, relay: number): RelayRuleUI =>
rules.find((r) => r.device_id === dev && r.relay === relay) ?? { device_id: dev, relay, mode: 'off', freq_lo_khz: 0, freq_hi_khz: 0, bands: [] };
// Apply a patch and persist (commit=true) or keep local only (commit=false, for
// freq inputs that persist on blur so we don't switch relays on every keystroke).
const patchRule = (dev: string, relay: number, patch: Partial<RelayRuleUI>, commit = true) => {
const next = { ...ruleFor(dev, relay), ...patch };
const others = rules.filter((r) => !(r.device_id === dev && r.relay === relay));
const all = [...others, next];
setRules(all);
if (commit) save(enabled, all);
};
const toggleBand = (dev: string, relay: number, band: string) => {
const cur = ruleFor(dev, relay);
const has = cur.bands.includes(band);
patchRule(dev, relay, { bands: has ? cur.bands.filter((b) => b !== band) : [...cur.bands, band] });
};
return (
<div className="space-y-4 max-w-3xl">
<p className="text-xs text-muted-foreground">{t('relayauto.hint')}</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={enabled} disabled={!loaded} onCheckedChange={(c) => { const v = !!c; setEnabled(v); save(v, rules); }} />
{t('relayauto.enable')}
</label>
{devices.length === 0 && loaded && (
<p className="text-xs text-muted-foreground italic">{t('relayauto.noDevices')}</p>
)}
{devices.map((dev) => (
<div key={dev.id} className="rounded-md border border-border">
<div className="px-3 py-1.5 border-b border-border bg-muted/40 text-xs font-semibold">{dev.name || dev.id}</div>
<div className="divide-y divide-border/60">
{Array.from({ length: relayCountUI(dev.type) }, (_, i) => i + 1).map((relay) => {
const r = ruleFor(dev.id, relay);
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
return (
<div key={relay} className="flex items-start gap-3 px-3 py-2">
<span className="w-28 shrink-0 text-xs font-mono pt-1.5 truncate" title={label}>{label}</span>
<Select value={r.mode || 'off'} onValueChange={(v) => patchRule(dev.id, relay, { mode: v })}>
<SelectTrigger className="h-8 w-32 text-xs shrink-0"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="off">{t('relayauto.modeOff')}</SelectItem>
<SelectItem value="freq">{t('relayauto.modeFreq')}</SelectItem>
<SelectItem value="band">{t('relayauto.modeBand')}</SelectItem>
</SelectContent>
</Select>
<div className="flex-1 min-w-0 pt-0.5">
{r.mode === 'freq' && (
<div className="flex items-center gap-1.5 text-xs">
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.from')}
defaultValue={r.freq_lo_khz || ''}
onChange={(e) => patchRule(dev.id, relay, { freq_lo_khz: parseFloat(e.target.value) || 0 }, false)}
onBlur={() => save(enabled, rules)} />
<span className="text-muted-foreground"></span>
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.to')}
defaultValue={r.freq_hi_khz || ''}
onChange={(e) => patchRule(dev.id, relay, { freq_hi_khz: parseFloat(e.target.value) || 0 }, false)}
onBlur={() => save(enabled, rules)} />
<span className="text-muted-foreground">kHz</span>
</div>
)}
{r.mode === 'band' && (
<div className="flex flex-wrap gap-1">
{RELAY_BANDS.map((b) => {
const on = r.bands.includes(b);
return (
<button key={b} type="button" onClick={() => toggleBand(dev.id, relay, b)}
className={cn('px-1.5 py-0.5 rounded text-[11px] font-mono border transition-colors',
on ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{b}
</button>
);
})}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
// MainViewPanes lets the operator choose what the Main tab's left and right
// panes show, independently: the great-circle map, the locator street map, the
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
{ value: 'map1', label: 'Map — great-circle + beam' },
{ value: 'map2', label: 'Map — locator (street)' },
{ value: 'cluster', label: 'Cluster spots' },
{ value: 'worked', label: 'Worked before' },
{ value: 'recent', label: 'Recent QSOs' },
{ value: 'netcontrol', label: 'Net control' },
];
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
const { t } = useI18n();
const [left, setLeft] = useState('map1');
const [right, setRight] = useState('map2');
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
const options = [
...MAIN_PANE_OPTIONS,
...(flexAvailable ? [{ value: 'flex', label: 'FlexRadio controls' }] : []),
...(icomAvailable ? [{ value: 'icom', label: 'Icom console' }] : []),
].sort((a, b) => a.label.localeCompare(b.label));
...MAIN_PANE_VALUES,
...(flexAvailable ? ['flex'] : []),
...(icomAvailable ? ['icom'] : []),
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
.sort((a, b) => a.label.localeCompare(b.label));
useEffect(() => {
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_VALUES.includes(v);
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
}, []);
@@ -599,11 +782,11 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
};
return (
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">Main view</h4>
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</p>
<h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4>
<p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p>
<div className="grid grid-cols-2 gap-3 max-w-xl">
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">Left pane</span>
<span className="text-muted-foreground">{t('settings.leftPane')}</span>
<Select value={left} onValueChange={(v) => pick('left', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -612,7 +795,7 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
</Select>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">Right pane</span>
<span className="text-muted-foreground">{t('settings.rightPane')}</span>
<Select value={right} onValueChange={(v) => pick('right', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -759,6 +942,20 @@ function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
);
}
// Known Icom models paired with their FACTORY default CI-V address. Picking a
// model sets icom_addr so the backend identifies it (civ.ModelName) and the UI
// adapts (e.g. the attenuator steps differ by model). Keep in lockstep with
// civ.ModelName in internal/cat/civ/civ.go. `addr` is decimal.
const ICOM_MODELS: { name: string; addr: number }[] = [
{ name: 'IC-705', addr: 0xA4 },
{ name: 'IC-7300', addr: 0x94 },
{ name: 'IC-7610', addr: 0x98 },
{ name: 'IC-7700', addr: 0x88 },
{ name: 'IC-7800', addr: 0x80 },
{ name: 'IC-9100', addr: 0x7C },
{ name: 'IC-9700', addr: 0xA2 },
];
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) {
const { t } = useI18n();
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
@@ -795,20 +992,20 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '',
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8',
});
const [rotator, setRotator] = useState<RotatorSettings>({
enabled: false, host: '127.0.0.1', port: 12000, has_elevation: false,
});
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
} as any);
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Ultrabeam antenna (TCP) settings.
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number; follow: boolean; step_khz: number }>({
enabled: false, host: '', port: 23, follow: false, step_khz: 50,
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean }>({
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false,
});
const [ubTesting, setUbTesting] = useState(false);
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -863,6 +1060,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
const [dvkErr, setDvkErr] = useState('');
const [monitorOn, setMonitorOn] = useState(false);
const [txOn, setTxOn] = useState(false);
useEffect(() => {
AudioMonitorActive().then(setMonitorOn).catch(() => {});
AudioTXActive().then(setTxOn).catch(() => {});
}, []);
const toggleMonitor = async () => {
try {
if (monitorOn) { await AudioStopMonitor(); setMonitorOn(false); }
else { await AudioStartMonitor(); setMonitorOn(true); }
} catch (err: any) { setDvkErr(String(err?.message ?? err)); }
};
const toggleTX = async () => {
try {
if (txOn) { await AudioStopTX(); setTxOn(false); }
else { await AudioStartTX(); setTxOn(true); }
} catch (err: any) { setDvkErr(String(err?.message ?? err)); }
};
// General behaviour prefs (mirrored to the DB so they travel with data/).
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
@@ -870,6 +1085,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
// Password-encryption (secret vault) state.
const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false });
@@ -903,6 +1119,7 @@ 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 };
@@ -962,6 +1179,44 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [clublogTesting, setClublogTesting] = useState(false);
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [lotwTesting, setLotwTesting] = useState(false);
// LoTW user-activity list (for the callsign badge): count + last refresh.
const [lotwUsers, setLotwUsers] = useState<{ count: number; updated?: string }>({ count: 0 });
const [lotwUsersBusy, setLotwUsersBusy] = useState(false);
useEffect(() => { GetLoTWUsersStatus().then((s) => setLotwUsers(s as any)).catch(() => {}); }, []);
const downloadLotwUsers = async () => {
setLotwUsersBusy(true);
try { await DownloadLoTWUsers(); const s = await GetLoTWUsersStatus(); setLotwUsers(s as any); }
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
finally { setLotwUsersBusy(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);
const [ulsProgress, setUlsProgress] = useState<{ stage: string; pct: number } | null>(null);
const [ulsMsg, setUlsMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [backfillBusy, setBackfillBusy] = useState(false);
const [backfillMsg, setBackfillMsg] = useState<string | null>(null);
useEffect(() => { ULSStatus().then((s) => setUlsStatus(s as any)).catch(() => {}); }, []);
useEffect(() => {
const off1 = EventsOn('uls:progress', (p: any) => setUlsProgress({ stage: p?.stage ?? '', pct: p?.pct ?? 0 }));
const off2 = EventsOn('uls:done', async (r: any) => {
setUlsBusy(false); setUlsProgress(null);
setUlsMsg(r?.ok ? { ok: true, text: t('uscty.done', { n: r?.count ?? 0 }) } : { ok: false, text: r?.error || 'error' });
try { const s = await ULSStatus(); setUlsStatus(s as any); } catch {}
});
return () => { off1(); off2(); };
}, []);
const downloadUls = async () => {
setUlsBusy(true); setUlsMsg(null); setUlsProgress({ stage: '', pct: 0 });
try { await DownloadULSCounties(); }
catch (e: any) { setUlsBusy(false); setUlsProgress(null); setUlsMsg({ ok: false, text: String(e?.message ?? e) }); }
};
const runBackfill = async () => {
setBackfillBusy(true); setBackfillMsg(null);
try { const r: any = await BackfillUSCounties(); setBackfillMsg(t('uscty.backfillDone', { c: r?.county ?? 0, g: r?.grid ?? 0, s: r?.scanned ?? 0 })); }
catch (e: any) { setBackfillMsg(String(e?.message ?? e)); }
finally { setBackfillBusy(false); }
};
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hrdlogTesting, setHrdlogTesting] = useState(false);
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -2007,6 +2262,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
{t('cat.flexSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexSpotsHint')}</span>
</label>
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={!!catCfg.flex_decode_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_decode_spots: !!c }))} />
{t('cat.flexDecodeSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexDecodeSpotsHint')}</span>
</label>
{catCfg.flex_decode_spots && (
<div className="col-span-2 flex items-center gap-2 pl-6">
<Label className="text-sm">{t('cat.flexDecodeSecs')}</Label>
<Input type="number" className="w-24" min={10} max={3600}
value={catCfg.flex_decode_secs || 120}
onChange={(e) => setCatCfg((s) => ({ ...s, flex_decode_secs: parseInt(e.target.value) || 120 }))} />
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
</div>
)}
</>
)}
{catCfg.backend === 'icom' && (
@@ -2034,12 +2302,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</SelectContent>
</Select>
</div>
<div className="space-y-1 col-span-2">
<div className="space-y-1">
<Label>{t('cat.icomModel')}</Label>
<Select
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'}
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
<SelectItem value="_custom">{t('cat.icomModelOther')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>{t('cat.civAddr')}</Label>
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} />
<p className="text-xs text-muted-foreground">{t('cat.civHint')}</p>
</div>
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.civHint')}</p>
</>
)}
{catCfg.backend === 'icom-net' && (
@@ -2049,6 +2329,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Input placeholder="192.168.1.60" value={catCfg.icom_net_host ?? ''}
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_host: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>{t('cat.icomModel')}</Label>
<Select
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'}
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
<SelectItem value="_custom">{t('cat.icomModelOther')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>{t('cat.civAddr')}</Label>
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
@@ -2065,6 +2357,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} />
</div>
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.icomNetHint')}</p>
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={!!(catCfg as any).icom_net_audio}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, icom_net_audio: !!c } as any))} />
<span>
{t('cat.icomNetAudio')}
<span className="block text-[11px] text-muted-foreground">{t('cat.icomNetAudioHint')}</span>
</span>
</label>
</>
)}
{catCfg.backend === 'tci' && (
@@ -2152,7 +2452,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setRotatorTest(null);
try {
await TestRotator(rotator as any);
setRotatorTest({ ok: true, msg: t('cat.rotatorOk') });
setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
} catch (e: any) {
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
@@ -2174,36 +2474,92 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
function UltrabeamPanel() {
const isSteppir = ultrabeam.type === 'steppir';
const isSerial = isSteppir && ultrabeam.transport === 'serial';
return (
<>
<SectionHeader
title={t('hw.ultrabeam')}
title={t('hw.motorAntenna')}
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
Enable Ultrabeam control
{t('hw.motorEnable')}
</label>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input
value={ultrabeam.host ?? ''}
onChange={(e) => setUltrabeam((s) => ({ ...s, host: e.target.value }))}
placeholder="192.168.1.50"
className="font-mono"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>TCP port</Label>
<Input
type="number" min={1} max={65535}
value={ultrabeam.port}
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
className="font-mono"
/>
<Label>{t('hw.motorType')}</Label>
{/* Ultrabeam is TCP only; picking it forces the transport back to TCP
so the serial fields never apply to it. */}
<Select value={ultrabeam.type ?? 'ultrabeam'}
onValueChange={(v) => setUltrabeam((s) => ({ ...s, type: v, transport: v === 'ultrabeam' ? 'tcp' : s.transport }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ultrabeam">Ultrabeam</SelectItem>
<SelectItem value="steppir">SteppIR</SelectItem>
</SelectContent>
</Select>
</div>
{isSteppir && (
<div className="space-y-1">
<Label>{t('hw.motorTransport')}</Label>
<Select value={ultrabeam.transport ?? 'tcp'}
onValueChange={(v) => setUltrabeam((s) => ({ ...s, transport: v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tcp">{t('hw.motorTcp')}</SelectItem>
<SelectItem value="serial">{t('hw.motorSerial')}</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{isSerial ? (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>{t('hw.motorCom')}</Label>
<Input
value={ultrabeam.com ?? ''}
onChange={(e) => setUltrabeam((s) => ({ ...s, com: e.target.value }))}
placeholder="COM3"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>{t('hw.motorBaud')}</Label>
<Select value={String(ultrabeam.baud || 9600)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, baud: parseInt(v, 10) || 9600 }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{[1200, 4800, 9600, 19200].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
) : (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input
value={ultrabeam.host ?? ''}
onChange={(e) => setUltrabeam((s) => ({ ...s, host: e.target.value }))}
placeholder="192.168.1.50"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>TCP port</Label>
<Input
type="number" min={1} max={65535}
value={ultrabeam.port}
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
className="font-mono"
/>
</div>
</div>
)}
{isSteppir && (
<p className="text-xs text-muted-foreground">{t('hw.steppirHint')}</p>
)}
<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={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
@@ -2224,8 +2580,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
)}
</div>
<div className="border-t border-border/60 pt-3 space-y-1">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={ultrabeam.tx_inhibit} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, tx_inhibit: !!c }))} />
{t('hw.motorTxInhibit')}
</label>
<p className="text-xs text-muted-foreground pl-6">{t('hw.motorTxInhibitHint')}</p>
</div>
<div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || !ultrabeam.host.trim()}>
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || (isSerial ? !ultrabeam.com.trim() : !ultrabeam.host.trim())}>
{ubTesting ? t('hw.connecting') : t('hw.testConn')}
</Button>
</div>
@@ -2318,41 +2681,73 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
function RotatorPanel() {
const isRG = (rotator as any).type === 'rotgenius';
return (
<>
<SectionHeader
title="Rotator"
hint={t('rot.hint')}
hint={isRG ? undefined : t('rot.hint')}
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
Enable PstRotator control
{t('rot.enable')}
</label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('rot.type')}</Label>
{/* Switching to Rotator Genius moves the default port to its native
9006; back to PstRotator restores 12000. */}
<Select value={(rotator as any).type ?? 'pst'}
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : 12000 } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
</SelectContent>
</Select>
</div>
{isRG && (
<div className="space-y-1">
<Label>{t('rot.rotatorNum')}</Label>
<Select value={String((rotator as any).rotator_num ?? 1)}
onValueChange={(v) => setRotator((s) => ({ ...s, rotator_num: parseInt(v, 10) || 1 } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host</Label>
<Label>Host / IP</Label>
<Input
value={rotator.host ?? ''}
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
placeholder="127.0.0.1"
placeholder={isRG ? '192.168.1.60' : '127.0.0.1'}
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>UDP port</Label>
<Label>{isRG ? 'TCP port' : 'UDP port'}</Label>
<Input
type="number" min={1} max={65535}
value={rotator.port}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || 12000 }))}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))}
className="font-mono"
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
This rotator supports elevation (VHF / satellite)
</label>
{!isRG && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
This rotator supports elevation (VHF / satellite)
</label>
)}
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
<div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
@@ -2360,9 +2755,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
Stop
</Button>
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
Park
</Button>
{!isRG && (
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
Park
</Button>
)}
</div>
{rotatorTest && (
<div className={cn(
@@ -3485,6 +3882,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)}
</div>
</div>
{/* LoTW user list powers the colour-coded "LoTW" badge next to the
entered callsign (green < 1 week · amber 14 weeks · red > 30 days). */}
<div className="border-t border-border/60 pt-3 space-y-2">
<Label className="text-sm font-medium">{t('lotw.usersTitle')}</Label>
<p className="text-[11px] text-muted-foreground">{t('lotw.usersHint')}</p>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={downloadLotwUsers} disabled={lotwUsersBusy}>
<ArrowDown className="size-3.5" /> {lotwUsersBusy ? t('lotw.usersDownloading') : t('lotw.usersDownload')}
</Button>
<span className="text-xs text-muted-foreground">
{lotwUsers.count > 0
? t('lotw.usersLoaded', { n: lotwUsers.count.toLocaleString(), date: lotwUsers.updated ? new Date(lotwUsers.updated).toLocaleDateString() : '?' })
: t('lotw.usersNone')}
</span>
</div>
</div>
</div>
) : extSvcTab === 'pota' ? (
<div className="space-y-4 max-w-2xl">
@@ -3747,6 +4161,40 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
</p>
<div className="flex items-center gap-3">
<Button
variant={monitorOn ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={toggleMonitor}
disabled={!monitorOn && !audioCfg.from_radio}
title="Hear the rig's RX audio (From Radio) through your Listening device"
>
{monitorOn ? '■ Stop listening' : '▶ Listen to radio'}
</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).'}
</span>
</div>
<div className="flex items-center gap-3">
<Button
variant={txOn ? 'destructive' : 'outline'}
size="sm"
className="h-8"
onClick={toggleTX}
disabled={!txOn && !audioCfg.to_radio}
title="Key PTT and pipe your live mic into the rig (To Radio device)"
>
{txOn ? '■ Stop talking (TX)' : '🎙 Talk to radio (TX)'}
</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).'}
</span>
</div>
</div>
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
@@ -3923,6 +4371,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<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>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={showQsoRate} onCheckedChange={(c) => { const v = !!c; setShowQsoRate(v); writeUiPref('opslog.showQsoRate', v ? '1' : '0'); }} />
{t('gen.showQsoRate')} <span className="text-xs text-muted-foreground">{t('gen.showQsoRateHint')}</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
@@ -4052,7 +4504,15 @@ 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>
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
<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>
<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>
@@ -4091,6 +4551,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
);
}
function USCountiesPanel() {
const loaded = ulsStatus.count > 0;
return (
<div className="max-w-2xl space-y-5">
<div>
<h3 className="text-sm font-semibold mb-1">{t('uscty.title')}</h3>
<p className="text-xs text-muted-foreground leading-relaxed">{t('uscty.intro')}</p>
</div>
{/* Required-download notice */}
<div className="rounded-md border border-warning/40 bg-warning/10 p-3 text-xs text-foreground/90 leading-relaxed">
{t('uscty.needDownload')}
</div>
{/* Status + download */}
<div className="rounded-md border border-border p-3 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs">
<div className="font-medium">{t('uscty.dbStatus')}</div>
<div className="text-muted-foreground">
{loaded
? t('uscty.loaded', { n: ulsStatus.count.toLocaleString(), date: ulsStatus.updated_at ? new Date(ulsStatus.updated_at).toLocaleDateString() : '—' })
: t('uscty.notLoaded')}
</div>
</div>
<Button size="sm" onClick={downloadUls} disabled={ulsBusy}>
{ulsBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : <ArrowDown className="size-3.5 mr-1.5" />}
{loaded ? t('uscty.update') : t('uscty.download')}
</Button>
</div>
{ulsProgress && (
<div className="space-y-1">
<div className="text-[11px] text-muted-foreground flex justify-between">
<span>{ulsProgress.stage}</span><span>{ulsProgress.pct}%</span>
</div>
<div className="h-1.5 w-full rounded bg-muted overflow-hidden">
<div className="h-full bg-primary transition-all" style={{ width: `${ulsProgress.pct}%` }} />
</div>
</div>
)}
{ulsMsg && (
<div className={cn('text-xs', ulsMsg.ok ? 'text-success' : 'text-destructive')}>{ulsMsg.text}</div>
)}
</div>
{/* Backfill existing QSOs */}
<div className="rounded-md border border-border p-3 space-y-2">
<div className="text-xs font-medium">{t('uscty.backfillTitle')}</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('uscty.backfillIntro')}</p>
<div className="flex items-center gap-3">
<Button size="sm" variant="secondary" onClick={runBackfill} disabled={backfillBusy || !loaded}>
{backfillBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
{t('uscty.backfillRun')}
</Button>
{backfillMsg && <span className="text-xs text-muted-foreground">{backfillMsg}</span>}
</div>
</div>
</div>
);
}
// Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = {
general: GeneralPanel,
@@ -4105,8 +4627,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
'lists-modes': ModesPanel,
cluster: ClusterPanel,
udp: UDPIntegrationsPanelWrapper,
// Rendered as a real element (not called as a bare function) so its own hooks
// — useState/useEffect/useI18n — get a proper component context; PANELS[x]()
// is a plain call and hook-holding panels must go through JSX like this.
adifmon: () => <ADIFMonitorPanel />,
relayauto: () => <RelayAutoPanel />,
backup: BackupPanel,
database: DatabasePanel,
uscounties: USCountiesPanel,
autostart: () => <AutostartPanelComponent />,
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
@@ -0,0 +1,505 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
import { RotorCompass } from '@/components/RotorCompass';
import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] };
type Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' };
function blankDevice(): Device {
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
}
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
// Station Control tab: a live compass you can click to turn, a heading readout, a
// GoTo box, quick N/E/S/W presets, and Stop. Heading is polled by the panel and
// passed in (so the panel can also order this widget); it drives the shared
// rotator backend.
function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: RotatorProps & {
hd: Heading; refetch: () => void; t: (k: string, v?: any) => string;
}) {
const [goto, setGoto] = useState('');
const [err, setErr] = useState('');
const turn = (az: number) => {
const a = ((Math.round(az) % 360) + 360) % 360;
RotatorGoTo(a, -1).then(refetch).catch((e) => setErr(String(e?.message ?? e)));
};
const poll = refetch;
const presets: [string, number][] = [['N', 0], ['E', 90], ['S', 180], ['W', 270]];
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Compass className="size-4 text-primary" />
<div className="text-sm font-semibold">{t('station.rotator')}</div>
<span className={cn('ml-auto size-2 rounded-full', hd.ok ? 'bg-success' : 'bg-warning')}
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
</div>
<div className="p-3 flex gap-4 items-start">
<RotorCompass
bearing={bearing ?? null}
headings={hd.ok ? [hd.azimuth] : []}
centerLat={centerLat ?? null}
centerLon={centerLon ?? null}
rotorEnabled={hd.ok}
onGoto={(az) => turn(az)}
/>
<div className="flex-1 min-w-0 space-y-2">
<div className="font-mono">
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
</div>
<div className="flex gap-1">
{presets.map(([lbl, az]) => (
<button key={lbl} type="button" onClick={() => turn(az)}
className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
{lbl}
</button>
))}
</div>
<div className="flex gap-1.5">
<Input value={goto} onChange={(e) => setGoto(e.target.value.replace(/[^0-9]/g, ''))}
onKeyDown={(e) => { if (e.key === 'Enter' && goto) turn(parseInt(goto, 10)); }}
placeholder="0359" className="h-8 flex-1 min-w-0 font-mono text-sm" />
<Button size="sm" className="h-8 shrink-0" disabled={!goto} onClick={() => turn(parseInt(goto, 10))}>{t('station.go')}</Button>
</div>
<Button size="sm" variant="outline" className="h-8 w-full" onClick={() => RotatorStop().then(poll).catch((e) => setErr(String(e?.message ?? e)))}>
<Square className="size-3 mr-1" /> {t('station.stop')}
</Button>
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
</div>
</div>
</div>
);
}
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[] };
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
// only — per-element length adjustment. Heading/state is polled by the panel.
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
// elementName maps an element index to a ham-radio name: 0 = reflector,
// 1 = driven element, then Director 1, 2, 3…
function elementName(i: number, t: (k: string, v?: any) => string): string {
if (i === 0) return t('station.reflector');
if (i === 1) return t('station.driven');
return `${t('station.director')} ${i - 1}`;
}
function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) {
const [err, setErr] = useState('');
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
const [reading, setReading] = useState(false);
const [busyEl, setBusyEl] = useState<number | null>(null);
const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
const [editVal, setEditVal] = useState('');
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
const isUB = ant.type !== 'steppir';
const readLengths = useCallback(async () => {
setReading(true); setErr('');
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
catch (e: any) { setErr(String(e?.message ?? e)); }
finally { setReading(false); }
}, []);
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
// +/- starts from the real values rather than blind.
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
// Send an absolute length to one element and remember it as the new baseline.
const setLen = async (i: number, mm: number) => {
const next = Math.max(0, Math.round(mm));
const prev = lengths[i] ?? 0;
setBusyEl(i); setErr('');
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
try { await MotorSetElement(i, next); refetch(); }
catch (e: any) {
// Rejected by the controller — most often the element is at its travel
// limit for this band (extending on a low band). Revert the optimistic
// value so the display stays truthful, and explain the likely cause when
// the failed move was an extension.
setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; });
setErr(next > prev ? t('station.atMax') : String(e?.message ?? e));
}
finally { setBusyEl(null); }
};
// Nudge one element by ±2 mm from its current known length.
const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta);
// Commit a typed exact length (click on the mm value). Lets the operator fix
// the baseline when the auto-read is off, so +/- then work reliably.
const commitEdit = (i: number) => {
const v = parseInt(editVal, 10);
setEditingEl(null);
if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v);
};
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<AntennaIcon className="size-4 text-primary" />
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
</div>
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
title={ant.connected ? t('station.online') : t('station.offline')} />
</div>
<div className="p-3 space-y-3">
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.pattern')}</div>
<div className="flex gap-1">
{dirs.map(([d, lbl]) => (
<button key={d} type="button" disabled={!ant.connected}
onClick={() => run(SetUltrabeamDirection(d))}
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors disabled:opacity-40',
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
{lbl}
</button>
))}
</div>
</div>
<button type="button" disabled={!ant.connected}
onClick={() => run(UltrabeamRetract())}
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1.5 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
</button>
{isUB && (
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
</button>
</div>
{lengths.length === 0 ? (
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
) : (
<div className="space-y-1.5">
{/* Hide 0-length elements an Ultrabeam reports 6 slots but a
3-element beam only uses the first few. */}
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
<button type="button" disabled={!ant.connected || busyEl !== null}
onClick={() => nudge(i, -ELEMENT_STEP)}
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
<Minus className="size-3.5" />
</button>
{editingEl === i ? (
<input autoFocus type="number" value={editVal}
onChange={(e) => setEditVal(e.target.value)}
onBlur={() => commitEdit(i)}
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }}
className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" />
) : (
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
title={t('station.setExactLen')}
onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
</span>
)}
<button type="button" disabled={!ant.connected || busyEl !== null}
onClick={() => nudge(i, ELEMENT_STEP)}
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
<Plus className="size-3.5" />
</button>
</div>
))}
</div>
)}
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
</div>
)}
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
</div>
</div>
);
}
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
const { t } = useI18n();
const [devices, setDevices] = useState<Device[]>([]);
const [status, setStatus] = useState<Record<string, DevStatus>>({});
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
const [ant, setAnt] = useState<AntStatus>({ enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [] });
// Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
const [order, setOrder] = useState<string[]>(() => {
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
});
const dragId = useRef<string | null>(null);
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
const loadDevices = useCallback(async () => {
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
}, []);
const poll = useCallback(async () => {
try {
const s = ((await GetStationStatus()) ?? []) as DevStatus[];
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
} catch { /* ignore transient */ }
}, []);
const pollRot = useCallback(async () => {
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
}, []);
const pollAnt = useCallback(async () => {
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
}, []);
useEffect(() => { loadDevices(); }, [loadDevices]);
useEffect(() => {
poll(); pollRot(); pollAnt();
const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
return () => window.clearInterval(id);
}, [poll, pollRot, pollAnt, devices.length]);
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
// Reorder so `dragged` lands just before `target`.
const onDrop = (targetId: string) => {
const src = dragId.current; dragId.current = null;
if (!src || src === targetId) return;
const ids = widgetIds.filter((id) => id !== src);
const at = ids.indexOf(targetId);
ids.splice(at < 0 ? ids.length : at, 0, src);
persistOrder(ids);
};
const persist = async (next: Device[]) => {
setDevices(next);
try { await SaveStationDevices(next as any); } catch { /* surfaced by status */ }
await loadDevices();
poll();
};
const toggle = async (dev: Device, relay: number, on: boolean) => {
const key = `${dev.id}:${relay}`;
setBusy((b) => ({ ...b, [key]: true }));
// Optimistic flip so the switch feels instant; the poll reconciles.
setStatus((st) => {
const d = st[dev.id]; if (!d) return st;
return { ...st, [dev.id]: { ...d, relays: d.relays.map((r) => (r.number === relay ? { ...r, on } : r)) } };
});
try { await StationSetRelay(dev.id, relay, on); } catch { /* poll will correct */ }
await poll();
setBusy((b) => ({ ...b, [key]: false }));
};
const saveEdit = async () => {
if (!editing) return;
const d = { ...editing, name: editing.name.trim() || TYPE_LABEL[editing.type], host: editing.host.trim() };
const exists = devices.some((x) => x.id && x.id === d.id);
await persist(exists ? devices.map((x) => (x.id === d.id ? d : x)) : [...devices, d]);
setEditing(null);
};
const removeDevice = async (id: string) => { await persist(devices.filter((x) => x.id !== id)); };
// Build the widget list (rotator first by default, then devices), then order it
// by the saved drag order — unknown ids fall to the end in their natural order.
const deviceCard = (dev: Device) => {
const st = status[dev.id];
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<PlugZap className="size-4 text-primary" />
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
<div className="text-[10px] text-muted-foreground font-mono truncate">{TYPE_LABEL[dev.type]} · {dev.host}</div>
</div>
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
</div>
<div className="p-3 grid grid-cols-2 gap-2">
{relays.map((r) => {
const key = `${dev.id}:${r.number}`;
const label = r.label || `${t('station.relay')} ${r.number}`;
return (
<button key={r.number} type="button" disabled={!st?.connected}
onClick={() => toggle(dev, r.number, !r.on)}
className={cn('flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors disabled:opacity-40',
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
<span className={cn('flex items-center justify-center size-7 rounded-md shrink-0',
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
{busy[key] ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
</span>
<span className="min-w-0">
<span className="block text-xs font-medium truncate">{label}</span>
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
{r.on ? t('station.on') : t('station.off')}
</span>
</span>
</button>
);
})}
</div>
</div>
);
};
const widgets: { id: string; node: React.ReactNode }[] = [];
if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
}
if (ant.enabled) {
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
}
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
const widgetIds = ordered.map((w) => w.id);
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled;
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
// fill however many columns are available.
const gridCols: Record<string, string> = {
auto: 'sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4',
'1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', '4': 'grid-cols-4',
};
return (
<div className="flex-1 min-h-0 overflow-auto p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
<div className="flex items-center gap-2">
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
{(['auto', '2', '3', '4'] as const).map((c) => (
<button key={c} type="button"
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
{c === 'auto' ? t('station.colsAuto') : c}
</button>
))}
</div>
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
</Button>
</div>
</div>
{noDevices && !editing && (
<div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
{t('station.empty')}
</div>
)}
<div className={cn('grid gap-3 items-start', gridCols[cols] ?? gridCols.auto)}>
{ordered.map((w) => (
<div key={w.id} draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }}
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}>
{w.node}
</div>
))}
</div>
{!noDevices && (
<p className="text-[11px] text-muted-foreground mt-2">{t('station.dragHint')}</p>
)}
{editing && (
<DeviceEditor device={editing} onChange={setEditing} onSave={saveEdit} onCancel={() => setEditing(null)} t={t} />
)}
</div>
);
}
function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
device: Device; onChange: (d: Device) => void; onSave: () => void; onCancel: () => void; t: (k: string, v?: any) => string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
const setType = (type: string) => {
const n = RELAY_COUNT[type] ?? 5;
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
onChange({ ...device, type, labels });
};
const isKM = device.type === 'kmtronic';
return (
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('station.type')}</Label>
<Select value={device.type} onValueChange={setType}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>{t('station.name')}</Label>
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
</div>
</div>
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
<Label>{t('station.host')}</Label>
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
</div>
{isKM && (
<>
<div className="space-y-1">
<Label>{t('station.user')}</Label>
<Input value={device.user ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, user: e.target.value })} />
</div>
<div className="space-y-1">
<Label>{t('station.pass')}</Label>
<Input type="password" value={device.pass ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, pass: e.target.value })} />
</div>
</>
)}
</div>
<div className="space-y-1">
<Label>{t('station.labels')}</Label>
<div className="grid grid-cols-4 gap-2">
{device.labels.map((lab, i) => (
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
onChange={(e) => { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
</div>
</div>
);
}
+771
View File
@@ -0,0 +1,771 @@
import { useEffect, useMemo, useState } from 'react';
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
import { GetLogStats, GetContestRuns, GetOperators } from '../../wailsjs/go/main/App';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
// ─────────────────────────────────────────────────────────────────────────────
// Statistics dashboard.
//
// Colour rules this file obeys (they are not stylistic — they are what keeps the
// charts readable and colour-blind-safe):
//
// • A chart with ONE series uses ONE hue (--chart-1) for every bar. Colour
// encodes identity, never a magnitude the bar's length already shows.
// • The categorical slots (--chart-1..8) are used in FIXED ORDER and only where
// the categories ARE the subject (the continent donut). The order is the
// colour-blind-safety mechanism, so it is never shuffled or cycled.
// • The donut is for part-to-whole read at a glance. It is NOT used for modes:
// CW and SSB are nearly tied, and a donut hides exactly the comparison that
// makes them interesting. Close values belong in bars.
// • Every value is direct-labelled, so nothing is reachable only by hovering, and
// a table view gives the WCAG-clean twin.
// ─────────────────────────────────────────────────────────────────────────────
type Bucket = { key: string; count: number };
type Gap = { start: string; end: string; minutes: number };
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
type Stats = {
total: number; unique_calls: number; entities: number; continents: number;
first_qso: string; last_qso: string;
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
// Period / contest metrics.
window_start: string; window_end: string; window_hours: number;
avg_per_hour: number; avg_per_active: number;
on_air_minutes: number; off_air_minutes: number;
peak_hour_key: string; peak_hour_count: number; best_60: number;
gaps: Gap[]; rate: Bucket[];
rate_ops: string[]; rate_by_op: number[][];
};
// Minutes → "3 h 12" (a bare "192 min" makes you do arithmetic to read a break).
const dur = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}` : `${m} min`);
const CONTINENT_NAME: Record<string, string> = {
EU: 'Europe', NA: 'North America', SA: 'South America',
AS: 'Asia', AF: 'Africa', OC: 'Oceania', AN: 'Antarctica',
};
const nf = (n: number) => n.toLocaleString('en-US');
// ── Shell ────────────────────────────────────────────────────────────────────
function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) {
return (
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
<header className="mb-3 shrink-0">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
</header>
{children}
</section>
);
}
// A headline number IS the chart — a one-bar bar chart would be noise.
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
</div>
);
}
// ── Horizontal bars (long category names: modes, operators, entities) ─────────
// One series → one hue. The value is direct-labelled, so no reader ever depends
// on a tooltip to get a number.
function HBars({ data, max, empty, share, labelWidth = 'w-20' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string }) {
// max is a display cap for long tails (top entities). Where EVERY row matters —
// the operators of a multi-op — it is deliberately not set: a capped chart would
// silently drop the 9th operator, and "who worked what" is the whole question.
const top = max ? data.slice(0, max) : data;
const peak = Math.max(1, ...top.map((d) => d.count));
const total = data.reduce((s, d) => s + d.count, 0);
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
return (
<div className="flex flex-col gap-1.5 min-w-0">
{top.map((d) => (
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key}${nf(d.count)}`}>
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
<div className="flex-1 min-w-0 h-[14px] flex items-center">
<div
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
/>
</div>
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
{share && (
<span className="w-10 shrink-0 text-[11px] text-muted-foreground text-right tabular-nums">
{total ? ((d.count / total) * 100).toFixed(0) : 0}%
</span>
)}
</div>
))}
</div>
);
}
// ── Vertical columns (short ordered labels: bands in band-plan order, hours) ──
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
// information.
function VBars({ data, empty, height = 150, showValues, colorful }: { data: Bucket[]; empty: string; height?: number; showValues?: boolean; colorful?: boolean }) {
const peak = Math.max(1, ...data.map((d) => d.count));
if (data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
// Thin the x labels once the bars get narrow. A label under EVERY one of 48
// hourly bars is unreadable mush — the axis exists to orient, not to enumerate
// (the value is in the tooltip, and every number is in the table view).
const every = data.length <= 16 ? 1 : Math.ceil(data.length / 12);
return (
// The container includes the x-axis band — a fixed height that excludes it is
// how cards end up with a tiny nested scrollbar.
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
{data.map((d, i) => (
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
title={`${d.key}${nf(d.count)}`}>
<span className={cn('text-[9px] font-semibold mb-0.5 tabular-nums whitespace-nowrap',
showValues ? 'text-foreground' : 'text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity')}>
{nf(d.count)}
</span>
<div
className="w-full rounded-t-[4px] transition-[height] duration-300"
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : 'var(--chart-1)' }}
/>
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
{i % every === 0 ? d.key : ''}
</span>
</div>
))}
</div>
);
}
// ── Contest rate, stacked by operator ────────────────────────────────────────
// The categories (the operators) ARE the subject, so this is categorical colour,
// in the FIXED order the backend sends (busiest first). An operator therefore
// keeps the same hue everywhere on the page — a chart that repaints its series
// when the filter changes is a chart nobody can trust.
function RateStack({ rate, ops, byOp, empty, height = 130 }: {
rate: Bucket[]; ops: string[]; byOp: number[][]; empty: string; height?: number;
}) {
const [hov, setHov] = useState<number | null>(null);
if (rate.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
const peak = Math.max(1, ...rate.map((d) => d.count));
const every = rate.length <= 16 ? 1 : Math.ceil(rate.length / 12);
const single = ops.length <= 1; // one operator → one hue; a legend would be noise
return (
<div className="min-w-0">
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
{rate.map((d, i) => (
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full"
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}>
{/* The column is the hour's total; the segments are who made it. */}
<div className="w-full flex flex-col-reverse justify-start rounded-t-[4px] overflow-hidden"
style={{ height: `${Math.max(1, (d.count / peak) * 100)}%` }}>
{(byOp[i] ?? []).map((n, o) => n > 0 && (
<div key={o} style={{
height: `${(n / Math.max(1, d.count)) * 100}%`,
background: single ? 'var(--chart-1)' : `var(--chart-${(o % 8) + 1})`,
}} />
))}
</div>
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">
{i % every === 0 ? d.key : ''}
</span>
</div>
))}
</div>
{/* Hover read-out: the hour, its total, and the split. Values are also in the
rate sheet below, so nothing is reachable by hover alone. */}
<p className="mt-1 h-4 text-[11px] text-muted-foreground tabular-nums truncate">
{hov !== null && (
<>
<span className="text-foreground font-medium">{rate[hov].key}</span>
{' · '}{nf(rate[hov].count)} QSO
{!single && (byOp[hov] ?? []).map((n, o) => n > 0 && (
<span key={o}> · <span style={{ color: `var(--chart-${(o % 8) + 1})` }}></span> {ops[o]} {n}</span>
))}
</>
)}
</p>
{!single && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
{ops.map((op, o) => (
<span key={op} className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="size-2.5 rounded-[3px]" style={{ background: `var(--chart-${(o % 8) + 1})` }} />
{op}
</span>
))}
</div>
)}
</div>
);
}
// ── The rate sheet: hour × operator, with the hour total ─────────────────────
// Exact numbers, many of them — that is a table's job, not a chart's. Contesters
// read rate sheets as tables, and every value here is also the one the chart draws.
function RateSheet({ rate, ops, byOp }: { rate: Bucket[]; ops: string[]; byOp: number[][] }) {
if (rate.length === 0) return null;
const shown = rate.map((d, i) => ({ d, i })).filter(({ d }) => d.count > 0); // silent hours add nothing here
const totals = ops.map((_, o) => rate.reduce((s, _d, i) => s + (byOp[i]?.[o] ?? 0), 0));
const grand = rate.reduce((s, d) => s + d.count, 0);
return (
<div className="overflow-auto max-h-[260px] rounded-md border border-border">
<table className="w-full text-[11px] tabular-nums">
<thead className="sticky top-0 bg-muted/60 backdrop-blur">
<tr className="text-muted-foreground">
<th className="text-left font-medium px-2 py-1">UTC</th>
{ops.map((op, o) => (
<th key={op} className="text-right font-medium px-2 py-1 whitespace-nowrap">
<span className="inline-block size-2 rounded-[2px] mr-1 align-middle"
style={{ background: `var(--chart-${(o % 8) + 1})` }} />
{op}
</th>
))}
<th className="text-right font-semibold px-2 py-1 text-foreground">Total</th>
</tr>
</thead>
<tbody>
{shown.map(({ d, i }) => (
<tr key={d.key} className="border-t border-border/50">
<td className="px-2 py-0.5 text-muted-foreground whitespace-nowrap">{d.key}</td>
{ops.map((_, o) => (
<td key={o} className="px-2 py-0.5 text-right">
{byOp[i]?.[o] ? nf(byOp[i][o]) : <span className="text-muted-foreground/40">·</span>}
</td>
))}
<td className="px-2 py-0.5 text-right font-semibold">{nf(d.count)}</td>
</tr>
))}
</tbody>
<tfoot className="sticky bottom-0 bg-muted/60 backdrop-blur">
<tr className="border-t border-border font-semibold">
<td className="px-2 py-1">Total</td>
{totals.map((n, o) => <td key={o} className="px-2 py-1 text-right">{nf(n)}</td>)}
<td className="px-2 py-1 text-right">{nf(grand)}</td>
</tr>
</tfoot>
</table>
</div>
);
}
// ── Activity over time (single series → area, one hue) ────────────────────────
// Only the PEAK and the LAST point are direct-labelled. A number on every point
// is chaos and goes unread.
function AreaTrend({ data, height = 160, empty }: { data: Bucket[]; height?: number; empty: string }) {
const [hover, setHover] = useState<number | null>(null);
if (data.length < 2) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
const W = 1000, H = 100, peak = Math.max(1, ...data.map((d) => d.count));
const x = (i: number) => (i / (data.length - 1)) * W;
const y = (v: number) => H - (v / peak) * H;
const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.count).toFixed(1)}`).join(' ');
const area = `${line} L${W},${H} L0,${H} Z`;
const peakIdx = data.reduce((b, d, i) => (d.count > data[b].count ? i : b), 0);
const h = hover ?? -1;
return (
<div className="min-w-0">
<div className="relative" style={{ height }}>
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-full overflow-visible"
onMouseLeave={() => setHover(null)}>
{/* Recessive hairline grid — solid, never dashed. */}
{[0, 0.5, 1].map((f) => (
<line key={f} x1={0} x2={W} y1={H * f} y2={H * f} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
))}
<defs>
<linearGradient id="statsArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity="0.28" />
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={area} fill="url(#statsArea)" />
<path d={line} fill="none" stroke="var(--chart-1)" strokeWidth={2} vectorEffect="non-scaling-stroke"
strokeLinejoin="round" strokeLinecap="round" />
{h >= 0 && (
<>
<line x1={x(h)} x2={x(h)} y1={0} y2={H} stroke="var(--muted-foreground)" strokeWidth={1} vectorEffect="non-scaling-stroke" opacity={0.5} />
{/* 2px surface ring so the marker reads on top of the area. */}
<circle cx={x(h)} cy={y(data[h].count)} r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
</>
)}
{/* Hit areas are far wider than the marks — pinpoint targets are unusable. */}
{data.map((_, i) => (
<rect key={i} x={x(i) - W / data.length / 2} y={0} width={W / data.length} height={H}
fill="transparent" onMouseEnter={() => setHover(i)} />
))}
</svg>
{h >= 0 && (
<div className="pointer-events-none absolute -top-1 z-10 rounded-md border border-border bg-popover px-2 py-1 text-[11px] shadow-lg whitespace-nowrap"
style={{ left: `${(h / (data.length - 1)) * 100}%`, transform: 'translateX(-50%)' }}>
<span className="text-muted-foreground">{data[h].key}</span>{' '}
<span className="font-semibold tabular-nums">{nf(data[h].count)}</span>
</div>
)}
</div>
<div className="flex justify-between mt-1.5 text-[10px] text-muted-foreground tabular-nums">
<span>{data[0].key}</span>
<span className="text-foreground font-medium"> {data[peakIdx].key} · {nf(data[peakIdx].count)}</span>
<span>{data[data.length - 1].key}</span>
</div>
</div>
);
}
// ── Part-to-whole (continents) → the donut ───────────────────────────────────
// A pie/donut is legitimate for exactly this: part-to-whole, read at a glance,
// few segments, one share obviously dominant. It is the WRONG form for comparing
// close values (which is why modes stay bars — CW and SSB are nearly tied, and a
// donut would hide precisely the fact that makes them interesting).
//
// The categories ARE the subject here, so this is the one chart using the
// categorical slots — in FIXED order, with a legend and printed values, because
// the dark palette sits in the CVD floor band where labels are mandatory.
function Donut({ data, empty }: { data: Bucket[]; empty: string }) {
const [hov, setHov] = useState<number | null>(null);
const total = data.reduce((s, d) => s + d.count, 0);
if (total === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
const slots = data.slice(0, 8); // never generate a 9th hue
const R = 58, SW = 22, C = 2 * Math.PI * R;
let acc = 0;
const arcs = slots.map((d, i) => {
const frac = d.count / total;
// A 2px surface gap between segments — the correct separator, not a border.
const len = Math.max(0, frac * C - 2);
const a = { key: d.key, i, len, off: -acc * C, frac };
acc += frac;
return a;
});
return (
<div className="flex items-center gap-4 min-w-0">
<div className="relative shrink-0">
<svg viewBox="0 0 160 160" className="size-[140px] -rotate-90">
{arcs.map((a) => (
<circle key={a.key} cx={80} cy={80} r={R} fill="none"
stroke={`var(--chart-${a.i + 1})`} strokeWidth={hov === a.i ? SW + 4 : SW}
strokeDasharray={`${a.len} ${C - a.len}`} strokeDashoffset={a.off}
className="transition-[stroke-width] duration-150 cursor-default"
onMouseEnter={() => setHov(a.i)} onMouseLeave={() => setHov(null)} />
))}
</svg>
{/* The hole is not decoration — it carries the total the slices add up to. */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
{hov === null ? (
<>
<span className="text-[17px] font-semibold leading-none">{nf(total)}</span>
<span className="text-[10px] text-muted-foreground mt-0.5">QSO</span>
</>
) : (
<>
<span className="text-[15px] font-semibold leading-none">{(arcs[hov].frac * 100).toFixed(1)}%</span>
<span className="text-[10px] text-muted-foreground mt-0.5 truncate max-w-[92px] text-center">
{CONTINENT_NAME[slots[hov].key] ?? slots[hov].key}
</span>
</>
)}
</div>
</div>
<ul className="flex-1 min-w-0 flex flex-col gap-1">
{slots.map((d, i) => (
<li key={d.key}
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}
className={cn('flex items-center gap-1.5 min-w-0 text-[11px] rounded px-1 -mx-1',
hov === i && 'bg-muted')}>
{/* The swatch carries identity; the text stays in ink tokens. */}
<span className="size-2.5 rounded-[3px] shrink-0" style={{ background: `var(--chart-${i + 1})` }} />
<span className="truncate text-muted-foreground">{CONTINENT_NAME[d.key] ?? d.key}</span>
<span className="ml-auto shrink-0 tabular-nums text-foreground">{nf(d.count)}</span>
<span className="w-9 shrink-0 text-right tabular-nums text-muted-foreground">
{((d.count / total) * 100).toFixed(0)}%
</span>
</li>
))}
</ul>
</div>
);
}
// ── A single ratio against a limit → a meter, not a 2-slice pie ───────────────
function Meter({ label, value, total }: { label: string; value: number; total: number }) {
const pct = total > 0 ? (value / total) * 100 : 0;
return (
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2 mb-1">
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
<span className="text-[11px] tabular-nums">
<span className="font-semibold text-foreground">{pct.toFixed(1)}%</span>
<span className="text-muted-foreground"> · {nf(value)}</span>
</span>
</div>
{/* Same-ramp track: the meter and its track are one hue, not two. */}
<div className="h-2 w-full rounded-full" style={{ background: 'var(--chart-seq-1)' }}>
<div className="h-full rounded-full transition-[width] duration-500"
style={{ width: `${Math.max(1, pct)}%`, background: 'var(--chart-1)' }} />
</div>
</div>
);
}
// ── Period ───────────────────────────────────────────────────────────────────
// ONE filter row above everything it scopes — never a control inside a card, or
// the charts would each show a different slice of time.
type Period = 'all' | 'ytd' | 'y12' | 'd30' | 'custom';
const iso = (d: Date) => d.toISOString().slice(0, 10);
// Resolve a preset to the [from, to] the backend expects. Empty = no bound.
function periodRange(p: Period, from: string, to: string): [string, string] {
const now = new Date();
switch (p) {
case 'ytd': return [`${now.getUTCFullYear()}-01-01`, ''];
case 'y12': {
const d = new Date(now); d.setUTCMonth(d.getUTCMonth() - 12);
return [iso(d), ''];
}
case 'd30': {
const d = new Date(now); d.setUTCDate(d.getUTCDate() - 30);
return [iso(d), ''];
}
case 'custom': return [from, to];
default: return ['', ''];
}
}
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
const total = data.reduce((s, d) => s + d.count, 0);
return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<p className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground border-b border-border bg-muted/30">{title}</p>
<table className="w-full text-[11px]">
<tbody>
{data.map((d) => (
<tr key={d.key} className="border-b border-border/50 last:border-0">
<td className="px-3 py-1 truncate">{d.key}</td>
<td className="px-3 py-1 text-right tabular-nums font-medium">{nf(d.count)}</td>
<td className="px-3 py-1 text-right tabular-nums text-muted-foreground w-14">
{total ? ((d.count / total) * 100).toFixed(1) + '%' : '—'}
</td>
</tr>
))}
{data.length === 0 && <tr><td className="px-3 py-2 text-muted-foreground italic"></td></tr>}
</tbody>
</table>
</div>
);
}
// ── Panel ────────────────────────────────────────────────────────────────────
export function StatsPanel() {
const { t } = useI18n();
const [stats, setStats] = useState<Stats | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [view, setView] = useState<'charts' | 'table'>('charts');
const [period, setPeriod] = useState<Period>('all');
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
// Contest picker: "ID|YEAR", or '' for none. Selecting one narrows the log to
// that contest AND lets the window derive from its own span — no date typing.
const [runs, setRuns] = useState<ContestRun[]>([]);
const [contest, setContest] = useState('');
// Operator picker: '' = all operators, "—" = station owner (empty OPERATOR).
const [operators, setOperators] = useState<string[]>([]);
const [operator, setOperator] = useState('');
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
useEffect(() => { GetOperators().then((o: any) => setOperators((o ?? []) as string[])).catch(() => {}); }, []);
const load = async (p: Period = period, f = from, t2 = to, c = contest, op = operator) => {
// A contest defines its own window (its first→last QSO), so we send no dates
// with it — the backend derives them. Sending a period as well would be two
// filters fighting over the same axis.
const [cid, cyr] = c ? c.split('|') : ['', '0'];
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
setBusy(true); setErr('');
try {
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0, op)) as any;
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
// .length on null unmounts the whole React tree — a white screen. Normalise
// once, here, rather than guarding at every use site and missing one.
const arr = (v: any) => (Array.isArray(v) ? v : []);
setStats({
...raw,
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
rate: arr(raw.rate), gaps: arr(raw.gaps),
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
} as Stats);
}
catch (e: any) { setErr(String(e?.message ?? e)); }
finally { setBusy(false); }
};
// Re-run whenever the filter changes. A custom range only fires once BOTH ends
// are set — otherwise every keystroke in the date box would re-scan the log.
useEffect(() => {
if (!contest && period === 'custom' && !(from && to)) return;
load(period, from, to, contest, operator);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [period, from, to, contest, operator]);
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
// between two ordinary evenings — numbers that are true and completely useless.
// So: show it for a picked contest, or for a hand-set range short enough to be a
// real operating session (which is exactly when an hourly rate chart exists).
const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);
const span = useMemo(() => {
if (!stats?.first_qso) return '';
const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
return `${f.toISOString().slice(0, 10)}${l.toISOString().slice(0, 10)}`;
}, [stats]);
if (busy && !stats) {
return <div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
<Loader2 className="size-4 animate-spin" /> <span className="text-xs">{t('stats.loading')}</span>
</div>;
}
if (err) return <div className="p-4 text-xs text-destructive">{err}</div>;
if (!stats) return null;
const empty = t('stats.noData');
return (
<div className="flex-1 min-h-0 overflow-auto p-3">
{/* ONE filter/action row above everything it scopes — never per-card controls. */}
<div className="flex items-center flex-wrap gap-2 mb-3">
<h2 className="text-sm font-semibold">{t('stats.title')}</h2>
{span && <span className="text-[11px] text-muted-foreground tabular-nums">{span}</span>}
{/* A contest is picked from what's actually IN the log (CONTEST_ID + year),
never from a static list so it can't offer a contest you never entered.
Choosing one supersedes the period: it brings its own window. */}
<select value={contest} onChange={(e) => setContest(e.target.value)}
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[240px]">
<option value="">{t('stats.noContest')}</option>
{runs.map((r) => (
<option key={`${r.id}|${r.year}`} value={`${r.id}|${r.year}`}>
{r.id} {r.year} {nf(r.count)} QSO
</option>
))}
</select>
{/* Operator picker narrows every stat (totals, DXCC, top countries,
continent split, rate) to one operator from the log. "—" = the
station owner (QSOs logged with no OPERATOR set). */}
{operators.length > 1 && (
<select value={operator} onChange={(e) => setOperator(e.target.value)}
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[180px]"
title={t('stats.operatorTip')}>
<option value="">{t('stats.allOperators')}</option>
{operators.map((op) => (
<option key={op} value={op}>{op === '—' ? t('stats.stationOwner') : op}</option>
))}
</select>
)}
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
{([
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
['y12', t('stats.p12m')], ['d30', t('stats.p30d')], ['custom', t('stats.pCustom')],
] as [Period, string][]).map(([p, label], i) => (
<button key={p} onClick={() => setPeriod(p)}
className={cn('px-2 h-7 text-xs whitespace-nowrap', i > 0 && 'border-l border-border',
period === p ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
{label}
</button>
))}
</div>
{!contest && period === 'custom' && (
<div className="inline-flex items-center gap-1">
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)}
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
<span className="text-xs text-muted-foreground"></span>
<input type="date" value={to} onChange={(e) => setTo(e.target.value)}
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
</div>
)}
<div className="flex-1" />
<div className="inline-flex rounded-md border border-border overflow-hidden">
<button onClick={() => setView('charts')}
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs', view === 'charts' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
<BarChart3 className="size-3.5" />{t('stats.charts')}
</button>
<button onClick={() => setView('table')}
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs border-l border-border', view === 'table' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
<Table2 className="size-3.5" />{t('stats.table')}
</button>
</div>
<button onClick={() => load()} disabled={busy} title={t('stats.refresh')}
className="inline-flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-50">
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
</button>
</div>
{/* Headline figures: stat tiles, not a grouped bar chart. */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
</div>
{/* Period / contest block ONLY when a window is selected. "12 QSO/h" across
seventeen years is noise; across a contest weekend it IS the result. */}
{windowed && stats.total > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5 mb-3">
<Card title={t('stats.rate')} sub={t('stats.rateSub')} className="lg:col-span-2">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
{/* Two rates on purpose: one honest, one flattering — see the tooltip. */}
<div title={t('stats.avgWindowTip')}>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgWindow')}</p>
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_hour.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
</div>
<div title={t('stats.avgActiveTip')}>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgActive')}</p>
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_active.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
</div>
<div title={t('stats.best60Tip')}>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.best60')}</p>
<p className="text-[22px] leading-none font-semibold mt-1">{nf(stats.best_60)}</p>
</div>
{/* On-air + off-air = the window, by construction. The first version
counted "clock hours containing a QSO", which gave 39 h on air AND
16 h off air inside a 45 h contest and rightly wasn't believed. */}
<div title={t('stats.onAirTip')}>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.activeHours')}</p>
<p className="text-[22px] leading-none font-semibold mt-1">
{dur(stats.on_air_minutes)}
<span className="text-[11px] text-muted-foreground font-normal"> / {Math.round(stats.window_hours)} h</span>
</p>
</div>
</div>
{stats.rate.length > 1
? <RateStack rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} empty={empty} />
: <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
</Card>
<Card title={t('stats.offAir')} sub={t('stats.offAirSub', { d: dur(stats.off_air_minutes) })}>
{stats.gaps.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.noGaps')}</p>
) : (
<ul className="flex flex-col gap-1 min-w-0">
{stats.gaps.map((g, i) => (
<li key={i} className="flex items-center gap-2 text-[11px] min-w-0">
<span className="tabular-nums text-muted-foreground truncate">
{g.start.slice(5, 16).replace('T', ' ')} {g.end.slice(11, 16)}
</span>
<span className="flex-1 h-[6px] rounded-full min-w-0"
style={{
background: 'var(--chart-seq-1)',
}}>
<span className="block h-full rounded-full"
style={{
width: `${Math.min(100, (g.minutes / Math.max(1, stats.gaps[0].minutes)) * 100)}%`,
background: 'var(--chart-1)',
}} />
</span>
<span className="shrink-0 tabular-nums font-medium">{dur(g.minutes)}</span>
</li>
))}
</ul>
)}
</Card>
{/* The rate sheet: exact numbers, many of them, hour by hour and operator
by operator. That is a table's job, not a chart's and it's how
contesters actually read a run. Every value here is also what the
stacked chart above draws, so the two can never disagree. */}
{stats.rate.length > 1 && (
<Card title={t('stats.rateSheet')} sub={t('stats.rateSheetSub')} className="lg:col-span-3">
<RateSheet rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} />
</Card>
)}
</div>
)}
{view === 'table' ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5">
<BucketTable title={t('stats.byBand')} data={stats.by_band} />
<BucketTable title={t('stats.byMode')} data={stats.by_mode} />
<BucketTable title={t('stats.byOperator')} data={stats.by_operator} />
<BucketTable title={t('stats.byContinent')} data={stats.by_continent} />
<BucketTable title={t('stats.topEntities')} data={stats.top_entities} />
<BucketTable title={t('stats.byYear')} data={stats.by_year} />
<BucketTable title={t('stats.byStation')} data={stats.by_station} />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
<VBars data={stats.by_band} empty={empty} showValues colorful />
</Card>
<Card title={t('stats.byMode')}>
<HBars data={stats.by_mode} max={8} empty={empty} />
</Card>
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
<AreaTrend data={stats.by_month} empty={empty} />
</Card>
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
worked what, and a cap would quietly delete the 9th operator. Scrolls
instead of truncating. */}
<Card title={t('stats.byOperator')}>
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
<HBars data={stats.by_operator} empty={empty} share />
</div>
</Card>
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
<Donut data={stats.by_continent} empty={empty} />
</Card>
<Card title={t('stats.topEntities')}>
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" />
</Card>
<div className="flex flex-col gap-2.5 min-w-0">
<Card title={t('stats.confirmations')} className="flex-1">
<div className="flex flex-col gap-3 justify-center flex-1">
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
</div>
</Card>
<Card title={t('stats.byStation')}>
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
<HBars data={stats.by_station} empty={empty} share />
</div>
</Card>
</div>
</div>
)}
</div>
);
}
@@ -321,7 +321,6 @@ function EditDialog({
<Label>{t('udpp.name')}</Label>
<Input
autoFocus
placeholder={draft.direction === 'inbound' ? t('udpp.namePhInbound') : t('udpp.namePhOutbound')}
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
/>
+242 -38
View File
File diff suppressed because one or more lines are too long
+22
View File
@@ -14,3 +14,25 @@ export function sMeterRST(s: number, overDb: number, mode?: string): string {
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
return `5${strength}`;
}
// RST dropdown lists, shared by the entry form and the QSO editor so both offer
// the same per-mode choices (Settings → Modes → RST report lists).
export type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
// rstCategory maps an ADIF mode to its RST family (phone / cw / digital).
export function rstCategory(mode: string): keyof RSTLists {
const m = (mode || '').toUpperCase();
const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
if (digital.includes(m)) return 'digital';
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
return 'phone';
}
// rstOptions returns the valid report choices for a mode from the user's
// editable lists, with a tiny fallback before they load.
export function rstOptions(mode: string, lists: RSTLists): string[] {
const cat = rstCategory(mode);
const l = lists[cat];
if (l && l.length) return l;
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
}
+35 -3
View File
@@ -1,15 +1,16 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
import { writeUiPref } from './uiPref';
import { GetUIPref } from '../../wailsjs/go/main/App';
// Theme system. Each choice maps to a `data-theme` value on <html> that the
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
// preference. The choice is persisted (localStorage + portable UI pref, so it
// travels with the data/ folder like the language).
export type ThemeChoice = 'auto' | 'light-warm' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
// Selectable, concrete themes (excludes 'auto') in display order.
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
'light-warm', 'dark-warm', 'dark-graphite', 'high-contrast',
'light-warm', 'light-cool', 'light-sage', 'dim-slate', 'dark-warm', 'dark-graphite', 'high-contrast',
];
export const LS_KEY = 'opslog.theme';
@@ -49,13 +50,44 @@ export function useTheme(): Ctx { return useContext(ThemeCtx); }
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
// Set once the operator changes the theme by hand, so the self-heal below
// never clobbers a fresh choice with a value it read a moment earlier.
const userPicked = useRef(false);
const setTheme = useCallback((t: ThemeChoice) => {
userPicked.current = true;
setThemeState(t);
applyThemeToDom(t);
writeUiPref(LS_KEY, t);
}, []);
// Self-heal the persisted theme. The synchronous boot read (localStorage) can
// miss it when the WebView cleared its storage, OR when syncPortablePrefs ran
// while the backend was still starting (settings store not wired yet → GetUIPref
// returned "" with no error, so nothing was restored) — the "restart lands on
// the light theme sometimes" bug. Re-read the portable pref from the DB once the
// backend is up and apply it, retrying briefly to ride out a slow startup.
useEffect(() => {
let cancelled = false;
let tries = 0;
const load = () => {
tries += 1;
GetUIPref(LS_KEY).then((raw) => {
if (cancelled || userPicked.current) return;
const v = raw as ThemeChoice;
if (v && ALL.includes(v)) {
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
applyThemeToDom(v); // idempotent — safe to call unconditionally
setThemeState(v);
return; // restored
}
if (tries < 8) window.setTimeout(load, 300); // empty (unset or backend not ready yet) → retry
}).catch(() => { if (!cancelled && tries < 8) window.setTimeout(load, 300); });
};
load();
return () => { cancelled = true; };
}, []);
// While in 'auto', re-resolve when the OS light/dark preference flips.
useEffect(() => {
if (theme !== 'auto') return;
+7
View File
@@ -19,6 +19,7 @@ const PORTABLE_KEYS = [
'opslog.showRotor', // rotor compass shown next to the keyers
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
@@ -26,6 +27,12 @@ const PORTABLE_KEYS = [
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
'opslog.mapBasemap', // world map basemap (light / street / satellite)
// Cluster filter selections — restored on reopen.
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.activeTab', // last selected tab
'hamlog.awardColsShown', // which award columns are shown in the QSO grid
];
// syncPortablePrefs reconciles the DB with the local cache at startup:
+258
View File
@@ -92,6 +92,207 @@
color-scheme: light;
}
/* ---- Theme 1b: Cool light (slate/blue) — crisp daylight, neutral cool --- */
[data-theme="light-cool"] {
--background: #f4f6f8; /* cool blue-white */
--foreground: #0f172a; /* slate-900 */
--card: #ffffff;
--card-foreground: #0f172a;
--popover: #ffffff;
--popover-foreground: #0f172a;
--primary: #2563eb; /* blue-600 */
--primary-foreground: #ffffff;
--secondary: #e2e8f0; /* slate-200 */
--secondary-foreground: #0f172a;
--muted: #e6ebf1; /* toolbars / table headers */
--muted-foreground: #475569; /* slate-600 — readable on muted */
--accent: #dbeafe; /* blue-100 */
--accent-foreground: #1e3a8a; /* blue-900 */
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--destructive-muted: #fef2f2;
--destructive-muted-foreground: #b91c1c;
--border: #d3dae2; /* cool slate border */
--input: #d3dae2;
--ring: #3b82f6; /* blue-500 focus ring */
--success: #16a34a;
--success-foreground: #ffffff;
--success-muted: #dcfce7;
--success-muted-foreground: #166534;
--success-border: #86efac;
--warning: #d97706;
--warning-foreground: #ffffff;
--warning-muted: #fef3c7;
--warning-muted-foreground: #92400e;
--warning-border: #fcd34d;
--caution: #ca8a04;
--caution-foreground: #1c1917;
--caution-muted: #fef9c3;
--caution-muted-foreground: #854d0e;
--caution-border: #fde047;
--danger: #e11d48;
--danger-foreground: #ffffff;
--danger-muted: #ffe4e6;
--danger-muted-foreground: #9f1239;
--danger-border: #fda4af;
--info: #0284c7;
--info-foreground: #ffffff;
--info-muted: #e0f2fe;
--info-muted-foreground: #075985;
--info-border: #7dd3fc;
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, cool neutral base. */
--mx-call-conf: #15803d;
--mx-call-work: #6ee7b7;
--mx-dx-conf: #3730a3;
--mx-dx-work: #a5b4fc;
--mx-none: #e2e8f0;
--scrollbar-thumb: #b6c2d1;
--scrollbar-thumb-hover: #8fa1b5;
--card-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(15, 23, 42, 0.02);
color-scheme: light;
}
/* ---- Theme 1c: Sage light (Nordic) — soft green-grey, low-fatigue ------- */
[data-theme="light-sage"] {
--background: #eef1ec; /* pale sage */
--foreground: #1a2420; /* deep pine-ink */
--card: #f8faf6; /* lifted off-white with a green cast */
--card-foreground: #1a2420;
--popover: #f8faf6;
--popover-foreground: #1a2420;
--primary: #2f855a; /* deep sage green */
--primary-foreground: #f0fdf4;
--secondary: #dde5db;
--secondary-foreground: #1a2420;
--muted: #dde5db; /* toolbars / table headers */
--muted-foreground: #445048; /* muted pine — readable on muted */
--accent: #d7e8d5; /* soft green */
--accent-foreground: #1e4634; /* forest-900 */
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--destructive-muted: #fef2f2;
--destructive-muted-foreground: #b91c1c;
--border: #cdd8ce; /* soft sage border */
--input: #cdd8ce;
--ring: #38a169; /* green focus ring */
--success: #16a34a;
--success-foreground: #ffffff;
--success-muted: #dcfce7;
--success-muted-foreground: #166534;
--success-border: #86efac;
--warning: #d97706;
--warning-foreground: #ffffff;
--warning-muted: #fef3c7;
--warning-muted-foreground: #92400e;
--warning-border: #fcd34d;
--caution: #ca8a04;
--caution-foreground: #1c1917;
--caution-muted: #fef9c3;
--caution-muted-foreground: #854d0e;
--caution-border: #fde047;
--danger: #e11d48;
--danger-foreground: #ffffff;
--danger-muted: #ffe4e6;
--danger-muted-foreground: #9f1239;
--danger-border: #fda4af;
--info: #0284c7;
--info-foreground: #ffffff;
--info-muted: #e0f2fe;
--info-muted-foreground: #075985;
--info-border: #7dd3fc;
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, sage neutral base. */
--mx-call-conf: #15803d;
--mx-call-work: #6ee7b7;
--mx-dx-conf: #3730a3;
--mx-dx-work: #a5b4fc;
--mx-none: #dde5db;
--scrollbar-thumb: #b3c2b3;
--scrollbar-thumb-hover: #91a591;
--card-shadow: 0 1px 2px rgba(26, 36, 32, 0.06), 0 0 0 1px rgba(26, 36, 32, 0.02);
color-scheme: light;
}
/* ---- Theme 1d: Dim slate — mid-tone twilight, between light and dark ---- */
[data-theme="dim-slate"] {
--background: #343b47; /* medium slate — light text, not deep dark */
--foreground: #eceef2;
--card: #3d4552; /* lifted panel */
--card-foreground: #eceef2;
--popover: #3d4552;
--popover-foreground: #eceef2;
--primary: #fb923c; /* orange-400 — brand warmth, pops on slate */
--primary-foreground: #241206;
--secondary: #454e5c;
--secondary-foreground: #eceef2;
--muted: #414a58; /* toolbars / table headers */
--muted-foreground: #b4bcc9; /* readable on muted */
--accent: #495468; /* hover / selection tint */
--accent-foreground: #dce3ef;
--destructive: #ef4444;
--destructive-foreground: #1a0808;
--destructive-muted: #3f1f1f;
--destructive-muted-foreground: #fca5a5;
--border: #4e5867;
--input: #4e5867;
--ring: #fdba74; /* orange-300 focus ring */
--success: #34d399;
--success-foreground: #06231a;
--success-muted: #1c3d34;
--success-muted-foreground: #6ee7b7;
--success-border: #2a5f4c;
--warning: #fbbf24;
--warning-foreground: #241a06;
--warning-muted: #41371a;
--warning-muted-foreground: #fcd34d;
--warning-border: #5e4d22;
--caution: #facc15;
--caution-foreground: #242006;
--caution-muted: #403b1a;
--caution-muted-foreground: #fde047;
--caution-border: #5b5222;
--danger: #fb7185;
--danger-foreground: #260a11;
--danger-muted: #421f28;
--danger-muted-foreground: #fda4af;
--danger-border: #63303c;
--info: #38bdf8;
--info-foreground: #061f2b;
--info-muted: #16384a;
--info-muted-foreground: #7dd3fc;
--info-border: #1d5670;
/* Matrix: bright confirmed, medium worked, slate not-worked (sits on dim). */
--mx-call-conf: #22c55e;
--mx-call-work: #2f7d55;
--mx-dx-conf: #6366f1;
--mx-dx-work: #3f3f8a;
--mx-none: #4a5262;
--scrollbar-thumb: #565f70;
--scrollbar-thumb-hover: #6b7588;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(236, 238, 242, 0.05);
color-scheme: dark;
}
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
[data-theme="dark-warm"] {
--background: #221d18;
@@ -294,6 +495,63 @@
color-scheme: dark;
}
/* Data-viz palette (Statistics dashboard)
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
reorder, recycle, or generate a 9th hue fold the tail into "Other" instead.
The dark column is the same eight hues re-stepped for a dark surface, NOT an
automatic inversion.
--chart-1 is the DEFAULT single-series hue: a chart with one series uses it for
every bar. Colour is for identity, never to re-encode a length the bar already
shows.
--chart-seq-* is a ONE-HUE ramp for magnitude (the hour×band heatmap). On light
themes it runs lightdark; on dark themes darklight, so "near zero" always
recedes toward the surface instead of glowing. */
:root,
[data-theme="light-warm"],
[data-theme="light-cool"],
[data-theme="light-sage"] {
--chart-1: #2a78d6; /* blue — default single-series hue */
--chart-2: #1baf7a; /* aqua */
--chart-3: #eda100; /* yellow */
--chart-4: #008300; /* green */
--chart-5: #4a3aa7; /* violet */
--chart-6: #e34948; /* red */
--chart-7: #e87ba4; /* magenta */
--chart-8: #eb6834; /* orange */
--chart-seq-1: #cde2fb;
--chart-seq-2: #9ec5f4;
--chart-seq-3: #6da7ec;
--chart-seq-4: #3987e5;
--chart-seq-5: #256abf;
--chart-seq-6: #104281;
}
[data-theme="dim-slate"],
[data-theme="dark-warm"],
[data-theme="dark-graphite"],
[data-theme="high-contrast"] {
--chart-1: #3987e5;
--chart-2: #199e70;
--chart-3: #c98500;
--chart-4: #008300;
--chart-5: #9085e9;
--chart-6: #e66767;
--chart-7: #d55181;
--chart-8: #d95926;
/* Reversed: the lowest step must sink toward the dark surface. */
--chart-seq-1: #0d366b;
--chart-seq-2: #184f95;
--chart-seq-3: #256abf;
--chart-seq-4: #3987e5;
--chart-seq-5: #6da7ec;
--chart-seq-6: #9ec5f4;
}
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
the generated utilities reference var(--) directly, so overriding a var in
a [data-theme] block re-skins every utility at runtime. */
+1 -1
View File
@@ -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.19.1';
export const APP_VERSION = '0.20.0';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+104
View File
@@ -11,12 +11,14 @@ import {awardref} from '../models';
import {cluster} from '../models';
import {extsvc} from '../models';
import {powergenius} from '../models';
import {solar} from '../models';
import {winkeyer} from '../models';
import {alerts} from '../models';
import {audio} from '../models';
import {contest} from '../models';
import {operating} from '../models';
import {udp} from '../models';
import {lotwusers} from '../models';
import {lookup} from '../models';
import {netctl} from '../models';
@@ -32,10 +34,26 @@ export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
export function AntGeniusDeselect(arg1:number):Promise<void>;
export function ApplyAwardImport(arg1:string,arg2:Record<string, string>):Promise<main.AwardImportResult>;
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
export function ApplyAwardUpdate(arg1:string):Promise<void>;
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
export function AudioMonitorActive():Promise<boolean>;
export function AudioStartMonitor():Promise<void>;
export function AudioStartTX():Promise<void>;
export function AudioStopMonitor():Promise<void>;
export function AudioStopTX():Promise<void>;
export function AudioTXActive():Promise<boolean>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
export function AwardFields():Promise<Array<string>>;
@@ -44,6 +62,10 @@ export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
export function AwardsFolder():Promise<string>;
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
export function BrowseExecutable():Promise<string>;
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
@@ -120,20 +142,30 @@ export function DisconnectClusterServer(arg1:number):Promise<void>;
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
export function DismissAwardUpdate(arg1:string):Promise<void>;
export function DownloadAllReferenceLists():Promise<string>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
export function DownloadLoTWUsers():Promise<number>;
export function DownloadULSCounties():Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
export function ExportAward(arg1:string):Promise<string>;
export function ExportAwards():Promise<string>;
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
@@ -190,6 +222,8 @@ export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetMic(arg1:number):Promise<void>;
export function FlexSetMicProfile(arg1:string):Promise<void>;
export function FlexSetMon(arg1:boolean):Promise<void>;
export function FlexSetMonLevel(arg1:number):Promise<void>;
@@ -210,6 +244,10 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
export function FlexSetRIT(arg1:boolean):Promise<void>;
export function FlexSetRITFreq(arg1:number):Promise<void>;
export function FlexSetRXAntenna(arg1:string):Promise<void>;
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
@@ -218,6 +256,8 @@ export function FlexSetSplit(arg1:boolean):Promise<void>;
export function FlexSetTXAntenna(arg1:string):Promise<void>;
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetTXSlice(arg1:number):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>;
@@ -228,8 +268,18 @@ export function FlexSetVoxDelay(arg1:number):Promise<void>;
export function FlexSetVoxLevel(arg1:number):Promise<void>;
export function FlexSetWNB(arg1:boolean):Promise<void>;
export function FlexSetWNBLevel(arg1:number):Promise<void>;
export function FlexSetXIT(arg1:boolean):Promise<void>;
export function FlexSetXITFreq(arg1:number):Promise<void>;
export function FlexTune(arg1:boolean):Promise<void>;
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
export function GetActiveProfile():Promise<profile.Profile>;
export function GetAlertEmailTo():Promise<string>;
@@ -252,6 +302,8 @@ export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
export function GetAwardUpdates():Promise<Array<main.AwardUpdate>>;
export function GetAwards():Promise<Array<award.Result>>;
export function GetBackupSettings():Promise<main.BackupSettings>;
@@ -262,6 +314,8 @@ export function GetCATState():Promise<cat.RigState>;
export function GetCWDecoderPitch():Promise<number>;
export function GetCatalogCodes():Promise<Array<string>>;
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
@@ -270,6 +324,8 @@ export function GetClusterAutoConnect():Promise<boolean>;
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
export function GetContestRuns():Promise<Array<qso.ContestRun>>;
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
@@ -298,36 +354,58 @@ export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveStatusEnabled():Promise<boolean>;
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
export function GetLogFilePath():Promise<string>;
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number,arg5:string):Promise<qso.Stats>;
export function GetLogbookRevision():Promise<string>;
export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetMySQLSettings():Promise<main.MySQLSettings>;
export function GetOfflineStatus():Promise<main.OfflineStatus>;
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
export function GetOperators():Promise<Array<string>>;
export function GetPGXLSettings():Promise<main.PGXLSettings>;
export function GetPGXLStatus():Promise<powergenius.Status>;
export function GetPOTAToken():Promise<string>;
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
export function GetQSLDefaults():Promise<main.QSLDefaults>;
export function GetQSO(arg1:number):Promise<qso.QSO>;
export function GetQSORate():Promise<main.QSORate>;
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetSlotStats():Promise<qso.SlotStats>;
export function GetSolarData():Promise<solar.Data>;
export function GetStartupStatus():Promise<main.StartupStatus>;
export function GetStationDevices():Promise<Array<main.StationDevice>>;
export function GetStationSettings():Promise<main.StationSettings>;
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
export function GetTelemetryEnabled():Promise<boolean>;
export function GetUIPref(arg1:string):Promise<string>;
@@ -434,6 +512,8 @@ export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<numbe
export function ImportAwards():Promise<main.AwardImportResult>;
export function InspectAwardImport():Promise<main.AwardImportPreview>;
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
@@ -466,10 +546,16 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function MotorReadElements():Promise<Array<number>>;
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
export function MoveDatabase(arg1:string):Promise<void>;
export function NetActivate(arg1:string):Promise<qso.QSO>;
@@ -508,6 +594,8 @@ export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
export function OpenADIFFile():Promise<string>;
export function OpenAwardsFolder():Promise<void>;
export function OpenDatabase(arg1:string):Promise<void>;
export function OpenExternalURL(arg1:string):Promise<void>;
@@ -516,6 +604,8 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
export function PGXLSetFanMode(arg1:string):Promise<void>;
export function PickADIFMonitorFile():Promise<string>;
export function PickAudioFolder():Promise<string>;
export function PickBackupFolder():Promise<string>;
@@ -572,6 +662,8 @@ export function QuitApp():Promise<void>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshSolar():Promise<void>;
export function ReloadUDPIntegrations():Promise<Array<string>>;
export function RemovePassphrase(arg1:string):Promise<void>;
@@ -592,6 +684,8 @@ export function RestartApp():Promise<void>;
export function RestartQSORecorder():Promise<void>;
export function RetryOfflineSync():Promise<number>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
export function RotatorPark():Promise<void>;
@@ -602,6 +696,8 @@ export function RunBackupNow():Promise<string>;
export function SaveADIFFile():Promise<string>;
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
@@ -646,8 +742,12 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
@@ -696,6 +796,8 @@ export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
export function StopCWDecoder():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>;
@@ -724,6 +826,8 @@ export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
export function ULSStatus():Promise<main.ULSStatusResult>;
export function UltrabeamRetract():Promise<void>;
export function UnlockSecrets(arg1:string):Promise<void>;
+204
View File
@@ -26,14 +26,46 @@ export function AntGeniusDeselect(arg1) {
return window['go']['main']['App']['AntGeniusDeselect'](arg1);
}
export function ApplyAwardImport(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardImport'](arg1, arg2);
}
export function ApplyAwardPreset(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
}
export function ApplyAwardUpdate(arg1) {
return window['go']['main']['App']['ApplyAwardUpdate'](arg1);
}
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
}
export function AudioMonitorActive() {
return window['go']['main']['App']['AudioMonitorActive']();
}
export function AudioStartMonitor() {
return window['go']['main']['App']['AudioStartMonitor']();
}
export function AudioStartTX() {
return window['go']['main']['App']['AudioStartTX']();
}
export function AudioStopMonitor() {
return window['go']['main']['App']['AudioStopMonitor']();
}
export function AudioStopTX() {
return window['go']['main']['App']['AudioStopTX']();
}
export function AudioTXActive() {
return window['go']['main']['App']['AudioTXActive']();
}
export function AwardCellQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
}
@@ -50,6 +82,14 @@ export function AwardRefsForQSOs(arg1) {
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
}
export function AwardsFolder() {
return window['go']['main']['App']['AwardsFolder']();
}
export function BackfillUSCounties() {
return window['go']['main']['App']['BackfillUSCounties']();
}
export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable']();
}
@@ -202,6 +242,10 @@ export function DiscoverFlexRadios() {
return window['go']['main']['App']['DiscoverFlexRadios']();
}
export function DismissAwardUpdate(arg1) {
return window['go']['main']['App']['DismissAwardUpdate'](arg1);
}
export function DownloadAllReferenceLists() {
return window['go']['main']['App']['DownloadAllReferenceLists']();
}
@@ -214,10 +258,22 @@ export function DownloadConfirmations(arg1, arg2, arg3) {
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
}
export function DownloadLoTWUsers() {
return window['go']['main']['App']['DownloadLoTWUsers']();
}
export function DownloadULSCounties() {
return window['go']['main']['App']['DownloadULSCounties']();
}
export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
}
export function ExplainAward(arg1, arg2) {
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
}
export function ExportADIF(arg1, arg2) {
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
}
@@ -230,6 +286,10 @@ export function ExportADIFSelected(arg1, arg2, arg3) {
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
}
export function ExportAward(arg1) {
return window['go']['main']['App']['ExportAward'](arg1);
}
export function ExportAwards() {
return window['go']['main']['App']['ExportAwards']();
}
@@ -342,6 +402,10 @@ export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1);
}
export function FlexSetMicProfile(arg1) {
return window['go']['main']['App']['FlexSetMicProfile'](arg1);
}
export function FlexSetMon(arg1) {
return window['go']['main']['App']['FlexSetMon'](arg1);
}
@@ -382,6 +446,14 @@ export function FlexSetProcessorLevel(arg1) {
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
}
export function FlexSetRIT(arg1) {
return window['go']['main']['App']['FlexSetRIT'](arg1);
}
export function FlexSetRITFreq(arg1) {
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
}
export function FlexSetRXAntenna(arg1) {
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
}
@@ -398,6 +470,10 @@ export function FlexSetTXAntenna(arg1) {
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
}
export function FlexSetTXFilter(arg1, arg2) {
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
}
export function FlexSetTXSlice(arg1) {
return window['go']['main']['App']['FlexSetTXSlice'](arg1);
}
@@ -418,10 +494,30 @@ export function FlexSetVoxLevel(arg1) {
return window['go']['main']['App']['FlexSetVoxLevel'](arg1);
}
export function FlexSetWNB(arg1) {
return window['go']['main']['App']['FlexSetWNB'](arg1);
}
export function FlexSetWNBLevel(arg1) {
return window['go']['main']['App']['FlexSetWNBLevel'](arg1);
}
export function FlexSetXIT(arg1) {
return window['go']['main']['App']['FlexSetXIT'](arg1);
}
export function FlexSetXITFreq(arg1) {
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
}
export function FlexTune(arg1) {
return window['go']['main']['App']['FlexTune'](arg1);
}
export function GetADIFMonitor() {
return window['go']['main']['App']['GetADIFMonitor']();
}
export function GetActiveProfile() {
return window['go']['main']['App']['GetActiveProfile']();
}
@@ -466,6 +562,10 @@ export function GetAwardStats(arg1) {
return window['go']['main']['App']['GetAwardStats'](arg1);
}
export function GetAwardUpdates() {
return window['go']['main']['App']['GetAwardUpdates']();
}
export function GetAwards() {
return window['go']['main']['App']['GetAwards']();
}
@@ -486,6 +586,10 @@ export function GetCWDecoderPitch() {
return window['go']['main']['App']['GetCWDecoderPitch']();
}
export function GetCatalogCodes() {
return window['go']['main']['App']['GetCatalogCodes']();
}
export function GetChatHistory(arg1) {
return window['go']['main']['App']['GetChatHistory'](arg1);
}
@@ -502,6 +606,10 @@ export function GetClusterStatus() {
return window['go']['main']['App']['GetClusterStatus']();
}
export function GetContestRuns() {
return window['go']['main']['App']['GetContestRuns']();
}
export function GetCtyDatInfo() {
return window['go']['main']['App']['GetCtyDatInfo']();
}
@@ -558,10 +666,18 @@ export function GetLiveStatusEnabled() {
return window['go']['main']['App']['GetLiveStatusEnabled']();
}
export function GetLoTWUsersStatus() {
return window['go']['main']['App']['GetLoTWUsersStatus']();
}
export function GetLogFilePath() {
return window['go']['main']['App']['GetLogFilePath']();
}
export function GetLogStats(arg1, arg2, arg3, arg4, arg5) {
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4, arg5);
}
export function GetLogbookRevision() {
return window['go']['main']['App']['GetLogbookRevision']();
}
@@ -574,10 +690,18 @@ export function GetMySQLSettings() {
return window['go']['main']['App']['GetMySQLSettings']();
}
export function GetOfflineStatus() {
return window['go']['main']['App']['GetOfflineStatus']();
}
export function GetOnlineOperators() {
return window['go']['main']['App']['GetOnlineOperators']();
}
export function GetOperators() {
return window['go']['main']['App']['GetOperators']();
}
export function GetPGXLSettings() {
return window['go']['main']['App']['GetPGXLSettings']();
}
@@ -590,6 +714,10 @@ export function GetPOTAToken() {
return window['go']['main']['App']['GetPOTAToken']();
}
export function GetPendingQSOs() {
return window['go']['main']['App']['GetPendingQSOs']();
}
export function GetQSLDefaults() {
return window['go']['main']['App']['GetQSLDefaults']();
}
@@ -598,6 +726,14 @@ export function GetQSO(arg1) {
return window['go']['main']['App']['GetQSO'](arg1);
}
export function GetQSORate() {
return window['go']['main']['App']['GetQSORate']();
}
export function GetRelayAuto() {
return window['go']['main']['App']['GetRelayAuto']();
}
export function GetRotatorHeading() {
return window['go']['main']['App']['GetRotatorHeading']();
}
@@ -610,14 +746,30 @@ export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus']();
}
export function GetSlotStats() {
return window['go']['main']['App']['GetSlotStats']();
}
export function GetSolarData() {
return window['go']['main']['App']['GetSolarData']();
}
export function GetStartupStatus() {
return window['go']['main']['App']['GetStartupStatus']();
}
export function GetStationDevices() {
return window['go']['main']['App']['GetStationDevices']();
}
export function GetStationSettings() {
return window['go']['main']['App']['GetStationSettings']();
}
export function GetStationStatus() {
return window['go']['main']['App']['GetStationStatus']();
}
export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled']();
}
@@ -830,6 +982,10 @@ export function ImportAwards() {
return window['go']['main']['App']['ImportAwards']();
}
export function InspectAwardImport() {
return window['go']['main']['App']['InspectAwardImport']();
}
export function LaunchAutostartProgram(arg1) {
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
}
@@ -894,6 +1050,10 @@ export function ListUDPIntegrations() {
return window['go']['main']['App']['ListUDPIntegrations']();
}
export function LoTWUserInfo(arg1) {
return window['go']['main']['App']['LoTWUserInfo'](arg1);
}
export function LogUDPLoggedADIF(arg1) {
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
}
@@ -902,6 +1062,14 @@ export function LookupCallsign(arg1) {
return window['go']['main']['App']['LookupCallsign'](arg1);
}
export function MotorReadElements() {
return window['go']['main']['App']['MotorReadElements']();
}
export function MotorSetElement(arg1, arg2) {
return window['go']['main']['App']['MotorSetElement'](arg1, arg2);
}
export function MoveDatabase(arg1) {
return window['go']['main']['App']['MoveDatabase'](arg1);
}
@@ -978,6 +1146,10 @@ export function OpenADIFFile() {
return window['go']['main']['App']['OpenADIFFile']();
}
export function OpenAwardsFolder() {
return window['go']['main']['App']['OpenAwardsFolder']();
}
export function OpenDatabase(arg1) {
return window['go']['main']['App']['OpenDatabase'](arg1);
}
@@ -994,6 +1166,10 @@ export function PGXLSetFanMode(arg1) {
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
}
export function PickADIFMonitorFile() {
return window['go']['main']['App']['PickADIFMonitorFile']();
}
export function PickAudioFolder() {
return window['go']['main']['App']['PickAudioFolder']();
}
@@ -1106,6 +1282,10 @@ export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat']();
}
export function RefreshSolar() {
return window['go']['main']['App']['RefreshSolar']();
}
export function ReloadUDPIntegrations() {
return window['go']['main']['App']['ReloadUDPIntegrations']();
}
@@ -1146,6 +1326,10 @@ export function RestartQSORecorder() {
return window['go']['main']['App']['RestartQSORecorder']();
}
export function RetryOfflineSync() {
return window['go']['main']['App']['RetryOfflineSync']();
}
export function RotatorGoTo(arg1, arg2) {
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
}
@@ -1166,6 +1350,10 @@ export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile']();
}
export function SaveADIFMonitor(arg1) {
return window['go']['main']['App']['SaveADIFMonitor'](arg1);
}
export function SaveAlertRule(arg1) {
return window['go']['main']['App']['SaveAlertRule'](arg1);
}
@@ -1254,10 +1442,18 @@ export function SaveQSLDefaults(arg1) {
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
}
export function SaveRelayAuto(arg1) {
return window['go']['main']['App']['SaveRelayAuto'](arg1);
}
export function SaveRotatorSettings(arg1) {
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
}
export function SaveStationDevices(arg1) {
return window['go']['main']['App']['SaveStationDevices'](arg1);
}
export function SaveStationSettings(arg1) {
return window['go']['main']['App']['SaveStationSettings'](arg1);
}
@@ -1354,6 +1550,10 @@ export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
export function StationSetRelay(arg1, arg2, arg3) {
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
}
export function StopCWDecoder() {
return window['go']['main']['App']['StopCWDecoder']();
}
@@ -1410,6 +1610,10 @@ export function TestUltrabeam(arg1) {
return window['go']['main']['App']['TestUltrabeam'](arg1);
}
export function ULSStatus() {
return window['go']['main']['App']['ULSStatus']();
}
export function UltrabeamRetract() {
return window['go']['main']['App']['UltrabeamRetract']();
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -120,6 +120,18 @@ func SingleRecordADIF(q qso.QSO) string {
return b.String()
}
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
// so it can be written out and read back with nothing dropped. Used by the
// offline queue: a QSO parked in the safety file must come back identical
// (extras carry its queue id, awards refs, ADIF 3.1.7 leftovers…).
func FullRecordADIF(q qso.QSO) string {
var b strings.Builder
bw := bufio.NewWriter(&b)
writeRecord(bw, q, true)
bw.Flush()
return b.String()
}
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
+21 -3
View File
@@ -32,9 +32,13 @@ const (
)
// Antenna is one configured antenna (index + name as stored on the device).
// Bands is the device's band bitmask for the antenna (which bands it covers);
// 0 = unknown/all. The UI uses it to show only band-appropriate antennas, like
// the native 4O3A app.
type Antenna struct {
Index int `json:"index"`
Name string `json:"name"`
Bands int `json:"bands"`
}
// Status is the snapshot the UI renders.
@@ -65,6 +69,8 @@ type Client struct {
statusMu sync.RWMutex
status Status
antennas map[int]string // index → name (rebuilt into status.Antennas)
antBands map[int]int // index → band bitmask (which bands the antenna covers)
antRawN int // one-shot: how many raw antenna lines we've logged
stop chan struct{}
running bool
@@ -80,6 +86,7 @@ func New(host string, port int, password string) *Client {
password: strings.TrimSpace(password),
stop: make(chan struct{}),
antennas: map[int]string{},
antBands: map[int]int{},
status: Status{Host: host},
}
}
@@ -356,13 +363,24 @@ func (c *Client) parseAntenna(msg string) {
// The device stores spaces as underscores in names.
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
}
// Band bitmask: which bands this antenna is configured for. The device sends
// it as "band=<mask>" (a bitmask). Kept so the UI can show only band-relevant
// antennas (like the native app). Log the first few raw antenna lines so the
// exact field name + bit values can be confirmed on real hardware.
bands := kvInt(msg, "band")
c.statusMu.Lock()
if c.antRawN < 8 {
c.antRawN++
applog.Printf("antgenius: antenna raw #%d: %q (parsed band mask=%d/0x%X)", c.antRawN, msg, bands, bands)
}
if name != "" && !isPlaceholderName(name) {
c.antennas[id] = name
c.antBands[id] = bands
} else {
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
delete(c.antBands, id)
}
c.status.Antennas = sortedAntennas(c.antennas)
c.status.Antennas = sortedAntennas(c.antennas, c.antBands)
c.status.Connected = true
c.statusMu.Unlock()
}
@@ -431,10 +449,10 @@ func isPlaceholderName(name string) bool {
return err == nil
}
func sortedAntennas(m map[int]string) []Antenna {
func sortedAntennas(m map[int]string, bands map[int]int) []Antenna {
out := make([]Antenna, 0, len(m))
for idx, name := range m {
out = append(out, Antenna{Index: idx, Name: name})
out = append(out, Antenna{Index: idx, Name: name, Bands: bands[idx]})
}
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
return out
+57
View File
@@ -0,0 +1,57 @@
package audio
import "fmt"
// Codec converts between the wire payload of a network audio stream (Icom 50003)
// and OpsLog's internal PCM (16 kHz mono 16-bit little-endian — the format the
// capture/render engine and the pcmRing use). It exists so the transport code
// (icomaudio.go) never hard-codes a format: today PCM is an identity passthrough;
// tomorrow an Opus codec implements the same two methods and drops in unchanged.
// This mirrors how civTransport abstracts the CAT byte stream from its transport.
type Codec interface {
// Name is a short label for logs/UI.
Name() string
// Decode turns one received audio payload into internal PCM. The returned
// slice is freshly allocated (the caller may retain it).
Decode(payload []byte) ([]byte, error)
// Encode turns internal PCM into a payload to transmit.
Encode(pcm []byte) ([]byte, error)
}
// pcm16Codec is the uncompressed 16-bit-PCM codec — the Icom "uncompressed"
// audio mode (rxcodec/txcodec in the conninfo). Icom sends little-endian 16-bit
// mono samples, which is byte-for-byte OpsLog's internal format, so decode/encode
// are copies. It is the Phase-4/5 default: zero-dependency, lossless, ideal on a
// LAN. Opus (for WAN/internet bandwidth) becomes another Codec later.
//
// NOTE: rate conversion is deliberately NOT this layer's job. The Icom RX audio
// is 16 kHz = our internal rate, so RX needs none. The rig's TX side may run at a
// different rate (the captured conninfo showed 8 kHz) — Phase 5 will resample in
// the TX path before Encode; keeping the codec rate-agnostic keeps that concern
// in one place.
type pcm16Codec struct{}
// NewPCM16Codec returns the uncompressed 16-bit PCM codec.
func NewPCM16Codec() Codec { return pcm16Codec{} }
func (pcm16Codec) Name() string { return "pcm16" }
func (pcm16Codec) Decode(payload []byte) ([]byte, error) {
// Icom PCM payloads are whole 16-bit samples; an odd length means a truncated
// packet — trim the stray byte rather than emit a half-sample click.
n := len(payload) &^ 1
if n != len(payload) {
if n == 0 {
return nil, fmt.Errorf("pcm16: payload too short (%d bytes)", len(payload))
}
}
out := make([]byte, n)
copy(out, payload[:n])
return out, nil
}
func (pcm16Codec) Encode(pcm []byte) ([]byte, error) {
out := make([]byte, len(pcm))
copy(out, pcm)
return out, nil
}
+37
View File
@@ -0,0 +1,37 @@
package audio
import "testing"
func TestPCM16CodecRoundTrip(t *testing.T) {
c := NewPCM16Codec()
in := []byte{0x01, 0x02, 0x03, 0x04, 0xff, 0x7f}
enc, err := c.Encode(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
dec, err := c.Decode(enc)
if err != nil {
t.Fatalf("decode: %v", err)
}
if string(dec) != string(in) {
t.Fatalf("round-trip mismatch: got % X want % X", dec, in)
}
}
func TestPCM16CodecTrimsOddByte(t *testing.T) {
c := NewPCM16Codec()
dec, err := c.Decode([]byte{0x10, 0x20, 0x30}) // 3 bytes = 1 sample + stray
if err != nil {
t.Fatalf("decode: %v", err)
}
if len(dec) != 2 {
t.Fatalf("expected the stray byte trimmed to 2, got %d", len(dec))
}
}
func TestPCM16CodecRejectsSingleByte(t *testing.T) {
c := NewPCM16Codec()
if _, err := c.Decode([]byte{0x10}); err == nil {
t.Fatalf("expected an error for a sub-sample payload")
}
}
+149
View File
@@ -5,6 +5,7 @@ package audio
import (
"fmt"
"runtime"
"sync"
"time"
"unsafe"
@@ -269,3 +270,151 @@ func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct
time.Sleep(10 * time.Millisecond)
}
}
// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live
// render stream. Producers (a USB-codec capture, or a decoded network audio
// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared
// hand-off point between "where the audio comes from" (USB device / UDP 50003)
// and "where it's heard" (any WASAPI output) — so the transport can be swapped
// without touching the render side, mirroring the civTransport split on the CAT
// side. On overflow the oldest audio is dropped to keep latency bounded; on
// underrun Pull simply returns short and the render loop pads with silence.
type pcmRing struct {
mu sync.Mutex
buf []byte
max int // hard cap in bytes (drops oldest beyond this → bounded latency)
}
// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the
// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s.
func newPCMRing(maxBytes int) *pcmRing {
if maxBytes <= 0 {
maxBytes = bytesPerSec // 1 s default
}
return &pcmRing{max: maxBytes}
}
// Push appends samples, dropping the oldest audio if the backlog would exceed
// the cap (a slow/absent consumer never makes the producer block or grow without
// bound). A short glitch beats runaway latency for live monitoring.
func (r *pcmRing) Push(p []byte) {
if len(p) == 0 {
return
}
r.mu.Lock()
r.buf = append(r.buf, p...)
if len(r.buf) > r.max {
drop := len(r.buf) - r.max
r.buf = append(r.buf[:0], r.buf[drop:]...)
}
r.mu.Unlock()
}
// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil
// when empty. The render loop pads any shortfall with silence.
func (r *pcmRing) pull(maxBytes int) []byte {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.buf) == 0 || maxBytes <= 0 {
return nil
}
n := maxBytes
if n > len(r.buf) {
n = len(r.buf)
}
out := make([]byte, n)
copy(out, r.buf[:n])
r.buf = append(r.buf[:0], r.buf[n:]...)
return out
}
// renderStream continuously renders PCM pulled from src to a device until stop
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
// writes silence rather than glitching, keeping the WASAPI clock steady so live
// monitor audio flows smoothly even when the source stalls briefly. Runs on a
// COM-initialised, OS-locked thread.
func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := coInit(); err != nil {
return fmt.Errorf("CoInitialize: %w", err)
}
defer ole.CoUninitialize()
dev, err := openDevice(wca.ERender, deviceID)
if err != nil {
return err
}
defer dev.Release()
var ac *wca.IAudioClient
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
return fmt.Errorf("activate render: %w", err)
}
defer ac.Release()
frameBytes := ch * bits / 8
if frameBytes <= 0 {
return fmt.Errorf("bad audio format")
}
wfx := &wca.WAVEFORMATEX{
WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate),
NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes),
WBitsPerSample: uint16(bits), CbSize: 0,
}
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil {
return fmt.Errorf("initialize render: %w", err)
}
var bufFrames uint32
if err := ac.GetBufferSize(&bufFrames); err != nil {
return err
}
var arc *wca.IAudioRenderClient
if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil {
return fmt.Errorf("get render service: %w", err)
}
defer arc.Release()
// feed fills up to `frames` render frames: as much real audio as the ring
// has, the remainder silence (so the buffer stays full and the clock steady).
feed := func(frames int) error {
if frames <= 0 {
return nil
}
var data *byte
if err := arc.GetBuffer(uint32(frames), &data); err != nil {
return err
}
dst := unsafe.Slice(data, frames*frameBytes)
got := src.pull(frames * frameBytes)
n := copy(dst, got)
for i := n; i < len(dst); i++ {
dst[i] = 0 // silence-fill the shortfall
}
arc.ReleaseBuffer(uint32(frames), 0)
return nil
}
if err := feed(int(bufFrames)); err != nil { // pre-fill to avoid a start glitch
return err
}
if err := ac.Start(); err != nil {
return fmt.Errorf("start render: %w", err)
}
defer ac.Stop()
for {
select {
case <-stop:
return nil
default:
}
var padding uint32
ac.GetCurrentPadding(&padding)
if err := feed(int(bufFrames - padding)); err != nil {
return err
}
time.Sleep(8 * time.Millisecond)
}
}
+131 -1
View File
@@ -15,7 +15,10 @@ type Manager struct {
recStop chan struct{}
recDone chan recResult
playStop chan struct{}
onChange func() // fired on any record/playback state transition
monStop chan struct{} // RX monitor passthrough (capture → render)
monRing *pcmRing // live audio hand-off, also fed by the network stream
txStop chan struct{} // TX audio passthrough (mic → rig)
onChange func() // fired on any record/playback state transition
}
type recResult struct {
@@ -135,3 +138,130 @@ func (m *Manager) StopPlayback() {
m.notify()
}
}
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
//
// StartMonitor pipes live RX audio from inputDev (e.g. the rig's "USB Audio
// CODEC" capture endpoint) to outputDev (your speakers/headset) through a
// latency-bounded ring, so you HEAR the radio inside OpsLog. The very same ring
// is later fed by the network 50003 stream instead of a USB capture — the render
// half is transport-agnostic. inputDev "" = system default capture.
func (m *Manager) StartMonitor(inputDev, outputDev string) error {
return m.startMonitor(inputDev, outputDev, true)
}
// StartMonitorSink starts ONLY the render side (no USB capture) so an external
// producer — the network 50003 stream — can feed decoded RX PCM via
// PushMonitorAudio. Same output path as StartMonitor, minus the capture goroutine.
func (m *Manager) StartMonitorSink(outputDev string) error {
return m.startMonitor("", outputDev, false)
}
// startMonitor wires the RX monitor: always a render loop pulling from monRing;
// when capture is true it also captures inputDev into that ring (USB monitor).
// When false the ring is fed only by PushMonitorAudio (network audio).
func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
m.mu.Lock()
if m.monStop != nil {
m.mu.Unlock()
return fmt.Errorf("monitor already running")
}
stop := make(chan struct{})
ring := newPCMRing(bytesPerSec / 2) // ~500 ms cap — low latency for live monitor
m.monStop, m.monRing = stop, ring
m.mu.Unlock()
if capture {
// Producer: capture the rig's USB audio into the ring.
go func() {
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
}()
}
// Consumer: render the ring to the output device at the internal 16 kHz mono.
go func() {
_ = renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring)
}()
m.notify()
return nil
}
// StopMonitor stops the RX monitor passthrough.
func (m *Manager) StopMonitor() {
m.mu.Lock()
stop := m.monStop
m.monStop, m.monRing = nil, nil
m.mu.Unlock()
if stop != nil {
close(stop)
m.notify()
}
}
// MonitorActive reports whether the RX monitor passthrough is running.
func (m *Manager) MonitorActive() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.monStop != nil
}
// PushMonitorAudio feeds externally-sourced PCM (16 kHz mono 16-bit) into the
// active monitor's output — the hook the network 50003 audio stream uses to play
// decoded RX through the very same output path a USB capture feeds. No-op when no
// monitor is running. Keeps the unexported ring inside the package.
func (m *Manager) PushMonitorAudio(pcm []byte) {
m.mu.Lock()
ring := m.monRing
m.mu.Unlock()
if ring != nil {
ring.Push(pcm)
}
}
// ---- TX audio passthrough (Phase 3: live mic → rig over USB) --------------
//
// StartTXAudio pipes your live microphone (micDev) into the rig's audio input
// (toRadioDev — for a USB-connected rig, its "USB Audio CODEC" render endpoint),
// so you talk through the PC. It is the mirror of StartMonitor (same ring +
// capture + render primitives, source/sink swapped). PTT keying is the caller's
// job (the app layer keys PTT before this and unkeys after) so this stays a pure
// audio route. The captured 16 kHz mono stream is also the exact shape the future
// network 50003 TX will encode and send — so Phase 5 reuses this capture side.
func (m *Manager) StartTXAudio(micDev, toRadioDev string) error {
m.mu.Lock()
if m.txStop != nil {
m.mu.Unlock()
return fmt.Errorf("TX audio already running")
}
stop := make(chan struct{})
ring := newPCMRing(bytesPerSec / 4) // ~250 ms — tighter for live TX latency
m.txStop = stop
m.mu.Unlock()
go func() {
_ = captureStream(micDev, stop, func(chunk []byte) { ring.Push(chunk) })
}()
go func() {
_ = renderStream(toRadioDev, sampleRate, channels, bitsPerSample, stop, ring)
}()
m.notify()
return nil
}
// StopTXAudio stops the TX mic→rig passthrough.
func (m *Manager) StopTXAudio() {
m.mu.Lock()
stop := m.txStop
m.txStop = nil
m.mu.Unlock()
if stop != nil {
close(stop)
m.notify()
}
}
// TXAudioActive reports whether the TX mic→rig passthrough is running.
func (m *Manager) TXAudioActive() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.txStop != nil
}
+467 -90
View File
@@ -13,6 +13,10 @@
package award
import (
"embed"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
@@ -63,20 +67,25 @@ type Def struct {
// --- Type & matching ---
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
Field string `json:"field"` // QSO field to scan (see fieldRaw)
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
Multi bool `json:"multi,omitempty"` // a QSO may count for several references
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
Field string `json:"field"` // QSO field to scan (see fieldRaw)
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
// NOTE: there is no "one reference per QSO" switch, and there was never any
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
// `Multi` flag was read by nothing; its checkbox changed nothing.
// OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO
// earns a reference if the primary match OR any of these match. Lets a
// French department (DDFM) be found from "D74" in the note AND from a postal
// code "74140" in the address (pattern captures "74", Prefix "D" → "D74").
// OrRules are ordered FALLBACK searches for the primary one above: they are
// tried IN ORDER and only while nothing has matched yet — the first rule that
// yields a reference wins and the rest are skipped (short-circuit, like a
// chain of "else if"). Lets a Chinese province (WAPC) be found first from the
// province NAME in the address, and only if that fails, from a city regex
// (e.g. "\bJiangyin\b" → JS) — so a QSO already resolved by name isn't also
// re-tagged, possibly differently, by a later rule.
OrRules []OrRule `json:"or_rules,omitempty"`
// --- Scope ---
@@ -88,11 +97,45 @@ type Def struct {
// --- Confirmation ---
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
Validate []string `json:"validate,omitempty"` // validated/granted sources
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
// controls have been removed from the editor — a checkbox that quietly does
// nothing is worse than no checkbox, because it is trusted. Wire these up (in
// internal/adif) before showing them again.
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
Builtin bool `json:"builtin"` // shipped default (informational)
// --- Catalog updates ---
// Version is the revision of a SHIPPED award. Bump it in the catalog JSON when
// you fix a definition (a better OR chain, a corrected reference list) and want
// that fix to reach operators who already run the award: on startup, a catalog
// award whose Version is higher than the stored one REPLACES it, definition and
// references. Awards created by the operator have no version and are never touched.
Version int `json:"version,omitempty"`
// UserEdited marks an award the operator has changed. A catalog update then
// SKIPS it: their work outranks ours. Set the moment the award (or its reference
// list) is saved to something other than what the catalog ships.
UserEdited bool `json:"user_edited,omitempty"`
}
// SameContent reports whether two definitions describe the same award — ignoring
// the bookkeeping fields (version, the user-edited flag, the derived builtin bit),
// which say where a definition came from, not what it does. Used to decide whether
// a save actually changed anything.
func (d Def) SameContent(o Def) bool {
a, b := d, o
a.Version, b.Version = 0, 0
a.UserEdited, b.UserEdited = false, false
a.Builtin, b.Builtin = false, false
ja, err1 := json.Marshal(a)
jb, err2 := json.Marshal(b)
if err1 != nil || err2 != nil {
return false
}
return string(ja) == string(jb)
}
// OrRule is one additional search OR'd with the award's primary matching rule.
@@ -109,26 +152,124 @@ type OrRule struct {
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
}
// Defaults are the built-in awards seeded on first run (then user-editable).
func Defaults() []Def {
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
// "electronically verified" tier: LoTW only — a paper QSL confirms but does
// NOT validate (matches ARRL/Log4OM). eQSL counts only where the program
// accepts it (WAC).
lq := []string{"lotw", "qsl"}
lo := []string{"lotw"}
return []Def{
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lo, Total: 340, Valid: true, Builtin: true, Protected: true},
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lo, Total: 50, Valid: true, Builtin: true, Protected: true},
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lo, Total: 40, Valid: true, Builtin: true, Protected: true},
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: lo, Total: 6, Valid: true, Builtin: true, Protected: true},
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lo, Total: 96, Valid: true, Builtin: true, Protected: true},
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
// catalogFS holds the built-in award definitions as DATA, not Go code.
//
// An award is data — a field to scan, a pattern, a scope. Coding it in Go meant a
// recompile and a release for every new one, which is absurd for something that
// changes far more often than the engine that reads it. They now live one JSON per
// award in catalog/, embedded in the binary: adding an award is adding a file.
//
// This is the SEED only. Once a user has awards in their database, that database
// is the source of truth — the catalog never overwrites their edits behind their
// back.
//
//go:embed catalog/*.json
var catalogFS embed.FS
// CatalogEntry is one award in the catalog: its definition AND its reference list.
//
// The references are the point. An award's definition is a few lines; what makes
// WAPC worth anything is its 34 provinces and the city regexes attached to them.
// A catalog that shipped definitions only would hand every user an empty shell —
// which is exactly the trap this design is meant to avoid.
//
// References stay as raw JSON so this package (the matching ENGINE) never has to
// import the reference-store package. The caller decodes them.
type CatalogEntry struct {
Def Def `json:"def"`
References json.RawMessage `json:"references,omitempty"`
}
// catalogFile is what a file in catalog/ may contain. Two shapes are accepted:
//
// {"def": {...}, "references": [...]} — a catalog entry
// {"version":1, "awards":[{"def":…,"references":…}]} — an exported bundle
//
// Accepting the bundle shape is deliberate: it means an award EXPORTED from the UI
// can be dropped straight into catalog/ and shipped to everyone, with no
// conversion step. That is the whole loop — create an award, export it, drop it in,
// everybody has it.
type catalogFile struct {
Def Def `json:"def"`
References json.RawMessage `json:"references,omitempty"`
Awards []CatalogEntry `json:"awards,omitempty"`
// A bare Def (the original catalog shape) is still read via the fields above
// being empty and Code being set at the top level.
Code string `json:"code,omitempty"`
}
// Catalog returns every built-in award: definition + references, sorted by code.
//
// Sorted because an embed.FS walk is alphabetical and relying on that implicitly is
// how a user's award list quietly reorders itself on a rebuild.
func Catalog() []CatalogEntry {
entries, err := catalogFS.ReadDir("catalog")
if err != nil {
return nil // embedded: can only fail if the build is broken
}
out := make([]CatalogEntry, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
b, err := catalogFS.ReadFile("catalog/" + e.Name())
if err != nil {
continue
}
var f catalogFile
if err := json.Unmarshal(b, &f); err != nil {
// A malformed file must not take the other awards down with it.
continue
}
switch {
case len(f.Awards) > 0: // an exported bundle, dropped in as-is
for _, a := range f.Awards {
if strings.TrimSpace(a.Def.Code) != "" {
out = append(out, a)
}
}
case strings.TrimSpace(f.Def.Code) != "": // {"def":…,"references":…}
out = append(out, CatalogEntry{Def: f.Def, References: f.References})
case strings.TrimSpace(f.Code) != "": // a bare Def
var d Def
if err := json.Unmarshal(b, &d); err == nil {
out = append(out, CatalogEntry{Def: d})
}
}
}
// An award OpsLog SHIPS is built-in, by definition. Derive it here instead of
// trusting the flag in the file: you drop in an award you exported, its JSON
// says builtin:false (you wrote it, it wasn't built-in then), and it would
// quietly miss every future catalog correction. Making the author remember to
// flip a flag is exactly the kind of step nobody can guess.
for i := range out {
out[i].Def.Builtin = true
}
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
return out
}
// Defaults are the built-in award definitions seeded on first run.
func Defaults() []Def {
cat := Catalog()
out := make([]Def, 0, len(cat))
for _, e := range cat {
out = append(out, e.Def)
}
return out
}
// CatalogRefs returns the raw JSON reference list a catalog award ships with, if
// any. Awards whose list is seeded from code (DXCC entities, French departments)
// or fetched online (POTA/SOTA/WWFF) carry none.
func CatalogRefs(code string) (json.RawMessage, bool) {
code = strings.ToUpper(strings.TrimSpace(code))
for _, e := range Catalog() {
if strings.ToUpper(e.Def.Code) == code && len(e.References) > 0 {
return e.References, true
}
}
return nil, false
}
// Migrate upgrades award definitions saved before the richer model existed.
@@ -177,7 +318,7 @@ func Migrate(defs []Def) ([]Def, bool) {
func Fields() []string {
return []string{
"dxcc", "cqz", "ituz", "prefix", "callsign",
"state", "cont", "country", "grid", "grid4",
"state", "us_county", "cont", "country", "grid", "grid4",
"iota", "sota_ref", "pota_ref", "wwff",
"name", "qth", "address", "comment", "note",
}
@@ -257,6 +398,21 @@ type RefMeta struct {
}
// NewRefList builds the engine's reference view from (code, meta) pairs.
// compileAwardRE compiles an award / reference regex. Award patterns are run
// over free-text log fields (QTH, NOTES) whose capitalization is arbitrary — a
// log may hold "Tokyo", "TOKYO" or "TOKIO" — so we match case-insensitively by
// default. An author who set their own flag group (e.g. "(?s)") is respected.
func compileAwardRE(p string) (*regexp.Regexp, error) {
p = strings.TrimSpace(p)
if p == "" {
return nil, errors.New("empty pattern")
}
if !strings.HasPrefix(p, "(?") {
p = "(?i)" + p
}
return regexp.Compile(p)
}
func NewRefList(metas []RefMeta) refList {
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
for _, m := range metas {
@@ -265,7 +421,7 @@ func NewRefList(metas []RefMeta) refList {
continue
}
if p := strings.TrimSpace(m.Pattern); p != "" {
if re, err := regexp.Compile(p); err == nil {
if re, err := compileAwardRE(p); err == nil {
m.re = re
rl.withPattern = append(rl.withPattern, code)
}
@@ -297,7 +453,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
perr := make([]string, len(defs))
for i := range defs {
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
re, err := regexp.Compile(p)
re, err := compileAwardRE(p)
if err != nil {
perr[i] = "bad pattern: " + err.Error()
} else {
@@ -432,24 +588,14 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
}
var re *regexp.Regexp
if p := strings.TrimSpace(d.Pattern); p != "" {
if c, err := regexp.Compile(p); err == nil {
if c, err := compileAwardRE(p); err == nil {
re = c
} else {
return nil
}
}
rl := NewRefList(metas)
found := candidates(&d, re, q, rl, len(metas) > 0)
// Merge operator-assigned references (manual override). These let the
// operator tag a QSO for an award whose field/description matching can't
// auto-detect the reference — e.g. WAPC scans the ADDRESS field for a
// province NAME, so a contact whose address doesn't spell it out needs the
// province picked by hand. For a predefined award the override is still
// validated against its reference list.
if man := manualRefs(q, d.Code); len(man) > 0 {
found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic)
}
return found
return candidates(&d, re, q, rl, len(metas) > 0)
}
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
@@ -484,30 +630,6 @@ func manualRefs(q *qso.QSO, code string) []string {
return out
}
// mergeManual appends operator-assigned codes to the auto-found set, deduped.
// When the award is predefined, only references present and valid in its list
// are kept (so a typo can't invent a reference).
func mergeManual(found, manual []string, rl refList, predefined bool) []string {
seen := map[string]struct{}{}
for _, c := range found {
seen[normalizeRef(c)] = struct{}{}
}
for _, c := range manual {
c = normalizeRef(c)
if _, dup := seen[c]; dup {
continue
}
if predefined {
if m, ok := rl.byCode[c]; !ok || !m.Valid {
continue
}
}
seen[c] = struct{}{}
found = append(found, c)
}
return found
}
// Confirmed reports whether a QSO satisfies any of the given confirmation
// sources (lotw|qsl|eqsl). Exported for the statistics view.
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
@@ -521,6 +643,63 @@ func InScope(d Def, q *qso.QSO) bool { return inScope(&d, q) }
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
func EmissionOf(mode string) string { return emissionOf(mode) }
// USCountyKey normalises a QSO's state + cnty into the canonical "STATE,COUNTY"
// match code used by the US Counties (USA-CA) award, so the reference list (in
// that same form) matches whatever shape the logbook holds. It is the SINGLE
// source of truth: cmd/cntygen builds the reference codes by calling it, so the
// two sides can never drift.
//
// Real logs are a mess — "MA,MIDDLESEX", bare "Middlesex" with the state in its
// own column, mixed case, county-type suffixes, the odd "0", plus the FIPS list
// abbreviating "Saint"→"St." and hyphenating "Matanuska-Susitna". The rules:
// - if cnty already carries "ST,County", split on the first comma; else take
// the state from the STATE column;
// - upper-case; drop periods and apostrophes; hyphens→space; strip a trailing
// County/Parish/Borough/Census Area/Municipality; fold Saint(e)→St(e);
// collapse whitespace;
// - require a 2-letter state and a non-empty county, else no match ("").
func USCountyKey(state, cnty string) string {
s := strings.TrimSpace(cnty)
if s == "" || s == "0" {
return ""
}
var st, co string
if i := strings.IndexByte(s, ','); i >= 0 {
st, co = strings.TrimSpace(s[:i]), strings.TrimSpace(s[i+1:])
} else {
st, co = strings.TrimSpace(state), s
}
co = strings.ToUpper(co)
co = strings.ReplaceAll(co, ".", "")
co = strings.ReplaceAll(co, "'", "")
co = strings.ReplaceAll(co, "-", " ")
for _, suf := range []string{" COUNTY", " PARISH", " BOROUGH", " CENSUS AREA", " MUNICIPALITY"} {
if strings.HasSuffix(co, suf) {
co = strings.TrimSuffix(co, suf)
break
}
}
switch {
case strings.HasPrefix(co, "SAINTE "):
co = "STE " + co[len("SAINTE "):]
case strings.HasPrefix(co, "SAINT "):
co = "ST " + co[len("SAINT "):]
}
// Drop ALL internal spaces last: FIPS writes DeKalb/DuPage/LaSalle as one
// word, logs often split them ("De Kalb"). Removing spaces on both sides
// folds those together and can't collide two real counties in one state.
co = strings.ReplaceAll(co, " ", "")
st = strings.ToUpper(st)
if len(st) != 2 || co == "" {
return ""
}
// Separator is "/", NOT ",": the QSOFIELDS matcher splits a field value on
// commas/semicolons (n-fer POTA "US-1,US-2"), which would shatter "AL,AUTAUGA"
// into two non-matching tokens. The stored ADIF cnty keeps its comma; only
// this internal match key uses "/".
return st + "/" + co
}
// labelRef fills a worked reference's name/group from the reference list (or the
// name resolver as a fallback).
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
@@ -565,6 +744,14 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
found = append(found, nc.code)
}
}
// A reference may also declare its own regex to broaden matching beyond
// its plain name (e.g. Tokyo's `\bTok[iy]o\b` also catches "TOKIO"). Test
// those too — the name match and the pattern are OR'd.
for _, code := range rl.withPattern {
if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
found = append(found, code)
}
}
case predefined && !exact:
// "Search reference inside the field": look up each token of the field in
// the list — O(tokens), not O(all references) — plus test the few
@@ -593,32 +780,207 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
return found
}
// ── Explain: why did (or didn't) this QSO count for this award? ──────────────
//
// An award that silently matches nothing is the hardest kind of bug to see: the
// UI shows an empty column and the operator has no way to tell whether the QSO is
// out of scope, the field is empty, the rule looked in the wrong place, or the
// reference simply isn't on the list. Explain replays the matcher on ONE QSO and
// reports every step it took.
// Rejected is a candidate a rule produced that did not survive.
type Rejected struct {
Candidate string `json:"candidate"`
Reason string `json:"reason"`
}
// Step is one matching rule as it actually ran.
type Step struct {
Rule string `json:"rule"` // "primary", "OR 1", …
Field string `json:"field"` // the QSO field it scanned
MatchBy string `json:"match_by,omitempty"` // code | description | pattern
Exact bool `json:"exact,omitempty"` // whole field IS the reference
Pattern string `json:"pattern,omitempty"` // the rule's regex, if any
FieldValue string `json:"field_value,omitempty"` // what the field actually held
Candidates []string `json:"candidates,omitempty"` // what the rule produced, before validation
Kept []string `json:"kept,omitempty"` // what survived the reference list
Rejected []Rejected `json:"rejected,omitempty"` // and what didn't, with the reason
Skipped bool `json:"skipped,omitempty"` // never ran: an earlier rule already matched
Error string `json:"error,omitempty"` // e.g. a bad regex, which SKIPS the rule
}
// Explanation is the full account of one QSO against one award.
type Explanation struct {
Code string `json:"code"`
InScope bool `json:"in_scope"`
ScopeError string `json:"scope_error,omitempty"` // why the QSO is out of scope
Predefined bool `json:"predefined"` // matches are validated against a reference list
RefCount int `json:"ref_count"` // size of that list
Steps []Step `json:"steps"`
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
Result []string `json:"result"` // what the QSO finally counts for
}
// Explain runs the matcher on a single QSO and reports what it did. It goes
// through the SAME code path as Compute — not a re-implementation — so what it
// shows is what actually happens.
func Explain(d Def, metas []RefMeta, q *qso.QSO) Explanation {
ex := Explanation{Code: d.Code, Steps: []Step{}, Result: []string{}}
rl := NewRefList(metas)
ex.Predefined = len(metas) > 0 && !d.Dynamic
ex.RefCount = len(metas)
var why string
if !inScopeWhy(&d, q, &why) {
ex.ScopeError = why
return ex // out of scope: no rule ever runs, and saying so IS the answer
}
ex.InScope = true
var re *regexp.Regexp
if p := strings.TrimSpace(d.Pattern); p != "" {
c, err := compileAwardRE(p)
if err != nil {
ex.Steps = append(ex.Steps, Step{Rule: "primary", Field: d.Field, Pattern: d.Pattern,
Error: "bad regex: " + err.Error()})
return ex
}
re = c
}
candidatesTrace(&d, re, q, rl, len(metas) > 0, &ex)
return ex
}
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
return candidatesTrace(d, re, q, rl, hasList, nil)
}
// candidatesTrace is the matcher. ex is optional: when non-nil (only Explain
// passes it) each rule records what it scanned, what it produced and what was
// rejected. The tracing MUST live inside the real matcher rather than in a
// parallel "explain" implementation — a trace that can drift from the code it
// describes is worse than no trace, because it is believed.
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
predefined := hasList && !d.Dynamic
// Primary search, then each OR rule — a QSO earns a reference if any matches.
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
// run executes one rule and, when tracing, records it.
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
kept := keepRefs(predefined, rl, raw)
if ex != nil {
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
Candidates: raw, Kept: kept}
keptSet := map[string]struct{}{}
for _, k := range kept {
keptSet[k] = struct{}{}
}
for _, c := range raw {
n := normalizeRef(c)
if _, ok := keptSet[n]; ok {
continue
}
s.Rejected = append(s.Rejected, rejection(predefined, rl, n))
}
ex.Steps = append(ex.Steps, s)
}
return kept
}
// Primary search first; the OR rules are ordered FALLBACKS — try the next
// only while nothing has matched yet, and stop at the first that yields a
// reference (short-circuit). So a province already found by NAME isn't also
// re-derived, possibly differently, from a later city-regex rule.
//
// The short-circuit tests what a rule REALLY yielded — the references that
// survive the predefined list — not its raw candidates. A rule can always
// produce a raw candidate and still find nothing: "the whole ADDRESS field is
// the code" hands back "SERIATE (BG) 24068 ITALY", which is not a province.
// Testing the raw candidate would call that a hit, skip every fallback, and
// only then drop it as unlisted — leaving the QSO unmatched even though the
// next rule ("find the code inside the QTH") would have found BG.
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "")
for i := range d.OrRules {
r := &d.OrRules[i]
label := fmt.Sprintf("OR %d", i+1)
if len(found) > 0 {
if ex != nil {
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Exact: r.ExactMatch,
Pattern: r.Pattern, Skipped: true})
}
continue // an earlier rule already matched — fallbacks short-circuit
}
var rre *regexp.Regexp
if p := strings.TrimSpace(r.Pattern); p != "" {
c, err := regexp.Compile(p)
c, err := compileAwardRE(p)
if err != nil {
if ex != nil {
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Pattern: r.Pattern,
Error: "bad regex: " + err.Error()})
}
continue // skip a rule with a bad regex rather than failing the award
}
rre = c
}
found = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...)
found = run(label, r.Field, r.MatchBy, r.Pattern, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix)
}
// Merge operator-assigned references (manual override, ManualRefsKey). Lets
// the operator tag a QSO for an award whose field/description matching can't
// auto-detect the reference — e.g. WAPC scans ADDRESS for a province NAME, so
// a contact whose address doesn't spell it out needs the province picked by
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
// awards panel and the per-QSO refs editor — honours overrides too. For a
// predefined award the ref is still validated against the list below.
manual := keepRefs(predefined, rl, manualRefs(q, d.Code))
if ex != nil {
ex.Manual = manual
}
found = append(found, manual...)
out := dedupe(found)
if ex != nil {
ex.Result = out
}
return out
}
// rejection explains why a candidate the operator can SEE in the trace did not
// become a reference. "Nothing matched" is the least useful thing a matcher can
// say; every one of this week's award bugs was a rejection with a plain reason
// that nothing was printing.
func rejection(predefined bool, rl refList, code string) Rejected {
switch {
case code == "":
return Rejected{Candidate: code, Reason: "empty"}
case !predefined:
return Rejected{Candidate: code, Reason: "duplicate"}
}
m, ok := rl.byCode[code]
if !ok {
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
}
if !m.Valid {
return Rejected{Candidate: code, Reason: "listed but disabled"}
}
return Rejected{Candidate: code, Reason: "duplicate"}
}
// keepRefs reduces a rule's raw candidates to the references that actually count.
// For a predefined award that means the codes on the list, enabled. The
// award-level DXCCFilter already scopes which QSOs are considered (see inScope),
// so we do NOT additionally require the QSO's entity to match the reference's own
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
// 291). Per-reference DXCC stays metadata for the picker.
func keepRefs(predefined bool, rl refList, found []string) []string {
if !predefined {
return dedupe(found)
out := make([]string, 0, len(found))
for _, c := range found {
if c = normalizeRef(c); c != "" {
out = append(out, c)
}
}
return dedupe(out)
}
// Enforce the predefined list: keep only listed, valid references. The
// award-level DXCCFilter already scopes which QSOs are considered (see
// inScope), so we do NOT additionally require the QSO's entity to match the
// reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is
// DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker.
var out []string
seen := map[string]struct{}{}
for _, c := range found {
@@ -759,24 +1121,37 @@ func natLess(a, b string) bool {
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
// bands, modes, emission category, validity dates).
func inScope(d *Def, q *qso.QSO) bool {
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
func inScope(d *Def, q *qso.QSO) bool { return inScopeWhy(d, q, nil) }
// inScopeWhy is inScope with an optional explanation. why is filled ONLY when the
// QSO is out of scope AND a caller asked for the reason (Explain does; Compute,
// which runs this for every QSO × every award, passes nil and pays nothing).
// Keeping both behind one function is the point: a scope check that disagrees with
// the scope check it explains would be worse than no explanation at all.
func inScopeWhy(d *Def, q *qso.QSO, why *string) bool {
fail := func(format string, args ...any) bool {
if why != nil {
*why = fmt.Sprintf(format, args...)
}
return false
}
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
return fail("DXCC %d is not in the award's filter %v", q.DXCC, d.DXCCFilter)
}
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
return false
return fail("band %q is not among the valid bands %v", q.Band, d.ValidBands)
}
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
return false
return fail("mode %q is not among the valid modes %v", q.Mode, d.ValidModes)
}
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
return false
return fail("mode %q is %s emission; the award accepts %v", q.Mode, emissionOf(q.Mode), d.Emission)
}
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
return false
return fail("QSO of %s predates the award's start date (%s)", q.QSODate.Format("2006-01-02"), d.ValidFrom)
}
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
return false
return fail("QSO of %s is after the award's end date (%s)", q.QSODate.Format("2006-01-02"), d.ValidTo)
}
return true
}
@@ -827,6 +1202,8 @@ func fieldRaw(field string, q *qso.QSO) string {
return q.Callsign
case "state":
return q.State
case "us_county":
return USCountyKey(q.State, q.County)
case "cont":
return q.Continent
case "country":
+446
View File
@@ -1,8 +1,11 @@
package award
import (
"encoding/json"
"sort"
"strings"
"testing"
"time"
"hamlog/internal/qso"
)
@@ -128,6 +131,146 @@ func TestComputeMatchByDescription(t *testing.T) {
}
}
// OR rules are ordered fallbacks: the primary match wins and short-circuits the
// rules; when the primary finds nothing, the first OR rule that hits wins and
// later rules are skipped. DDFM-style: a French department from the NOTE first,
// else captured from a postal code in the address (Prefix "D" → "D74").
func TestComputeOrRuleFallback(t *testing.T) {
def := Def{Code: "DDFM", Type: TypeQSOFields, Field: "notes", Pattern: `\b(D\d{2})\b`,
DXCCFilter: []int{227}, Valid: true,
OrRules: []OrRule{
{Field: "address", MatchBy: "pattern", Pattern: `\b(\d{2})\d{3}\b`, Prefix: "D"},
},
}
refMetas := map[string][]RefMeta{"DDFM": {
{Code: "D74", Name: "Haute-Savoie", Valid: true},
{Code: "D75", Name: "Paris", Valid: true},
}}
worked := func(q qso.QSO) []string {
r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
var out []string
for _, rf := range r.Refs {
if rf.Worked {
out = append(out, rf.Ref)
}
}
return out
}
// Primary (note "D74") matches → the postal OR rule is skipped, so the
// address's 75001 does NOT also add D75. Exactly the user's ask: first
// condition wins, no fall-through.
if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "worked D74", Address: "75001 Paris"}); len(got) != 1 || got[0] != "D74" {
t.Errorf("primary wins: got %v, want [D74] (no postal fall-through to D75)", got)
}
// No note dept → fall through to the OR rule → postal 74140 → D74.
if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "", Address: "74140 Annecy"}); len(got) != 1 || got[0] != "D74" {
t.Errorf("OR fallback: got %v, want [D74]", got)
}
}
// The user's WAPC cascade: (1) province NAME in QTH, else (2) province NAME in
// ADDRESS, else (3) match-by-pattern in QTH, which runs each reference's OWN
// regex (city lists). Confirms the ordered fallback + that a match-by-pattern
// rule with an empty rule-pattern triggers the per-reference regexes.
func TestComputeProvinceCascade(t *testing.T) {
def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
DXCCFilter: []int{318}, Valid: true,
OrRules: []OrRule{
{Field: "address", MatchBy: "description"},
{Field: "qth", MatchBy: "pattern"}, // empty pattern → per-reference regexes
},
}
refMetas := map[string][]RefMeta{"WAPC": {
{Code: "JS", Name: "Jiangsu", Pattern: `\bJiangyin\b`, Valid: true},
{Code: "ZJ", Name: "Zhejiang", Pattern: `\bHangzhou\b`, Valid: true},
}}
worked := func(q qso.QSO) []string {
r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
var out []string
for _, rf := range r.Refs {
if rf.Worked {
out = append(out, rf.Ref)
}
}
return out
}
cases := []struct {
name, qth, addr string
want string
}{
{"name in qth", "Wuxi, Jiangsu", "", "JS"},
{"name in address", "Wuxi", "Jiangsu Province", "JS"},
{"city regex (Jiangyin) in qth", "Jiangyin", "", "JS"},
{"city regex (Hangzhou) in qth", "Hangzhou", "", "ZJ"},
}
for _, c := range cases {
got := worked(qso.QSO{Callsign: "BA1A", DXCC: ip(318), QTH: c.qth, Address: c.addr})
if len(got) != 1 || got[0] != c.want {
t.Errorf("%s: got %v, want [%s]", c.name, got, c.want)
}
}
}
// A manually-assigned override (ManualRefsKey) must count in Compute — not just
// MatchQSO — so a custom award like WAPC (matches ADDRESS by description, writes
// no QSO field) sticks after the operator picks the province by hand. The engine
// only auto-detects "Jiangsu" when the address spells it out; here it doesn't, so
// the ref exists solely as the override.
func TestComputeManualOverride(t *testing.T) {
def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "address", MatchBy: "description",
DXCCFilter: []int{318}, Confirm: []string{"lotw", "qsl"}, Valid: true}
qsos := []qso.QSO{
// Address doesn't name the province → only the override tags it.
{Callsign: "BA1ABC", Band: "20m", DXCC: ip(318), Address: "Beijing Rd 5",
Extras: map[string]string{ManualRefsKey: "WAPC@JS"}},
}
refMetas := map[string][]RefMeta{"WAPC": {
{Code: "JS", Name: "Jiangsu", Valid: true},
{Code: "GD", Name: "Guangdong", Valid: true},
}}
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
if r.Worked != 1 {
t.Fatalf("WAPC worked = %d, want 1 (Jiangsu via manual override); got %v", r.Worked, refCodes(r))
}
var worked []string
for _, rf := range r.Refs {
if rf.Worked {
worked = append(worked, rf.Ref)
}
}
if len(worked) != 1 || worked[0] != "JS" {
t.Errorf("worked refs = %v, want [JS]", worked)
}
// An override for a ref NOT in the predefined list is rejected (no typo refs).
qsos[0].Extras[ManualRefsKey] = "WAPC@ZZ"
if w := Compute([]Def{def}, qsos, refMetas, nil)[0].Worked; w != 0 {
t.Errorf("bogus override ZZ: worked = %d, want 0", w)
}
}
// A description-match award must also honour a REFERENCE's own regex (used to
// broaden matching past the plain name) AND match case-insensitively — a log
// QTH "ITABASHIKU, TOKIO" (uppercase, "Tokio" spelling) must count for Tokyo
// via its `\bTok[iy]o\b` pattern, which the plain name "Tokyo" can't catch.
func TestComputeDescriptionRefPattern(t *testing.T) {
def := Def{Code: "WAJA", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
DXCCFilter: []int{339}, Confirm: []string{"lotw", "qsl"}, Valid: true}
qsos := []qso.QSO{
{Callsign: "JA1LZB", Band: "10m", DXCC: ip(339), QTH: "ITABASHIKU, TOKIO"}, // pattern + case
{Callsign: "JA3DEF", Band: "40m", DXCC: ip(339), QTH: "osaka pref"}, // lowercase name
}
refMetas := map[string][]RefMeta{"WAJA": {
{Code: "13", Name: "Tokyo", Pattern: `\bTok[iy]o\b`, Valid: true},
{Code: "27", Name: "Osaka", Valid: true},
{Code: "01", Name: "Hokkaido", Valid: true},
}}
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
if r.Worked != 2 {
t.Errorf("WAJA worked = %d, want 2 (Tokyo via pattern + Osaka via lowercase name); got %v", r.Worked, refCodes(r))
}
}
// VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid
// line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the
// 4-char prefix of GRIDSQUARE.
@@ -146,6 +289,160 @@ func TestComputeGrid4VUCC(t *testing.T) {
}
}
// FFMA ships a fixed list of the 488 grids of the contiguous 48 states, taken
// from arrl.org/ffma. The count is the award's own checksum — if this test ever
// fails on the count, the catalog file is wrong, not the test.
func TestCatalogFFMA(t *testing.T) {
raw, ok := CatalogRefs("FFMA")
if !ok {
t.Fatal("FFMA has no reference list in the embedded catalog")
}
var refs []struct {
Code string `json:"code"`
DXCC int `json:"dxcc"`
Valid bool `json:"valid"`
}
if err := json.Unmarshal(raw, &refs); err != nil {
t.Fatalf("FFMA references: %v", err)
}
if len(refs) != 488 {
t.Fatalf("FFMA has %d grids, want exactly 488", len(refs))
}
var def Def
metas := make([]RefMeta, 0, len(refs))
for _, r := range refs {
// Rule 4(c): a grid may be activated from Canadian or Mexican soil, or from
// water. Pinning a reference to a DXCC entity would reject those contacts.
if r.DXCC != 0 {
t.Fatalf("FFMA grid %s is pinned to DXCC %d — rule 4(c) allows working it from outside the US", r.Code, r.DXCC)
}
metas = append(metas, RefMeta{Code: r.Code, Valid: r.Valid})
}
for _, d := range Defaults() {
if d.Code == "FFMA" {
def = d
}
}
if def.Total != 488 || def.Field != "grid4" {
t.Fatalf("FFMA def: total=%d field=%q, want 488 / grid4", def.Total, def.Field)
}
d1983 := time.Date(1990, 5, 1, 0, 0, 0, 0, time.UTC)
qsos := []qso.QSO{
{Callsign: "K5ABC", Band: "6m", Grid: "EM00AA", QSODate: d1983, LOTWRcvd: "Y"}, // counts
{Callsign: "VE3XYZ", Band: "6m", Grid: "FN25AA", QSODate: d1983, LOTWRcvd: "Y"}, // a VE3 in an FFMA grid: counts (4c)
{Callsign: "W1LINE", Band: "6m", VUCCGrids: "FN31,FN32", QSODate: d1983, QSLRcvd: "Y"}, // grid line: 2 grids (4d)
{Callsign: "G4XXX", Band: "6m", Grid: "IO91AA", QSODate: d1983, LOTWRcvd: "Y"}, // not an FFMA grid
{Callsign: "K5ABC", Band: "2m", Grid: "EM10AA", QSODate: d1983, LOTWRcvd: "Y"}, // wrong band
{Callsign: "K5OLD", Band: "6m", Grid: "EM20AA", QSODate: time.Date(1982, 6, 1, 0, 0, 0, 0, time.UTC), LOTWRcvd: "Y"}, // before 1983 (rule 2)
}
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"FFMA": metas}, nil)[0]
var got []string
for _, rf := range r.Refs {
if rf.Worked {
got = append(got, rf.Ref)
}
}
sort.Strings(got)
want := []string{"EM00", "FN25", "FN31", "FN32"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("FFMA worked = %v, want %v", got, want)
}
if r.Worked != len(want) || r.Total != 488 {
t.Errorf("FFMA worked=%d total=%d, want %d / 488", r.Worked, r.Total, len(want))
}
}
// WAIP: the primary rule takes the WHOLE address as the province code (exact
// match), which can never be one — but it does produce a raw candidate. The OR
// fallback (find the code inside the QTH, "SERIATE (BG)") is the rule that works,
// and it must still run: a rule that yields nothing VALID is not a hit.
func TestComputeOrFallbackAfterUnlistedPrimary(t *testing.T) {
def := Def{
Code: "WAIP", Name: "Worked All Italian Provinces", Valid: true,
Type: TypeReference, Field: "address", MatchBy: "code", ExactMatch: true,
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}}, // not exact → search inside the field
Confirm: []string{"lotw", "qsl"},
}
metas := []RefMeta{
{Code: "BG", Name: "Bergamo", Valid: true},
{Code: "MI", Name: "Milano", Valid: true},
}
qsos := []qso.QSO{
{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy", LOTWRcvd: "Y"},
}
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"WAIP": metas}, nil)[0]
if r.Worked != 1 {
t.Fatalf("WAIP worked = %d, want 1 (BG, from the QTH fallback)", r.Worked)
}
for _, rf := range r.Refs {
if rf.Ref == "BG" && rf.Worked {
return
}
}
t.Errorf("BG not worked; refs = %v", refCodes(r))
}
// Explain must account for the WAIP case exactly: the primary rule produces a
// candidate that is NOT a province, says so, and the QTH fallback then finds BG.
func TestExplainAccountsForEveryRule(t *testing.T) {
def := Def{
Code: "WAIP", Valid: true, Type: TypeReference,
Field: "address", MatchBy: "code", ExactMatch: true,
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}},
Confirm: []string{"lotw"},
}
metas := []RefMeta{{Code: "BG", Name: "Bergamo", Valid: true}}
q := &qso.QSO{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy"}
ex := Explain(def, metas, q)
if !ex.InScope || len(ex.Steps) != 2 {
t.Fatalf("in scope=%v, %d steps, want in scope with 2 (primary + OR 1): %+v", ex.InScope, len(ex.Steps), ex)
}
// The primary rule LOOKS like it found something. Saying only "no match" here is
// what cost hours: the trace has to show the candidate and why it lost.
p := ex.Steps[0]
if len(p.Candidates) == 0 {
t.Error("primary produced no candidate; the whole point is that it produces a bogus one")
}
if len(p.Kept) != 0 || len(p.Rejected) == 0 {
t.Fatalf("primary kept=%v rejected=%v, want nothing kept and a stated reason", p.Kept, p.Rejected)
}
if !strings.Contains(p.Rejected[0].Reason, "not in the award's reference list") {
t.Errorf("reason = %q, want it to name the real cause", p.Rejected[0].Reason)
}
if or1 := ex.Steps[1]; or1.Skipped || len(or1.Kept) != 1 || or1.Kept[0] != "BG" {
t.Errorf("OR 1 = %+v, want it to run and find BG", or1)
}
if len(ex.Result) != 1 || ex.Result[0] != "BG" {
t.Errorf("result = %v, want [BG]", ex.Result)
}
// A trace that disagrees with the matcher is worse than none: it is believed.
if got := MatchQSO(def, metas, q); strings.Join(got, ",") != strings.Join(ex.Result, ",") {
t.Errorf("Explain says %v but MatchQSO says %v — the trace does not describe the code", ex.Result, got)
}
}
func TestExplainOutOfScopeSaysWhy(t *testing.T) {
def := Def{Code: "FFMA", Valid: true, Field: "grid4", MatchBy: "code", ExactMatch: true,
ValidBands: []string{"6m"}, ValidFrom: "1983-01-01", Confirm: []string{"lotw"}}
q := &qso.QSO{Callsign: "K1ABC", Band: "2m", Grid: "EM00AA",
QSODate: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)}
ex := Explain(def, []RefMeta{{Code: "EM00", Valid: true}}, q)
if ex.InScope {
t.Fatal("a 2 m QSO is not in scope for a 6 m-only award")
}
if !strings.Contains(ex.ScopeError, "2m") {
t.Errorf("scope error = %q, want it to name the band that fails", ex.ScopeError)
}
if len(ex.Steps) != 0 {
t.Errorf("%d steps ran on an out-of-scope QSO; none should", len(ex.Steps))
}
}
func refCodes(r Result) []string {
out := make([]string, 0, len(r.Refs))
for _, rf := range r.Refs {
@@ -238,3 +535,152 @@ func TestComputePredefinedList(t *testing.T) {
t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
}
}
// The built-in awards moved from Go code into embedded JSON (catalog/*.json).
// A refactor is only a refactor if the behaviour is identical, so pin down what
// the catalog MUST still produce — a typo in a JSON file would otherwise silently
// disable an award, and nobody would notice until a QSO stopped counting.
func TestCatalogDefaults(t *testing.T) {
defs := Defaults()
byCode := map[string]Def{}
for _, d := range defs {
byCode[d.Code] = d
}
// The catalog is MEANT to grow — dropping a JSON in is how an award ships. So
// assert the ten originals are still there, never that the count is exactly ten:
// a test that fails the moment you add an award is a test that teaches you to
// ignore it.
want := []string{"DDFM", "DXCC", "IOTA", "POTA", "SOTA", "WAC", "WAS", "WAZ", "WPX", "WWFF"}
if len(defs) < len(want) {
t.Fatalf("catalog has %d awards, want at least the %d built-ins (%v)", len(defs), len(want), byCode)
}
for _, c := range want {
d, ok := byCode[c]
if !ok {
t.Errorf("%s missing from the embedded catalog", c)
continue
}
// Valid=false would hide the award entirely; Builtin=false would let a
// "reset to defaults" delete it. Both are silent failures.
if !d.Valid || !d.Builtin {
t.Errorf("%s: valid=%v builtin=%v — both must be true", c, d.Valid, d.Builtin)
}
if d.Type == "" || d.Field == "" {
t.Errorf("%s: type=%q field=%q — neither may be empty", c, d.Type, d.Field)
}
if len(d.Confirm) == 0 {
t.Errorf("%s: no confirmation sources — nothing would ever count as confirmed", c)
}
}
// Spot-check the two that carry real matching logic, since a mangled escape in
// JSON is exactly the failure this test exists to catch.
if got := byCode["DDFM"].Pattern; got != `(?i)\b(D\d{1,2}[AB]?)\b` {
t.Errorf("DDFM pattern = %q — the regex did not survive the JSON round-trip", got)
}
if _, err := compileAwardRE(byCode["DDFM"].Pattern); err != nil {
t.Errorf("DDFM pattern does not compile: %v", err)
}
if got := byCode["WAS"].DXCCFilter; len(got) != 3 || got[0] != 291 {
t.Errorf("WAS DXCC filter = %v, want [291 110 6]", got)
}
if !byCode["WAS"].ExactMatch || byCode["WAS"].MatchBy != "code" {
t.Errorf("WAS: exact=%v matchBy=%q, want true/code", byCode["WAS"].ExactMatch, byCode["WAS"].MatchBy)
}
if !byCode["WPX"].Dynamic {
t.Error("WPX must be dynamic — its references aren't a fixed list")
}
// Deterministic order: an embed walk that reorders would shuffle the user's list.
for i := 1; i < len(defs); i++ {
if defs[i-1].Code > defs[i].Code {
t.Errorf("catalog not sorted by code: %q before %q", defs[i-1].Code, defs[i].Code)
}
}
}
// The whole point of the catalog: an award you EXPORT from the UI can be dropped
// straight into catalog/ and shipped to everyone — no conversion step. So the
// loader must accept the exported bundle shape, and it must carry the REFERENCES,
// because a WAPC without its provinces and their city regexes is an empty shell.
func TestCatalogAcceptsExportedBundle(t *testing.T) {
// Exactly what ExportAward writes.
exported := []byte(`{
"version": 1,
"exported_at": "2026-07-13T10:00:00Z",
"awards": [
{
"def": {"code":"WAPC","name":"Worked All Provinces of China","valid":true,
"type":"QSOFIELDS","field":"address","match_by":"description",
"dxcc_filter":[318],"confirm":["lotw","qsl"],"total":34},
"references": [
{"code":"JS","name":"Jiangsu","pattern":"\\bJiangyin\\b","valid":true},
{"code":"ZJ","name":"Zhejiang","valid":true}
]
}
]
}`)
var f catalogFile
if err := json.Unmarshal(exported, &f); err != nil {
t.Fatalf("an exported bundle must parse as a catalog file: %v", err)
}
if len(f.Awards) != 1 {
t.Fatalf("got %d awards, want 1", len(f.Awards))
}
e := f.Awards[0]
if e.Def.Code != "WAPC" || e.Def.Field != "address" || e.Def.MatchBy != "description" {
t.Errorf("definition lost in the round-trip: %+v", e.Def)
}
if len(e.References) == 0 {
t.Fatal("references dropped — this is the whole value of sharing an award")
}
// The per-reference regex must survive: it is what makes the award actually work.
var refs []struct {
Code string `json:"code"`
Pattern string `json:"pattern"`
}
if err := json.Unmarshal(e.References, &refs); err != nil {
t.Fatalf("references not decodable: %v", err)
}
if len(refs) != 2 || refs[0].Code != "JS" || refs[0].Pattern != `\bJiangyin\b` {
t.Errorf("reference regex did not survive: %+v", refs)
}
if _, err := compileAwardRE(refs[0].Pattern); err != nil {
t.Errorf("the shipped regex does not compile: %v", err)
}
}
// A single malformed file in the catalog must not take the other awards down with
// it — one bad JSON should cost you one award, not all of them.
func TestCatalogSurvivesOneBadFile(t *testing.T) {
if len(Catalog()) < 10 {
t.Fatalf("catalog has %d entries, want the 10 built-ins", len(Catalog()))
}
var f catalogFile
if err := json.Unmarshal([]byte(`{ this is not json `), &f); err == nil {
t.Error("expected a parse error on malformed JSON")
}
// Catalog() skips unparseable files rather than returning nil, so the others
// still load. (Verified structurally: the loader `continue`s on error.)
}
// Anything OpsLog SHIPS is built-in, whatever the file says.
//
// You create an award, export it (its JSON records builtin:false — it wasn't
// built-in when you wrote it), then drop that same file into the catalog to ship
// it. If we trusted the flag, the award would go out to everyone marked "not
// built-in" and would then silently miss every future catalog correction. Making
// the author remember to flip a flag first is a step nobody can guess — so the
// catalog derives it instead.
func TestCatalogForcesBuiltin(t *testing.T) {
for _, e := range Catalog() {
if !e.Def.Builtin {
t.Errorf("%s: shipped in the catalog but Builtin=false — it would miss catalog corrections", e.Def.Code)
}
}
// The realistic case: a user-authored award whose JSON says builtin:false.
if len(Catalog()) == 0 {
t.Fatal("empty catalog")
}
}
+107
View File
@@ -0,0 +1,107 @@
package award
import (
"encoding/json"
"fmt"
"testing"
"time"
"hamlog/internal/qso"
)
// A synthetic log with the shape a real one has: a spread of bands, modes,
// entities, and the free-text fields the harder awards actually scan.
func benchLog(n int) []qso.QSO {
bands := []string{"160m", "80m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"}
modes := []string{"SSB", "CW", "FT8", "FT4", "RTTY"}
entities := []int{291, 248, 227, 230, 339, 5, 108, 110, 6, 281}
towns := []string{"SERIATE (BG)", "MILANO (MI)", "ROMA (RM)", "TORINO (TO)", "NAPOLI (NA)"}
out := make([]qso.QSO, n)
base := time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC)
for i := range out {
e := entities[i%len(entities)]
out[i] = qso.QSO{
ID: int64(i + 1),
Callsign: fmt.Sprintf("I%dABC", i%10),
Band: bands[i%len(bands)],
Mode: modes[i%len(modes)],
DXCC: &e,
QSODate: base.Add(time.Duration(i) * time.Hour),
QTH: towns[i%len(towns)],
Address: "Via Roma 12, 24068 " + towns[i%len(towns)] + " Italy",
Grid: "JN45AA",
State: "CA",
LOTWRcvd: "Y",
}
}
return out
}
// WAIP is the worst realistic shape: a predefined list, an exact-match primary
// that always produces a doomed candidate, then a fallback chain that tokenises a
// free-text field. If anything is slow, it is this.
func benchWAIP() (Def, []RefMeta) {
d := Def{
Code: "WAIP", Valid: true, Type: TypeReference,
Field: "address", MatchBy: "code", ExactMatch: true,
OrRules: []OrRule{
{Field: "qth", MatchBy: "description"},
{Field: "qth", MatchBy: "code"},
{Field: "address", MatchBy: "code"},
},
DXCCFilter: []int{248},
Confirm: []string{"lotw", "qsl"},
}
codes := []string{"AG", "AL", "AN", "AO", "AP", "AQ", "AR", "AT", "AV", "BA", "BG", "BI", "BL", "BN", "BO", "BR", "BS", "BT", "BZ", "CA", "CB", "CE", "CH", "CL", "CN", "CO", "CR", "CS", "CT", "CZ", "EN", "FC", "FE", "FG", "FI", "FM", "FR", "GE", "GO", "GR", "IM", "IS", "KR", "LC", "LE", "LI", "LO", "LT", "LU", "MB", "MC", "ME", "MI", "MN", "MO", "MS", "MT", "NA", "NO", "NU", "OR", "PA", "PC", "PD", "PE", "PG", "PI", "PN", "PO", "PR", "PT", "PU", "PV", "PZ", "RA", "RC", "RE", "RG", "RI", "RM", "RN", "RO", "SA", "SI", "SO", "SP", "SR", "SS", "SU", "SV", "TA", "TE", "TN", "TO", "TP", "TR", "TS", "TV", "UD", "VA", "VB", "VC", "VE", "VI", "VR", "VT", "VV"}
metas := make([]RefMeta, 0, len(codes))
for _, c := range codes {
metas = append(metas, RefMeta{Code: c, Name: "Province " + c, Valid: true})
}
return d, metas
}
// One award, whole log — what GetAward(code) does when the Awards panel opens.
func BenchmarkComputeOneAward(b *testing.B) {
d, metas := benchWAIP()
for _, n := range []int{10_000, 50_000, 100_000} {
log := benchLog(n)
b.Run(fmt.Sprintf("WAIP/%dqso", n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
Compute([]Def{d}, log, map[string][]RefMeta{"WAIP": metas}, nil)
}
})
}
}
// Every shipped award at once — GetAwards(), the heaviest thing the app can ask.
func BenchmarkComputeWholeCatalog(b *testing.B) {
defs := Defaults()
metas := map[string][]RefMeta{}
wd, wm := benchWAIP()
defs = append(defs, wd)
metas["WAIP"] = wm
for _, c := range Catalog() {
if raw, ok := CatalogRefs(c.Def.Code); ok {
var refs []struct {
Code string `json:"code"`
Name string `json:"name"`
Valid bool `json:"valid"`
}
if json.Unmarshal(raw, &refs) == nil {
m := make([]RefMeta, 0, len(refs))
for _, r := range refs {
m = append(m, RefMeta{Code: r.Code, Name: r.Name, Valid: r.Valid})
}
metas[c.Def.Code] = m
}
}
}
for _, n := range []int{10_000, 100_000} {
log := benchLog(n)
b.Run(fmt.Sprintf("%dawards/%dqso", len(defs), n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
Compute(defs, log, metas, nil)
}
})
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"def": {
"code": "DDFM",
"name": "Départements Français Métropolitains",
"valid": true,
"protected": true,
"type": "QSOFIELDS",
"field": "note",
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
"dxcc_filter": [
227
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 96,
"builtin": true
}
}
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"def": {
"code": "DXCC",
"name": "DX Century Club",
"valid": true,
"protected": true,
"type": "DXCC",
"field": "dxcc",
"pattern": "",
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 340,
"builtin": true
}
}
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"def": {
"code": "IOTA",
"name": "Islands On The Air",
"valid": true,
"protected": true,
"type": "REFERENCE",
"field": "iota",
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"confirm": [
"qsl"
],
"validate": [
"lotw"
],
"total": 0,
"builtin": true
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"def": {
"code": "POTA",
"name": "Parks On The Air",
"valid": true,
"protected": true,
"type": "REFERENCE",
"field": "pota_ref",
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 0,
"builtin": true
}
}
+156
View File
@@ -0,0 +1,156 @@
{
"version": 1,
"exported_at": "2026-07-13T17:00:44Z",
"awards": [
{
"def": {
"code": "RAC",
"name": "RAC Canadian Provinces",
"description": "RAC Canadian Provinces",
"valid": true,
"protected": true,
"url": "https://www.rac.ca/canadaward/",
"valid_from": "1977-01-07",
"ref_display": "name",
"type": "QSOFIELDS",
"field": "state",
"match_by": "code",
"pattern": "",
"or_rules": [
{
"field": "qth",
"match_by": "description"
},
{
"field": "address",
"match_by": "description"
}
],
"dxcc_filter": [
1
],
"emission": [
"CW",
"PHONE",
"DIGITAL"
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": true
},
"references": [
{
"code": "AB",
"name": "Alberta",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "BC",
"name": "British Columbia",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "MB",
"name": "Manitoba",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NB",
"name": "New Brunswick",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NL",
"name": "Newfoundland and Labrador",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NS",
"name": "Nova Scotia",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NT",
"name": "Northwest Territories",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NU",
"name": "Nunavut",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "ON",
"name": "Ontario",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "PE",
"name": "Prince Edward Island",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "QC",
"name": "Quebec",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "SK",
"name": "Saskatchewan",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "YT",
"name": "Yukon",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
}
]
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"def": {
"code": "SOTA",
"name": "Summits On The Air",
"valid": true,
"protected": true,
"type": "REFERENCE",
"field": "sota_ref",
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 0,
"builtin": true
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"def": {
"code": "USA-CA",
"name": "USA-CA (US Counties)",
"description": "CQ United States of America Counties Award. Matches the QSO's county, tolerating LoTW \"ST,County\" and bare county-name shapes. Independent cities of Virginia/Nevada and DC are excluded per the award rules.",
"valid": true,
"protected": true,
"type": "QSOFIELDS",
"field": "us_county",
"match_by": "code",
"exact_match": true,
"pattern": "",
"ref_display": "name",
"url": "https://cq-amateur-radio.com/cq_awards/cq_usa_ca_awards/cq_usa_ca_awards.html",
"dxcc_filter": [
291,
110,
6
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 3077,
"builtin": true
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"version": 1,
"exported_at": "2026-07-13T17:00:44Z",
"awards": [
{
"def": {
"code": "VUCC",
"name": "VHF/UHF Century Club",
"description": "VHF/UHF Century Club",
"valid": true,
"protected": true,
"url": "https://www.arrl.org/files/file/Awards%20Application%20Forms/VUCCRULE1a.pdf",
"valid_from": "1970-01-01",
"valid_to": "9999-01-30",
"type": "QSOFIELDS",
"field": "grid4",
"match_by": "code",
"exact_match": true,
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"valid_bands": [
"6m",
"4m",
"2m",
"70cm",
"23cm",
"13cm",
"1.25m"
],
"emission": [
"CW",
"PHONE",
"DIGITAL"
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": true
},
"references": null
}
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"def": {
"code": "WAC",
"name": "Worked All Continents",
"valid": true,
"protected": true,
"type": "QSOFIELDS",
"field": "cont",
"match_by": "code",
"exact_match": true,
"pattern": "",
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl",
"eqsl"
],
"validate": [
"lotw"
],
"total": 6,
"builtin": true
}
}
+929
View File
@@ -0,0 +1,929 @@
{
"version": 1,
"exported_at": "2026-07-13T21:57:23Z",
"awards": [
{
"def": {
"code": "WAIP",
"name": "ARI Worked All Italian Provinces",
"valid": true,
"url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
"ref_url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
"type": "REFERENCE",
"field": "qth",
"match_by": "code",
"exact_match": true,
"pattern": "",
"or_rules": [
{
"field": "qth",
"match_by": "description"
},
{
"field": "address",
"match_by": "code"
},
{
"field": "address",
"match_by": "description"
}
],
"dxcc_filter": [
248
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": false
},
"references": [
{
"code": "AG",
"name": "Agrigento",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "AL",
"name": "Alessandria",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "AN",
"name": "Ancona",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "AO",
"name": "Aosta",
"dxcc": 248,
"group": "Val d'Aosta",
"subgrp": "",
"valid": true
},
{
"code": "AP",
"name": "Ascoli Piceno",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "AQ",
"name": "L'Aquila",
"dxcc": 248,
"group": "Abruzzo",
"subgrp": "",
"valid": true
},
{
"code": "AR",
"name": "Arezzo",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "AT",
"name": "Asti",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "AV",
"name": "Avellino",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "BA",
"name": "Bari",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "BG",
"name": "Bergamo",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "BI",
"name": "Biella",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "BL",
"name": "Belluno",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "BN",
"name": "Benevento",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "BO",
"name": "Bologna",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "BR",
"name": "Brindisi",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "BS",
"name": "Brescia",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "BT",
"name": "Barletta-Andria-Trani",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "BZ",
"name": "Bolzano/Bozen",
"dxcc": 248,
"group": "Trentino-AltoAdige/Südtirol",
"subgrp": "",
"valid": true
},
{
"code": "CA",
"name": "Cagliari",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "CB",
"name": "Campobasso",
"dxcc": 248,
"group": "Molise",
"subgrp": "",
"valid": true
},
{
"code": "CE",
"name": "Caserta",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "CH",
"name": "Chieti",
"dxcc": 248,
"group": "Abruzzo",
"subgrp": "",
"valid": true
},
{
"code": "CI",
"name": "Carbonia-Iglesias",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "CL",
"name": "Caltanissetta",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "CN",
"name": "Cuneo",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "CO",
"name": "Como",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "CR",
"name": "Cremona",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "CS",
"name": "Cosenza",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
},
{
"code": "CT",
"name": "Catania",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "CZ",
"name": "Catanzaro",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
},
{
"code": "EN",
"name": "Enna",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "FC",
"name": "Forlì-Cesena",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "FE",
"name": "Ferrara",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "FG",
"name": "Foggia",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "FI",
"name": "Firenze",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "FM",
"name": "Fermo",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "FR",
"name": "Frosinone",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "GE",
"name": "Genova",
"dxcc": 248,
"group": "Liguria",
"subgrp": "",
"valid": true
},
{
"code": "GO",
"name": "Gorizia",
"dxcc": 248,
"group": "Friuli-Venezia Giulia",
"subgrp": "",
"valid": true
},
{
"code": "GR",
"name": "Grosseto",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "IM",
"name": "Imperia",
"dxcc": 248,
"group": "Liguria",
"subgrp": "",
"valid": true
},
{
"code": "IS",
"name": "Isernia",
"dxcc": 248,
"group": "Molise",
"subgrp": "",
"valid": true
},
{
"code": "KR",
"name": "Crotone",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
},
{
"code": "LC",
"name": "Lecco",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "LE",
"name": "Lecce",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "LI",
"name": "Livorno",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "LO",
"name": "Lodi",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "LT",
"name": "Latina",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "LU",
"name": "Lucca",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "MB",
"name": "Monza e Brianza",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "MC",
"name": "Macerata",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "ME",
"name": "Messina",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "MI",
"name": "Milano",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "MN",
"name": "Mantova",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "MO",
"name": "Modena",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "MS",
"name": "Massa-Carrara",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "MT",
"name": "Matera",
"dxcc": 248,
"group": "Basilicata",
"subgrp": "",
"valid": true
},
{
"code": "NA",
"name": "Napoli",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "NO",
"name": "Novara",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "NU",
"name": "Nuoro",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "OG",
"name": "Ogliastra",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "OR",
"name": "Oristano",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "OT",
"name": "Olbia-Tempio",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "PA",
"name": "Palermo",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "PC",
"name": "Piacenza",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "PD",
"name": "Padova",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "PE",
"name": "Pescara",
"dxcc": 248,
"group": "Abruzzo",
"subgrp": "",
"valid": true
},
{
"code": "PG",
"name": "Perugia",
"dxcc": 248,
"group": "Umbria",
"subgrp": "",
"valid": true
},
{
"code": "PI",
"name": "Pisa",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "PN",
"name": "Pordenone",
"dxcc": 248,
"group": "Friuli-Venezia Giulia",
"subgrp": "",
"valid": true
},
{
"code": "PO",
"name": "Prato",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "PR",
"name": "Parma",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "PT",
"name": "Pistoia",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "PU",
"name": "Pesaro-Urbino",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "PV",
"name": "Pavia",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "PZ",
"name": "Potenza",
"dxcc": 248,
"group": "Basilicata",
"subgrp": "",
"valid": true
},
{
"code": "RA",
"name": "Ravenna",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "RC",
"name": "Reggio Calabria",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
},
{
"code": "RE",
"name": "Reggio Emilia",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "RG",
"name": "Ragusa",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "RI",
"name": "Rieti",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "RM",
"name": "Roma",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "RN",
"name": "Rimini",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "RO",
"name": "Rovigo",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "SA",
"name": "Salerno",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "SI",
"name": "Siena",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "SO",
"name": "Sondrio",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "SP",
"name": "La Spezia",
"dxcc": 248,
"group": "Liguria",
"subgrp": "",
"valid": true
},
{
"code": "SR",
"name": "Siracusa",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "SS",
"name": "Sassari",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "SV",
"name": "Savona",
"dxcc": 248,
"group": "Liguria",
"subgrp": "",
"valid": true
},
{
"code": "TA",
"name": "Taranto",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "TE",
"name": "Teramo",
"dxcc": 248,
"group": "Abruzzo",
"subgrp": "",
"valid": true
},
{
"code": "TN",
"name": "Trento",
"dxcc": 248,
"group": "Trentino-AltoAdige/Südtirol",
"subgrp": "",
"valid": true
},
{
"code": "TO",
"name": "Torino",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "TP",
"name": "Trapani",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "TR",
"name": "Terni",
"dxcc": 248,
"group": "Umbria",
"subgrp": "",
"valid": true
},
{
"code": "TS",
"name": "Trieste",
"dxcc": 248,
"group": "Friuli-Venezia Giulia",
"subgrp": "",
"valid": true
},
{
"code": "TV",
"name": "Treviso",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "UD",
"name": "Udine",
"dxcc": 248,
"group": "Friuli-Venezia Giulia",
"subgrp": "",
"valid": true
},
{
"code": "VA",
"name": "Varese",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "VB",
"name": "Verbano-Cusio-Ossola",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "VC",
"name": "Vercelli",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "VE",
"name": "Venezia",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "VI",
"name": "Vicenza",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "VR",
"name": "Verona",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "VS",
"name": "Medio Campidano",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "VT",
"name": "Viterbo",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "VV",
"name": "Vibo Valentia",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
}
]
}
]
}
+432
View File
@@ -0,0 +1,432 @@
{
"version": 1,
"exported_at": "2026-07-13T15:44:38Z",
"awards": [
{
"def": {
"code": "WAJA",
"name": "Worked All Japanese Prefectures",
"description": "Worked All Japanese Prefectures",
"valid": true,
"protected": true,
"valid_from": "1970-01-01",
"ref_display": "name",
"type": "QSOFIELDS",
"field": "qth",
"match_by": "description",
"pattern": "",
"or_rules": [
{
"field": "qth",
"match_by": "pattern"
}
],
"dxcc_filter": [
339
],
"valid_bands": [
"80m",
"40m",
"20m",
"15m",
"10m",
"160m"
],
"emission": [
"CW",
"PHONE",
"DIGITAL"
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": true
},
"references": [
{
"code": "1",
"name": "Hokkaido",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "10",
"name": "Gunma",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "11",
"name": "Saitama",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "12",
"name": "Chiba",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "13",
"name": "Tokyo",
"dxcc": 0,
"group": "",
"subgrp": "",
"pattern": "\\bTok[iy]o\\b",
"valid": true
},
{
"code": "14",
"name": "Kanagawa",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "15",
"name": "Niigata",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "16",
"name": "Toyama",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "17",
"name": "Ishikawa",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "18",
"name": "Fukui",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "19",
"name": "Yamanashi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "2",
"name": "Aomori",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "20",
"name": "Nagano",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "21",
"name": "Gifu",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "22",
"name": "Shizuoka",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "23",
"name": "Aichi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "24",
"name": "Mie",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "25",
"name": "Shiga",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "26",
"name": "Kyoto",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "27",
"name": "Osaka",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "28",
"name": "Hyogo",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "29",
"name": "Nara",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "3",
"name": "Iwate",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "30",
"name": "Wakayama",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "31",
"name": "Tottori",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "32",
"name": "Shimane",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "33",
"name": "Okayama",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "34",
"name": "Hiroshima",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "35",
"name": "Yamaguchi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "36",
"name": "Tokushima",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "37",
"name": "Kagawa",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "38",
"name": "Ehime",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "39",
"name": "Kochi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "4",
"name": "Miyagi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "40",
"name": "Fukuoka",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "41",
"name": "Saga",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "42",
"name": "Nagasaki",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "43",
"name": "Kumamoto",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "44",
"name": "Oita",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "45",
"name": "Miyazaki",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "46",
"name": "Kagoshima",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "47",
"name": "Okinawa",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "5",
"name": "Akita",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "6",
"name": "Yamagata",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "7",
"name": "Fukushima",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "8",
"name": "Ibaraki",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "9",
"name": "Tochigi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
}
]
}
]
}
+335
View File
@@ -0,0 +1,335 @@
{
"version": 1,
"exported_at": "2026-07-13T15:43:49Z",
"awards": [
{
"def": {
"code": "WAPC",
"name": "Worked All Provinces of China",
"description": "Worked All Provinces of China",
"valid": true,
"protected": true,
"url": "http://www.mulandxc.com/index/wapc_medal_app",
"valid_from": "2012-04-21",
"valid_to": "9999-01-31",
"type": "QSOFIELDS",
"field": "address",
"match_by": "description",
"pattern": "",
"or_rules": [
{
"field": "qth",
"match_by": "description"
},
{
"field": "qth",
"match_by": "pattern"
}
],
"dxcc_filter": [
318,
152,
321
],
"valid_bands": [
"80m",
"40m",
"20m",
"15m",
"10m"
],
"emission": [
"CW",
"PHONE",
"DIGITAL"
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": true
},
"references": [
{
"code": "AH",
"name": "Anhui",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "BJ",
"name": "Beijing",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "CQ",
"name": "Chongqing",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "FJ",
"name": "Fujian",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "GD",
"name": "Guangdong",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "GS",
"name": "Gansu",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "GX",
"name": "Guangxi",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "GZ",
"name": "Guizhou",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HA",
"name": "Henan",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HB",
"name": "Hubei",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HE",
"name": "Hebei",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HI",
"name": "Hainan",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HK",
"name": "Hong Kong",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HL",
"name": "Heilongjiang",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HN",
"name": "Hunan",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "JL",
"name": "Jilin",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "JS",
"name": "Jiangsu",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"pattern": "\\bJiangyin\\b",
"valid": true
},
{
"code": "JX",
"name": "Jiangxi",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "LN",
"name": "Liaoning",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "MO",
"name": "Macau",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"pattern": "\\bMaca[uo]\\b",
"valid": true
},
{
"code": "NM",
"name": "NeiMongol",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "NX",
"name": "Ningxia",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "QH",
"name": "Qinghai",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "SC",
"name": "Sichuan",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "SD",
"name": "Shandong",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "SH",
"name": "Shanghai",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "SN",
"name": "Shaanxi",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "SX",
"name": "Shanxi",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "TJ",
"name": "Tianjin",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "TW",
"name": "Taiwan",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "XJ",
"name": "Xinjiang",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "XZ",
"name": "Xizang",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "YN",
"name": "Yunnan",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "ZJ",
"name": "Zhejiang",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
}
]
}
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"def": {
"code": "WAS",
"name": "Worked All States",
"valid": true,
"protected": true,
"type": "QSOFIELDS",
"field": "state",
"match_by": "code",
"exact_match": true,
"pattern": "",
"dxcc_filter": [
291,
110,
6
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 50,
"builtin": true
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"def": {
"code": "WAZ",
"name": "Worked All Zones (CQ)",
"valid": true,
"protected": true,
"type": "QSOFIELDS",
"field": "cqz",
"match_by": "code",
"exact_match": true,
"pattern": "",
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 40,
"builtin": true
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"def": {
"code": "WPX",
"name": "Worked All Prefixes (CQ WPX)",
"valid": true,
"protected": true,
"type": "QSOFIELDS",
"field": "prefix",
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 0,
"builtin": true
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"def": {
"code": "WWFF",
"name": "World Wide Flora & Fauna",
"valid": true,
"protected": true,
"type": "REFERENCE",
"field": "wwff",
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 0,
"builtin": true
}
}
+27
View File
@@ -0,0 +1,27 @@
package award
import "testing"
func TestUSCountyKey(t *testing.T) {
cases := []struct {
state, cnty, want string
}{
{"MA", "MA,MIDDLESEX", "MA/MIDDLESEX"}, // LoTW "ST,County" shape
{"NJ", "Middlesex", "NJ/MIDDLESEX"}, // bare name + state column
{"TX", "Montgomery", "TX/MONTGOMERY"}, // title case
{"FL", "Saint Lucie", "FL/STLUCIE"}, // Saint→St, space dropped
{"FL", "St. Lucie", "FL/STLUCIE"}, // FIPS abbreviation, period dropped
{"AK", "Matanuska-Susitna", "AK/MATANUSKASUSITNA"}, // hyphen→space→dropped
{"IL", "De Kalb", "IL/DEKALB"}, // split name
{"IL", "DeKalb County", "IL/DEKALB"}, // suffix + one word
{"LA", "Acadia Parish", "LA/ACADIA"}, // parish suffix
{"", "Honolulu", ""}, // no state → no match
{"HI", "0", ""}, // garbage
{"HI", "", ""}, // empty
}
for _, c := range cases {
if got := USCountyKey(c.state, c.cnty); got != c.want {
t.Errorf("USCountyKey(%q,%q) = %q, want %q", c.state, c.cnty, got, c.want)
}
}
}
+3
View File
@@ -17,6 +17,7 @@ import (
// - WAC → continent code ("EU", "NA", …)
// - WAS → ADIF STATE code ("AL", …)
// - DDFM → "D06" (the award pattern captures the leading D)
// - USA-CA → canonical "STATE/COUNTY" key (see award.USCountyKey)
func BuiltinRefs(code string) ([]Ref, bool) {
switch code {
case "DXCC":
@@ -29,6 +30,8 @@ func BuiltinRefs(code string) ([]Ref, bool) {
return usStates().Refs, true
case "DDFM":
return frenchDepartments(), true
case "USA-CA":
return usCounties(), true
}
return nil, false
}
File diff suppressed because it is too large Load Diff
+30 -5
View File
@@ -205,11 +205,12 @@ func (m *Manager) SetPTT(on bool) error {
// the backend picks a default when it's empty. (Status-based colouring can be
// driven later by setting Color per spot.)
type SpotInfo struct {
FreqHz int64
Callsign string
Mode string
Color string
Comment string
FreqHz int64
Callsign string
Mode string
Color string
Comment string
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
}
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
@@ -275,6 +276,11 @@ type FlexTXState struct {
Mon bool `json:"mon"`
MonLevel int `json:"mon_level"`
MicLevel int `json:"mic_level"`
TXFilterLow int `json:"tx_filter_low"` // TX filter low cut (Hz)
TXFilterHigh int `json:"tx_filter_high"` // TX filter high cut (Hz)
// Mic profiles (SmartSDR): the list of available profiles + the loaded one.
MicProfile string `json:"mic_profile,omitempty"`
MicProfiles []string `json:"mic_profiles,omitempty"`
ATUStatus string `json:"atu_status,omitempty"`
ATUMemories bool `json:"atu_memories"`
// Active RX slice DSP controls.
@@ -296,6 +302,15 @@ type FlexTXState struct {
NRLevel int `json:"nr_level"`
ANF bool `json:"anf"`
ANFLevel int `json:"anf_level"`
WNB bool `json:"wnb"`
WNBLevel int `json:"wnb_level"`
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
// moving the slice. The offset survives the switch being turned off, so
// re-enabling restores it, exactly like the radio's own knob.
RIT bool `json:"rit"`
RITFreq int `json:"rit_freq"`
XIT bool `json:"xit"`
XITFreq int `json:"xit_freq"`
// CW / mode-specific controls.
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
CWSpeed int `json:"cw_speed"`
@@ -322,6 +337,7 @@ type FlexMeter struct {
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
Unit string `json:"unit,omitempty"`
Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise
Value float64 `json:"value"`
Lo float64 `json:"lo"`
Hi float64 `json:"hi"`
@@ -336,6 +352,7 @@ type FlexController interface {
SetRFPower(int) error
SetTunePower(int) error
SetTune(bool) error
SetTXInhibit(bool) error
SetVOX(bool) error
SetVOXLevel(int) error
SetVOXDelay(int) error
@@ -344,6 +361,8 @@ type FlexController interface {
SetMon(bool) error
SetMonLevel(int) error
SetMic(int) error
SetTXFilter(low, high int) error // transmit-audio bandwidth (Hz)
SetMicProfile(string) error // load a SmartSDR mic profile by name
ATUStart() error
ATUBypass() error
SetATUMemories(bool) error
@@ -365,6 +384,12 @@ type FlexController interface {
SetANFLevel(int) error
SetAPF(bool) error
SetAPFLevel(int) error
SetWNB(bool) error
SetWNBLevel(int) error
SetRIT(bool) error
SetRITFreq(int) error
SetXIT(bool) error
SetXITFreq(int) error
// CW keyer + mode-specific controls.
SetCWSpeed(int) error
SetCWPitch(int) error
+2
View File
@@ -313,6 +313,8 @@ func ModelName(addr byte) string {
return "IC-7300"
case 0x98:
return "IC-7610"
case 0x7C:
return "IC-9100"
case 0xA2:
return "IC-9700"
case 0xA4:
+284 -26
View File
@@ -38,6 +38,8 @@ type Flex struct {
slices map[int]*flexSlice
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
amp flexAmp // external amplifier (PowerGenius XL) state
micProfiles []string // available mic profiles (SmartSDR "profile mic list")
micProfile string // currently loaded mic profile
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
lastStateSig string // last logged derived-state signature (log only on change)
boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).
@@ -58,6 +60,7 @@ type Flex struct {
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
// OnSpotClick is called (off the reader goroutine's hot path) when the user
@@ -85,8 +88,14 @@ type flexSlice struct {
anfLevel int
apf bool // CW audio peaking filter
apfLevel int
filterLo int // slice filter low cut (Hz)
filterHi int // slice filter high cut (Hz)
wnb bool // wideband noise blanker
wnbLevel int
rit bool // receive incremental tuning enabled
ritFreq int // RIT offset in Hz (negative = down)
xit bool // transmit incremental tuning enabled
xitFreq int // XIT offset in Hz
filterLo int // slice filter low cut (Hz)
filterHi int // slice filter high cut (Hz)
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
txAnt string // selected TX antenna
antList []string // antennas valid for this slice (RX side)
@@ -100,6 +109,7 @@ type flexTX struct {
tunePower int
tune bool
transmitting bool // interlock state == TRANSMITTING
inhibit bool // transmit inhibited (e.g. while a motorized antenna moves)
voxEnable bool
voxLevel int
voxDelay int
@@ -108,6 +118,8 @@ type flexTX struct {
mon bool
monLevel int
micLevel int
filterLow int // TX filter low cut (Hz)
filterHigh int // TX filter high cut (Hz)
atuStatus string
atuMemories bool
// CW keyer params (set via the top-level "cw" commands).
@@ -133,6 +145,7 @@ type meterInfo struct {
src string // SLC (slice), TX-, COD, RAD, AMP…
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
unit string // dbm, dbfs, swr, volts, degc, watts…
slc int // for src=SLC meters, the slice index (the ".num" field); -1 otherwise
lo float64
hi float64
}
@@ -151,7 +164,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
return &Flex{
host: strings.TrimSpace(host), port: port,
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, pendingSplit: map[int]bool{},
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
}
@@ -209,6 +222,8 @@ func (f *Flex) Connect() error {
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
f.send("sub profile all") // mic/global/tx profiles (for the mic-profile dropdown)
f.send("profile mic info") // request the current mic profile list + selection
f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below)
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
if f.spotsEnabled {
@@ -344,6 +359,7 @@ func (f *Flex) reader(conn net.Conn) {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.spotCall[idx] = call
f.spotIdx[idx] = true
f.spotByCall[strings.ToUpper(call)] = idx
}
}
// A successful "slice create" for split returns the new slice's index;
@@ -445,6 +461,12 @@ func (f *Flex) handleStatus(payload string) {
f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
case "mic_level", "miclevel":
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
// TX filter: the transmit STATUS reports the passband as lo/hi (the
// SET command is filter_low/filter_high — a SmartSDR quirk).
case "lo", "filter_low":
f.tx.filterLow = atoiDefault(val, f.tx.filterLow)
case "hi", "filter_high":
f.tx.filterHigh = atoiDefault(val, f.tx.filterHigh)
}
}
f.mu.Unlock()
@@ -526,6 +548,35 @@ func (f *Flex) handleStatus(payload string) {
}
f.mu.Unlock()
}
// Mic-profile object — "profile mic list=A^B^C" (available profiles) and
// "profile mic current=<name>" (loaded one). Names can contain spaces, so
// values are taken from the raw payload after the key. Logged once so the
// exact field names are confirmable on real hardware.
if len(fields) >= 2 && fields[0] == "profile" && fields[1] == "mic" {
debugLog.Printf("Flex: profile status: %s", payload)
if i := strings.Index(payload, "list="); i >= 0 {
var profs []string
for _, p := range strings.Split(payload[i+len("list="):], "^") {
if p = strings.TrimSpace(p); p != "" {
profs = append(profs, p)
}
}
f.mu.Lock()
f.micProfiles = profs
f.mu.Unlock()
}
// The loaded profile arrives as current=<name> (some firmwares:
// selection=<name>); accept either.
for _, key := range []string{"current=", "selection="} {
if i := strings.Index(payload, key); i >= 0 {
cur := strings.TrimSpace(payload[i+len(key):])
f.mu.Lock()
f.micProfile = cur
f.mu.Unlock()
break
}
}
}
// Interlock object — transmit state (RECEIVE / TRANSMITTING / …).
if len(fields) >= 1 && fields[0] == "interlock" {
f.mu.Lock()
@@ -596,7 +647,7 @@ func (f *Flex) handleStatus(payload string) {
// One meter per token; its fields are '#'-separated:
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
num := -1
var mi meterInfo
mi := meterInfo{slc: -1}
for _, sub := range strings.Split(tok, "#") {
key, val, ok := splitKV(sub)
if !ok {
@@ -616,6 +667,12 @@ func (f *Flex) handleStatus(payload string) {
mi.src = val
case "nam":
mi.name = val
case "num":
// For a slice (SLC) meter this field is the slice index —
// how we tell slice A's S-meter from slice B's.
if v, e := strconv.Atoi(strings.TrimSpace(val)); e == nil {
mi.slc = v
}
case "unit", "units":
mi.unit = val
case "low", "lo":
@@ -630,6 +687,7 @@ func (f *Flex) handleStatus(payload string) {
old, seen := f.meterMeta[num]
if !seen {
newIDs = append(newIDs, num)
old.slc = -1
}
if mi.src != "" {
old.src = mi.src
@@ -640,6 +698,9 @@ func (f *Flex) handleStatus(payload string) {
if mi.unit != "" {
old.unit = mi.unit
}
if mi.slc >= 0 {
old.slc = mi.slc
}
if mi.lo != 0 {
old.lo = mi.lo
}
@@ -651,7 +712,7 @@ func (f *Flex) handleStatus(payload string) {
f.mu.Unlock()
for _, id := range newIDs {
mi := f.meterMeta[id]
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s → sub", id, mi.src, mi.name, mi.unit)
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s lo=%g hi=%g → sub", id, mi.src, mi.name, mi.unit, mi.lo, mi.hi)
f.subscribeMeter(id)
}
}
@@ -744,6 +805,18 @@ func (f *Flex) handleStatus(payload string) {
s.apf = val == "1"
case "apf_level":
s.apfLevel = atoiDefault(val, s.apfLevel)
case "wnb":
s.wnb = val == "1"
case "wnb_level":
s.wnbLevel = atoiDefault(val, s.wnbLevel)
case "rit_on":
s.rit = val == "1"
case "rit_freq":
s.ritFreq = atoiDefault(val, s.ritFreq)
case "xit_on":
s.xit = val == "1"
case "xit_freq":
s.xitFreq = atoiDefault(val, s.xitFreq)
case "filter_lo":
s.filterLo = atoiDefault(val, s.filterLo)
case "filter_hi":
@@ -803,24 +876,44 @@ func (f *Flex) ReadState() (RigState, error) {
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
// use. Caller holds f.mu.
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
best, bestS := 1<<30, (*flexSlice)(nil)
for idx, s := range f.slices {
// Iterate in ASCENDING index order — NEVER map-iteration order, which Go
// randomises. When two slices transiently BOTH report active=1 (e.g. an
// external controller like DXHunter activates a slice on another band while
// ours still holds active, before SmartSDR sends active=0 to the old one),
// map order returned a RANDOM active slice each call → the operating frequency
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
// Deterministic order = the lowest-indexed active slice wins, stably.
firstInUse := -1
for _, idx := range f.sortedSliceIdxLocked() {
s := f.slices[idx]
if !s.inUse {
continue
}
if firstInUse < 0 {
firstInUse = idx
}
if s.active {
return idx, s
}
if idx < best {
best, bestS = idx, s
}
}
if bestS != nil {
return best, bestS
if firstInUse >= 0 {
return firstInUse, f.slices[firstInUse]
}
return -1, nil
}
// sortedSliceIdxLocked returns the slice indices in ascending order so every
// slice-selection helper is deterministic (map iteration is randomised). Caller
// holds f.mu.
func (f *Flex) sortedSliceIdxLocked() []int {
idxs := make([]int, 0, len(f.slices))
for idx := range f.slices {
idxs = append(idxs, idx)
}
sort.Ints(idxs)
return idxs
}
// activeSliceIndexLocked returns the slice index to send commands to (the main
// slice, else 0). Caller holds f.mu.
func (f *Flex) activeSliceIndexLocked() int {
@@ -841,8 +934,8 @@ func sliceLetter(idx int) string {
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
// Caller holds f.mu.
func (f *Flex) txSliceLocked() *flexSlice {
for _, s := range f.slices {
if s.inUse && s.tx {
for _, idx := range f.sortedSliceIdxLocked() {
if s := f.slices[idx]; s.inUse && s.tx {
return s
}
}
@@ -852,9 +945,11 @@ func (f *Flex) txSliceLocked() *flexSlice {
// operatingLocked resolves the operator's slices: the MAIN (active) slice for the
// mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is
// the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different
// freq) — detected from the pair itself, NOT from which slice is active (the TX
// slice often steals focus right after "slice create", which must NOT read as
// "no split"). A slice on another band is an independent receiver, ignored.
// freq) AND the SAME split class (both PHONE, or both CW) — detected from the
// pair itself, NOT from which slice is active (the TX slice often steals focus
// right after "slice create", which must NOT read as "no split"). A slice on
// another band is an independent receiver, ignored; so is a same-band slice in a
// different mode (SSB + FT8) or a data mode (FT8/FT4/RTTY never split).
// Caller holds f.mu.
func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
_, main = f.mainSliceLocked()
@@ -866,20 +961,31 @@ func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
if bt == "" {
return main, nil, nil
}
// RX = the active slice when it's a distinct same-band slice, else the first
// other in-use same-band slice.
if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt {
// Split only applies to PHONE/CW; a data-mode TX slice never splits.
ct := flexSplitClass(txS.mode)
if ct == "" {
return main, nil, nil
}
// A split partner is an in-use, same-band slice at a different freq in the
// SAME split class (so SSB + FT8 on one band is NOT a split).
sameSplit := func(s *flexSlice) bool {
return s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct
}
// RX = the active slice when it qualifies, else the first other qualifying
// same-band slice.
if sameSplit(main) {
rx = main
} else {
for _, s := range f.slices {
if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt {
for _, idx := range f.sortedSliceIdxLocked() {
if s := f.slices[idx]; sameSplit(s) {
rx = s
break
}
}
}
if rx == nil {
return main, nil, nil // tx slice alone (simplex) → not split
return main, nil, nil // tx slice alone (simplex) or no same-mode partner → not split
}
return main, rx, txS
}
@@ -927,6 +1033,15 @@ func (f *Flex) SetFrequency(hz int64) error {
f.mu.Lock()
idx := f.activeSliceIndexLocked()
connected := f.conn != nil
// Optimistically update the active slice's cached freq NOW, before the radio
// echoes the slice status back. Otherwise ReadState/FlexState keep reporting
// the OLD freq for the round-trip: the top display (optimistic liveFreqHz)
// jumped to the new band while the slice cache — which the FlexPanel and the
// Ultrabeam follow loop read — still showed the old one, so the antenna chased
// the stale value. The real echo confirms/corrects this a moment later.
if s := f.slices[idx]; s != nil {
s.freqHz = hz
}
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
@@ -952,6 +1067,13 @@ func (f *Flex) SetMode(mode string) error {
if fm == "" {
return fmt.Errorf("flex: unsupported mode %q", mode)
}
// Optimistically cache the new mode too (same reasoning as SetFrequency) so the
// panel reflects it immediately instead of lagging the radio's echo.
f.mu.Lock()
if s := f.slices[idx]; s != nil {
s.mode = fm
}
f.mu.Unlock()
// "slice s <rx> mode=<m>" — set command per the SmartSDR API.
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
return nil
@@ -975,8 +1097,30 @@ func (f *Flex) SendSpot(s SpotInfo) error {
if color == "" {
color = "#FFFFA500" // opaque orange default
}
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=1800 trigger_action=Tune timestamp=%d",
float64(s.FreqHz)/1e6, call, color, time.Now().Unix())
life := s.LifetimeSec
if life <= 0 {
life = 1800 // default 30 min (cluster spots)
}
// De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already
// spotted gets its previous spot removed first — one live spot per call,
// refreshed, instead of a pile that all expire independently. NB: capture the
// old index under the lock but send OUTSIDE it — f.send() takes f.mu itself,
// and Go mutexes aren't reentrant (calling send while locked deadlocks the
// whole Flex goroutine → the radio drops OFFLINE).
upperCall := strings.ToUpper(s.Callsign)
f.mu.Lock()
old, hadOld := f.spotByCall[upperCall]
if hadOld {
delete(f.spotByCall, upperCall)
delete(f.spotCall, old)
delete(f.spotIdx, old)
}
f.mu.Unlock()
if hadOld {
f.send(fmt.Sprintf("spot remove %d", old))
}
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
if m := flexEncode(s.Mode); m != "" {
cmd += " mode=" + m
}
@@ -1022,6 +1166,7 @@ func (f *Flex) ClearSpots() error {
f.mu.Lock()
f.spotIdx = map[int]bool{}
f.spotCall = map[int]string{}
f.spotByCall = map[string]int{}
connected := f.conn != nil
f.mu.Unlock()
if !connected {
@@ -1127,6 +1272,10 @@ func (f *Flex) FlexState() FlexTXState {
Mon: f.tx.mon,
MonLevel: f.tx.monLevel,
MicLevel: f.tx.micLevel,
TXFilterLow: f.tx.filterLow,
TXFilterHigh: f.tx.filterHigh,
MicProfile: f.micProfile,
MicProfiles: f.micProfiles,
ATUStatus: f.tx.atuStatus,
ATUMemories: f.tx.atuMemories,
// CW keyer (defaults applied so the sliders show sane values pre-read).
@@ -1177,6 +1326,12 @@ func (f *Flex) FlexState() FlexTXState {
st.ANFLevel = rx.anfLevel
st.APF = rx.apf
st.APFLevel = rx.apfLevel
st.WNB = rx.wnb
st.WNBLevel = rx.wnbLevel
st.RIT = rx.rit
st.RITFreq = rx.ritFreq
st.XIT = rx.xit
st.XITFreq = rx.xitFreq
st.FilterLo = rx.filterLo
st.FilterHi = rx.filterHi
st.RXAnt = rx.rxAnt
@@ -1201,7 +1356,7 @@ func (f *Flex) FlexState() FlexTXState {
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
for _, id := range ids {
mi := f.meterMeta[id]
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Slice: mi.slc, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
}
}
return st
@@ -1245,6 +1400,14 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.rxAnt = fmt.Sprint(val)
case "txant":
rx.txAnt = fmt.Sprint(val)
case "rit_on":
rx.rit = val == "1"
case "rit_freq":
rx.ritFreq = toInt(val)
case "xit_on":
rx.xit = val == "1"
case "xit_freq":
rx.xitFreq = toInt(val)
}
}
f.mu.Unlock()
@@ -1352,8 +1515,30 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on))
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
// switch is off, so turning RIT back on restores the last offset — same as the
// radio's own knob.
func (f *Flex) SetRIT(on bool) error { return f.sendSlice("rit_on", boolFlex(on)) }
func (f *Flex) SetRITFreq(hz int) error { return f.sendSlice("rit_freq", clampOffset(hz)) }
func (f *Flex) SetXIT(on bool) error { return f.sendSlice("xit_on", boolFlex(on)) }
func (f *Flex) SetXITFreq(hz int) error { return f.sendSlice("xit_freq", clampOffset(hz)) }
// clampOffset keeps a RIT/XIT offset inside what SmartSDR accepts (±99 999 Hz).
func clampOffset(hz int) int {
if hz > 99999 {
return 99999
}
if hz < -99999 {
return -99999
}
return hz
}
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
// ── CW keyer controls (top-level "cw" commands) ──
@@ -1524,6 +1709,23 @@ func (f *Flex) SetTune(on bool) error {
return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on })
}
// SetTXInhibit blocks (on=true) or allows transmission at the radio. Used to keep
// the operator from keying while a motorized antenna's elements are moving —
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
// footswitch or an external keyer, which a software PTT block could not.
//
// It also labels the interlock reason "OpsLog" so SmartSDR shows WHY TX is
// blocked. The reason is best-effort (a separate interlock object); the inhibit
// is what actually blocks TX, so a rig that ignores the reason still stays safe.
func (f *Flex) SetTXInhibit(on bool) error {
if on {
f.send("interlock set reason=OpsLog")
} else {
f.send("interlock set reason=")
}
return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
}
func (f *Flex) SetVOX(on bool) error {
return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on })
}
@@ -1571,6 +1773,45 @@ func (f *Flex) SetMic(l int) error {
return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l })
}
// SetTXFilter sets the transmit-audio bandwidth (low + high cut, in Hz). SmartSDR
// clamps to legal values per mode; we just pass them through in one command so
// both edges move together.
func (f *Flex) SetTXFilter(low, high int) error {
if low < 0 {
low = 0
}
if high < low {
high = low
}
// Guard both status field names (the echo comes back as lo/hi, not
// filter_low/high) so the radio's lagging echo doesn't snap the inputs back.
f.mu.Lock()
f.txSetAt["hi"] = time.Now()
f.mu.Unlock()
return f.txSet(fmt.Sprintf("transmit set filter_low=%d filter_high=%d", low, high), "lo",
func(t *flexTX) { t.filterLow, t.filterHigh = low, high })
}
// SetMicProfile loads a SmartSDR mic profile by name (quoted, as names may hold
// spaces). Optimistically caches the selection.
func (f *Flex) SetMicProfile(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("flex: empty mic profile")
}
f.mu.Lock()
connected := f.conn != nil
if connected {
f.micProfile = name
}
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
f.send(fmt.Sprintf("profile mic load \"%s\"", name))
return nil
}
func (f *Flex) ATUStart() error {
if !f.connected() {
return fmt.Errorf("flex: not connected")
@@ -1790,6 +2031,23 @@ func parseFloatDefault(s string, def float64) float64 {
}
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
// flexSplitClass groups a raw Flex slice mode into a split-capable class. A
// genuine split (one RX freq, one TX freq, SAME mode) only makes sense for PHONE
// and CW pileups. Data modes (FT8/FT4/RTTY all report as DIGU/DIGL/RTTY on a
// Flex) and digital voice never operate split, so they return "" (not
// split-capable). Two same-band slices whose classes differ (e.g. SSB + FT8) are
// independent operations, not a split.
func flexSplitClass(rawMode string) string {
switch flexModeToADIF(rawMode) {
case "SSB", "AM", "FM":
return "PHONE"
case "CW":
return "CW"
default: // DATA, RTTY, DIGITALVOICE, "" → never split
return ""
}
}
func flexModeToADIF(m string) string {
switch strings.ToUpper(strings.TrimSpace(m)) {
case "USB", "LSB":
+49
View File
@@ -0,0 +1,49 @@
package cat
import "testing"
// A genuine split is two same-band slices in the SAME split class (both PHONE or
// both CW). Two same-band slices in different modes (SSB + FT8), or in a data
// mode (FT8/FT4/RTTY = DIGU/DIGL), must NOT read as split.
func TestOperatingSplitMode(t *testing.T) {
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
}
cases := []struct {
name string
slices map[int]*flexSlice
wantSplit bool
}{
{"phone split (both USB)", map[int]*flexSlice{
0: mk(14_100_000, "USB", true, false),
1: mk(14_200_000, "USB", false, true),
}, true},
{"CW split (both CW)", map[int]*flexSlice{
0: mk(7_010_000, "CW", true, false),
1: mk(7_025_000, "CW", false, true),
}, true},
{"SSB + FT8 same band → not split", map[int]*flexSlice{
0: mk(14_100_000, "USB", true, false),
1: mk(14_074_000, "DIGU", false, true),
}, false},
{"both FT8 (DIGU) same band → not split", map[int]*flexSlice{
0: mk(14_074_000, "DIGU", true, false),
1: mk(14_080_000, "DIGU", false, true),
}, false},
{"different bands → not split", map[int]*flexSlice{
0: mk(14_100_000, "USB", true, false),
1: mk(7_100_000, "LSB", false, true),
}, false},
{"simplex (single slice) → not split", map[int]*flexSlice{
0: mk(14_100_000, "USB", true, true),
}, false},
}
for _, c := range cases {
f := &Flex{slices: c.slices}
_, rx, tx := f.operatingLocked()
gotSplit := rx != nil && tx != nil
if gotSplit != c.wantSplit {
t.Errorf("%s: split=%v, want %v", c.name, gotSplit, c.wantSplit)
}
}
}
+219
View File
@@ -0,0 +1,219 @@
package cat
// icomaudio.go — the NETWORK AUDIO stream (UDP 50003) for the Icom LAN protocol.
// It is the third stream alongside control (50001) and CI-V (50002): once the
// control login + conninfo (with rxenable=1) authorize audio, the rig streams RX
// audio here as data packets. This file dials/handshakes/keeps-alive that socket
// exactly like the CI-V stream (icomnet.go) — those parts are byte-for-byte the
// PROVEN transport — and hands each received audio payload to a sink callback
// (the app decodes it via an audio.Codec and plays it through the RX monitor).
//
// Reuses icomnet.go's helpers (icnCtrl, icnHandshake, icnPingReply, icnRecv,
// icnLocalID, icnLE) and the same seq/retransmit discipline.
//
// ⚠️ PAYLOAD OFFSET PENDING ON-RIG VERIFICATION. The stream framing (handshake,
// ping, idle, retransmit, common 16-byte header) is identical to CI-V and proven.
// The AUDIO data packet's inner layout — where the PCM starts and the datalen
// field — is reconstructed from wfview's audio_packet (ident@0x10, datalen@0x12,
// sendseq@0x14, audio@0x16) but NOT yet confirmed against a real 50003 capture.
// audioPump logs the first few raw packets (icaDumpFirst) so the offset can be
// confirmed/corrected on the first on-rig test without a packet capture, the same
// way the CI-V/scope framing was iterated. Nothing here can destabilize CAT: the
// audio stream is opt-in and entirely separate from control/CI-V.
import (
"net"
"sync"
"sync/atomic"
"time"
)
// icaAudioOffset is where the PCM payload begins inside an audio data packet
// (wfview audio_packet: 16-byte common header + ident@0x10 + datalen@0x12 +
// sendseq@0x14 → audio@0x16). Isolated as a const so a capture-confirmed change
// is a one-line edit.
const icaAudioOffset = 0x16
// icaDumpFirst is how many initial audio packets to hex-dump to the debug log for
// offset verification. After the layout is confirmed on a real rig this can go to
// 0 (or the const above corrected).
const icaDumpFirst = 6
// icomAudio is the connected audio stream. RX only for now (Phase 4); TX (Phase
// 5) will add an encode+send path mirroring icomNet.Write.
type icomAudio struct {
conn *net.UDPConn
aID, aRemote uint32
sink func([]byte) // receives each raw audio payload (app decodes + plays)
// Receive-side retransmit (audio is a heavy stream, like the scope): track the
// rig's data-packet send seq and ask it to resend gaps, or the rig drops the
// session. Same mechanism as icomNet. Owned solely by audioPump → no lock.
rxHaveSeq bool
rxLastSeq uint16
rxMissing map[uint16]int
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
lastRx atomic.Int64 // UnixNano of last packet (liveness)
done chan struct{}
closeOnce sync.Once
}
func (a *icomAudio) markRx() { a.lastRx.Store(time.Now().UnixNano()) }
// Close tears the audio stream down (disconnect a few times; UDP is lossy).
func (a *icomAudio) Close() {
a.closeOnce.Do(func() {
close(a.done)
for i := 0; i < 3; i++ {
_, _ = a.conn.Write(icnCtrl(0x05, 0, a.aID, a.aRemote)) // disconnect
time.Sleep(15 * time.Millisecond)
}
_ = a.conn.Close()
debugLog.Printf("icom audio: stream closed")
})
}
// dialIcomAudio opens the audio UDP stream to rig:50003, binding LOCAL :50003
// (mirroring the civ stream's local :50002). The control conninfo (rxenable=1,
// audioport=50003) must already have authorized it. sink receives each raw audio
// payload. cancel aborts a slow dial (Stop/Start).
func dialIcomAudio(host string, sink func([]byte), cancel <-chan struct{}) (*icomAudio, error) {
araddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50003"))
if err != nil {
return nil, err
}
conn, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50003}, araddr)
if err != nil {
debugLog.Printf("icom audio: cannot bind local :50003 (Remote Utility running?): %v", err)
return nil, err
}
aID := icnLocalID(conn)
aRemote, err := icnHandshake(conn, aID, cancel)
if err != nil {
_ = conn.Close()
debugLog.Printf("icom audio: handshake FAILED: %v", err)
return nil, err
}
_ = conn.SetReadBuffer(1 << 20)
a := &icomAudio{
conn: conn, aID: aID, aRemote: aRemote,
sink: sink,
rxMissing: make(map[uint16]int),
done: make(chan struct{}),
}
a.markRx()
debugLog.Printf("icom audio: stream up (rig id 0x%08X) — awaiting RX audio", aRemote)
go a.audioPump()
return a, nil
}
// audioPump drains the audio socket: replies to pings, sends idle keepalives,
// requests retransmits for lost packets, and hands each audio payload to sink.
func (a *icomAudio) audioPump() {
buf := make([]byte, 8192)
lastIdle := time.Now()
lastReq := time.Now()
for {
select {
case <-a.done:
return
default:
}
_ = a.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
if k, err := a.conn.Read(buf); err == nil && k >= 16 {
a.markRx()
switch typ := icnLE.Uint16(buf[4:]); {
case typ == 0x07: // ping
_, _ = a.conn.Write(icnPingReply(buf[:k], a.aID, a.aRemote))
case typ == 0x01: // retransmit request from the rig (we send no tracked audio yet)
case typ == 0x05: // rig-initiated disconnect
debugLog.Printf("icom audio: rig sent DISCONNECT — audio stream dropped by the rig")
case typ == 0x00 && k > icaAudioOffset: // audio data packet
a.trackRxSeq(icnLE.Uint16(buf[6:]))
if a.dumped < icaDumpFirst {
a.dumped++
debugLog.Printf("icom audio raw #%d: len=%d head=% X", a.dumped, k, buf[:min(icaAudioOffset+8, k)])
}
if a.sink != nil {
payload := append([]byte(nil), buf[icaAudioOffset:k]...)
a.sink(payload)
}
}
}
if time.Since(lastIdle) > 100*time.Millisecond {
_, _ = a.conn.Write(icnCtrl(0x00, 0, a.aID, a.aRemote))
lastIdle = time.Now()
}
if time.Since(lastReq) > 100*time.Millisecond {
a.sendRetransmitReq()
lastReq = time.Now()
}
}
}
// trackRxSeq / sendRetransmitReq mirror icomNet's receive-side retransmit exactly
// (audio is as loss-sensitive as the scope stream). Duplicated deliberately so
// the audio stream owns its own seq state with no shared locking.
func (a *icomAudio) trackRxSeq(seq uint16) {
if !a.rxHaveSeq {
a.rxHaveSeq = true
a.rxLastSeq = seq
return
}
switch d := int16(seq - a.rxLastSeq); {
case d == 0:
case d < 0:
delete(a.rxMissing, seq)
case d == 1:
a.rxLastSeq = seq
case int(d) <= icnMaxMissing:
for f := a.rxLastSeq + 1; f != seq; f++ {
a.rxMissing[f] = 0
}
a.rxLastSeq = seq
default:
a.rxMissing = make(map[uint16]int)
a.rxLastSeq = seq
}
}
func (a *icomAudio) sendRetransmitReq() {
if len(a.rxMissing) == 0 {
return
}
if len(a.rxMissing) > icnMaxMissing {
a.rxMissing = make(map[uint16]int)
return
}
var seqs []uint16
for s, cnt := range a.rxMissing {
if cnt >= 4 {
delete(a.rxMissing, s)
continue
}
a.rxMissing[s] = cnt + 1
seqs = append(seqs, s)
}
switch {
case len(seqs) == 0:
return
case len(seqs) == 1:
_, _ = a.conn.Write(icnCtrl(0x01, seqs[0], a.aID, a.aRemote))
default:
b := make([]byte, 16+4*len(seqs))
icnLE.PutUint32(b[0:], uint32(len(b)))
icnLE.PutUint16(b[4:], 0x01)
icnLE.PutUint32(b[8:], a.aID)
icnLE.PutUint32(b[12:], a.aRemote)
off := 16
for _, s := range seqs {
icnLE.PutUint16(b[off:], s)
icnLE.PutUint16(b[off+2:], s)
off += 4
}
_, _ = a.conn.Write(b)
}
}
+53 -9
View File
@@ -36,7 +36,13 @@ var icnBE = binary.BigEndian
// NewIcomNet builds an (unconnected) Icom backend whose transport is the network
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
// credentials. Reuses the whole IcomSerial controller — only `open` differs.
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *IcomSerial {
//
// audioSink (optional) enables the network RX audio stream (UDP 50003): when
// non-nil the conninfo asks the rig to stream audio and each received payload is
// passed to audioSink (the app decodes it via an audio.Codec and plays it). nil
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
// so enabling it can't affect freq/mode/DSP control.
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
if civAddr <= 0 || civAddr > 0xFF {
civAddr = 0x98 // IC-7610
}
@@ -57,7 +63,7 @@ func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *Ic
b.dialMu.Lock()
cancel := b.dialCancel
b.dialMu.Unlock()
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel)
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel, audioSink)
}
return b
}
@@ -130,6 +136,10 @@ type icomNet struct {
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
lastRx atomic.Int64
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
// Torn down alongside the CI-V/control streams in Close.
audio *icomAudio
done chan struct{}
closeOnce sync.Once
}
@@ -232,6 +242,9 @@ var icnTrace = false
func (n *icomNet) Close() error {
n.closeOnce.Do(func() {
close(n.done)
if n.audio != nil {
n.audio.Close()
}
// Tell the rig we're leaving so it frees its SINGLE control session at
// once. If it never gets a disconnect it holds the session for minutes and
// refuses every new login — which is why a lost link (or a hard app exit)
@@ -273,19 +286,33 @@ func (n *icomNet) ctrlPump() {
switch icnLE.Uint16(buf[4:]) {
case 0x07: // ping
_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
case 0x00: // idle keepalive from the rig — nothing to do
case 0x01: // retransmit request — resend from the CONTROL sent-buffer
if k >= 8 {
n.ctrlResend(icnLE.Uint16(buf[6:]))
}
case 0x05: // rig-initiated disconnect — it dropped US
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
default:
// Anything else on the control stream is (almost always) the rig's
// reply to our token renewal. Log it: a 0x40-length token packet
// carries a result code, and if the rig is REJECTING renewals this is
// where the ~2-3 min disconnect originates. The hex makes the cause
// visible in the friend's log without a protocol analyzer.
if k >= 0x18 {
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:0x18])
} else {
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:k])
}
}
}
if time.Since(lastIdle) > 100*time.Millisecond {
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
lastIdle = time.Now()
}
if time.Since(lastToken) > 45*time.Second {
// Renew well inside the rig's ~2-min token timeout. 30 s (was 45) leaves room
// for one lost renewal + its retransmit before the token would lapse.
if time.Since(lastToken) > 30*time.Second {
n.renewToken()
lastToken = time.Now()
}
@@ -475,8 +502,9 @@ func (n *icomNet) resend(seq uint16) {
// ------------------------- connect -------------------------
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}) (*icomNet, error) {
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X)", host, user, compName, rigAddr)
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}, audioSink func([]byte)) (*icomNet, error) {
wantAudio := audioSink != nil
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X, audio=%v)", host, user, compName, rigAddr, wantAudio)
// ---- control stream (50001): handshake → login → token → conninfo ----
craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
if err != nil {
@@ -562,7 +590,11 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
rigMAC = make([]byte, 6)
}
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
var rxEnable byte
if wantAudio {
rxEnable = 0x01 // ask the rig to stream RX audio on 50003
}
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003, rxEnable))
cTracked++
cInner++
drainEnd := time.Now().Add(500 * time.Millisecond)
@@ -634,6 +666,18 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
go n.ctrlPump()
go n.civPump()
// Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to
// stream audio; open the socket + handshake now. A failure here is NON-fatal:
// CAT works without audio, so we log and continue rather than tear down a
// perfectly good control/CI-V session.
if wantAudio {
if a, err := dialIcomAudio(host, audioSink, cancel); err != nil {
debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err)
} else {
n.audio = a
}
}
return n, nil
}
@@ -772,7 +816,7 @@ func icnTokenRenew(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) [
return b
}
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte {
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16, rxEnable byte) []byte {
b := make([]byte, 0x90)
icnLE.PutUint32(b[0:], 0x90)
icnLE.PutUint16(b[6:], seq)
@@ -788,8 +832,8 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
copy(b[0x2a:0x30], rigMAC)
copy(b[0x40:0x60], []byte("IC-7610"))
copy(b[0x60:0x70], icnPasscode(user))
b[0x70] = 0x00 // rxenable (audio off — CI-V only)
b[0x71] = 0x00 // txenable
b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only
b[0x71] = 0x00 // txenable (Phase 5)
b[0x72] = 0x10 // rxcodec
b[0x73] = 0x04 // txcodec
icnBE.PutUint32(b[0x74:], 16000)
+20 -4
View File
@@ -210,17 +210,25 @@ func (b *IcomSerial) Connect() error {
go b.netScopeFeeder(sc.ScopeChan(), b.readerDone)
}
// Best-effort model identification: ask the rig for its own CI-V address.
// Best-effort model identification: ask the rig for its own CI-V address. The
// 0x19 ID read returns the rig's FACTORY default address (e.g. 0x94 for an
// IC-7300) regardless of the address it's currently OPERATING on — so it's the
// reliable model signal even when the user runs the rig at a non-default CI-V
// address (a common trick to make CAT "just work" at 0x98).
idAddr := b.rigAddr // fallback: the configured address if the ID read fails
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
}); err == nil {
b.model = civ.ModelName(f.Data[1])
idAddr = f.Data[1]
}
}
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
// selector byte; single-scope rigs (IC-7300…) do not.
b.dualScope = b.rigAddr == 0x98 || b.rigAddr == 0xA2
// selector byte; single-scope rigs (IC-7300…) do not. Decide from the
// IDENTIFIED model, NOT the configured address: an IC-7300 run at 0x98 must
// still parse single-scope frames (this was the "scope blank on the 7300" bug).
b.dualScope = idAddr == 0x98 || idAddr == 0xA2
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network
// the rig may still be booting (or off) at Connect, so an immediate readDSP
// would time out and leave every control at 0 / off with no retry. ReadState
@@ -275,6 +283,7 @@ func (b *IcomSerial) ReadState() (RigState, error) {
b.dspMu.Unlock()
return s, nil
}
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
return RigState{}, err // control link dead → let the Manager reconnect
}
// USB (no liveness signal): the rig briefly stops answering CI-V while it
@@ -590,7 +599,7 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
}
continue
}
if rawN < 4 {
if rawN < 24 {
rawN++
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
}
@@ -696,6 +705,13 @@ func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) {
// and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader
// routes every 0x27 frame to scopeLoop.
func (b *IcomSerial) SetScope(on bool) error {
if on {
// Context for the scope-diagnostic log: which rig + whether we expect the
// dual-scope (main/sub) frame layout. If the IC-7300 (single scope) streams
// but nothing shows, compare its `icom scope raw` frames against this.
applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)",
b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope])
}
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
// continue rather than abort the second command.
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
+10
View File
@@ -414,6 +414,16 @@ func (o *OmniRig) SetPTT(on bool) error {
debugLog.Printf("OmniRig.SetPTT error: %v", err)
return fmt.Errorf("set Tx=%s: %w", name, err)
}
// Read the Tx param straight back. OmniRig is async — this may still show the
// previous value for a poll cycle — but if a key/unkey NEVER changes it, the
// write was coalesced or the rig isn't honouring PM_TX/PM_RX (wrong .ini).
if v, err := oleutil.GetProperty(o.rig, "Tx"); err == nil {
txState := "PM_RX"
if v.Val&pmTX != 0 {
txState = "PM_TX"
}
debugLog.Printf("OmniRig.SetPTT: Tx readback = 0x%X (%s)", v.Val, txState)
}
return nil
}
+17
View File
@@ -202,6 +202,23 @@ func (db *DB) Resolve(call string, date time.Time) (Exception, bool) {
return Exception{}, false
}
// HasException reports whether the callsign has ANY full-call exception (at any
// date). Used to tell a genuinely date-sensitive call (G1T: Scotland only from
// 2024) — where cty.dat's date-blind exact-call override is wrong for older
// QSOs — from an ordinary call cty.dat handles fine.
func (db *DB) HasException(call string) bool {
if db == nil {
return false
}
c := strings.ToUpper(strings.TrimSpace(call))
for _, key := range candidates(c) {
if len(db.exceptions[key]) > 0 {
return true
}
}
return false
}
// candidates yields the call and a version with one trailing affix removed.
func candidates(c string) []string {
out := []string{c}
+50
View File
@@ -0,0 +1,50 @@
package clublog
import (
"testing"
"time"
)
func d(s string) time.Time {
t, _ := time.Parse("2006-01-02", s)
return t
}
// G1T is a contest call: Scotland ONLY from 2024-02-21; before that it's England
// by prefix. cty.dat's date-blind "=G1T → Scotland" override is wrong for a 2012
// QSO — the date-aware ClubLog cascade must give England then, Scotland now.
func TestG1TDatedException(t *testing.T) {
db := &DB{
exceptions: map[string][]Exception{
"G1T": {{Call: "G1T", Entity: "Scotland", ADIF: 279, Start: d("2024-02-21")}},
},
prefixes: map[string][]Exception{
"G": {{Entity: "England", ADIF: 223}},
},
}
// 2012 QSO: exception doesn't cover it, but the call HAS an exception →
// caller should use the prefix (England).
if _, ok := db.Resolve("G1T", d("2012-07-29")); ok {
t.Fatalf("2012: exception should NOT cover a pre-2024 date")
}
if !db.HasException("G1T") {
t.Fatalf("HasException(G1T) should be true")
}
pe, pok := db.ResolvePrefix("G1T", d("2012-07-29"))
if !pok || pe.Entity != "England" {
t.Fatalf("2012 prefix: got %q ok=%v, want England", pe.Entity, pok)
}
// 2025 QSO: the exception covers it → Scotland.
e, ok := db.Resolve("G1T", d("2025-01-01"))
if !ok || e.Entity != "Scotland" {
t.Fatalf("2025: got %q ok=%v, want Scotland", e.Entity, ok)
}
// An ordinary call with no exception → HasException false (caller leaves it
// to cty.dat).
if db.HasException("G4ABC") {
t.Fatalf("HasException(G4ABC) should be false")
}
}
+27
View File
@@ -50,6 +50,17 @@ func (m *Manager) Info() (date string, count int) {
return m.db.Date(), m.db.Count()
}
// NeedsRefresh reports whether the cached country file is missing or older than
// maxAge — so the caller re-downloads it to pick up newly-added date-ranged
// exceptions (e.g. an event call's fresh SARDINIA period).
func (m *Manager) NeedsRefresh(maxAge time.Duration) bool {
fi, err := os.Stat(m.Path())
if err != nil {
return true // missing
}
return time.Since(fi.ModTime()) > maxAge
}
// EnsureLoaded loads the cached file into memory if present. Does NOT download.
func (m *Manager) EnsureLoaded() error {
if m.Loaded() {
@@ -125,3 +136,19 @@ func (m *Manager) Resolve(call string, date time.Time) (Exception, bool) {
}
return db.Resolve(call, date)
}
// ResolvePrefix resolves a call by ClubLog's date-ranged PREFIX table.
func (m *Manager) ResolvePrefix(call string, date time.Time) (Exception, bool) {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
return db.ResolvePrefix(call, date)
}
// HasException reports whether the call has any full-call exception (any date).
func (m *Manager) HasException(call string) bool {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
return db.HasException(call)
}
+148 -12
View File
@@ -61,6 +61,11 @@ type Spot struct {
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
ReceivedAt time.Time `json:"received_at"`
Raw string `json:"raw"`
// Historical marks a spot recovered from a SH/DX table rather than heard live.
// It belongs in the grid, but must NOT fire alerts or reach the panadapter:
// replaying 100 past spots would spam both, and a station spotted three hours
// ago is not on the air now.
Historical bool `json:"historical,omitempty"`
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
POTAName string `json:"pota_name,omitempty"` // park name
}
@@ -92,6 +97,21 @@ type ServerStatus struct {
}
// session is one telnet connection bound to a single server config.
// Line is one raw line of cluster traffic: everything the server says, plus the
// commands we send (echoed, so the console reads as a conversation).
//
// Spots are PARSED OUT of this stream — but everything else used to be silently
// dropped. You could type SH/DX/100 or WHO and never see the answer, which made
// the command box look broken. The raw stream is the fix: the console shows what
// the server actually said, spot or not.
type Line struct {
ServerID int64 `json:"server_id"`
ServerName string `json:"server_name"`
Text string `json:"text"`
Sent bool `json:"sent"` // true = a command WE sent
At string `json:"at"` // HH:MM:SS, local
}
// Internal — callers use Manager. The onStatus callback is fire-and-
// forget: it tells the manager something changed; the frontend fetches
// the new aggregate via Status() rather than receiving per-server diffs.
@@ -99,6 +119,7 @@ type session struct {
cfg ServerConfig
login string
onSpot func(Spot)
onLine func(Line)
onStatus func()
mu sync.RWMutex
@@ -117,6 +138,7 @@ type Manager struct {
mu sync.RWMutex
sessions map[int64]*session
onSpot func(Spot)
onLine func(Line)
onStatus func()
}
@@ -124,10 +146,11 @@ type Manager struct {
// spot (with the source server filled in). emitStatusChanged is called
// whenever ANY server's status changes — the frontend then re-fetches
// the aggregate Status() via a Wails binding.
func NewManager(emitSpot func(Spot), emitStatusChanged func()) *Manager {
func NewManager(emitSpot func(Spot), emitStatusChanged func(), emitLine func(Line)) *Manager {
return &Manager{
sessions: make(map[int64]*session),
onSpot: emitSpot,
onLine: emitLine,
onStatus: emitStatusChanged,
}
}
@@ -155,6 +178,7 @@ func (m *Manager) StartServer(cfg ServerConfig, login string) {
cfg: cfg,
login: login,
onSpot: m.onSpot,
onLine: m.onLine,
onStatus: m.emitStatus,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
@@ -242,6 +266,12 @@ func (s *session) send(cmd string) error {
}
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
_, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n"))
if err == nil {
// Echo it into the console, so the operator sees WHAT was sent and can pair
// it with the reply that follows. A console that only shows one side of the
// conversation is barely better than none.
s.emitLine(cmd, true)
}
return err
}
@@ -353,8 +383,18 @@ func (s *session) runOnce() (time.Time, error) {
}()
}
// Init commands: fire 1s after login goes through. Each command on
// its own line; blank lines and "//" comments are skipped.
// Init commands, once per connection (so they replay after a reconnect).
//
// Timing: the cluster needs a moment after login before it will take commands,
// hence the lead-in wait; then they are PACED, because a DXSpider/AR-Cluster
// happily swallows a burst of back-to-back lines and silently ignores half of
// them. A fast-but-dropped command is worse than a slow one.
//
// They go through s.send() — NOT a raw conn.Write, which is what they used to
// do. That matters for two reasons: the raw write skipped the write deadline
// (a wedged server could hang this goroutine forever), and it skipped the
// console echo, so there was no way to SEE whether your init commands had
// actually been applied. Now you watch them go out and the reply come back.
initFired := false
fireInitCommands := func() {
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
@@ -362,10 +402,9 @@ func (s *session) runOnce() (time.Time, error) {
}
initFired = true
go func() {
time.Sleep(1 * time.Second)
time.Sleep(initCommandLeadIn)
for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
line = strings.TrimRight(line, "\r")
line = strings.TrimSpace(line)
line = strings.TrimSpace(strings.TrimRight(line, "\r"))
if line == "" || strings.HasPrefix(line, "//") {
continue
}
@@ -374,8 +413,11 @@ func (s *session) runOnce() (time.Time, error) {
return
default:
}
_, _ = conn.Write([]byte(line + "\r\n"))
time.Sleep(500 * time.Millisecond)
if err := s.send(line); err != nil {
s.emitLine("init command failed: "+line+" — "+err.Error(), false)
return
}
time.Sleep(initCommandGap)
}
}()
}
@@ -444,12 +486,28 @@ func (s *session) runOnce() (time.Time, error) {
fireInitCommands()
}
if spot, ok := parseSpot(line); ok {
// EVERY line goes to the console — spot or not. This is the whole point:
// SH/DX, WHO, the MOTD and error replies are not spots, and dropping them
// (which is what happened before) made the command box look inert.
s.emitLine(line, false)
// Live broadcast first; then the SH/DX table form. Without the second, a
// SH/DX reply arrived, matched nothing, and vanished — which is exactly why
// "SH/DX/100" looked like it did nothing at all.
spot, ok := parseSpot(line)
if !ok {
spot, ok = parseShowDX(line)
}
if ok {
spot.SourceID = s.cfg.ID
spot.SourceName = s.cfg.Name
s.mu.Lock()
s.spotsCnt++
s.status.SpotsCount = s.spotsCnt
// Historical spots are a replay of the past, not new traffic — counting
// them would inflate the server's "spots received" figure.
if !spot.Historical {
s.spotsCnt++
s.status.SpotsCount = s.spotsCnt
}
s.mu.Unlock()
if s.onSpot != nil {
s.onSpot(spot)
@@ -458,13 +516,91 @@ func (s *session) runOnce() (time.Time, error) {
}
}
// emitLine hands one line of traffic to the console. Blank lines are dropped —
// clusters send plenty and they'd only pad the scrollback.
func (s *session) emitLine(text string, sent bool) {
if s.onLine == nil || strings.TrimSpace(text) == "" {
return
}
s.onLine(Line{
ServerID: s.cfg.ID,
ServerName: s.cfg.Name,
Text: strings.TrimRight(text, "\r\n"),
Sent: sent,
At: time.Now().Format("15:04:05"),
})
}
// ---------- parsing ----------
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
//
// The spotter→freq separator is (?::\s*|\s+): a colon followed by ANY number of
// spaces (including ZERO), or one-or-more spaces with no colon. Some RBN skimmer
// nodes glue the frequency straight onto the colon — "DX de DL1HWS-3-#:14024.0 …"
// — which the old ":?\s+" (colon then a REQUIRED space) rejected, dropping every
// spot from those nodes.
var spotRE = regexp.MustCompile(
`^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
`^\s*DX\s+de\s+([A-Z0-9/#\-]+)(?::\s*|\s+)(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
)
// Pacing for the per-server init commands.
//
// A cluster is not ready to take commands the instant the login line goes out —
// it is still printing its banner — so we wait before the first one. And they are
// spaced, because DXSpider / AR-Cluster will happily accept a burst of
// back-to-back lines and silently apply only some of them: the connection stays
// up, no error is returned, your filters just aren't set. A command that is
// dropped is far worse than one that is slow.
const (
initCommandLeadIn = 1500 * time.Millisecond
initCommandGap = 700 * time.Millisecond
)
// showDXRE matches the reply to SH/DX — which is a TABLE, not the "DX de …"
// broadcast format, and therefore matched nothing at all:
//
// 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>
// freq dxcall date time comment <spotter>
//
// This is why "SH/DX/100 does nothing": the 100 lines arrive, fail the broadcast
// regex, and get dropped. The decimal point in the frequency is required — it is
// what keeps ordinary prose out of the spot grid.
var showDXRE = regexp.MustCompile(
`^\s*(\d{3,7}\.\d+)\s+([A-Z0-9]{1,3}[0-9][A-Z0-9/]*)\s+(\d{1,2}-[A-Za-z]{3}-\d{4})\s+(\d{4})Z?\s*(.*?)\s*(?:<\s*([A-Z0-9/#\-]+)\s*>)?\s*$`,
)
// parseShowDX turns one line of a SH/DX table into a Spot. The result is flagged
// Historical: these are PAST spots, so they belong in the grid but must not fire
// alerts or land on the panadapter — replaying 100 of them would spam both, and a
// station spotted three hours ago is not on the air now.
func parseShowDX(line string) (Spot, bool) {
if strings.Contains(strings.ToUpper(line), "DX DE") {
return Spot{}, false // that's the broadcast form; spotRE owns it
}
m := showDXRE.FindStringSubmatch(line)
if m == nil {
return Spot{}, false
}
khz, err := strconv.ParseFloat(m[1], 64)
if err != nil || khz <= 0 {
return Spot{}, false
}
hz := int64(khz * 1000)
return Spot{
Spotter: strings.ToUpper(m[6]),
DXCall: strings.ToUpper(m[2]),
FreqKHz: khz,
FreqHz: hz,
Band: bandFromHz(hz),
Comment: strings.TrimSpace(m[5]),
TimeUTC: m[4] + "Z",
ReceivedAt: time.Now(),
Raw: line,
Historical: true,
}, true
}
func parseSpot(line string) (Spot, bool) {
m := spotRE.FindStringSubmatch(line)
if m == nil {
+54
View File
@@ -63,3 +63,57 @@ func TestParseSpotRejectsNoise(t *testing.T) {
}
}
}
// The reply to SH/DX is a TABLE, not the "DX de …" broadcast — a completely
// different shape. Because only the broadcast form was parsed, a SH/DX/100 reply
// arrived, matched nothing and was dropped: the command looked like it did
// nothing at all. These lines must now land in the grid, flagged Historical.
func TestParseShowDX(t *testing.T) {
cases := []struct {
line string
call string
khz float64
spotter string
}{
{" 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>", "EA8DHH", 14195.0, "F5ABC"},
{" 7005.5 RA3XYZ 14-Jul-2026 0912Z <DL1ABC>", "RA3XYZ", 7005.5, "DL1ABC"},
{"21025.0 VK9/DL2XYZ 1-Jan-2026 0001Z up 2 <JA1ABC>", "VK9/DL2XYZ", 21025.0, "JA1ABC"},
// No spotter brackets — still a spot, just without a DE.
{" 50313.0 IK0ABC 3-Jul-2026 1500Z FT8", "IK0ABC", 50313.0, ""},
}
for _, c := range cases {
got, ok := parseShowDX(c.line)
if !ok {
t.Errorf("parseShowDX(%q) failed — the SH/DX reply would be dropped again", c.line)
continue
}
if got.DXCall != c.call || got.FreqKHz != c.khz || got.Spotter != c.spotter {
t.Errorf("parseShowDX(%q) = call %q / %.1f / de %q, want %q / %.1f / %q",
c.line, got.DXCall, got.FreqKHz, got.Spotter, c.call, c.khz, c.spotter)
}
if !got.Historical {
t.Errorf("parseShowDX(%q): must be flagged Historical — otherwise 100 replayed spots fire 100 alerts", c.line)
}
if got.Band == "" {
t.Errorf("parseShowDX(%q): band not derived from %.1f kHz", c.line, c.khz)
}
}
}
// The SH/DX parser must not swallow ordinary cluster prose — a console full of
// chatter turned into fake spots would be worse than no parser at all.
func TestParseShowDXRejectsNoise(t *testing.T) {
noise := []string{
"DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it
"Hello and welcome to the DXSpider cluster",
"WWV de VE7CC <18Z> : SFI=110, A=16, K=2",
"F4BPO de GB7DXC 12-Jul-2026 2130Z dxspider >",
"",
"There are 42 users online",
}
for _, l := range noise {
if s, ok := parseShowDX(l); ok {
t.Errorf("parseShowDX(%q) wrongly produced a spot: %+v", l, s)
}
}
}
+66
View File
@@ -0,0 +1,66 @@
package db
import (
"database/sql"
"database/sql/driver"
"errors"
"net"
"strings"
"github.com/go-sql-driver/mysql"
)
// IsConnLost reports whether err means the database was UNREACHABLE (network
// down, server gone, dead pooled connection) as opposed to a legitimate
// server-side SQL error (constraint violation, bad data, syntax).
//
// This distinction is the linchpin of the offline safety net: a QSO is parked in
// the local ADIF outbox ONLY on a connection loss. If we queued on any error, a
// genuine data bug would silently vanish into the file instead of being
// reported — the worst possible outcome.
//
// A *mysql.MySQLError means the SERVER answered and rejected us, so it is never
// a connection loss, whatever its code.
func IsConnLost(err error) bool {
if err == nil {
return false
}
// The server responded → a real SQL error, not a lost link.
var me *mysql.MySQLError
if errors.As(err, &me) {
return false
}
if errors.Is(err, driver.ErrBadConn) || errors.Is(err, sql.ErrConnDone) || errors.Is(err, sql.ErrTxDone) {
return true
}
var ne net.Error
if errors.As(err, &ne) {
return true
}
var oe *net.OpError
if errors.As(err, &oe) {
return true
}
// The MySQL driver reports a few of these as plain strings.
s := strings.ToLower(err.Error())
for _, p := range []string{
"invalid connection",
"bad connection",
"connection refused",
"connection reset",
"broken pipe",
"no such host",
"i/o timeout",
"dial tcp",
"unexpected eof",
"driver: bad connection",
"can't connect",
"network is unreachable",
"host is unreachable",
} {
if strings.Contains(s, p) {
return true
}
}
return false
}
+13 -5
View File
@@ -339,17 +339,25 @@ func normalizeCallsign(s string) string {
// which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not
// Ecuador). This is the same class of rule as KG4 and /MM.
if areaDigit != 0 {
main = replaceFirstDigit(main, areaDigit)
main = replaceAreaDigit(main, areaDigit)
}
return main
}
// replaceFirstDigit substitutes the first 0-9 digit of a call with d (used to
// apply a "/N" call-area change). Returns the call unchanged if it has no digit.
func replaceFirstDigit(call string, d byte) string {
// replaceAreaDigit substitutes the CALL-AREA digit of a call with d (used to
// apply a "/N" call-area change). The area digit is the first digit that comes
// AFTER a prefix letter — NOT a leading digit that is part of a digit-first
// prefix (7X, 3A, 4X, 9A, 2E…). So 7X2ARA/4 → 7X4ARA (still Algeria, area 4),
// NOT 4X2ARA (which is Israel — the old first-digit bug). Returns the call
// unchanged if it has no such digit.
func replaceAreaDigit(call string, d byte) string {
b := []byte(call)
seenLetter := false
for i := range b {
if b[i] >= '0' && b[i] <= '9' {
switch {
case b[i] >= 'A' && b[i] <= 'Z':
seenLetter = true
case b[i] >= '0' && b[i] <= '9' && seenLetter:
b[i] = d
return string(b)
}
+3
View File
@@ -219,6 +219,9 @@ func TestNormalize(t *testing.T) {
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
"7X2ARA/4": "7X4ARA", // "/4" changes the AREA digit (2→4), NOT the 7 of the 7X prefix (was wrongly 4X2ARA = Israel)
"3A2ABC/6": "3A6ABC", // digit-first prefix 3A: area digit is the 2, not the 3
"4X1AB/2": "4X2AB", // 4X (Israel) area 1→2, keep the leading 4
"DL/F4BPO": "DL",
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
"MM/LY3X/P": "MM",
+64 -3
View File
@@ -21,6 +21,12 @@ const clublogRealtimeURL = "https://clublog.org/realtime.php"
// N QSOs is one HTTP request instead of N realtime.php calls.
const clublogBatchURL = "https://clublog.org/putlogs.php"
// clublogUserAgent identifies OpsLog to Club Log. Go's default
// "Go-http-client/1.1" User-Agent is blocked by Club Log's web front end (nginx
// returns 403 Forbidden before the request reaches the app), so every request
// must send a real, app-identifying User-Agent.
const clublogUserAgent = "OpsLog/1.0 (+https://github.com/GregTroar/OpsLog)"
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
// requires an api parameter that identifies the client software (not the
// user) — the same way Log4OM embeds its own key — so we ship it baked in
@@ -122,6 +128,7 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("User-Agent", clublogUserAgent)
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
@@ -135,10 +142,63 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
if resp.StatusCode == http.StatusOK {
return UploadResult{OK: true, Message: msg}, nil
}
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
diag := clublogFailureDiag(resp, msg)
return UploadResult{OK: false, Message: diag}, fmt.Errorf("clublog: batch upload failed: %s", diag)
}
// clublogFailureDiag turns a non-200 response into a readable one-liner that
// names WHO blocked the request — Club Log's app vs. an intermediary (Cloudflare,
// Sucuri, a corporate proxy, an antivirus TLS shim). A bare "403 Forbidden nginx"
// page means the request never reached the app; the fingerprint headers below
// tell the operator whether it's Club Log's own WAF or something on their side.
func clublogFailureDiag(resp *http.Response, body string) string {
server := strings.TrimSpace(resp.Header.Get("Server"))
// Notable intermediary fingerprints — presence points at the culprit.
fp := []string{}
for _, h := range []string{"CF-RAY", "CF-Mitigated", "X-Sucuri-ID", "X-Sucuri-Block", "X-Squid-Error", "Via", "X-Cache", "Retry-After"} {
if v := strings.TrimSpace(resp.Header.Get(h)); v != "" {
fp = append(fp, h+"="+v)
}
}
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: batch upload failed: %s", msg)
// Collapse the HTML error page to something short.
summary := stripHTMLBrief(body)
if summary == "" {
summary = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
out := fmt.Sprintf("HTTP %d — %s", resp.StatusCode, summary)
if server != "" {
out += " [server=" + server + "]"
}
if len(fp) > 0 {
out += " [" + strings.Join(fp, " ") + "]"
}
return out
}
// stripHTMLBrief removes tags and collapses whitespace, returning the first ~160
// chars of visible text — enough to read "403 Forbidden" without the markup.
func stripHTMLBrief(s string) string {
var b strings.Builder
depth := 0
for _, r := range s {
switch r {
case '<':
depth++
case '>':
if depth > 0 {
depth--
}
default:
if depth == 0 {
b.WriteRune(r)
}
}
}
out := strings.Join(strings.Fields(b.String()), " ")
if len(out) > 160 {
out = out[:160] + "…"
}
return out
}
// TestClublog validates the configured credentials by attempting a no-op
@@ -164,6 +224,7 @@ func clublogPost(ctx context.Context, client *http.Client, endpoint string, form
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", clublogUserAgent)
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
+149
View File
@@ -53,6 +53,155 @@ func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string)
return msg, nil
}
// eQSL Inbox download endpoint + base host. DownloadInBox.cfm builds an ADIF of
// the account's *received* eQSLs (confirmations) and returns an HTML page that
// links to the generated .adi file; we then fetch that file (two-step API).
const (
eqslDownloadInboxURL = "https://www.eQSL.cc/qslcard/DownloadInBox.cfm"
eqslBaseURL = "https://www.eQSL.cc"
)
// eqslAdiHrefRe pulls the generated .adi link out of the DownloadInBox reply.
// eQSL serves old HTML 4.01 with UPPERCASE tags and often UNQUOTED attributes,
// e.g. `<A HREF=/downloadedFiles/xxxx.adi>` or `<a href="…/xxxx.adi">`, and the
// path may be root-relative or a bare filename. Match case-insensitively with
// optional quoting; resolve to an absolute URL afterwards.
var eqslAdiHrefRe = regexp.MustCompile(`(?i)href\s*=\s*["']?([^"'>\s]+\.adi)`)
// DownloadEQSLConfirmations fetches the account's Inbox (received eQSLs =
// confirmations) as ADIF text. since is "YYYY-MM-DD" (only QSLs received since
// then, incremental "last download"); qthNick comes from the config when the
// account has several QTH profiles.
func DownloadEQSLConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
user := strings.ToUpper(strings.TrimSpace(cfg.Username))
if user == "" || strings.TrimSpace(cfg.Password) == "" {
return "", fmt.Errorf("eqsl: username/password not set")
}
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
q := url.Values{}
q.Set("UserName", user)
q.Set("Password", cfg.Password)
if nick := strings.TrimSpace(cfg.QTHNickname); nick != "" {
q.Set("QTHNickname", nick)
}
if s := eqslRcvdSince(since); s != "" {
q.Set("RcvdSince", s) // eQSL wants YYYYMMDDHHMM
}
// Step 1: ask eQSL to build the inbox ADIF; the reply is HTML linking to it.
page, err := eqslGet(ctx, client, eqslDownloadInboxURL+"?"+q.Encode())
if err != nil {
return "", err
}
if reason := authErrEQSL(page); reason != "" {
return "", fmt.Errorf("eqsl: %s", reason)
}
m := eqslAdiHrefRe.FindStringSubmatch(page)
if m == nil {
// eQSL reports problems inline ("Error: …", "You have no … confirmations").
return "", fmt.Errorf("eqsl: no download link in response: %s", eqslNoLinkReason(page))
}
// Resolve the link to an absolute URL. eQSL is a Windows/ColdFusion server, so
// the path may use BACKSLASHES and a leading `..` (e.g. `..\downloadedFiles\
// xxx.adi`); normalise those. It may also be absolute or a bare filename.
link := strings.TrimSpace(m[1])
link = strings.ReplaceAll(link, `\`, "/")
link = strings.TrimPrefix(link, "..") // "../downloadedFiles/…" → "/downloadedFiles/…"
var fileURL string
switch {
case strings.HasPrefix(strings.ToLower(link), "http"):
fileURL = link
case strings.HasPrefix(link, "/"):
fileURL = eqslBaseURL + link
default:
fileURL = eqslBaseURL + "/" + link
}
// Step 2: fetch the generated .adi file.
adif, err := eqslGet(ctx, client, fileURL)
if err != nil {
return "", fmt.Errorf("eqsl: fetch adif: %w", err)
}
return adif, nil
}
// eqslNoLinkReason builds a helpful message when no .adi link was found: eQSL's
// own error/notice text if present, else a short tag-stripped snippet of the page
// so the real wording (e.g. "You have no Inbox items") is visible in the log.
func eqslNoLinkReason(page string) string {
low := strings.ToLower(page)
for _, marker := range []string{"you have no", "no inbox", "error", "invalid", "not found"} {
if i := strings.Index(low, marker); i >= 0 {
seg := page[i:]
if len(seg) > 160 {
seg = seg[:160]
}
return strings.Join(strings.Fields(stripTags(seg)), " ")
}
}
txt := strings.Join(strings.Fields(stripTags(page)), " ")
if len(txt) > 200 {
txt = txt[:200]
}
if txt == "" {
txt = "empty response"
}
return txt
}
// stripTags removes HTML tags for a readable one-line diagnostic.
func stripTags(s string) string {
var b strings.Builder
depth := 0
for _, r := range s {
switch r {
case '<':
depth++
case '>':
if depth > 0 {
depth--
}
default:
if depth == 0 {
b.WriteRune(r)
}
}
}
return b.String()
}
// eqslGet does a plain GET and returns the body as text (32 MB cap), erroring on
// a non-200 status.
func eqslGet(ctx context.Context, client *http.Client, u string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", fmt.Errorf("eqsl: build request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("eqsl: request failed: %w", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("eqsl: http %d", resp.StatusCode)
}
return string(b), nil
}
// eqslRcvdSince converts the app's "YYYY-MM-DD" into eQSL's RcvdSince format
// "YYYYMMDDHHMM" (midnight). Empty in → empty out (full pull).
func eqslRcvdSince(since string) string {
s := strings.ReplaceAll(strings.TrimSpace(since), "-", "")
if len(s) == 8 { // YYYYMMDD → append 0000 (00:00)
return s + "0000"
}
return s
}
// authErrEQSL returns a reason when the response signals bad credentials, else
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
func authErrEQSL(body string) string {
+11
View File
@@ -3,6 +3,7 @@ package extsvc
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
@@ -11,6 +12,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
@@ -212,6 +214,15 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
if runErr != nil {
if ee, ok := runErr.(*exec.ExitError); ok {
code = ee.ExitCode()
} else if errors.Is(runErr, syscall.Errno(740)) || strings.Contains(strings.ToLower(runErr.Error()), "requires elevation") {
// ERROR_ELEVATION_REQUIRED (740): tqsl.exe is set to require admin
// rights (its "Run as administrator" compatibility flag, or an
// AppCompat RUNASADMIN entry), but OpsLog isn't elevated so Windows
// refuses to launch it. Actionable message instead of the raw error.
return UploadResult{}, fmt.Errorf(
"lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". " +
"Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). " +
"Or run OpsLog itself as administrator.", tqsl)
} else {
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
}
+50 -4
View File
@@ -43,9 +43,20 @@ type Event struct {
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
DXGrid string // ServiceWSJT (Status)
Mode string // ServiceWSJT (Status)
Mode string // ServiceWSJT (Status/Decode)
FreqHz int64 // ServiceWSJT (Status)
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
// A WSJT-X Decode (heard station) to render on the panadapter.
DecodeCall string // transmitting (DE) callsign
DecodeFreqHz int64 // RF frequency (dial + audio offset)
DecodeSNR int // reported SNR (dB)
DecodeCQ bool // the decode was a CQ
// ClearCall is set when a WSJT/JTDX/MSHV Status message reports an EMPTY DX
// Call after previously reporting one — i.e. the operator cleared the call in
// the digital app. OpsLog clears its entry to match.
ClearCall bool
}
// Server is a single inbound UDP listener.
@@ -57,6 +68,9 @@ type Server struct {
done chan struct{}
stopped bool
mu sync.Mutex
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
}
func newServer(cfg Config, out chan<- Event) *Server {
@@ -175,9 +189,29 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
return
}
if !ok {
applog.Printf("udp: [%s] WSJT msg type ignored\n", s.cfg.Name)
return
}
// Status carries the current dial frequency; remember it so Decode audio
// offsets can be turned into RF frequencies for the panadapter.
if w.FreqHz > 0 && !w.IsDecode {
s.mu.Lock()
s.lastDialHz = w.FreqHz
s.mu.Unlock()
}
if w.IsDecode {
s.mu.Lock()
dial := s.lastDialHz
s.mu.Unlock()
if dial <= 0 {
return // no dial freq yet (Status not seen) → can't place the spot
}
ev.DecodeCall = w.DecodeCall
ev.DecodeFreqHz = dial + w.DeltaFreqHz
ev.DecodeSNR = w.SNR
ev.DecodeCQ = w.IsCQ
ev.Mode = w.Mode
break
}
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
ev.DXCall = w.DXCall
@@ -185,6 +219,17 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.Mode = w.Mode
ev.FreqHz = w.FreqHz
ev.LoggedADIF = w.LoggedADIF
// A Status with an empty DX Call, right after one that had a call, means the
// operator cleared it in WSJT-X / JTDX / MSHV. Fire ONE clear (tracked per
// server) — an idle app sends empty Status every second, and we must not
// re-clear (which would fight a manual entry) on each of those.
s.mu.Lock()
prev := s.lastDX
s.lastDX = w.DXCall
s.mu.Unlock()
if w.DXCall == "" && prev != "" {
ev.ClearCall = true
}
case ServiceADIF:
// JTAlert / GridTracker forward a text ADIF record after a QSO is
// logged. Guard against keep-alive / non-ADIF chatter on the socket:
@@ -240,8 +285,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
default:
return
}
// Empty events are useless; skip.
if ev.DXCall == "" && ev.LoggedADIF == "" {
// Empty events are useless; skip — EXCEPT a clear signal, which is meant to be
// empty (the DX Call was cleared in the digital app).
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall {
return
}
select {
+122 -5
View File
@@ -37,18 +37,27 @@ const (
)
// WSJTEvent is the parsed, typed result of decoding a single packet.
// One of (DXCall, LoggedADIF) is non-empty depending on the message.
// One of (DXCall, LoggedADIF, DecodeCall) is non-empty depending on the message.
type WSJTEvent struct {
DXCall string // current "DX Call" field in the WSJT app
DXGrid string // optional grid for that call
DXCall string // current "DX Call" field in the WSJT app (Status)
DXGrid string // optional grid for that call (Status)
Mode string // FT8 / FT4 / …
FreqHz int64 // current dial freq when available
FreqHz int64 // current dial freq when available (Status)
LoggedADIF string // full ADIF text when message is LoggedADIF
ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup
// Decode (type 2): the transmitting station heard on the band. FreqHz is NOT
// set here (Decode carries only the audio offset); the caller adds the last
// known dial frequency (from Status) to DeltaFreqHz to get the RF frequency.
IsDecode bool
DecodeCall string // the sender (DE) callsign extracted from the message text
DeltaFreqHz int64 // audio offset within the passband (Hz)
SNR int // reported signal-to-noise (dB)
IsCQ bool // the decode was a CQ call
}
// ParseWSJT decodes one UDP packet. Returns ok=false for messages we
// don't care about (heartbeat, decode lines, clears, etc.).
// don't care about (heartbeat, clears, etc.).
func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
if len(pkt) < 12 {
return WSJTEvent{}, false, fmt.Errorf("packet too short")
@@ -143,6 +152,56 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))
return ev, true, nil
case wsjtMsgDecode:
// Decode payload (v2):
// bool is_new
// quint32 time (ms since midnight)
// qint32 snr
// double delta_time (seconds)
// quint32 delta_frequency (Hz, audio offset in the passband)
// QUtf8 mode
// QUtf8 message (the decoded text, e.g. "CQ K1ABC FN42")
// bool low_confidence
// bool off_air
var b uint8
if err := binary.Read(r, binary.BigEndian, &b); err != nil { // is_new
return WSJTEvent{}, false, err
}
var t32, df uint32
var snr int32
if err := binary.Read(r, binary.BigEndian, &t32); err != nil { // time
return WSJTEvent{}, false, err
}
if err := binary.Read(r, binary.BigEndian, &snr); err != nil {
return WSJTEvent{}, false, err
}
var dt float64
if err := binary.Read(r, binary.BigEndian, &dt); err != nil { // delta_time
return WSJTEvent{}, false, err
}
if err := binary.Read(r, binary.BigEndian, &df); err != nil { // delta_frequency
return WSJTEvent{}, false, err
}
mode, err := readQString(r)
if err != nil {
return WSJTEvent{}, false, err
}
msg, err := readQString(r)
if err != nil {
return WSJTEvent{}, false, err
}
call, isCQ := wsjtSender(msg)
if call == "" {
return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore
}
ev.IsDecode = true
ev.DecodeCall = call
ev.IsCQ = isCQ
ev.DeltaFreqHz = int64(df)
ev.SNR = int(snr)
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
return ev, true, nil
case wsjtMsgLoggedADIF:
// Payload: a single QString containing the ADIF record.
adif, err := readQString(r)
@@ -155,6 +214,64 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
return WSJTEvent{}, false, nil
}
// wsjtSender extracts the transmitting (DE) callsign from a WSJT-X message and
// whether it was a CQ. Grammar:
//
// CQ [modifier] <de_call> [grid] → de_call, isCQ=true
// <to_call> <de_call> [report|…] → de_call, isCQ=false
//
// Returns "" for free-text / telemetry / hashed-call messages we can't resolve.
func wsjtSender(message string) (call string, isCQ bool) {
f := strings.Fields(strings.ToUpper(strings.TrimSpace(message)))
if len(f) == 0 {
return "", false
}
if f[0] == "CQ" {
// Skip an optional modifier after CQ (DX / a region like NA / a zone like
// 020) — it never looks like a callsign (no letter+digit mix).
idx := 1
if len(f) > 2 && !looksLikeCall(f[1]) {
idx = 2
}
if idx < len(f) && looksLikeCall(f[idx]) {
return f[idx], true
}
return "", true
}
// Standard exchange: the DE (sender) call is the second token.
if len(f) >= 2 && looksLikeCall(f[1]) {
return f[1], false
}
return "", false
}
// looksLikeCall is a loose callsign test: 312 chars of AZ/09//, with at least
// one letter AND one digit. Rejects the fixed exchange tokens (RR73/RRR/73) that
// would otherwise pass.
func looksLikeCall(s string) bool {
switch s {
case "RR73", "RRR", "73":
return false
}
if len(s) < 3 || len(s) > 12 {
return false
}
hasL, hasD := false, false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'A' && c <= 'Z':
hasL = true
case c >= '0' && c <= '9':
hasD = true
case c == '/':
default:
return false
}
}
return hasL && hasD
}
// readQString reads a Qt QString as written by QDataStream: an int32 byte
// length (or -1 for null) followed by the UTF-8 bytes.
func readQString(r *bytes.Reader) (string, error) {
@@ -0,0 +1,32 @@
package udp
import "testing"
func TestWSJTSender(t *testing.T) {
cases := []struct {
msg string
wantCall string
wantCQ bool
}{
{"CQ K1ABC FN42", "K1ABC", true},
{"CQ DX W2XYZ EM12", "W2XYZ", true}, // modifier "DX" skipped
{"CQ NA VE3ABC FN03", "VE3ABC", true}, // region modifier skipped
{"CQ 020 JA1XYZ PM95", "JA1XYZ", true},// zone modifier skipped
{"W2XYZ K1ABC -10", "K1ABC", false}, // exchange → sender is 2nd call
{"W2XYZ K1ABC R-10", "K1ABC", false},
{"W2XYZ K1ABC RR73", "K1ABC", false},
{"F4BPO K1ABC/P 73", "K1ABC/P", false},// portable call kept
{"CQ F/DL1ABC JO31", "F/DL1ABC", true},// compound prefix
// Non-callsign / free text → no sender.
{"TNX 73 GL", "", false},
{"K1ABC RR73", "", false}, // only one call + a token → 2nd token not a call
{"", "", false},
{"CQ CQ CQ", "", true}, // CQ but no resolvable call
}
for _, c := range cases {
call, cq := wsjtSender(c.msg)
if call != c.wantCall || cq != c.wantCQ {
t.Errorf("wsjtSender(%q) = (%q,%v), want (%q,%v)", c.msg, call, cq, c.wantCall, c.wantCQ)
}
}
}
+178
View File
@@ -0,0 +1,178 @@
// Package lotwusers tracks which callsigns are LoTW users and when they last
// uploaded, from ARRL's public "lotw-user-activity.csv" feed. OpsLog shows a
// colour-coded badge next to the entered callsign (recent upload = likely to
// confirm on LoTW). The list is cached on disk so it survives restarts.
package lotwusers
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// feedURL is ARRL's public LoTW last-upload activity list (CSV:
// "CALLSIGN,YYYY-MM-DD,HH:MM:SS", one line per registered LoTW callsign).
const feedURL = "https://lotw.arrl.org/lotw-user-activity.csv"
const cacheFile = "lotw-user-activity.csv"
// Info is a lookup result for one callsign.
type Info struct {
IsUser bool `json:"is_user"`
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
}
// Manager holds the parsed list + cache location.
type Manager struct {
mu sync.RWMutex
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
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{
users: map[string]time.Time{},
dir: dataDir,
client: &http.Client{Timeout: 90 * 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 list, caches it to disk and replaces the in-memory
// map. Returns the number of callsigns loaded.
func (m *Manager) Download(ctx context.Context) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, 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("lotwusers: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("lotwusers: http %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024*1024))
if err != nil {
return 0, fmt.Errorf("lotwusers: read: %w", err)
}
n := m.parse(body)
if n == 0 {
return 0, fmt.Errorf("lotwusers: feed 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 CSV bytes into the map and returns the count.
func (m *Manager) parse(data []byte) int {
users := make(map[string]time.Time, 1<<20)
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
parts := strings.Split(line, ",")
if len(parts) < 2 {
continue
}
call := strings.ToUpper(strings.TrimSpace(parts[0]))
if call == "" || strings.EqualFold(call, "callsign") {
continue // header row
}
t, err := time.Parse("2006-01-02", strings.TrimSpace(parts[1]))
if err != nil {
continue
}
users[call] = t
}
if len(users) == 0 {
return 0
}
m.mu.Lock()
m.users = users
m.mu.Unlock()
return len(users)
}
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
// the longest slash-separated segment — the base call LoTW is keyed on.
func (m *Manager) Lookup(callsign string) Info {
c := strings.ToUpper(strings.TrimSpace(callsign))
if c == "" {
return Info{DaysAgo: -1}
}
m.mu.RLock()
defer m.mu.RUnlock()
if len(m.users) == 0 {
return Info{DaysAgo: -1}
}
t, ok := m.users[c]
if !ok && strings.Contains(c, "/") {
base := ""
for _, seg := range strings.Split(c, "/") {
if len(seg) > len(base) {
base = seg
}
}
t, ok = m.users[base]
}
if !ok {
return Info{DaysAgo: -1}
}
days := int(time.Since(t).Hours() / 24)
if days < 0 {
days = 0
}
return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days}
}
// Count returns how many callsigns are loaded.
func (m *Manager) Count() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.users)
}
// 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
}
+202
View File
@@ -0,0 +1,202 @@
// Package offlineq is OpsLog's offline safety net.
//
// When the shared MySQL logbook is unreachable, a QSO must never be lost just
// because the network blinked. Instead of failing, the QSO is appended to a
// local ADIF file (the "outbox") and replayed into the database as soon as it
// comes back — then the file is ARCHIVED, never deleted.
//
// Deliberately NOT a sync engine: it only ever PUSHES the operator's own QSOs.
// There is no mirror, no pull, no merge, no tombstones — which is exactly why it
// stays small. The cost, accepted by design: during an outage you don't see other
// operators' QSOs and the worked-before check doesn't know about your pending
// ones. See the queue view in the UI for what's waiting.
//
// The file lives in OpsLog's data directory — NEVER in a cloud-synced folder
// (Seafile/OneDrive): replicating a live file byte-by-byte is what we're
// escaping in the first place.
package offlineq
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"hamlog/internal/adif"
"hamlog/internal/qso"
)
// FileName is the outbox. Pending QSOs accumulate here while the DB is down.
const FileName = "opslog-pending.adi"
// Queue owns the outbox file. All operations are serialised: the logging path
// (append) and the replay loop (read/rewrite/archive) run on different
// goroutines.
type Queue struct {
dir string
mu sync.Mutex
}
// New returns a queue backed by <dir>/opslog-pending.adi.
func New(dir string) *Queue { return &Queue{dir: strings.TrimSpace(dir)} }
// Path is the outbox file's location (shown in the UI so the operator always
// knows where their QSOs physically are).
func (q *Queue) Path() string { return filepath.Join(q.dir, FileName) }
// newQueueID mints the id that makes replay idempotent.
func newQueueID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("t%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
// Append parks a QSO in the outbox, stamping it with a queue id (returned) so a
// repeated replay can recognise it. The file is created with an ADIF header on
// first use, then appended to — an append can't corrupt what's already there.
func (q *Queue) Append(rec qso.QSO) (string, error) {
q.mu.Lock()
defer q.mu.Unlock()
if rec.Extras == nil {
rec.Extras = map[string]string{}
}
qid := strings.TrimSpace(rec.Extras[qso.OfflineQueueKey])
if qid == "" {
qid = newQueueID()
rec.Extras[qso.OfflineQueueKey] = qid
}
if err := os.MkdirAll(q.dir, 0o755); err != nil {
return "", fmt.Errorf("offlineq: create dir: %w", err)
}
path := q.Path()
_, statErr := os.Stat(path)
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return "", fmt.Errorf("offlineq: open %s: %w", path, err)
}
defer f.Close()
var b strings.Builder
if os.IsNotExist(statErr) { // brand-new file → write the ADIF header once
b.WriteString("OpsLog offline queue — QSOs logged while the database was unreachable.\n")
b.WriteString("<ADIF_VER:5>3.1.4 <PROGRAMID:6>OpsLog <EOH>\n\n")
}
b.WriteString(strings.TrimRight(adif.FullRecordADIF(rec), "\r\n"))
b.WriteString("\n")
if _, err := f.WriteString(b.String()); err != nil {
return "", fmt.Errorf("offlineq: write: %w", err)
}
// Flush to disk: the whole point is surviving a crash/power cut.
if err := f.Sync(); err != nil {
return "", fmt.Errorf("offlineq: sync: %w", err)
}
return qid, nil
}
// Pending parses the outbox into QSOs (each carrying its queue id in Extras).
// A missing file simply means nothing is pending.
func (q *Queue) Pending() ([]qso.QSO, error) {
q.mu.Lock()
defer q.mu.Unlock()
return q.pendingLocked()
}
func (q *Queue) pendingLocked() ([]qso.QSO, error) {
f, err := os.Open(q.Path())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer f.Close()
var out []qso.QSO
err = adif.Parse(f, func(rec adif.Record) error {
if v, ok := adif.RecordToQSO(rec); ok {
out = append(out, v)
}
return nil
})
if err != nil {
return out, fmt.Errorf("offlineq: parse %s: %w", q.Path(), err)
}
return out, nil
}
// Count is how many QSOs are waiting (0 when the file is absent).
func (q *Queue) Count() int {
p, err := q.Pending()
if err != nil {
return 0
}
return len(p)
}
// Rewrite replaces the outbox with exactly these QSOs — used after a replay to
// keep only the ones that FAILED. Written to a temp file and renamed, so a crash
// mid-write can't truncate the queue.
func (q *Queue) Rewrite(keep []qso.QSO) error {
q.mu.Lock()
defer q.mu.Unlock()
return q.rewriteLocked(keep)
}
func (q *Queue) rewriteLocked(keep []qso.QSO) error {
path := q.Path()
if len(keep) == 0 {
// Nothing left: remove the (now empty) outbox. The caller archives the
// original content first, so this never destroys the only copy.
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("offlineq: remove: %w", err)
}
return nil
}
var b strings.Builder
b.WriteString("OpsLog offline queue — QSOs logged while the database was unreachable.\n")
b.WriteString("<ADIF_VER:5>3.1.4 <PROGRAMID:6>OpsLog <EOH>\n\n")
for _, v := range keep {
b.WriteString(strings.TrimRight(adif.FullRecordADIF(v), "\r\n"))
b.WriteString("\n")
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
return fmt.Errorf("offlineq: write temp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("offlineq: replace: %w", err)
}
return nil
}
// Archive copies the outbox to a timestamped file BEFORE it is cleared, so the
// QSOs always exist somewhere on disk even if the replay later turns out to have
// gone wrong. Deleting the only copy of someone's contacts is the one mistake
// you don't get to undo.
func (q *Queue) Archive() (string, error) {
q.mu.Lock()
defer q.mu.Unlock()
data, err := os.ReadFile(q.Path())
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
name := fmt.Sprintf("opslog-pending-%s.adi", time.Now().Format("2006-01-02-1504"))
dst := filepath.Join(q.dir, name)
if err := os.WriteFile(dst, data, 0o644); err != nil {
return "", fmt.Errorf("offlineq: archive: %w", err)
}
return dst, nil
}
+93
View File
@@ -0,0 +1,93 @@
package offlineq
import (
"os"
"path/filepath"
"testing"
"time"
"hamlog/internal/qso"
)
// The outbox is the ONLY copy of a QSO logged while the database was down, so the
// round-trip must be lossless: append → read back identical (callsign, band, mode,
// date) and each record must carry a queue id (what makes the replay idempotent).
func TestQueueRoundTrip(t *testing.T) {
dir := t.TempDir()
q := New(dir)
if n := q.Count(); n != 0 {
t.Fatalf("fresh queue: count = %d, want 0", n)
}
mk := func(call, band, mode string) qso.QSO {
return qso.QSO{
Callsign: call, Band: band, Mode: mode,
QSODate: time.Date(2026, 7, 10, 12, 34, 0, 0, time.UTC),
}
}
id1, err := q.Append(mk("K1ABC", "20m", "SSB"))
if err != nil {
t.Fatalf("append 1: %v", err)
}
id2, err := q.Append(mk("DL1XYZ", "40m", "CW"))
if err != nil {
t.Fatalf("append 2: %v", err)
}
if id1 == "" || id2 == "" || id1 == id2 {
t.Fatalf("queue ids must be non-empty and distinct: %q / %q", id1, id2)
}
pending, err := q.Pending()
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 2 {
t.Fatalf("pending = %d, want 2", len(pending))
}
if pending[0].Callsign != "K1ABC" || pending[0].Band != "20m" || pending[0].Mode != "SSB" {
t.Errorf("record 1 round-tripped wrong: %+v", pending[0])
}
if pending[1].Callsign != "DL1XYZ" || pending[1].Mode != "CW" {
t.Errorf("record 2 round-tripped wrong: %+v", pending[1])
}
// The queue id must survive the ADIF round-trip — without it the replay
// can't be idempotent and a crash would duplicate contacts.
for i, p := range pending {
if p.Extras[qso.OfflineQueueKey] == "" {
t.Errorf("record %d lost its %s extra", i, qso.OfflineQueueKey)
}
}
// Archive keeps a copy BEFORE we clear anything.
arch, err := q.Archive()
if err != nil || arch == "" {
t.Fatalf("archive: %v (path %q)", err, arch)
}
if _, err := os.Stat(arch); err != nil {
t.Fatalf("archive file missing: %v", err)
}
// Partial replay: the first QSO went in, the second failed → keep only it.
if err := q.Rewrite(pending[1:]); err != nil {
t.Fatalf("rewrite: %v", err)
}
left, err := q.Pending()
if err != nil {
t.Fatalf("pending after rewrite: %v", err)
}
if len(left) != 1 || left[0].Callsign != "DL1XYZ" {
t.Fatalf("after rewrite: got %d records (%v), want just DL1XYZ", len(left), left)
}
// Everything replayed → the outbox is removed (the archive still holds it).
if err := q.Rewrite(nil); err != nil {
t.Fatalf("rewrite empty: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, FileName)); !os.IsNotExist(err) {
t.Errorf("outbox should be gone once fully replayed, stat err = %v", err)
}
if n := q.Count(); n != 0 {
t.Errorf("count after full replay = %d, want 0", n)
}
}
+49
View File
@@ -0,0 +1,49 @@
package qso
import (
"testing"
"time"
)
func tm(s string) time.Time {
t, _ := time.Parse("2006-01-02T15:04", s)
return t
}
func TestMatchIndexWindowAndMode(t *testing.T) {
idx := &MatchIndex{byMode: map[string][]matchRef{}}
// Local log: a CW QSO, an SSB (logged as USB) QSO, and an FT8 QSO.
idx.Add("4Z4DX", "15m", "CW", tm("2026-06-20T10:13"), 1)
idx.Add("V85NPV", "15m", "USB", tm("2025-04-18T17:01"), 2)
idx.Add("DK0SWL", "20m", "FT8", tm("2026-03-01T09:00"), 3)
win := 10 * time.Minute
cases := []struct {
name string
call, band, mode string
when string
wantID int64
wantOK bool
}{
{"exact", "4Z4DX", "15m", "CW", "2026-06-20T10:13", 1, true},
{"1-min drift", "4Z4DX", "15m", "CW", "2026-06-20T10:12", 1, true},
{"9-min drift ok", "4Z4DX", "15m", "CW", "2026-06-20T10:04", 1, true},
{"30-min drift misses", "4Z4DX", "15m", "CW", "2026-06-20T10:43", 0, false},
// Phone sidebands are interchangeable: an SSB confirmation matches a USB log.
{"SSB conf vs USB log", "V85NPV", "15m", "SSB", "2025-04-18T17:00", 2, true},
{"LSB conf vs USB log", "V85NPV", "15m", "LSB", "2025-04-18T17:00", 2, true},
// But a digital/phone mismatch does NOT match (exact for non-phone).
{"FT8 conf vs SSB log misses", "V85NPV", "15m", "FT8", "2025-04-18T17:00", 0, false},
// Digital is exact: FT8 matches FT8, but FT4 would not.
{"FT8 exact", "DK0SWL", "20m", "FT8", "2026-03-01T09:01", 3, true},
{"FT4 vs FT8 misses", "DK0SWL", "20m", "FT4", "2026-03-01T09:01", 0, false},
{"wrong band misses", "DK0SWL", "40m", "FT8", "2026-03-01T09:01", 0, false},
{"unknown call misses", "N0CALL", "15m", "CW", "2026-06-20T10:13", 0, false},
}
for _, c := range cases {
id, ok := idx.Match(c.call, c.band, c.mode, tm(c.when), win)
if ok != c.wantOK || (ok && id != c.wantID) {
t.Errorf("%s: got (id=%d ok=%v), want (id=%d ok=%v)", c.name, id, ok, c.wantID, c.wantOK)
}
}
}
+352 -6
View File
@@ -490,6 +490,7 @@ type UploadRow struct {
// Manager may filter on (guards the dynamic column name in the query).
var uploadStatusCols = map[string]bool{
"lotw_sent": true,
"eqsl_sent": true,
"qrzcom_qso_upload_status": true,
"clublog_qso_upload_status": true,
"hrdlog_qso_upload_status": true,
@@ -723,6 +724,10 @@ var bulkEditableCols = map[string]bool{
"my_antenna": true,
"my_sig": true,
"my_sig_info": true,
"my_name": true,
"my_arrl_sect": true,
"my_darc_dok": true,
"my_vucc_grids": true,
// Contest — the exchange/label fields that are constant across a run.
// (srx/stx serial numbers stay excluded: they are per-QSO.)
"contest_id": true,
@@ -1010,6 +1015,32 @@ func FilterableFields() []string {
}
// columnExpr resolves a filter field to a safe SQL expression — either a
// OfflineQueueKey is the ADIF extras key stamped on a QSO that was parked in the
// offline safety file. It survives the ADIF round-trip and makes the replay
// IDEMPOTENT: if the app dies between "inserted into the DB" and "removed from
// the file", the next replay sees the id already in the log and skips it instead
// of creating a duplicate.
const OfflineQueueKey = "APP_OPSLOG_QUEUEID"
// ExistsByQueueID reports whether a QSO carrying this offline-queue id is already
// in the logbook — the guard that makes replaying the safety file safe to repeat.
func (r *Repo) ExistsByQueueID(ctx context.Context, qid string) (bool, error) {
qid = strings.TrimSpace(qid)
if qid == "" {
return false, nil
}
expr := "json_extract(extras_json, '$." + OfflineQueueKey + "')"
if db.IsMySQL() {
expr = "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(extras_json,''), '$." + OfflineQueueKey + "'))"
}
var n int
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM qso WHERE `+expr+` = ?`, qid).Scan(&n); err != nil {
return false, err
}
return n > 0, nil
}
// whitelisted column name or a json_extract over extras_json.
func columnExpr(field string) (string, bool) {
f := strings.ToLower(strings.TrimSpace(field))
@@ -1574,7 +1605,7 @@ func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
// column to this list AND populate it in scanAwardQSO below, or that award will
// silently see an empty value during stats/computation.
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
`grid, vucc_grids, country, state, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
`grid, vucc_grids, country, state, cnty, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
`name, qth, address, comment, notes, ` +
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
@@ -1609,6 +1640,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
qsoDateStr string
freqHz sql.NullInt64
grid, vucc, country, state sql.NullString
cnty sql.NullString
cont, iotaRef, sota, pota sql.NullString
dxcc, cqz, ituz sql.NullInt64
name, qth, address sql.NullString
@@ -1618,7 +1650,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
)
if err := s.Scan(
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
&grid, &vucc, &country, &state, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
&grid, &vucc, &country, &state, &cnty, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
&name, &qth, &address, &comment, &notes,
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
); err != nil {
@@ -1633,6 +1665,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
q.VUCCGrids = vucc.String
q.Country = country.String
q.State = state.String
q.County = cnty.String
q.Continent = cont.String
if cqz.Valid {
v := int(cqz.Int64)
@@ -1751,6 +1784,78 @@ func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error)
return out, rows.Err()
}
// WorkedCountyKeys returns the set of counties already worked, keyed by the
// caller-supplied normaliser (award.USCountyKey — passed in to avoid importing
// the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county
// are considered. Empty keys (unresolvable state/county) are skipped.
func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty string) string) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 1024)
for rows.Next() {
var state, cnty string
if err := rows.Scan(&state, &cnty); err != nil {
return nil, err
}
if k := keyFn(state, cnty); k != "" {
out[k] = struct{}{}
}
}
return out, rows.Err()
}
// WorkedCallBandModeKeys returns the set of every worked "CALL|BAND|MODE" key
// (all upper-cased), loaded in one pass. It backs the in-memory worked-index the
// alert engine checks per cluster spot — a DB query per spot cannot keep up with
// an FT8 skimmer firehose (and hammers a remote MySQL).
func (r *Repo) WorkedCallBandModeKeys(ctx context.Context) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT upper(callsign), upper(band), upper(mode) FROM qso WHERE callsign != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 4096)
for rows.Next() {
var c, b, m string
if err := rows.Scan(&c, &b, &m); err != nil {
return nil, err
}
out[c+"|"+b+"|"+m] = struct{}{}
}
return out, rows.Err()
}
// WorkedPOTARefs returns the set of POTA park references already worked
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
// (an n-fer); each is added separately.
func (r *Repo) WorkedPOTARefs(ctx context.Context) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT pota_ref FROM qso WHERE pota_ref IS NOT NULL AND pota_ref != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 256)
for rows.Next() {
var ref string
if err := rows.Scan(&ref); err != nil {
return nil, err
}
for _, p := range strings.Split(ref, ",") {
if p = strings.ToUpper(strings.TrimSpace(p)); p != "" {
out[p] = struct{}{}
}
}
}
return out, rows.Err()
}
// Count returns the total number of QSOs in the database.
func (r *Repo) Count(ctx context.Context) (int64, error) {
var n int64
@@ -1758,6 +1863,40 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
return n, err
}
// RecentRate counts QSOs whose start time falls within each trailing window from
// `now` — the live "QSO rate" meter shown in the header. It scans only the most
// recently inserted rows (ORDER BY id DESC LIMIT), since any QSO in the last hour
// was inserted recently; that keeps it cheap even on a large log. qso_date is the
// repo's text column, parsed with parseTimeLoose (backend-format agnostic).
func (r *Repo) RecentRate(ctx context.Context, now time.Time, windows ...time.Duration) ([]int, error) {
counts := make([]int, len(windows))
// 400 rows covers a full hour even at a blistering contest rate (>300/h); any
// QSO inside the trailing windows is among the most recently inserted.
rows, err := r.db.QueryContext(ctx, `SELECT qso_date FROM qso ORDER BY id DESC LIMIT 400`)
if err != nil {
return counts, err
}
defer rows.Close()
now = now.UTC()
for rows.Next() {
var dateStr sql.NullString
if err := rows.Scan(&dateStr); err != nil {
return counts, err
}
t := parseTimeLoose(dateStr.String).UTC()
if t.IsZero() || t.After(now) {
continue
}
age := now.Sub(t)
for i, w := range windows {
if age <= w {
counts[i]++
}
}
}
return counts, rows.Err()
}
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
// used by the ADIF importer to skip records that would re-create the
// same contact. The key is callsign|YYYY-MM-DDTHH:MM|band|mode — minute
@@ -1813,21 +1952,214 @@ func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
return out, rows.Err()
}
// matchRef is one local QSO's time + id, for time-window confirmation matching.
type matchRef struct {
when time.Time
id int64
}
// MatchIndex matches a downloaded confirmation to a local QSO within a TIME
// WINDOW (not an exact minute): QSL confirmations carry the OTHER station's
// logged time, which drifts a minute or two from ours. The MODE must match, with
// ONE equivalence: the phone modes SSB/USB/LSB are interchangeable (a station may
// confirm USB where we logged SSB). Every other mode is exact (FT8 only matches
// FT8, FT4 only FT4, CW only CW…). Built in one table scan.
type MatchIndex struct {
byMode map[string][]matchRef // call|band|canonMode → refs
}
// canonMode folds the phone sidebands into a single "SSB" bucket; every other
// mode is returned uppercased and unchanged (exact-match).
func canonMode(mode string) string {
m := strings.ToUpper(strings.TrimSpace(mode))
switch m {
case "SSB", "USB", "LSB":
return "SSB"
}
return m
}
func matchKeyMode(call, band, mode string) string {
return strings.ToUpper(call) + "|" + strings.ToLower(band) + "|" + canonMode(mode)
}
// parseQSODate reads the stored qso_date (ISO "2006-01-02T15:04[:05…]") to minute
// precision as UTC. Zero time on failure (never matched).
func parseQSODate(s string) time.Time {
if len(s) >= 16 {
if t, err := time.Parse("2006-01-02T15:04", s[:16]); err == nil {
return t
}
}
return time.Time{}
}
// BuildMatchIndex scans the log once and indexes every QSO by call+band(+mode)
// with its time, for windowed confirmation matching. When ownerCall is non-empty
// the index is SCOPED to QSOs whose station_callsign is that call (or blank —
// legacy QSOs that predate station-callsign stamping), so a confirmation download
// for one of the operator's calls (e.g. F4BPO) never touches QSOs logged under
// another (e.g. TM2Q).
func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchIndex, error) {
idx := &MatchIndex{byMode: map[string][]matchRef{}}
query := `SELECT id, callsign, qso_date, band, mode FROM qso`
var args []any
if oc := strings.ToUpper(strings.TrimSpace(ownerCall)); oc != "" {
query += ` WHERE UPPER(TRIM(COALESCE(station_callsign,''))) IN ('', ?)`
args = append(args, oc)
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return idx, err
}
defer rows.Close()
for rows.Next() {
var id int64
var call, when, band, mode string
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
return idx, err
}
idx.add(call, band, mode, parseQSODate(when), id)
}
return idx, rows.Err()
}
// add inserts a QSO into the index (also used to register a freshly-inserted
// "add not-found" QSO so later confirmations in the same batch can match it).
func (idx *MatchIndex) add(call, band, mode string, when time.Time, id int64) {
mk := matchKeyMode(call, band, mode)
idx.byMode[mk] = append(idx.byMode[mk], matchRef{when: when, id: id})
}
// Add registers a QSO in the index (exported wrapper for callers that inserted a
// not-found confirmation).
func (idx *MatchIndex) Add(call, band, mode string, when time.Time, id int64) {
idx.add(call, band, mode, when, id)
}
// Match returns the id of the local QSO closest in time to `when` (within
// `window`) for the given call/band/mode. The mode must match (phone sidebands
// folded together by canonMode). Ok is false when nothing is within the window.
func (idx *MatchIndex) Match(call, band, mode string, when time.Time, window time.Duration) (int64, bool) {
return closestRef(idx.byMode[matchKeyMode(call, band, mode)], when, window)
}
func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, bool) {
var best int64
bestD := window + time.Second
found := false
for _, rf := range refs {
if rf.when.IsZero() {
continue
}
d := when.Sub(rf.when)
if d < 0 {
d = -d
}
if d <= window && d < bestD {
bestD, best, found = d, rf.id, true
}
}
return best, found
}
// ConfirmedSets captures which DXCC / band / slot combinations are already
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
// ConfirmedSets captures confirmed combinations for the QSL Manager's NEW flags.
// Modes are grouped into CLASSES (Phone/CW/Digital) — a digital confirmation is a
// "new mode/slot" only if no digital mode was confirmed there before (RTTY and FT8
// are the same DIGI class). Raw-mode granularity lives only in the cluster/matrix.
type ConfirmedSets struct {
DXCC map[int]bool // dxcc entity confirmed
Band map[string]bool // "dxcc|band"
Slot map[string]bool // "dxcc|band|mode"
Mode map[string]bool // "dxcc|class"
Slot map[string]bool // "dxcc|band|class"
}
// SlotKey / BandKey build the composite keys used in ConfirmedSets.
// Key builders for ConfirmedSets. Band is mode-agnostic; Mode/Slot use the class.
func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) }
func ModeClassKey(dxcc int, mode string) string {
return fmt.Sprintf("%d|%s", dxcc, modeClass(mode))
}
func SlotClassKey(dxcc int, band, mode string) string {
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), modeClass(mode))
}
// SlotKey is the RAW-mode slot key, kept for the cluster/matrix new-slot flag.
func SlotKey(dxcc int, band, mode string) string {
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
}
// SlotStats is the worked/confirmed tally for the QSL Manager, counting slots by
// mode CLASS (Phone / CW / Digital) — NOT raw mode. Raw-mode granularity (RTTY ≠
// FT8) is kept only for the cluster/matrix "new slot" flag; the totals here match
// how slots are conventionally counted (a band in a class, not in each digital
// sub-mode).
type SlotStats struct {
SlotsWorked int `json:"slots_worked"` // distinct DXCC × band × class, all QSOs
SlotsConfirmed int `json:"slots_confirmed"` // distinct DXCC × band × class, confirmed
DXCCWorked int `json:"dxcc_worked"` // distinct DXCC entities worked
DXCCConfirmed int `json:"dxcc_confirmed"` // distinct DXCC entities confirmed
// Per-class slot breakdown (Phone / CW / Digital) so the numbers are checkable.
PHWorked int `json:"ph_worked"`
PHConfirmed int `json:"ph_confirmed"`
CWWorked int `json:"cw_worked"`
CWConfirmed int `json:"cw_confirmed"`
DIGWorked int `json:"dig_worked"`
DIGConfirmed int `json:"dig_confirmed"`
}
// GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass.
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,'')),
CASE WHEN lotw_rcvd='Y' OR qsl_rcvd='Y' THEN 1 ELSE 0 END
FROM qso`)
if err != nil {
return SlotStats{}, err
}
defer rows.Close()
dxccW, slotW := map[int]bool{}, map[string]bool{}
dxccC, slotC := map[int]bool{}, map[string]bool{}
// Per-class distinct slots (worked "w" / confirmed "c").
clsW := map[string]map[string]bool{"PH": {}, "CW": {}, "DIG": {}}
clsC := map[string]map[string]bool{"PH": {}, "CW": {}, "DIG": {}}
for rows.Next() {
var dxcc, conf int
var band, mode string
if err := rows.Scan(&dxcc, &band, &mode, &conf); err != nil {
return SlotStats{}, err
}
if dxcc == 0 {
continue
}
dxccW[dxcc] = true
if conf == 1 {
dxccC[dxcc] = true
}
if band == "" {
continue // no band → counts for DXCC but not for a slot
}
key := SlotClassKey(dxcc, band, mode)
cl := modeClass(mode)
slotW[key] = true
clsW[cl][key] = true
if conf == 1 {
slotC[key] = true
clsC[cl][key] = true
}
}
return SlotStats{
SlotsWorked: len(slotW), SlotsConfirmed: len(slotC),
DXCCWorked: len(dxccW), DXCCConfirmed: len(dxccC),
PHWorked: len(clsW["PH"]), PHConfirmed: len(clsC["PH"]),
CWWorked: len(clsW["CW"]), CWConfirmed: len(clsC["CW"]),
DIGWorked: len(clsW["DIG"]), DIGConfirmed: len(clsC["DIG"]),
}, rows.Err()
}
// confirmedCols whitelists the received-status columns ConfirmedSlots may
// OR together (guards the dynamic SQL).
var confirmedCols = map[string]bool{
@@ -1843,7 +2175,7 @@ var confirmedCols = map[string]bool{
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
// {qrzcom_qso_download_status}.
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}}
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Mode: map[string]bool{}, Slot: map[string]bool{}}
var conds []string
for _, c := range cols {
if confirmedCols[c] {
@@ -1872,7 +2204,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
}
sets.DXCC[dxcc] = true
sets.Band[BandKey(dxcc, band)] = true
sets.Slot[SlotKey(dxcc, band, mode)] = true
sets.Mode[ModeClassKey(dxcc, mode)] = true
sets.Slot[SlotClassKey(dxcc, band, mode)] = true
}
return sets, rows.Err()
}
@@ -1890,6 +2223,19 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
return nil
}
// MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after
// an eQSL Inbox download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET eqsl_rcvd = 'Y', eqsl_rcvd_date = ?,
updated_at = ? WHERE id = ?`,
date, db.NowISO(), id)
if err != nil {
return fmt.Errorf("mark eqsl confirmed %d: %w", id, err)
}
return nil
}
// MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
+713
View File
@@ -0,0 +1,713 @@
package qso
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"time"
)
// Statistics over the whole logbook.
//
// Everything is aggregated IN GO from one lean scan rather than with SQL GROUP
// BYs. Two reasons: the date maths (year / month / hour of day) would need
// dialect-specific functions — strftime() on SQLite vs YEAR()/HOUR() on MySQL —
// which is exactly the kind of thing that silently works on one backend and
// breaks on the other; and a single pass over a few columns of a 30k-row log is
// a few tens of milliseconds, so the complexity buys nothing.
// Bucket is one labelled count (mode, band, operator, entity…).
type Bucket struct {
Key string `json:"key"`
Count int `json:"count"`
}
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
// are the expensive minutes: they are where the score went.
type Gap struct {
Start string `json:"start"` // RFC3339 — the last QSO before the silence
End string `json:"end"` // the first QSO after it
Minutes int `json:"minutes"`
}
// ContestRun is one contest the operator actually took part in, discovered FROM
// THE LOG (a CONTEST_ID plus the year it ran) rather than from a static list — so
// the picker only ever offers contests you really entered, and never an empty one.
type ContestRun struct {
ID string `json:"id"`
Year int `json:"year"`
Count int `json:"count"`
Start string `json:"start"` // first QSO, RFC3339
End string `json:"end"` // last QSO
}
// ContestRuns lists every (contest, year) pair present in the logbook, most
// recent first.
func (r *Repo) ContestRuns(ctx context.Context) ([]ContestRun, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT contest_id, qso_date FROM qso WHERE contest_id IS NOT NULL AND contest_id <> ''`)
if err != nil {
return nil, err
}
defer rows.Close()
type key struct {
id string
year int
}
agg := map[key]*ContestRun{}
for rows.Next() {
var id, dateStr sql.NullString
if err := rows.Scan(&id, &dateStr); err != nil {
return nil, err
}
cid := strings.ToUpper(strings.TrimSpace(id.String))
if cid == "" {
continue
}
t := parseTimeLoose(dateStr.String).UTC()
if t.IsZero() {
continue
}
k := key{cid, t.Year()}
c, ok := agg[k]
if !ok {
c = &ContestRun{ID: cid, Year: t.Year(), Start: t.Format(time.RFC3339), End: t.Format(time.RFC3339)}
agg[k] = c
}
c.Count++
if t.Format(time.RFC3339) < c.Start {
c.Start = t.Format(time.RFC3339)
}
if t.Format(time.RFC3339) > c.End {
c.End = t.Format(time.RFC3339)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]ContestRun, 0, len(agg))
for _, c := range agg {
out = append(out, *c)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Year != out[j].Year {
return out[i].Year > out[j].Year // most recent first
}
return out[i].ID < out[j].ID
})
return out, nil
}
// gapThreshold is the silence that counts as "off the air". Short enough to catch
// a real break, long enough not to flag the normal pause between two QSOs.
const gapThreshold = 30 * time.Minute
// rateMaxHours caps the per-hour rate timeline. A contest weekend is ~48 h, so a
// week is generous. This is a READABILITY limit, not a memory one: at 30 days the
// chart is 720 hourly bars, each about a pixel wide with an unreadable label — it
// looks broken, which is exactly how it first shipped. Past this the UI says
// "period too long for an hourly chart" instead of drawing mush.
const rateMaxHours = 7 * 24
// Stats is the whole dashboard payload.
type Stats struct {
// Headline figures.
Total int `json:"total"`
UniqueCalls int `json:"unique_calls"`
Entities int `json:"entities"` // distinct DXCC entities
Continents int `json:"continents"` // distinct continents
FirstQSO string `json:"first_qso"` // RFC3339, "" when the log is empty
LastQSO string `json:"last_qso"`
// Confirmations (of Total).
ConfirmedLoTW int `json:"confirmed_lotw"`
ConfirmedEQSL int `json:"confirmed_eqsl"`
ConfirmedQSL int `json:"confirmed_qsl"`
ConfirmedAny int `json:"confirmed_any"`
// Breakdowns, each sorted most → least (bands keep frequency order).
ByMode []Bucket `json:"by_mode"`
ByBand []Bucket `json:"by_band"`
ByOperator []Bucket `json:"by_operator"`
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
ByContinent []Bucket `json:"by_continent"`
TopEntities []Bucket `json:"top_entities"`
ByYear []Bucket `json:"by_year"` // chronological
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
// ── Period / contest metrics ──
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
// nothing, but across a contest weekend it is the score. The window is the
// requested [from,to] when given, else the span of the log.
WindowStart string `json:"window_start"`
WindowEnd string `json:"window_end"`
WindowHours float64 `json:"window_hours"`
AvgPerHour float64 `json:"avg_per_hour"` // QSOs ÷ window hours (breaks included — the honest rate)
AvgPerActive float64 `json:"avg_per_active"` // QSOs ÷ ON-AIR hours
// On-air and off-air are a TIME BUDGET and must add up to the window:
// OnAirMinutes + OffAirMinutes == window
// The first version counted "clock hours containing at least one QSO" as on-air,
// so a single QSO at 08:05 booked the whole 08:00 hour. On a 45 h contest that
// gave 39 h on air AND 16 h 43 off air — 56 h inside a 45 h window. Two numbers
// measured on incompatible bases can't be compared, and the operator rightly
// didn't believe either of them.
OnAirMinutes int `json:"on_air_minutes"`
OffAirMinutes int `json:"off_air_minutes"`
PeakHourKey string `json:"peak_hour_key"` // best clock hour (kept for reference)
PeakHourCount int `json:"peak_hour_count"`
Best60 int `json:"best_60"` // best ROLLING 60 min — the number contesters quote
Gaps []Gap `json:"gaps"` // the silences that make up OffAirMinutes, longest first
Rate []Bucket `json:"rate"` // QSO per clock hour across the window ("MM-DD HH")
// The contest RATE SHEET: hour by hour, who made the QSOs.
// RateOps are the operators, busiest first — that fixed order is also the
// colour/legend order, so an operator keeps their hue across the whole page.
// RateByOp[h][o] is operator o's count in hour h; rows align 1:1 with Rate, so
// the per-operator numbers always sum to the hour's total.
RateOps []string `json:"rate_ops"`
RateByOp [][]int `json:"rate_by_op"`
}
// entry is one dated QSO with the operator who made it — the pair the contest
// rate sheet needs. A bare timestamp can tell you HOW MANY, never BY WHOM.
type entry struct {
t time.Time
op string
}
// bandOrder sorts bands by frequency (160m → 70cm) rather than alphabetically,
// so the band chart reads like a band plan instead of a jumble.
var bandOrder = map[string]int{
"2190m": 1, "630m": 2, "160m": 3, "80m": 4, "60m": 5, "40m": 6, "30m": 7,
"20m": 8, "17m": 9, "15m": 10, "12m": 11, "10m": 12, "6m": 13, "4m": 14,
"2m": 15, "1.25m": 16, "70cm": 17, "23cm": 18, "13cm": 19,
}
// yes reports whether an ADIF confirmation flag means "confirmed".
func yes(s string) bool {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "Y", "V": // V = verified (LoTW)
return true
}
return false
}
// Stats scans the logbook once and returns every breakdown the dashboard needs,
// restricted to [from, to] (a zero time means "no bound", so a zero/zero pair is
// the whole log).
//
// The window is applied HERE, in Go, on the parsed timestamp — not as a SQL
// WHERE. qso_date is a text column whose format differs between the two backends,
// so a string comparison would quietly select the wrong rows on one of them. We
// already parse every date in this pass; filtering on the parsed value is both
// correct and free.
// contestID (with an optional year, 0 = any) narrows the log to one contest. When
// it is set and no explicit window is given, the window becomes the contest's own
// span — so rate, best-hour and off-air figures are computed over the contest
// itself without the operator having to look its dates up.
// Operators returns the distinct operators in the log (upper-cased), with "—"
// for QSOs the station owner logged himself (empty OPERATOR). Sorted, with "—"
// last so the picker reads real callsigns first.
func (r *Repo) Operators(ctx context.Context) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT COALESCE(operator,'') FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
seen := map[string]struct{}{}
for rows.Next() {
var op string
if err := rows.Scan(&op); err != nil {
return nil, err
}
op = strings.ToUpper(strings.TrimSpace(op))
if op == "" {
op = "—"
}
seen[op] = struct{}{}
}
out := make([]string, 0, len(seen))
hasOwner := false
for op := range seen {
if op == "—" {
hasOwner = true
continue
}
out = append(out, op)
}
sort.Strings(out)
if hasOwner {
out = append(out, "—")
}
return out, rows.Err()
}
func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int, operator string) (Stats, error) {
var s Stats
contestID = strings.ToUpper(strings.TrimSpace(contestID))
// Operator filter: "" = all operators; "—" = QSOs the station owner logged
// himself (empty OPERATOR); any other value = that operator's QSOs only.
opFilter := strings.ToUpper(strings.TrimSpace(operator))
rows, err := r.db.QueryContext(ctx, `
SELECT callsign, qso_date, band, mode, cont, country, dxcc,
operator, station_callsign, lotw_rcvd, eqsl_rcvd, qsl_rcvd, contest_id
FROM qso`)
if err != nil {
return s, err
}
defer rows.Close()
var (
calls = map[string]struct{}{}
entities = map[int]struct{}{}
modeC = map[string]int{}
bandC = map[string]int{}
opC = map[string]int{}
stationC = map[string]int{}
contC = map[string]int{}
entityC = map[string]int{}
yearC = map[string]int{}
monthC = map[string]int{}
times []entry // every dated QSO (+ its operator), for the rate / gap maths
first, last time.Time
)
for rows.Next() {
var (
call, band, mode, cont, country sql.NullString
oper, station sql.NullString
lotw, eqsl, paper sql.NullString
dxcc sql.NullInt64
dateStr, contestID2 sql.NullString
)
if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc,
&oper, &station, &lotw, &eqsl, &paper, &contestID2); err != nil {
return s, err
}
// Contest filter first — same reasoning as the window below: a QSO that
// isn't in this contest must not reach ANY bucket.
if contestID != "" && strings.ToUpper(strings.TrimSpace(contestID2.String)) != contestID {
continue
}
// An empty OPERATOR means "the station owner logged it himself" — bucket
// it as "—". Computed here so the operator filter can also drop QSOs that
// aren't this operator's before they reach ANY bucket.
op := strings.ToUpper(strings.TrimSpace(oper.String))
if op == "" {
op = "—"
}
if opFilter != "" && op != opFilter {
continue
}
// Window first: a QSO outside the period must not reach ANY bucket. Doing
// this after the counting (the obvious mistake) would leave the mode/band/
// operator charts showing the whole log while only the trend was filtered.
// parseTimeLoose is the repo's existing convention for qso_date — it copes
// with what each backend hands back (SQLite ISO string, MySQL DATETIME).
t := parseTimeLoose(dateStr.String).UTC()
dated := !t.IsZero()
if year > 0 && (!dated || t.Year() != year) {
continue
}
if !from.IsZero() && (!dated || t.Before(from)) {
continue
}
if !to.IsZero() && (!dated || t.After(to)) {
continue
}
s.Total++
if c := strings.ToUpper(strings.TrimSpace(call.String)); c != "" {
calls[c] = struct{}{}
}
if dxcc.Valid && dxcc.Int64 > 0 {
entities[int(dxcc.Int64)] = struct{}{}
}
if m := strings.ToUpper(strings.TrimSpace(mode.String)); m != "" {
modeC[m]++
}
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
bandC[b]++
}
// op was resolved above (with the operator filter applied).
opC[op]++
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
stationC[st]++
}
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
contC[c]++
}
if c := strings.TrimSpace(country.String); c != "" {
entityC[c]++
}
cl, el, pl := yes(lotw.String), yes(eqsl.String), yes(paper.String)
if cl {
s.ConfirmedLoTW++
}
if el {
s.ConfirmedEQSL++
}
if pl {
s.ConfirmedQSL++
}
if cl || el || pl {
s.ConfirmedAny++
}
// An undated QSO still counts in the mode/band/operator totals above, but
// it can't be placed on a time axis — leave it out of the trend rather than
// parking it at year zero.
if !dated {
continue
}
if first.IsZero() || t.Before(first) {
first = t
}
if last.IsZero() || t.After(last) {
last = t
}
yearC[t.Format("2006")]++
monthC[t.Format("2006-01")]++
times = append(times, entry{t: t, op: op})
}
if err := rows.Err(); err != nil {
return s, err
}
s.UniqueCalls = len(calls)
s.Entities = len(entities)
s.Continents = len(contC)
if !first.IsZero() {
s.FirstQSO = first.UTC().Format(time.RFC3339)
s.LastQSO = last.UTC().Format(time.RFC3339)
}
s.ByMode = topBuckets(modeC, 0)
s.ByOperator = topBuckets(opC, 0)
s.ByStation = topBuckets(stationC, 0)
s.ByContinent = topBuckets(contC, 0)
s.TopEntities = topBuckets(entityC, 15)
// Bands read in band-plan order, not by count — the shape of the chart IS
// the band plan, and re-sorting it by size would destroy that.
s.ByBand = sortedBuckets(bandC, func(a, b string) bool {
oa, ob := bandOrder[a], bandOrder[b]
if oa == 0 {
oa = 99
}
if ob == 0 {
ob = 99
}
if oa != ob {
return oa < ob
}
return a < b
})
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
// chart would invent activity that never happened. A gap in the log is real
// information: it belongs on the chart as zeros.
s.ByYear = fillYears(yearC, first, last)
s.ByMonth = fillMonths(monthC, first, last)
s.periodMetrics(times, from, to, first, last)
s.ensureNonNil()
return s, nil
}
// ensureNonNil replaces every nil slice with an empty one.
//
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
// UI then calls .length / .map on null — a TypeError that unmounts the whole React
// tree and leaves a WHITE SCREEN. It bites exactly in the innocent cases: a contest
// with no break ≥ 30 min (Gaps nil), or a window too long for the hourly chart
// (Rate nil). The awards code carries the same guard for the same reason.
func (s *Stats) ensureNonNil() {
if s.ByMode == nil {
s.ByMode = []Bucket{}
}
if s.ByBand == nil {
s.ByBand = []Bucket{}
}
if s.ByOperator == nil {
s.ByOperator = []Bucket{}
}
if s.ByStation == nil {
s.ByStation = []Bucket{}
}
if s.ByContinent == nil {
s.ByContinent = []Bucket{}
}
if s.TopEntities == nil {
s.TopEntities = []Bucket{}
}
if s.ByYear == nil {
s.ByYear = []Bucket{}
}
if s.ByMonth == nil {
s.ByMonth = []Bucket{}
}
if s.Rate == nil {
s.Rate = []Bucket{}
}
if s.Gaps == nil {
s.Gaps = []Gap{}
}
if s.RateOps == nil {
s.RateOps = []string{}
}
if s.RateByOp == nil {
s.RateByOp = [][]int{}
}
}
// periodMetrics derives the rate / off-air figures that make a contest window
// readable. The window is the caller's [from,to] when given, else the span of the
// log itself.
//
// Two rates are reported on purpose, because a single one always flatters:
// • AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
// • AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
// how fast you go when you ARE at the radio.
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
if len(times) == 0 {
return
}
sort.Slice(times, func(i, j int) bool { return times[i].t.Before(times[j].t) })
winStart, winEnd := from, to
if winStart.IsZero() {
winStart = first
}
if winEnd.IsZero() {
winEnd = last
}
if !winEnd.After(winStart) {
return
}
s.WindowStart = winStart.Format(time.RFC3339)
s.WindowEnd = winEnd.Format(time.RFC3339)
s.WindowHours = winEnd.Sub(winStart).Hours()
if s.WindowHours > 0 {
s.AvgPerHour = float64(len(times)) / s.WindowHours
}
// Clock-hour buckets — for the rate chart and the best clock hour only. NOT for
// "hours on air": a single QSO at 08:05 would book the whole 08:00 hour.
hourly := map[string]int{}
for _, e := range times {
hourly[e.t.Format("2006-01-02 15")]++
}
for k, v := range hourly {
if v > s.PeakHourCount || (v == s.PeakHourCount && k < s.PeakHourKey) {
s.PeakHourKey, s.PeakHourCount = k, v
}
}
// Best ROLLING 60 minutes — not the best clock hour. A run straddling 13:45
// 14:45 is invisible to clock-hour bucketing, and it's the figure contesters
// actually quote. Two pointers over the sorted times: O(n).
lo := 0
for hi := range times {
for times[hi].t.Sub(times[lo].t) >= time.Hour {
lo++
}
if n := hi - lo + 1; n > s.Best60 {
s.Best60 = n
}
}
// Off-air is a TIME BUDGET, and it has to close on the window:
// OnAirMinutes + OffAirMinutes == window
// So every silence ≥ 30 min counts — including the lead-in before the first QSO
// and the tail after the last, when an explicit window was asked for. Skipping
// those (the first version did) makes "on air" and "off air" sum to more than
// the window, and then neither number is believable.
addGap := func(a, b time.Time) {
d := b.Sub(a)
if d < gapThreshold {
return
}
s.OffAirMinutes += int(d.Minutes())
s.Gaps = append(s.Gaps, Gap{
Start: a.Format(time.RFC3339),
End: b.Format(time.RFC3339),
Minutes: int(d.Minutes()),
})
}
addGap(winStart, times[0].t) // lead-in
for i := 1; i < len(times); i++ { // the silences between QSOs
addGap(times[i-1].t, times[i].t)
}
addGap(times[len(times)-1].t, winEnd) // tail
s.OnAirMinutes = int(winEnd.Sub(winStart).Minutes()) - s.OffAirMinutes
if s.OnAirMinutes < 0 {
s.OnAirMinutes = 0
}
if s.OnAirMinutes > 0 {
s.AvgPerActive = float64(len(times)) / (float64(s.OnAirMinutes) / 60)
}
sort.Slice(s.Gaps, func(i, j int) bool { return s.Gaps[i].Minutes > s.Gaps[j].Minutes })
if len(s.Gaps) > 10 {
s.Gaps = s.Gaps[:10] // the long ones are the story; the tail is noise
}
// Per-hour rate timeline + the RATE SHEET (who made those QSOs, hour by hour).
// Every hour of the window, zeros included, so the silences read as silences.
if s.WindowHours > rateMaxHours {
return
}
// Operators, busiest first. That order is fixed and reused as the colour/legend
// order, so an operator keeps the same hue everywhere on the page — a chart that
// repaints its series when the filter changes is a chart nobody can trust.
opTotals := map[string]int{}
for _, e := range times {
opTotals[e.op]++
}
s.RateOps = make([]string, 0, len(opTotals))
for op := range opTotals {
s.RateOps = append(s.RateOps, op)
}
sort.Slice(s.RateOps, func(i, j int) bool {
a, b := s.RateOps[i], s.RateOps[j]
if opTotals[a] != opTotals[b] {
return opTotals[a] > opTotals[b]
}
return a < b
})
// Never invent a 9th colour: past 8 operators the tail folds into "Other", which
// is honest and still sums correctly.
const maxOps = 8
folded := false
if len(s.RateOps) > maxOps {
s.RateOps = append(s.RateOps[:maxOps:maxOps], otherOp)
folded = true
}
opIdx := map[string]int{}
for i, op := range s.RateOps {
opIdx[op] = i
}
slotFor := func(op string) int {
if i, ok := opIdx[op]; ok {
return i
}
if folded {
return len(s.RateOps) - 1 // the "Other" bucket
}
return -1
}
// hourOps[hourKey][slot] — built from the same `times` as `hourly`, so the
// per-operator numbers ALWAYS sum to the hour's total. Deriving them separately
// is how a rate sheet ends up not adding up to its own total row.
hourOps := map[string][]int{}
for _, e := range times {
k := e.t.Format("2006-01-02 15")
row, ok := hourOps[k]
if !ok {
row = make([]int, len(s.RateOps))
hourOps[k] = row
}
if i := slotFor(e.op); i >= 0 {
row[i]++
}
}
cur := winStart.Truncate(time.Hour)
end := winEnd.Truncate(time.Hour)
for !cur.After(end) {
k := cur.Format("2006-01-02 15")
s.Rate = append(s.Rate, Bucket{Key: cur.Format("01-02 15"), Count: hourly[k]})
row := hourOps[k]
if row == nil {
row = make([]int, len(s.RateOps)) // a silent hour is zeros, not a missing row
}
s.RateByOp = append(s.RateByOp, row)
cur = cur.Add(time.Hour)
}
}
// otherOp is where operators past the 8th are folded. Generating a 9th colour is
// never the answer: under colour-blindness it is indistinguishable from one of the
// existing eight.
const otherOp = "Other"
// topBuckets sorts a count map most → least (ties alphabetical) and optionally
// keeps only the top n.
func topBuckets(m map[string]int, n int) []Bucket {
out := make([]Bucket, 0, len(m))
for k, v := range m {
out = append(out, Bucket{Key: k, Count: v})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Key < out[j].Key
})
if n > 0 && len(out) > n {
out = out[:n]
}
return out
}
// sortedBuckets keeps a caller-defined key order (band plan, chronology).
func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return less(keys[i], keys[j]) })
out := make([]Bucket, 0, len(keys))
for _, k := range keys {
out = append(out, Bucket{Key: k, Count: m[k]})
}
return out
}
// fillMonths emits EVERY month between the first and last QSO — zeros included —
// so the trend line's x-axis is real time rather than "months that happen to have
// data". A quiet decade must read as a decade at zero, not vanish.
func fillMonths(m map[string]int, first, last time.Time) []Bucket {
if first.IsZero() {
return nil
}
var out []Bucket
cur := time.Date(first.Year(), first.Month(), 1, 0, 0, 0, 0, time.UTC)
end := time.Date(last.Year(), last.Month(), 1, 0, 0, 0, 0, time.UTC)
for !cur.After(end) {
k := cur.Format("2006-01")
out = append(out, Bucket{Key: k, Count: m[k]})
cur = cur.AddDate(0, 1, 0)
}
return out
}
// fillYears does the same for the yearly view.
func fillYears(m map[string]int, first, last time.Time) []Bucket {
if first.IsZero() {
return nil
}
var out []Bucket
for y := first.Year(); y <= last.Year(); y++ {
k := fmt.Sprintf("%04d", y)
out = append(out, Bucket{Key: k, Count: m[k]})
}
return out
}
+228
View File
@@ -0,0 +1,228 @@
package qso
import (
"testing"
"time"
)
// Bands must read in BAND-PLAN order (160m → 70cm), never by count and never
// alphabetically — the order of that chart IS the information.
func TestBandPlanOrder(t *testing.T) {
counts := map[string]int{"20m": 9312, "160m": 77, "70cm": 3, "40m": 5196, "10m": 3401, "80m": 2332}
got := sortedBuckets(counts, func(a, b string) bool {
oa, ob := bandOrder[a], bandOrder[b]
if oa == 0 {
oa = 99
}
if ob == 0 {
ob = 99
}
if oa != ob {
return oa < ob
}
return a < b
})
want := []string{"160m", "80m", "40m", "20m", "10m", "70cm"}
if len(got) != len(want) {
t.Fatalf("got %d buckets, want %d", len(got), len(want))
}
for i := range want {
if got[i].Key != want[i] {
t.Errorf("position %d = %q, want %q (full: %v)", i, got[i].Key, want[i], got)
}
}
}
// A nil Go slice marshals to JSON `null`, not `[]` — and the UI then calls
// .length/.map on null, which unmounts the whole React tree and leaves a WHITE
// SCREEN. It bites in the innocent cases: a contest with no break ≥ 30 min (Gaps
// nil), or a window too long for the hourly chart (Rate nil). Every slice the
// dashboard reads must therefore come back non-nil, even when empty.
func TestStatsNoNilSlices(t *testing.T) {
var s Stats // the worst case: nothing computed at all
s.ensureNonNil()
checks := map[string]bool{
"ByMode": s.ByMode == nil, "ByBand": s.ByBand == nil, "ByOperator": s.ByOperator == nil,
"ByStation": s.ByStation == nil, "ByContinent": s.ByContinent == nil,
"TopEntities": s.TopEntities == nil, "ByYear": s.ByYear == nil, "ByMonth": s.ByMonth == nil,
"Rate": s.Rate == nil, "Gaps": s.Gaps == nil,
}
for name, isNil := range checks {
if isNil {
t.Errorf("%s is nil → marshals to JSON null → white screen in the UI", name)
}
}
// The realistic trigger: a short, gap-free run. No silence ≥ 30 min and a
// window that yields no hourly chart must still produce [] and not null.
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
times := []entry{{t: base, op: "A"}, {t: base.Add(2 * time.Minute), op: "A"}, {t: base.Add(5 * time.Minute), op: "B"}}
var s2 Stats
s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute))
s2.ensureNonNil()
if s2.Gaps == nil {
t.Error("Gaps nil for a gap-free run — this is exactly the contest that white-screened")
}
if len(s2.Gaps) != 0 {
t.Errorf("Gaps = %v, want empty (no silence ≥ 30 min in this run)", s2.Gaps)
}
}
// Contest metrics over a window. The two traps:
// 1. "Best hour" must be the best ROLLING 60 minutes, not the best clock hour —
// a run straddling 13:4514:45 is invisible to clock-hour bucketing, and the
// rolling figure is the one contesters quote.
// 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included)
// AND QSOs ÷ hours actually operated. Quoting only the latter is how an
// 8-hour effort gets sold as a 48-hour score.
func TestContestPeriodMetrics(t *testing.T) {
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
// A run straddling the clock hour: 10 QSOs from 12:40 to 13:20 (within 60 min),
// then a 2-hour silence, then 3 more.
var times []entry
for i := 0; i < 10; i++ {
times = append(times, entry{t: at(40 + i*4), op: "F4BPO"}) // 12:40 … 13:16
}
for i := 0; i < 3; i++ {
times = append(times, entry{t: at(240 + i*5), op: "F5XYZ"}) // 16:00 …
}
from := base // 12:00
to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window
var s Stats
s.periodMetrics(times, from, to, time.Time{}, time.Time{})
if s.WindowHours != 6 {
t.Errorf("window = %.1f h, want 6", s.WindowHours)
}
// 13 QSOs over a 6 h window.
if got := s.AvgPerHour; got < 2.16 || got > 2.17 {
t.Errorf("avg/h over the window = %.3f, want ~2.167 (13÷6)", got)
}
// The rolling hour must find the straddling run of 10 — a clock-hour bucket
// would only ever see part of it.
if s.Best60 != 10 {
t.Errorf("best rolling 60 min = %d, want 10 (the 12:40→13:16 run)", s.Best60)
}
if s.PeakHourCount >= 10 {
t.Errorf("peak CLOCK hour = %d — it should be < 10, which is exactly why the rolling figure exists", s.PeakHourCount)
}
// THE INVARIANT: on-air + off-air must close on the window. The first version
// counted "clock hours containing a QSO" as on-air, which on a real 45 h contest
// reported 39 h on air AND 16 h 43 off air — 56 h inside 45 h. Two numbers on
// incompatible bases; the operator believed neither, and was right.
if got := s.OnAirMinutes + s.OffAirMinutes; got != int(s.WindowHours*60) {
t.Errorf("on-air (%d) + off-air (%d) = %d min, but the window is %d min — the budget must close",
s.OnAirMinutes, s.OffAirMinutes, got, int(s.WindowHours*60))
}
// Off air = lead-in (12:00→12:40 = 40 min) + the 13:16→16:00 silence (164) +
// the tail (16:10→18:00 = 110). Silences ≥ 30 min all count, wherever they sit:
// ignoring the lead-in and tail is what broke the budget.
if s.OffAirMinutes != 40+164+110 {
t.Errorf("off-air = %d min, want %d (lead-in + gap + tail)", s.OffAirMinutes, 40+164+110)
}
if len(s.Gaps) != 3 {
t.Fatalf("gaps = %+v, want 3 (lead-in, the silence, the tail)", s.Gaps)
}
if s.AvgPerActive <= s.AvgPerHour {
t.Errorf("avg/on-air (%.2f) must exceed avg/window (%.2f) when there are breaks", s.AvgPerActive, s.AvgPerHour)
}
// The rate timeline covers EVERY hour of the window, silences as zeros.
if len(s.Rate) != 7 { // 12,13,14,15,16,17,18
t.Fatalf("rate timeline = %d hours, want 7 (every hour of the window)", len(s.Rate))
}
if s.Rate[2].Count != 0 || s.Rate[3].Count != 0 {
t.Errorf("the 14:00/15:00 silence must show as zeros, got %+v %+v", s.Rate[2], s.Rate[3])
}
}
// The contest RATE SHEET: hour by hour, who made the QSOs.
//
// The invariant that matters: for EVERY hour, the per-operator numbers must sum to
// that hour's total. Derive the two separately and a rate sheet quietly stops
// adding up to its own total row — the sort of error nobody spots until someone
// checks the score by hand.
func TestRateSheetSumsToHourTotal(t *testing.T) {
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
times := []entry{
{t: at(5), op: "F4BPO"}, {t: at(10), op: "F4BPO"}, {t: at(20), op: "F5XYZ"}, // hour 12: 3
{t: at(70), op: "F5XYZ"}, {t: at(80), op: "F5XYZ"}, // hour 13: 2
// hour 14 silent
{t: at(185), op: "F4BPO"}, // hour 15: 1
}
var s Stats
s.periodMetrics(times, base, base.Add(4*time.Hour), time.Time{}, time.Time{})
if len(s.Rate) != len(s.RateByOp) {
t.Fatalf("rate rows (%d) and rate-sheet rows (%d) must align 1:1", len(s.Rate), len(s.RateByOp))
}
// Both operators present, busiest first (they tie at 3 → alphabetical).
if len(s.RateOps) != 2 || s.RateOps[0] != "F4BPO" {
t.Fatalf("rate ops = %v, want [F4BPO F5XYZ]", s.RateOps)
}
for h := range s.Rate {
sum := 0
for _, n := range s.RateByOp[h] {
sum += n
}
if sum != s.Rate[h].Count {
t.Errorf("hour %s: operators sum to %d but the hour total is %d — the rate sheet doesn't add up",
s.Rate[h].Key, sum, s.Rate[h].Count)
}
if len(s.RateByOp[h]) != len(s.RateOps) {
t.Errorf("hour %s: row has %d columns, want %d (one per operator)", s.Rate[h].Key, len(s.RateByOp[h]), len(s.RateOps))
}
}
// The silent hour is a row of zeros, not a missing row.
if s.Rate[2].Count != 0 || s.RateByOp[2][0] != 0 || s.RateByOp[2][1] != 0 {
t.Errorf("the silent 14:00 hour must be zeros, got total=%d row=%v", s.Rate[2].Count, s.RateByOp[2])
}
}
// A quiet decade must appear on the trend as a decade AT ZERO. Emitting only the
// months that have QSOs would put 2012 next to 2022 as if consecutive — the chart
// would invent activity that never happened.
func TestTimeAxisIsContinuous(t *testing.T) {
first := time.Date(2009, 5, 30, 0, 0, 0, 0, time.UTC)
last := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
years := fillYears(map[string]int{"2009": 79, "2012": 1187, "2026": 14415}, first, last)
if len(years) != 18 { // 2009..2026 inclusive
t.Fatalf("years = %d, want 18 (2009→2026 with no holes)", len(years))
}
byKey := map[string]int{}
for _, b := range years {
byKey[b.Key] = b.Count
}
if byKey["2010"] != 0 || byKey["2018"] != 0 {
t.Errorf("silent years must be present as zero, got 2010=%d 2018=%d", byKey["2010"], byKey["2018"])
}
if byKey["2012"] != 1187 || byKey["2026"] != 14415 {
t.Errorf("real counts lost: 2012=%d 2026=%d", byKey["2012"], byKey["2026"])
}
months := fillMonths(map[string]int{"2009-05": 49, "2026-07": 1}, first, last)
// May 2009 → July 2026 inclusive = 17 years * 12 + 3 = 207 months.
if len(months) != 207 {
t.Fatalf("months = %d, want 207 (continuous)", len(months))
}
if months[0].Key != "2009-05" || months[0].Count != 49 {
t.Errorf("first month = %+v, want 2009-05 / 49", months[0])
}
if months[len(months)-1].Key != "2026-07" {
t.Errorf("last month = %q, want 2026-07", months[len(months)-1].Key)
}
// Every step is exactly one month — no jumps.
for i := 1; i < len(months); i++ {
prev, _ := time.Parse("2006-01", months[i-1].Key)
cur, _ := time.Parse("2006-01", months[i].Key)
if !prev.AddDate(0, 1, 0).Equal(cur) {
t.Fatalf("gap in the time axis between %q and %q", months[i-1].Key, months[i].Key)
}
}
}
+168
View File
@@ -0,0 +1,168 @@
// Package relaydev drives network relay boards used for station control (power
// sequencing, switching accessories). Two devices are supported, both over HTTP:
//
// - WebSwitch 1216H — 5 relays. Control: GET /relaycontrol/{on|off}/{n};
// status: GET /relaystate/get2/1$2$3$4$5$ → lines "n,state".
// (Protocol taken from the operator's own working ShackMaster driver.)
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
//
// A Device presents the same surface to the app regardless of wire protocol.
package relaydev
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// Device is one relay board.
type Device interface {
Count() int // number of user-controllable relays
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
Set(ctx context.Context, relay int, on bool) error // relay is 1-based
}
func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }
// get issues a GET with optional basic auth and returns the body on 2xx.
func get(ctx context.Context, url, user, pass string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if user != "" || pass != "" {
req.SetBasicAuth(user, pass)
}
resp, err := httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
// ── WebSwitch 1216H ────────────────────────────────────────────────────
type webswitch struct {
host string
count int
}
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
func (w *webswitch) Count() int { return w.count }
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > w.count {
return fmt.Errorf("relay %d out of range 1..%d", relay, w.count)
}
action := "off"
if on {
action = "on"
}
_, err := get(ctx, fmt.Sprintf("http://%s/relaycontrol/%s/%d", w.host, action, relay), "", "")
return err
}
func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
// Build the "1$2$3$..." selector the device expects.
var sel strings.Builder
for i := 1; i <= w.count; i++ {
sel.WriteString(strconv.Itoa(i))
sel.WriteByte('$')
}
body, err := get(ctx, fmt.Sprintf("http://%s/relaystate/get2/%s", w.host, sel.String()), "", "")
if err != nil {
return nil, err
}
out := make([]bool, w.count)
// Lines "n,state" — "1,1", "2,0", …
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
parts := strings.Split(strings.TrimSpace(line), ",")
if len(parts) != 2 {
continue
}
n, e1 := strconv.Atoi(parts[0])
st, e2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if e1 != nil || e2 != nil || n < 1 || n > w.count {
continue
}
out[n-1] = st == 1
}
return out, nil
}
// ── KMTronic LAN 8-relay WEB ───────────────────────────────────────────
type kmtronic struct {
host string
user, pass string
count int
}
// NewKMTronic builds a KMTronic LAN WEB relay client (8 relays). user/pass are
// blank unless the board's HTTP authentication is enabled.
func NewKMTronic(host, user, pass string) Device {
return &kmtronic{host: host, user: user, pass: pass, count: 8}
}
func (k *kmtronic) Count() int { return k.count }
func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > k.count {
return fmt.Errorf("relay %d out of range 1..%d", relay, k.count)
}
state := "00"
if on {
state = "01"
}
// FF<rr><ss>: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off.
_, err := get(ctx, fmt.Sprintf("http://%s/FF%02d%s", k.host, relay, state), k.user, k.pass)
return err
}
// kmStatus mirrors status.xml. relay0 is reserved; relay1..relay8 are the board.
type kmStatus struct {
XMLName xml.Name `xml:"response"`
Relays []struct {
XMLName xml.Name
Value string `xml:",chardata"`
} `xml:",any"`
}
func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
body, err := get(ctx, fmt.Sprintf("http://%s/status.xml", k.host), k.user, k.pass)
if err != nil {
return nil, err
}
var s kmStatus
if err := xml.Unmarshal(body, &s); err != nil {
return nil, fmt.Errorf("kmtronic: bad status.xml: %w", err)
}
out := make([]bool, k.count)
for _, r := range s.Relays {
// Element names are relay0..relay8; relay0 is reserved.
name := r.XMLName.Local
if !strings.HasPrefix(name, "relay") {
continue
}
n, e := strconv.Atoi(strings.TrimPrefix(name, "relay"))
if e != nil || n < 1 || n > k.count {
continue
}
out[n-1] = strings.TrimSpace(r.Value) == "1"
}
return out, nil
}
+101
View File
@@ -0,0 +1,101 @@
package relaydev
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The status parsers are the risky part; pin them against the documented wire
// formats using a stub HTTP server.
func TestWebswitchStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/relaystate/get2/") {
t.Errorf("unexpected status path %q", r.URL.Path)
}
_, _ = w.Write([]byte("1,1\n2,0\n3,1\n4,0\n5,1\n"))
}))
defer srv.Close()
d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://"))
st, err := d.Status(context.Background())
if err != nil {
t.Fatal(err)
}
want := []bool{true, false, true, false, true}
if len(st) != 5 {
t.Fatalf("got %d relays, want 5", len(st))
}
for i := range want {
if st[i] != want[i] {
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
}
}
}
func TestWebswitchSetURL(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
}))
defer srv.Close()
d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://"))
if err := d.Set(context.Background(), 4, true); err != nil {
t.Fatal(err)
}
if gotPath != "/relaycontrol/on/4" {
t.Errorf("set path = %q, want /relaycontrol/on/4", gotPath)
}
_ = d.Set(context.Background(), 4, false)
}
func TestKMTronicStatus(t *testing.T) {
// relay0 reserved (ignored); relay7 + relay8 ON.
xmlBody := `<?xml version="1.0"?><response>` +
`<relay0>0</relay0><relay1>0</relay1><relay2>0</relay2><relay3>0</relay3>` +
`<relay4>0</relay4><relay5>0</relay5><relay6>0</relay6><relay7>1</relay7><relay8>1</relay8>` +
`</response>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/status.xml" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(xmlBody))
}))
defer srv.Close()
d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "")
st, err := d.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if len(st) != 8 {
t.Fatalf("got %d relays, want 8", len(st))
}
if st[6] != true || st[7] != true {
t.Errorf("relay7/8 = %v/%v, want on/on", st[6], st[7])
}
for i := 0; i < 6; i++ {
if st[i] {
t.Errorf("relay %d unexpectedly on", i+1)
}
}
}
func TestKMTronicSetURL(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
}))
defer srv.Close()
d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "")
if err := d.Set(context.Background(), 8, true); err != nil {
t.Fatal(err)
}
if gotPath != "/FF0801" {
t.Errorf("set path = %q, want /FF0801", gotPath)
}
_ = d.Set(context.Background(), 1, false) // → /FF0100
}
+193
View File
@@ -0,0 +1,193 @@
// Package rotgenius drives a 4O3A Rotator Genius over its native TCP text
// protocol (rev 4, default port 9006). All data is fixed-length extended-ASCII;
// there is no sequence/framing wrapper — you send a short command and read back a
// fixed-length reply.
//
// Commands used here:
//
// |h read heading + full state (both rotators)
// |A<rot><az3> move rotator <rot> ('1'|'2') to azimuth az3 (000..360)
// |P<rot> / |M<rot> rotate CW / CCW
// |S stop all movement
//
// The |h reply is 72 bytes: "|h" + Active[1] + Panic[1] then, per rotator,
// CurrentAzimuth[3] LimitCW[3] LimitCCW[3] Config[1] Moving[1] Offset[4]
// TargetAzimuth[3] StartAzimuth[3] Limit[1] Name[12]. Numeric fields may be
// space-padded; a CurrentAzimuth of 999 means the sensor is not connected.
package rotgenius
import (
"fmt"
"net"
"strconv"
"strings"
"time"
)
const (
defaultPort = 9006
dialTimeout = 4 * time.Second
ioTimeout = 4 * time.Second
hdrReplyLen = 72 // fixed length of the |h reply
)
// Status is one rotator's live state parsed from a |h reply.
type Status struct {
Azimuth int // current heading in degrees (0..360)
Connected bool // false when the sensor reports 999 (not connected)
Moving int // 0 not moving, 1 CW, 2 CCW
Target int // target azimuth when moving (else -1)
}
// Client is a stateless connector: each call opens a short-lived TCP connection,
// mirroring how the PstRotator client works, so there is no socket to manage.
type Client struct {
host string
port int
}
func New(host string, port int) *Client {
if port <= 0 {
port = defaultPort
}
return &Client{host: host, port: port}
}
func (c *Client) dial() (net.Conn, error) {
d := net.Dialer{Timeout: dialTimeout}
conn, err := d.Dial("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)))
if err != nil {
return nil, err
}
_ = conn.SetDeadline(time.Now().Add(ioTimeout))
return conn, nil
}
// exchange sends cmd and returns up to max bytes of the reply.
func (c *Client) exchange(cmd string, max int) ([]byte, error) {
conn, err := c.dial()
if err != nil {
return nil, err
}
defer conn.Close()
if _, err := conn.Write([]byte(cmd)); err != nil {
return nil, fmt.Errorf("write %q: %w", cmd, err)
}
buf := make([]byte, 0, max)
tmp := make([]byte, max)
for len(buf) < max {
n, rerr := conn.Read(tmp)
if n > 0 {
buf = append(buf, tmp[:n]...)
}
if rerr != nil {
break // deadline or EOF — return what we have and let the parser judge
}
}
return buf, nil
}
// atoiField trims the space-padding a Rotator Genius field may carry and parses
// it. An empty or non-numeric field yields 0.
func atoiField(s string) int {
n, _ := strconv.Atoi(strings.TrimSpace(s))
return n
}
// Heading reads the current azimuth of the given rotator (1 or 2). raw is the
// decoded field for diagnostics.
func (c *Client) Heading(rotator int) (Status, string, error) {
st, err := c.Read(rotator)
if err != nil {
return Status{}, "", err
}
return st, strconv.Itoa(st.Azimuth), nil
}
// Read fetches and parses the full |h reply for one rotator (1 or 2).
func (c *Client) Read(rotator int) (Status, error) {
if rotator != 1 && rotator != 2 {
rotator = 1
}
reply, err := c.exchange("|h", hdrReplyLen)
if err != nil {
return Status{}, err
}
i := strings.Index(string(reply), "|h")
if i < 0 || len(reply)-i < hdrReplyLen {
return Status{}, fmt.Errorf("rotgenius: short |h reply (%d bytes)", len(reply))
}
p := reply[i:]
// Per-rotator block base: rotator 1 at offset 4, rotator 2 at 4+34=38.
base := 4
if rotator == 2 {
base = 38
}
// Within a rotator block: CurrentAzimuth@0, LimitCW@3, LimitCCW@6, Config@9,
// Moving@10, Offset@11, TargetAzimuth@15, StartAzimuth@18, Limit@21, Name@22.
cur := atoiField(string(p[base : base+3]))
moving := atoiField(string(p[base+10 : base+11]))
target := atoiField(string(p[base+15 : base+18]))
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
if target != 999 {
st.Target = target
}
return st, nil
}
// GoTo moves the rotator to az (0..360). The reply's status byte is 'K' on
// accept, 'F' on reject.
func (c *Client) GoTo(rotator, az int) error {
if rotator != 1 && rotator != 2 {
rotator = 1
}
if az < 0 {
az = 0
}
if az > 360 {
az = 360
}
reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
if err != nil {
return err
}
return checkKF(reply, "GoTo")
}
// Stop halts all movement.
func (c *Client) Stop() error {
reply, err := c.exchange("|S", 8)
if err != nil {
return err
}
return checkKF(reply, "Stop")
}
// CW / CCW nudge a rotator; it runs to its limit unless stopped.
func (c *Client) CW(rotator int) error { return c.rotate('P', rotator) }
func (c *Client) CCW(rotator int) error { return c.rotate('M', rotator) }
func (c *Client) rotate(cmd byte, rotator int) error {
if rotator != 1 && rotator != 2 {
rotator = 1
}
reply, err := c.exchange(fmt.Sprintf("|%c%d", cmd, rotator), 8)
if err != nil {
return err
}
return checkKF(reply, string(cmd))
}
// checkKF reads the accept/reject status: 'K' ok, 'F' failed. The reply carries
// no other letters (the rest is the header + digits), so scanning for them is
// unambiguous.
func checkKF(reply []byte, what string) error {
s := string(reply)
if strings.ContainsRune(s, 'K') {
return nil
}
if strings.ContainsRune(s, 'F') {
return fmt.Errorf("rotgenius: %s rejected by the controller", what)
}
return fmt.Errorf("rotgenius: no reply to %s", what)
}
+127
View File
@@ -0,0 +1,127 @@
package rotgenius
import "testing"
// Build a 72-byte |h reply from per-rotator field values, so the fixed offsets in
// Read() are pinned to the rev-4 layout. Numeric fields are space/zero padded to
// their documented widths.
func buildHReply(cur1, cw1, ccw1 string, cfg1, mv1 byte, off1, tgt1, start1 string, lim1 byte, name1,
cur2, cw2, ccw2 string, cfg2, mv2 byte, off2, tgt2, start2 string, lim2 byte, name2 string) []byte {
pad := func(s string, n int) string {
for len(s) < n {
s = " " + s
}
return s[:n]
}
b := []byte("|h")
b = append(b, '0', 0x00) // Active, Panic
block := func(cur, cw, ccw string, cfg, mv byte, off, tgt, start string, lim byte, name string) {
b = append(b, []byte(pad(cur, 3))...)
b = append(b, []byte(pad(cw, 3))...)
b = append(b, []byte(pad(ccw, 3))...)
b = append(b, cfg, mv)
b = append(b, []byte(pad(off, 4))...)
b = append(b, []byte(pad(tgt, 3))...)
b = append(b, []byte(pad(start, 3))...)
b = append(b, lim)
b = append(b, []byte(pad(name, 12))...)
}
block(cur1, cw1, ccw1, cfg1, mv1, off1, tgt1, start1, lim1, name1)
block(cur2, cw2, ccw2, cfg2, mv2, off2, tgt2, start2, lim2, name2)
return b
}
func TestReadParsesBothRotators(t *testing.T) {
// Rotator 1: az 100, moving CW (1), no target (999). Rotator 2: az 999 (sensor
// offline), not moving. Mirrors the manual's worked example.
reply := buildHReply(
"100", "005", "350", 'A', '1', "0", "999", "999", '0', "TOW1",
"999", "010", "060", 'E', '0', "1", "999", "999", '0', "")
c := &Client{}
_ = c
if len(reply) != hdrReplyLen {
t.Fatalf("built reply is %d bytes, want %d — field widths drifted from rev 4", len(reply), hdrReplyLen)
}
st1, err := parseFor(reply, 1)
if err != nil {
t.Fatal(err)
}
if st1.Azimuth != 100 || !st1.Connected || st1.Moving != 1 {
t.Errorf("rotator 1 = %+v, want az 100, connected, moving CW", st1)
}
if st1.Target != -1 {
t.Errorf("rotator 1 target = %d, want -1 (999 = not set)", st1.Target)
}
st2, err := parseFor(reply, 2)
if err != nil {
t.Fatal(err)
}
if st2.Connected || st2.Azimuth != 999 {
t.Errorf("rotator 2 = %+v, want disconnected (az 999)", st2)
}
}
// parseFor exercises the offset math without a socket.
func parseFor(reply []byte, rotator int) (Status, error) {
base := 4
if rotator == 2 {
base = 38
}
if len(reply) < hdrReplyLen {
return Status{}, errShort
}
cur := atoiField(string(reply[base : base+3]))
moving := atoiField(string(reply[base+10 : base+11]))
target := atoiField(string(reply[base+15 : base+18]))
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
if target != 999 {
st.Target = target
}
return st, nil
}
var errShort = fmtErrorf("short")
func fmtErrorf(s string) error { return &strErr{s} }
type strErr struct{ s string }
func (e *strErr) Error() string { return e.s }
func TestGoToFormatting(t *testing.T) {
// The command must zero-pad the azimuth to 3 digits, per the manual's fields.
cases := map[int]string{0: "|A1000", 5: "|A1005", 90: "|A1090", 360: "|A1360"}
for az, want := range cases {
got := "|A" + "1" + pad3(az)
if got != want {
t.Errorf("az %d → %q, want %q", az, got, want)
}
}
}
func pad3(az int) string {
s := ""
switch {
case az >= 100:
s = itoa(az)
case az >= 10:
s = "0" + itoa(az)
default:
s = "00" + itoa(az)
}
return s
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
+178
View File
@@ -0,0 +1,178 @@
// Package solar fetches live space-weather / propagation numbers (solar flux,
// sunspot number, A/K indices, X-ray, geomagnetic field) from N0NBH's public
// hamqsl.com XML feed, caches them, and refreshes periodically. OpsLog shows the
// numbers in a header strip and stamps SFI / A_INDEX / K_INDEX onto each logged
// QSO (SSN has no standard ADIF field — the app stores it as an extra).
package solar
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// feedURL is N0NBH's solar data as XML. Free, no key.
const feedURL = "https://www.hamqsl.com/solarxml.php"
// Data is the parsed snapshot exposed to the UI (strings — some fields read
// "No Report" or carry units, so we keep them verbatim and parse to numbers only
// when stamping a QSO).
type Data struct {
SFI string `json:"sfi"` // solar flux index
SSN string `json:"ssn"` // sunspot number
AIndex string `json:"a_index"` // geomagnetic A index
KIndex string `json:"k_index"` // geomagnetic K index
XRay string `json:"xray"` // X-ray flux class (e.g. C1.5)
GeomagField string `json:"geomag_field"` // QUIET / UNSETTLED / STORM…
Aurora string `json:"aurora"`
MUF string `json:"muf"`
Updated string `json:"updated"` // source timestamp text
Source string `json:"source"` // e.g. N0NBH
OK bool `json:"ok"` // last fetch succeeded
Error string `json:"error,omitempty"`
FetchedAt time.Time `json:"fetched_at"`
}
// xmlSolar mirrors the hamqsl solarxml.php document.
type xmlSolar struct {
XMLName xml.Name `xml:"solar"`
SolarData struct {
Source string `xml:"source"`
Updated string `xml:"updated"`
SolarFlux string `xml:"solarflux"`
AIndex string `xml:"aindex"`
KIndex string `xml:"kindex"`
XRay string `xml:"xray"`
Sunspots string `xml:"sunspots"`
Aurora string `xml:"aurora"`
GeomagField string `xml:"geomagfield"`
MUF string `xml:"muf"`
} `xml:"solardata"`
}
// Fetch performs one GET + parse of the feed.
func Fetch(ctx context.Context, client *http.Client) (Data, error) {
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
if err != nil {
return Data{}, err
}
req.Header.Set("User-Agent", "OpsLog")
resp, err := client.Do(req)
if err != nil {
return Data{}, fmt.Errorf("solar: request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return Data{}, fmt.Errorf("solar: read: %w", err)
}
if resp.StatusCode != http.StatusOK {
return Data{}, fmt.Errorf("solar: http %d", resp.StatusCode)
}
return parseSolar(body)
}
// parseSolar turns the feed's XML bytes into a Data (split out so it can be
// unit-tested without a network call).
func parseSolar(body []byte) (Data, error) {
var x xmlSolar
if err := xml.Unmarshal(body, &x); err != nil {
return Data{}, fmt.Errorf("solar: parse: %w", err)
}
s := x.SolarData
return Data{
SFI: strings.TrimSpace(s.SolarFlux),
SSN: strings.TrimSpace(s.Sunspots),
AIndex: strings.TrimSpace(s.AIndex),
KIndex: strings.TrimSpace(s.KIndex),
XRay: strings.TrimSpace(s.XRay),
GeomagField: strings.TrimSpace(s.GeomagField),
Aurora: strings.TrimSpace(s.Aurora),
MUF: strings.TrimSpace(s.MUF),
Updated: strings.TrimSpace(s.Updated),
Source: strings.TrimSpace(s.Source),
OK: true,
FetchedAt: time.Now(),
}, nil
}
// Manager caches the latest Data and refreshes it on a timer.
type Manager struct {
mu sync.RWMutex
data Data
client *http.Client
onChange func()
stop chan struct{}
once sync.Once
}
// NewManager builds a manager. onChange (optional) fires after each refresh so
// the host can push a UI event.
func NewManager(onChange func()) *Manager {
return &Manager{
client: &http.Client{Timeout: 20 * time.Second},
onChange: onChange,
stop: make(chan struct{}),
}
}
// Start fetches now and every hour until Stop (the feed updates roughly hourly).
func (m *Manager) Start() {
go func() {
m.refresh()
t := time.NewTicker(1 * time.Hour)
defer t.Stop()
for {
select {
case <-m.stop:
return
case <-t.C:
m.refresh()
}
}
}()
}
// Stop ends the refresh loop.
func (m *Manager) Stop() {
if m == nil {
return
}
m.once.Do(func() { close(m.stop) })
}
// Get returns the latest snapshot.
func (m *Manager) Get() Data {
if m == nil {
return Data{}
}
m.mu.RLock()
defer m.mu.RUnlock()
return m.data
}
// Refresh forces an immediate fetch (used by a manual "refresh" binding).
func (m *Manager) Refresh() { m.refresh() }
func (m *Manager) refresh() {
d, err := Fetch(context.Background(), m.client)
m.mu.Lock()
if err != nil {
m.data.OK = false
m.data.Error = err.Error()
} else {
m.data = d
}
m.mu.Unlock()
if m.onChange != nil {
m.onChange()
}
}
+48
View File
@@ -0,0 +1,48 @@
package solar
import "testing"
// A trimmed sample of the real N0NBH hamqsl.com solarxml.php document.
const sampleXML = `<?xml version="1.0"?>
<solar>
<solardata>
<source url="http://www.hamqsl.com/solar.html">N0NBH</source>
<updated>08 Jul 2025 2100 GMT</updated>
<solarflux>170</solarflux>
<aindex>5</aindex>
<kindex>2</kindex>
<xray>C1.5</xray>
<sunspots>150</sunspots>
<geomagfield>QUIET</geomagfield>
<muf>18.5</muf>
</solardata>
</solar>`
func TestParseSolar(t *testing.T) {
d, err := parseSolar([]byte(sampleXML))
if err != nil {
t.Fatalf("parse: %v", err)
}
if !d.OK {
t.Fatalf("expected OK")
}
checks := map[string]string{
"SFI": d.SFI, "SSN": d.SSN, "A": d.AIndex, "K": d.KIndex,
"XRay": d.XRay, "Geomag": d.GeomagField, "Source": d.Source,
}
want := map[string]string{
"SFI": "170", "SSN": "150", "A": "5", "K": "2",
"XRay": "C1.5", "Geomag": "QUIET", "Source": "N0NBH",
}
for k, got := range checks {
if got != want[k] {
t.Errorf("%s = %q, want %q", k, got, want[k])
}
}
}
func TestParseSolarBadXML(t *testing.T) {
if _, err := parseSolar([]byte("not xml")); err == nil {
t.Fatalf("expected an error for non-XML input")
}
}
+373
View File
@@ -0,0 +1,373 @@
// Package steppir controls a SteppIR SDA-100 / SDA-2000 antenna controller over
// its "Transceiver Interface" serial protocol, reached either directly on a COM
// port or over TCP through an RS232↔Ethernet bridge (the same way OpsLog talks to
// an Ultrabeam). The client mirrors the ultrabeam.Client surface so the app can
// drive either behind one interface.
//
// Protocol (cross-checked against the SteppIR "Transceiver Interface Operation"
// note, the we7u/steppir library, and the la1k.no write-up — three independent
// sources that agree, which is what makes the byte layout trustworthy):
//
// SET : "@A" <freq> 00 <dir> <cmd> 00 0x0D (11 bytes)
// <freq> = int32 big-endian of (Hz / 10)
// <dir> = 0x00 normal · 0x40 180° · 0x80 bidirectional · 0x20 3/4-wave
// <cmd> = '1' set freq+dir · 'R' autotrack ON · 'U' autotrack OFF
// 'S' home/retract · 'V' calibrate
// STATUS: "?A" 0x0D → 11 bytes back:
// [2:6] int32 big-endian frequency (× 10 = Hz)
// [6] active-motor bitmask (0xFF = command received / setup)
// [7] & 0xE0 direction
//
// Timing: the controller needs ≥100 ms between commands and dislikes status
// polls faster than ~10/s. The poll loop runs at 2 s, well inside that.
package steppir
import (
"encoding/binary"
"fmt"
"io"
"log"
"net"
"sync"
"time"
"go.bug.st/serial"
)
// Direction values, matching the app-wide convention (also used by Ultrabeam):
// 0 normal, 1 reverse (180°), 2 bidirectional.
const (
DirNormal = 0
Dir180 = 1
DirBi = 2
)
// SteppIR direction bytes on the wire.
const (
wireNormal = 0x00
wire180 = 0x40
wireBi = 0x80
)
// Transport says how to reach the controller.
type Transport struct {
Mode string // "tcp" | "serial"
Host string // tcp
Port int // tcp
COM string // serial device (COM3, /dev/ttyUSB0)
Baud int // serial baud (controller default 9600; 1200-19200 valid)
}
// Status is the antenna state, in the same shape the app reads from the
// Ultrabeam so the two are interchangeable at the UI.
type Status struct {
Connected bool `json:"connected"`
Frequency int `json:"frequency"` // kHz
Band int `json:"band"` // 0 (SteppIR does not report a band index)
Direction int `json:"direction"` // 0 normal, 1 180°, 2 bidirectional
MotorsMoving int `json:"motors_moving"`
}
type Client struct {
tr Transport
connMu sync.Mutex
conn io.ReadWriteCloser
// ioMu serialises EVERY exchange on the shared connection — a status query
// (write "?A" then read 11 bytes) and a command write must never interleave,
// or their bytes mix on the wire and both frames are corrupted. The status
// poll runs on one goroutine, tuning on another, so this is essential.
ioMu sync.Mutex
statusMu sync.RWMutex
lastStatus *Status
lastSetKHz int
// 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.
pendingDir int
pendingDirAt time.Time
pendingDirSet bool
stopChan chan struct{}
running bool
}
func New(tr Transport) *Client {
if tr.Baud <= 0 {
tr.Baud = 9600
}
return &Client{tr: tr, stopChan: make(chan struct{})}
}
func (c *Client) Start() error {
c.running = true
go c.pollLoop()
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stopChan)
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connMu.Unlock()
}
// LastSetKHz returns the frequency last commanded, or 0.
func (c *Client) LastSetKHz() int {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.lastSetKHz
}
func (c *Client) GetStatus() (*Status, error) {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
if c.lastStatus == nil {
return &Status{Connected: false}, nil
}
return c.lastStatus, nil
}
// open dials the transport. Callers hold connMu.
func (c *Client) open() (io.ReadWriteCloser, error) {
switch c.tr.Mode {
case "serial":
if c.tr.COM == "" {
return nil, fmt.Errorf("steppir: no serial port configured")
}
p, err := serial.Open(c.tr.COM, &serial.Mode{BaudRate: c.tr.Baud})
if err != nil {
return nil, err
}
// A finite read timeout so a silent controller doesn't wedge the poll loop.
_ = p.SetReadTimeout(2 * time.Second)
return p, nil
default: // tcp
if c.tr.Host == "" {
return nil, fmt.Errorf("steppir: no host configured")
}
d := net.Dialer{Timeout: 5 * time.Second}
return d.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port)))
}
}
func (c *Client) pollLoop() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-c.stopChan:
return
case <-ticker.C:
c.connMu.Lock()
if c.conn == nil {
conn, err := c.open()
if err != nil {
c.connMu.Unlock()
c.setDisconnected()
continue
}
c.conn = conn
}
c.connMu.Unlock()
st, err := c.queryStatus()
if err != nil {
log.Printf("steppir: status query failed, reconnecting: %v", err)
c.closeConn()
c.setDisconnected()
continue
}
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.lastStatus = st
c.statusMu.Unlock()
}
}
}
func (c *Client) setDisconnected() {
c.statusMu.Lock()
c.lastStatus = &Status{Connected: false}
c.statusMu.Unlock()
}
func (c *Client) closeConn() {
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connMu.Unlock()
}
// setDeadline applies a read/write deadline on TCP; serial uses its own timeout.
func setDeadline(conn io.ReadWriteCloser, d time.Duration) {
if nc, ok := conn.(net.Conn); ok {
_ = nc.SetDeadline(time.Now().Add(d))
}
}
func (c *Client) queryStatus() (*Status, error) {
c.connMu.Lock()
conn := c.conn
c.connMu.Unlock()
if conn == nil {
return nil, fmt.Errorf("steppir: not connected")
}
c.ioMu.Lock()
defer c.ioMu.Unlock()
setDeadline(conn, 3*time.Second)
if _, err := conn.Write([]byte("?A\r")); err != nil {
return nil, fmt.Errorf("write status cmd: %w", err)
}
buf := make([]byte, 11)
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, fmt.Errorf("read status: %w", err)
}
return parseStatus(buf)
}
// parseStatus decodes an 11-byte status frame.
func parseStatus(b []byte) (*Status, error) {
if len(b) < 11 {
return nil, fmt.Errorf("steppir: short status frame (%d bytes)", len(b))
}
freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10
active := b[6]
dir := decodeDir(b[7])
// active-motors byte: one bit per element that is currently moving.
// 0x04 driver · 0x08 DIR1 · 0x10 reflector · 0x20 DIR2 (mask 0x3C)
// Bit 0 (0x01) is documented as always set — not a motor. 0xFF means the
// controller just received a command, not motion. So "moving" is precisely
// "any real motor bit set", ignoring the always-on bit and the ack value.
const motorBits = 0x3C
moving := 0
if active != 0xFF && active&motorBits != 0 {
moving = 1
}
return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil
}
func decodeDir(b byte) int {
switch b & 0xE0 {
case wireBi:
return DirBi
case wire180:
return Dir180
default:
return DirNormal
}
}
func dirWireByte(dir int) byte {
switch dir {
case Dir180:
return wire180
case DirBi:
return wireBi
default:
return wireNormal
}
}
// buildSet frames a SET command: "@A" <freq be32 of Hz/10> 00 <dir> <cmd> 00 CR.
func buildSet(freqHz int, dir int, cmd byte) []byte {
var f [4]byte
binary.BigEndian.PutUint32(f[:], uint32(freqHz/10))
out := make([]byte, 0, 11)
out = append(out, '@', 'A')
out = append(out, f[:]...)
out = append(out, 0x00, dirWireByte(dir), cmd, 0x00, 0x0D)
return out
}
func (c *Client) writeCmd(pkt []byte) error {
c.connMu.Lock()
conn := c.conn
c.connMu.Unlock()
if conn == nil {
return fmt.Errorf("steppir: not connected")
}
c.ioMu.Lock()
defer c.ioMu.Unlock()
log.Printf("steppir: → % X", pkt)
setDeadline(conn, 3*time.Second)
if _, err := conn.Write(pkt); err != nil {
c.closeConn()
return err
}
// The controller needs breathing room between commands.
time.Sleep(120 * time.Millisecond)
return nil
}
// SetFrequency tunes the elements to freqKhz with the given direction.
//
// AUTOTRACK is (re-)enabled first, EVERY time: the controller ignores frequency
// sets unless it is in AUTOTRACK mode ("when not in AUTOTRACK only CALIBRATE and
// RETRACT work"), and it can be out of AUTOTRACK at power-on, after a Home, or if
// switched off on the front panel. Sending the 'R' command each tune is cheap and
// makes tuning work regardless of the controller's current mode — which is what
// was silently failing before.
func (c *Client) SetFrequency(freqKhz int, direction int) error {
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil { // AUTOTRACK ON
return err
}
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil { // set freq + dir
return err
}
c.statusMu.Lock()
c.lastSetKHz = freqKhz
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
c.statusMu.Unlock()
return nil
}
// SetDirection changes the pattern. SteppIR has no standalone direction command —
// it is a SET with the current frequency and the new direction byte.
func (c *Client) SetDirection(direction int) error {
khz := c.LastSetKHz()
if khz <= 0 {
if st, _ := c.GetStatus(); st != nil {
khz = st.Frequency
}
}
if khz <= 0 {
return fmt.Errorf("steppir: no frequency known yet — cannot set direction")
}
return c.SetFrequency(khz, direction)
}
// Retract homes the elements into the hubs (storage). This drops the controller
// out of AUTOTRACK, but that is handled transparently: the next SetFrequency
// re-issues AUTOTRACK ON before tuning.
func (c *Client) Retract() error {
// A valid frequency must accompany the command; reuse the last one.
khz := c.LastSetKHz()
if khz <= 0 {
if st, _ := c.GetStatus(); st != nil && st.Frequency > 0 {
khz = st.Frequency
} else {
khz = 14000 // any in-range value; the controller just homes
}
}
return c.writeCmd(buildSet(khz*1000, DirNormal, 'S'))
}
+106
View File
@@ -0,0 +1,106 @@
package steppir
import (
"encoding/binary"
"testing"
)
// The exact bytes are the correctness checksum. If buildSet ever drifts from the
// three-source-agreed layout, this fails — a wrong packet is a silently mistuned
// antenna, far worse than a compile error.
func TestBuildSetLayout(t *testing.T) {
// 14.074 MHz, normal, set-freq. freq/10 = 1_407_400 = 0x00 0x15 0x79 0xA8.
pkt := buildSet(14_074_000, DirNormal, '1')
want := []byte{'@', 'A', 0x00, 0x15, 0x79, 0xA8, 0x00, 0x00, '1', 0x00, 0x0D}
if len(pkt) != 11 {
t.Fatalf("packet is %d bytes, want 11", len(pkt))
}
for i := range want {
if pkt[i] != want[i] {
t.Fatalf("byte %d = 0x%02X, want 0x%02X\n got %X\nwant %X", i, pkt[i], want[i], pkt, want)
}
}
// Frequency must round-trip: bytes [2:6] × 10 = Hz.
if got := int(binary.BigEndian.Uint32(pkt[2:6])) * 10; got != 14_074_000 {
t.Fatalf("freq round-trip = %d, want 14074000", got)
}
}
func TestBuildSetDirectionAndCommand(t *testing.T) {
cases := []struct {
dir int
cmd byte
wantDir byte
wantCmd byte
}{
{DirNormal, '1', 0x00, '1'},
{Dir180, '1', 0x40, '1'},
{DirBi, '1', 0x80, '1'},
{DirNormal, 'S', 0x00, 'S'}, // retract / home
{DirNormal, 'R', 0x00, 'R'}, // autotrack on
}
for _, c := range cases {
pkt := buildSet(21_000_000, c.dir, c.cmd)
if pkt[7] != c.wantDir {
t.Errorf("dir %d → byte 0x%02X, want 0x%02X", c.dir, pkt[7], c.wantDir)
}
if pkt[8] != c.wantCmd {
t.Errorf("cmd %q → byte 0x%02X, want 0x%02X", c.cmd, pkt[8], c.wantCmd)
}
}
}
// A REAL status frame captured off an SDA controller (F4BPO's, 2026-07-15):
// 40 41 00 4C C5 84 00 87 30 38 0D — 50.313 MHz, idle, bidirectional, interface
// version "08". Using the actual device output (rather than hand-built bytes)
// pins parseStatus to real hardware, and independently confirms the ÷10 wire
// scale: 0x4CC584 × 10 = 50 313 000 Hz, a genuine 6 m frequency.
func TestParseStatusRealFrame(t *testing.T) {
frame := []byte{0x40, 0x41, 0x00, 0x4C, 0xC5, 0x84, 0x00, 0x87, 0x30, 0x38, 0x0D}
st, err := parseStatus(frame)
if err != nil {
t.Fatal(err)
}
if st.Frequency != 50313 {
t.Errorf("freq = %d kHz, want 50313 (50.313 MHz)", st.Frequency)
}
if st.Direction != DirBi { // 0x87 & 0xE0 = 0x80 = bidirectional
t.Errorf("direction = %d, want %d (bidirectional)", st.Direction, DirBi)
}
if st.MotorsMoving != 0 {
t.Errorf("moving = %d, want 0 (motors byte 0x00)", st.MotorsMoving)
}
}
// parseStatus decodes what the controller sends back — the inverse of buildSet's
// frequency field, plus the direction nibble and the motors-busy byte.
func TestParseStatus(t *testing.T) {
frame := []byte{0x40, 0x41, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '0', '8', 0x0D}
st, err := parseStatus(frame)
if err != nil {
t.Fatal(err)
}
if st.Frequency != 14074 {
t.Errorf("freq = %d kHz, want 14074", st.Frequency)
}
if st.Direction != Dir180 {
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
}
if st.MotorsMoving != 0 { // 0x01 is the always-set bit, not motion
t.Errorf("moving = %d, want 0 (only the always-on bit set)", st.MotorsMoving)
}
// Motors busy: a bit beyond 0x01 is set.
frame[6] = 0x07
st, _ = parseStatus(frame)
if st.MotorsMoving == 0 {
t.Error("active-motors 0x07 should read as moving")
}
// 0xFF is "command received", not motion.
frame[6] = 0xFF
st, _ = parseStatus(frame)
if st.MotorsMoving != 0 {
t.Error("active-motors 0xFF (command received) must not read as moving")
}
}
+426
View File
@@ -0,0 +1,426 @@
// Package uls resolves a US amateur callsign to its county and grid, offline,
// from the FCC ULS licence database cross-referenced with a ZIP→county/lat-lon
// table. It backs the US Counties (USA-CA) award and county hunting: an FCC
// spot or a bare CW/SSB spot carries only a callsign, and this turns that into
// a county with no per-lookup API call.
//
// Data lives in its OWN local SQLite file (data/uls.db), never in the logbook:
// it is ~800k rows of static reference data that would only bloat the log (and
// crawl over a remote MySQL link). It is downloaded on demand, not shipped.
//
// Sources, both public and free:
// - FCC ULS Amateur, full database: l_amat.zip → EN.dat (pipe-delimited).
// Fields used: [4] call_sign, [17] state, [18] zip_code.
// - GeoNames US postal codes: US.zip → US.txt (tab-delimited).
// Fields used: [1] zip, [4] state, [5] county, [9] lat, [10] lon.
//
// The county from a ZIP is the ZIP's primary county — a ZIP can straddle a line,
// so this is ~98% right for fixed stations (rovers/portables need the from-air
// grid, which arrives separately via heard_geo). Good enough to hunt with.
package uls
import (
"archive/zip"
"bufio"
"context"
"database/sql"
"errors"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
// errFCCMaintenance is raised when the FCC ULS download host bounces us to its
// maintenance page instead of serving the file (a frequent, FCC-side event).
var errFCCMaintenance = errors.New("fcc uls under maintenance")
// Default download URLs (overridable in Import for tests).
const (
fccAmateurURL = "https://data.fcc.gov/download/pub/uls/complete/l_amat.zip"
geoNamesURL = "https://download.geonames.org/export/zip/US.zip"
)
// Location is a resolved callsign's home county + grid.
type Location struct {
State string `json:"state"`
County string `json:"county"` // GeoNames county name (e.g. "Middlesex")
Grid string `json:"grid"` // 6-char Maidenhead from the ZIP centroid
}
// CNTY renders the ADIF "STATE,County" form for stamping a QSO's cnty field.
func (l Location) CNTY() string {
if l.State == "" || l.County == "" {
return ""
}
return l.State + "," + l.County
}
// Store owns the local uls.db connection.
type Store struct {
db *sql.DB
mu sync.RWMutex // guards the whole DB during a re-import (DELETE+bulk INSERT)
}
// Open opens (creating if needed) the ULS SQLite store at path.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS uls_callsign (
callsign TEXT PRIMARY KEY,
state TEXT NOT NULL DEFAULT '',
county TEXT NOT NULL DEFAULT '',
grid TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS uls_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);`); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
// Count returns how many callsigns are loaded.
func (s *Store) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
var n int
s.db.QueryRow(`SELECT COUNT(*) FROM uls_callsign`).Scan(&n)
return n
}
// UpdatedAt returns when the store was last imported (zero if never).
func (s *Store) UpdatedAt() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
var v string
if err := s.db.QueryRow(`SELECT value FROM uls_meta WHERE key='updated_at'`).Scan(&v); err != nil {
return time.Time{}
}
t, _ := time.Parse(time.RFC3339, v)
return t
}
// Resolve looks up a callsign's home county + grid. ok=false if unknown or the
// store is empty.
func (s *Store) Resolve(callsign string) (Location, bool) {
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
return Location{}, false
}
s.mu.RLock()
defer s.mu.RUnlock()
var l Location
err := s.db.QueryRow(`SELECT state, county, grid FROM uls_callsign WHERE callsign=?`, call).
Scan(&l.State, &l.County, &l.Grid)
if err != nil || l.County == "" {
return Location{}, false
}
return l, true
}
// Progress reports import stages to the caller (0..100 within a stage).
type Progress func(stage string, pct int)
// zipRow is one ZIP's primary county + centroid.
type zipRow struct {
state, county string
lat, lon float64
}
// Import downloads the FCC ULS + GeoNames data, rebuilds the callsign→county
// table, and records the timestamp. It replaces the table atomically: on any
// error the previous contents are left intact. tmpDir is where the (large) zips
// are streamed; "" uses the OS temp dir.
func (s *Store) Import(ctx context.Context, tmpDir string, prog Progress) error {
if prog == nil {
prog = func(string, int) {}
}
if tmpDir == "" {
tmpDir = os.TempDir()
}
// 1) ZIP→county/lat-lon crosswalk (small).
prog("Downloading ZIP crosswalk", 0)
geoPath := filepath.Join(tmpDir, "opslog_geonames_us.zip")
if err := download(ctx, geoNamesURL, geoPath, nil); err != nil {
return fmt.Errorf("download GeoNames: %w", err)
}
defer os.Remove(geoPath)
prog("Parsing ZIP crosswalk", 50)
zipmap, err := parseGeoNames(geoPath)
if err != nil {
return fmt.Errorf("parse GeoNames: %w", err)
}
if len(zipmap) == 0 {
return fmt.Errorf("GeoNames crosswalk is empty")
}
// 2) FCC ULS full amateur database (large).
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 {
return fmt.Errorf("download FCC ULS: %w", err)
}
defer os.Remove(amatPath)
// 3) Parse EN.dat, join the crosswalk, rebuild the table.
prog("Building county database", 0)
return s.rebuild(ctx, amatPath, zipmap, prog)
}
// rebuild streams EN.dat out of the FCC zip and replaces uls_callsign in one
// transaction (old data survives a failure).
func (s *Store) rebuild(ctx context.Context, amatZip string, zipmap map[string]zipRow, prog Progress) error {
zr, err := zip.OpenReader(amatZip)
if err != nil {
return fmt.Errorf("open FCC zip: %w", err)
}
defer zr.Close()
var en *zip.File
for _, f := range zr.File {
if strings.EqualFold(filepath.Base(f.Name), "EN.dat") {
en = f
break
}
}
if en == nil {
return fmt.Errorf("EN.dat not found in FCC zip")
}
rc, err := en.Open()
if err != nil {
return fmt.Errorf("open EN.dat: %w", err)
}
defer rc.Close()
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM uls_callsign`); err != nil {
return err
}
stmt, err := tx.PrepareContext(ctx, `INSERT OR REPLACE INTO uls_callsign(callsign,state,county,grid) VALUES(?,?,?,?)`)
if err != nil {
return err
}
defer stmt.Close()
sc := bufio.NewScanner(rc)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var n, kept int
for sc.Scan() {
if ctx.Err() != nil {
return ctx.Err()
}
n++
f := strings.Split(sc.Text(), "|")
if len(f) < 19 {
continue
}
call := strings.ToUpper(strings.TrimSpace(f[4]))
if call == "" {
continue
}
zip5 := zip5Of(f[18])
zr, ok := zipmap[zip5]
if !ok {
continue // no crosswalk entry → can't place it
}
state := strings.ToUpper(strings.TrimSpace(f[17]))
if state == "" {
state = zr.state
}
if _, err := stmt.ExecContext(ctx, call, state, zr.county, grid6(zr.lat, zr.lon)); err != nil {
return err
}
kept++
if kept%50000 == 0 {
prog("Building county database", int(math.Min(99, float64(kept)/8000)))
}
}
if err := sc.Err(); err != nil {
return fmt.Errorf("read EN.dat: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO uls_meta(key,value) VALUES('updated_at',?)`,
time.Now().UTC().Format(time.RFC3339)); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
prog("Done", 100)
return nil
}
// parseGeoNames reads US.txt out of the GeoNames zip into a zip→row map,
// keeping the first (primary) county seen for each ZIP.
func parseGeoNames(zipPath string) (map[string]zipRow, error) {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return nil, err
}
defer zr.Close()
var txt *zip.File
for _, f := range zr.File {
if strings.EqualFold(filepath.Base(f.Name), "US.txt") {
txt = f
break
}
}
if txt == nil {
return nil, fmt.Errorf("US.txt not found")
}
rc, err := txt.Open()
if err != nil {
return nil, err
}
defer rc.Close()
out := make(map[string]zipRow, 45000)
sc := bufio.NewScanner(rc)
sc.Buffer(make([]byte, 0, 64*1024), 256*1024)
for sc.Scan() {
f := strings.Split(sc.Text(), "\t")
if len(f) < 11 {
continue
}
zip5 := strings.TrimSpace(f[1])
if zip5 == "" {
continue
}
if _, dup := out[zip5]; dup {
continue
}
lat := parseFloat(f[9])
lon := parseFloat(f[10])
out[zip5] = zipRow{
state: strings.ToUpper(strings.TrimSpace(f[4])),
county: strings.TrimSpace(f[5]),
lat: lat,
lon: lon,
}
}
return out, sc.Err()
}
// 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 {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
// Catch the FCC maintenance bounce BEFORE following it: data.fcc.gov redirects
// to www.fcc.gov/system-maintenance during maintenance windows, and that page
// then HTTP/2-stream-errors — which surfaced as a cryptic "INTERNAL_ERROR"
// instead of a plain "try again later".
client := &http.Client{
CheckRedirect: func(r *http.Request, via []*http.Request) error {
if strings.Contains(r.URL.String(), "system-maintenance") {
return errFCCMaintenance
}
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
return nil
},
}
resp, err := client.Do(req)
if err != nil {
if errors.Is(err, errFCCMaintenance) || strings.Contains(err.Error(), "system-maintenance") {
return fmt.Errorf("the FCC ULS download service is under maintenance (fcc.gov redirected to its maintenance page) — please try again later")
}
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()
var body io.Reader = resp.Body
if prog != nil && resp.ContentLength > 0 {
body = &progReader{r: resp.Body, total: resp.ContentLength, prog: prog}
}
_, err = io.Copy(f, body)
return err
}
type progReader struct {
r io.Reader
total int64
read int64
last int
prog func(pct int)
}
func (p *progReader) Read(b []byte) (int, error) {
n, err := p.r.Read(b)
p.read += int64(n)
if pct := int(p.read * 100 / p.total); pct != p.last {
p.last = pct
p.prog(pct)
}
return n, err
}
func zip5Of(z string) string {
z = strings.TrimSpace(z)
if len(z) > 5 {
z = z[:5]
}
return z
}
func parseFloat(s string) float64 {
var v float64
fmt.Sscanf(strings.TrimSpace(s), "%g", &v)
return v
}
// grid6 converts latitude/longitude to a 6-character Maidenhead locator.
func grid6(lat, lon float64) string {
if lat == 0 && lon == 0 {
return ""
}
lon += 180
lat += 90
if lon < 0 || lon >= 360 || lat < 0 || lat >= 180 {
return ""
}
f0 := int(lon / 20)
f1 := int(lat / 10)
sq0 := int(math.Mod(lon, 20) / 2)
sq1 := int(math.Mod(lat, 10) / 1)
su0 := int(math.Mod(lon, 2) / (2.0 / 24))
su1 := int(math.Mod(lat, 1) / (1.0 / 24))
return string([]byte{
byte('A' + f0), byte('A' + f1),
byte('0' + sq0), byte('0' + sq1),
byte('a' + su0), byte('a' + su1),
})
}
+42
View File
@@ -0,0 +1,42 @@
package uls
import (
"os"
"testing"
)
func TestGrid6(t *testing.T) {
cases := []struct {
lat, lon float64
want string
}{
{38.90, -77.03, "FM18lw"}, // Washington DC
{40.71, -74.00, "FN30xr"}, // New York
{34.05, -118.24, "DM04vd"},// Los Angeles
}
for _, c := range cases {
if got := grid6(c.lat, c.lon); got[:4] != c.want[:4] {
t.Errorf("grid6(%v,%v)=%q want field/square %q", c.lat, c.lon, got, c.want[:4])
}
}
}
// TestParseGeoNames runs against the real GeoNames US.zip if present in the
// scratchpad (downloaded during development); skipped otherwise.
func TestParseGeoNames(t *testing.T) {
path := os.Getenv("GEONAMES_ZIP")
if path == "" {
t.Skip("set GEONAMES_ZIP to the US.zip path to run")
}
m, err := parseGeoNames(path)
if err != nil {
t.Fatal(err)
}
if len(m) < 30000 {
t.Fatalf("expected >30k ZIPs, got %d", len(m))
}
// A known ZIP: 20500 = The White House, DC.
if r, ok := m["20500"]; !ok || r.state != "DC" {
t.Errorf("ZIP 20500 = %+v (ok=%v)", r, ok)
}
}

Some files were not shown because too many files have changed in this diff Show More