Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
190b86eb1c | ||
|
|
cfc3d00ea1 | ||
|
|
9033e8518c | ||
|
|
bfbd9fa61a | ||
|
|
f0c4f22942 | ||
|
|
19c91f32a0 | ||
|
|
3ec23bc613 | ||
|
|
2fbb922bd2 | ||
|
|
f0afdcc498 | ||
|
|
22352c5748 | ||
|
|
6356d60a66 | ||
|
|
cafade0dbb | ||
|
|
2e39615554 | ||
|
|
666b933114 | ||
|
|
def59da748 | ||
|
|
fe69bc308c | ||
|
|
c07a17dc47 | ||
|
|
3cef885934 | ||
|
|
b8db653981 | ||
|
|
9729ef62ba | ||
|
|
cc6411a618 | ||
|
|
991831bdec | ||
|
|
1b2da95ad4 | ||
|
|
10d86db50a | ||
|
|
8538f48259 | ||
|
|
0fa91c3d5f | ||
|
|
c86d331bd9 | ||
|
|
77a752efe3 | ||
|
|
781fbfaa30 | ||
|
|
61c11c0fe3 | ||
|
|
64b746f007 | ||
|
|
9cc72c7575 | ||
|
|
9e4f43f648 | ||
|
|
5f044b959e | ||
|
|
68a49be8c1 | ||
|
|
8eb82d6cdb | ||
|
|
d327db3f57 | ||
|
|
59e6570f17 | ||
|
|
82a2c6cb7f | ||
|
|
24eaf597fd | ||
|
|
14a22ddb66 | ||
|
|
9156acea5f | ||
|
|
5d0906f00e | ||
|
|
901e967b53 | ||
|
|
4ab4f70349 | ||
|
|
64e80986ea | ||
|
|
816c6ffcf1 | ||
|
|
2166d1aa4b | ||
|
|
0a9a09bec2 | ||
|
|
34ec91684e | ||
|
|
11f1e332f7 | ||
|
|
bd9e091e65 | ||
|
|
d38c783dcc | ||
|
|
c825caa7a8 | ||
|
|
215652570c | ||
|
|
79552bfae1 | ||
|
|
8fc04563e1 | ||
|
|
19993bafc1 | ||
|
|
da1793a902 | ||
|
|
14c87f7fa9 | ||
|
|
9d4ccb9254 | ||
|
|
d30b305ff2 | ||
|
|
5abe4bd0c3 | ||
|
|
3ed9f29d9a | ||
|
|
04eaa91bd8 | ||
|
|
443698b507 | ||
|
|
db2908b3ef | ||
|
|
0838c2ec89 | ||
|
|
80c5fdc095 | ||
|
|
a1be0dfe68 | ||
|
|
ac4039393d | ||
|
|
bb53085c21 | ||
|
|
031dfa8f46 | ||
|
|
6322a425d8 | ||
|
|
9f08df1c39 | ||
|
|
aeeb658269 | ||
|
|
8e088576c7 | ||
|
|
eb2ff8ed59 | ||
|
|
dd3b51a2ae | ||
|
|
1a155e3627 | ||
|
|
cd13921322 | ||
|
|
46e3619a38 | ||
|
|
dcf006905c | ||
|
|
7cf2dfeaf9 | ||
|
|
69229964f4 | ||
|
|
09848adddc | ||
|
|
f853dd479e | ||
|
|
829c236d6c | ||
|
|
c9fd1379e1 | ||
|
|
28b6f04ea4 | ||
|
|
e6744c1d0a | ||
|
|
5152366717 | ||
|
|
e8691a43ba | ||
|
|
b83a4f4455 | ||
|
|
9d7091b1b8 | ||
|
|
f650183936 | ||
|
|
33a6342a07 | ||
|
|
75548812d0 | ||
|
|
5b96f53930 | ||
|
|
d354709939 | ||
|
|
3cd80ead81 | ||
|
|
9e2ffdb758 | ||
|
|
9718b8a78f | ||
|
|
ee844564de | ||
|
|
04b6431726 | ||
|
|
0c6f8e2d68 | ||
|
|
4fe0405432 | ||
|
|
1f0f75baf8 | ||
|
|
7f95a71426 | ||
|
|
08f4b61523 | ||
|
|
f5ffe81c72 | ||
|
|
656e238a59 | ||
|
|
c170d6091e | ||
|
|
ae60d58893 | ||
|
|
68982e9a85 | ||
|
|
eb9e2db41a | ||
|
|
b59c6856bd | ||
|
|
a00817b93e | ||
|
|
7398261c50 | ||
|
|
73f3ec51f7 | ||
|
|
6c39204301 | ||
|
|
0c3089344b | ||
|
|
6ab606fa54 | ||
|
|
96390110f8 | ||
|
|
6f2f9236b0 | ||
|
|
efb61107fe | ||
|
|
16e780c2df | ||
|
|
f3bf0b2f5c |
+251
@@ -0,0 +1,251 @@
|
||||
package main
|
||||
|
||||
// ADIF monitor: watches a configurable list of external ADIF files (fldigi's
|
||||
// RTTY logbook, N1MM, VarAC…) and imports newly appended QSOs into OpsLog as if
|
||||
// they had been logged here — same enrichment, dedup and automatic upload to
|
||||
// external services (QRZ, Club Log…). Deliberately simpler than Log4OM's monitor:
|
||||
// no per-file "upload" / "delete after load" toggles — importing + auto-upload is
|
||||
// just what happens.
|
||||
//
|
||||
// Design notes:
|
||||
// - A newly added file starts at its CURRENT size (Offset = -1 sentinel → set to
|
||||
// size on first scan) so the QSOs already in it are NOT bulk-imported; only
|
||||
// contacts appended AFTER you add the file come in.
|
||||
// - Reads only up to the last complete <eor>, so a half-written record waits for
|
||||
// the next poll instead of importing a truncated QSO.
|
||||
// - Each record is fed through LogUDPLoggedADIF, which already does the full
|
||||
// enrichment + ±2-minute dedup (shared udpLogMu, so it can't race the UDP
|
||||
// auto-log) + automatic external-service upload.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const keyADIFMonitor = "adifmon.config"
|
||||
|
||||
// ADIFWatchFile is one monitored ADIF file.
|
||||
type ADIFWatchFile struct {
|
||||
Path string `json:"path"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// Offset is the number of bytes already consumed. -1 means "not yet
|
||||
// initialised": the first scan sets it to the file's current size so existing
|
||||
// history is skipped.
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
||||
// ADIFMonitorConfig is the whole monitor setup: a master switch + the file list.
|
||||
type ADIFMonitorConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Files []ADIFWatchFile `json:"files"`
|
||||
}
|
||||
|
||||
const eorTag = "<eor>"
|
||||
|
||||
func (a *App) loadADIFMonitorLocked() ADIFMonitorConfig {
|
||||
var cfg ADIFMonitorConfig
|
||||
if a.settings == nil {
|
||||
return cfg
|
||||
}
|
||||
s, _ := a.settings.GetGlobal(a.ctx, keyADIFMonitor)
|
||||
if strings.TrimSpace(s) != "" {
|
||||
_ = json.Unmarshal([]byte(s), &cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (a *App) saveADIFMonitorLocked(cfg ADIFMonitorConfig) {
|
||||
b, _ := json.Marshal(cfg)
|
||||
a.setSettingGlobal(keyADIFMonitor, string(b))
|
||||
}
|
||||
|
||||
// GetADIFMonitor returns the monitor configuration for the settings UI.
|
||||
func (a *App) GetADIFMonitor() ADIFMonitorConfig {
|
||||
a.adifMonMu.Lock()
|
||||
defer a.adifMonMu.Unlock()
|
||||
return a.loadADIFMonitorLocked()
|
||||
}
|
||||
|
||||
// SaveADIFMonitor persists the monitor configuration. A file the user just added
|
||||
// gets Offset = -1 so its existing content is skipped (only QSOs logged AFTER it
|
||||
// was added import); a file already present keeps its current read position.
|
||||
func (a *App) SaveADIFMonitor(cfg ADIFMonitorConfig) error {
|
||||
a.adifMonMu.Lock()
|
||||
defer a.adifMonMu.Unlock()
|
||||
old := a.loadADIFMonitorLocked()
|
||||
oldOff := make(map[string]int64, len(old.Files))
|
||||
for _, f := range old.Files {
|
||||
oldOff[strings.TrimSpace(f.Path)] = f.Offset
|
||||
}
|
||||
for i := range cfg.Files {
|
||||
cfg.Files[i].Path = strings.TrimSpace(cfg.Files[i].Path)
|
||||
if off, ok := oldOff[cfg.Files[i].Path]; ok {
|
||||
cfg.Files[i].Offset = off // keep the read position of an existing file
|
||||
} else {
|
||||
cfg.Files[i].Offset = -1 // new file → skip its existing history
|
||||
}
|
||||
}
|
||||
a.saveADIFMonitorLocked(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PickADIFMonitorFile opens a file dialog to choose an ADIF file to monitor.
|
||||
func (a *App) PickADIFMonitorFile() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Choose an ADIF file to monitor",
|
||||
Filters: []wruntime.FileFilter{
|
||||
{DisplayName: "ADIF (*.adi;*.adif)", Pattern: "*.adi;*.adif"},
|
||||
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// adifMonitorLoop polls the enabled ADIF files every few seconds and imports any
|
||||
// newly appended QSOs. Runs for the app's lifetime on its own goroutine.
|
||||
func (a *App) adifMonitorLoop() {
|
||||
tick := time.NewTicker(5 * time.Second)
|
||||
defer tick.Stop()
|
||||
for range tick.C {
|
||||
if a.ctx == nil || a.qso == nil {
|
||||
continue
|
||||
}
|
||||
a.scanADIFMonitors()
|
||||
}
|
||||
}
|
||||
|
||||
// scanADIFMonitors walks the enabled files once, importing new records and
|
||||
// persisting advanced offsets.
|
||||
func (a *App) scanADIFMonitors() {
|
||||
a.adifMonMu.Lock()
|
||||
cfg := a.loadADIFMonitorLocked()
|
||||
a.adifMonMu.Unlock()
|
||||
if !cfg.Enabled || len(cfg.Files) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// path → new offset, for the files we advanced this pass.
|
||||
advanced := map[string]int64{}
|
||||
for i := range cfg.Files {
|
||||
f := &cfg.Files[i]
|
||||
if !f.Enabled || strings.TrimSpace(f.Path) == "" {
|
||||
continue
|
||||
}
|
||||
fi, err := os.Stat(f.Path)
|
||||
if err != nil {
|
||||
continue // not present (yet) — try again next tick
|
||||
}
|
||||
size := fi.Size()
|
||||
if f.Offset < 0 {
|
||||
// First sight of this file → skip whatever history it already holds.
|
||||
advanced[f.Path] = size
|
||||
continue
|
||||
}
|
||||
if size < f.Offset {
|
||||
f.Offset = 0 // truncated / rotated → re-read from the start (dedup protects us)
|
||||
}
|
||||
if size == f.Offset {
|
||||
continue // nothing new
|
||||
}
|
||||
newOff, n := a.importADIFAppend(f.Path, f.Offset, size)
|
||||
if newOff != f.Offset {
|
||||
advanced[f.Path] = newOff
|
||||
}
|
||||
if n > 0 {
|
||||
applog.Printf("adif monitor: imported %d QSO(s) from %s", n, f.Path)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "adifmon:imported", map[string]any{"file": f.Path, "count": n})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(advanced) == 0 {
|
||||
return
|
||||
}
|
||||
// Persist the advanced offsets WITHOUT clobbering a concurrent UI save of the
|
||||
// file list: re-read the stored config and only patch offsets for paths that
|
||||
// still exist there.
|
||||
a.adifMonMu.Lock()
|
||||
cur := a.loadADIFMonitorLocked()
|
||||
for i := range cur.Files {
|
||||
if off, ok := advanced[strings.TrimSpace(cur.Files[i].Path)]; ok {
|
||||
cur.Files[i].Offset = off
|
||||
}
|
||||
}
|
||||
a.saveADIFMonitorLocked(cur)
|
||||
a.adifMonMu.Unlock()
|
||||
}
|
||||
|
||||
// importADIFAppend reads bytes [from,to) of an ADIF file, imports every COMPLETE
|
||||
// record found (up to the last <eor>) and returns the new offset (just past that
|
||||
// last <eor>) plus how many QSOs were actually imported (duplicates excluded).
|
||||
func (a *App) importADIFAppend(path string, from, to int64) (int64, int) {
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return from, 0
|
||||
}
|
||||
defer fh.Close()
|
||||
if _, err := fh.Seek(from, io.SeekStart); err != nil {
|
||||
return from, 0
|
||||
}
|
||||
buf := make([]byte, to-from)
|
||||
nRead, err := io.ReadFull(fh, buf)
|
||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||
return from, 0
|
||||
}
|
||||
buf = buf[:nRead]
|
||||
|
||||
// Only consume up to the last complete <eor>; a half-written trailing record
|
||||
// waits for the next poll.
|
||||
lower := bytes.ToLower(buf)
|
||||
last := bytes.LastIndex(lower, []byte(eorTag))
|
||||
if last < 0 {
|
||||
return from, 0 // no complete record yet
|
||||
}
|
||||
end := last + len(eorTag)
|
||||
chunk := buf[:end]
|
||||
|
||||
count := 0
|
||||
for _, rec := range splitADIFRecords(chunk) {
|
||||
if strings.TrimSpace(rec) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := a.LogUDPLoggedADIF(rec); err == nil {
|
||||
count++
|
||||
}
|
||||
// A duplicate (already in the log within ±2 min) returns an error and is
|
||||
// simply not counted — expected when the same QSO is also logged in OpsLog.
|
||||
}
|
||||
return from + int64(end), count
|
||||
}
|
||||
|
||||
// splitADIFRecords cuts an ADIF byte slice into individual record texts, each
|
||||
// ending at its <eor> (case-insensitive). Any leading file header (up to the
|
||||
// first record's <eor>) rides along with the first record — LogUDPLoggedADIF
|
||||
// parses past an <EOH> header fine, and prepends one when there is none.
|
||||
func splitADIFRecords(b []byte) []string {
|
||||
lower := bytes.ToLower(b)
|
||||
var out []string
|
||||
start := 0
|
||||
for {
|
||||
rel := bytes.Index(lower[start:], []byte(eorTag))
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
end := start + rel + len(eorTag)
|
||||
out = append(out, string(b[start:end]))
|
||||
start = end
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/audio"
|
||||
"hamlog/internal/cwdecode"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
|
||||
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
|
||||
// only by the frontend, and only while the entry mode is CW.
|
||||
//
|
||||
// Pitch targeting: the single-channel decoder is far more reliable when it locks
|
||||
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
|
||||
// instead of auto-searching for the loudest tone. So we follow the radio's CW
|
||||
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
|
||||
// back to auto-search otherwise.
|
||||
|
||||
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
|
||||
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
|
||||
func (a *App) cwTargetPitch() int {
|
||||
if a.cwPitchHz > 0 {
|
||||
return a.cwPitchHz
|
||||
}
|
||||
if a.cat != nil {
|
||||
if st, ok := a.cat.FlexState(); ok && st.Available {
|
||||
// Only trust the radio's pitch when it's actually in CW.
|
||||
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
|
||||
if st.CWPitch > 0 {
|
||||
return st.CWPitch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// StartCWDecoder begins decoding CW from the configured RX audio device. The
|
||||
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
|
||||
// call repeatedly; a second call is a no-op while already running.
|
||||
func (a *App) StartCWDecoder() error {
|
||||
a.cwMu.Lock()
|
||||
defer a.cwMu.Unlock()
|
||||
if a.cwStop != nil {
|
||||
return nil // already running
|
||||
}
|
||||
dev := ""
|
||||
if a.settings != nil {
|
||||
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
|
||||
}
|
||||
if dev == "" {
|
||||
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
|
||||
}
|
||||
|
||||
dec := cwdecode.New(audio.SampleRate,
|
||||
func(text string) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cw:text", text)
|
||||
}
|
||||
},
|
||||
func(st cwdecode.Status) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cw:status", st)
|
||||
}
|
||||
},
|
||||
)
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
a.cwDecoder = dec
|
||||
|
||||
stop := make(chan struct{})
|
||||
a.cwStop = stop
|
||||
go func() {
|
||||
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
|
||||
applog.Printf("cw: capture failed: %v", err)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
|
||||
}
|
||||
}
|
||||
a.cwMu.Lock()
|
||||
if a.cwStop == stop {
|
||||
a.cwStop = nil
|
||||
a.cwDecoder = nil
|
||||
}
|
||||
a.cwMu.Unlock()
|
||||
}()
|
||||
// Follow the radio's CW pitch live (every second) while this run is active.
|
||||
go a.cwFollowPitch(stop, dec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
|
||||
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StopCWDecoder halts the CW decoder if running.
|
||||
func (a *App) StopCWDecoder() {
|
||||
a.cwMu.Lock()
|
||||
stop := a.cwStop
|
||||
a.cwStop = nil
|
||||
a.cwDecoder = nil
|
||||
a.cwMu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
}
|
||||
}
|
||||
|
||||
// CWDecoderRunning reports whether the decoder is currently capturing.
|
||||
func (a *App) CWDecoderRunning() bool {
|
||||
a.cwMu.Lock()
|
||||
defer a.cwMu.Unlock()
|
||||
return a.cwStop != nil
|
||||
}
|
||||
|
||||
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
|
||||
// the Flex CW pitch, or search). Applies live to a running decoder.
|
||||
func (a *App) SetCWDecoderPitch(hz int) {
|
||||
if hz < 0 {
|
||||
hz = 0
|
||||
}
|
||||
a.cwMu.Lock()
|
||||
a.cwPitchHz = hz
|
||||
dec := a.cwDecoder
|
||||
a.cwMu.Unlock()
|
||||
if dec != nil {
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
}
|
||||
}
|
||||
|
||||
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
|
||||
func (a *App) GetCWDecoderPitch() int {
|
||||
a.cwMu.Lock()
|
||||
defer a.cwMu.Unlock()
|
||||
return a.cwPitchHz
|
||||
}
|
||||
+10
-11
@@ -437,17 +437,16 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
||||
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||
return err
|
||||
}
|
||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
||||
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
||||
// independent. q came straight from GetByID, so a full Update rewrites the
|
||||
// row unchanged apart from this field.
|
||||
if q.Extras == nil {
|
||||
q.Extras = map[string]string{}
|
||||
}
|
||||
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
||||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
||||
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
|
||||
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
|
||||
//
|
||||
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
|
||||
// read up top is now stale after the slow e-mail send, and rewriting the whole
|
||||
// row would revert any column an auto-upload changed meanwhile — that's how
|
||||
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
|
||||
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
|
||||
return fmt.Errorf("QSL card sent but status not saved: %w", err)
|
||||
}
|
||||
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
||||
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
//go:embed changelog.json
|
||||
var changelogJSON []byte
|
||||
|
||||
// ChangelogEntry is one release's user-facing notes, in English and French. It's
|
||||
// shown once on the first launch after an update (the "What's new" dialog),
|
||||
// derived from the release's commit history.
|
||||
type ChangelogEntry struct {
|
||||
Version string `json:"version"`
|
||||
Date string `json:"date"`
|
||||
EN []string `json:"en"`
|
||||
FR []string `json:"fr"`
|
||||
}
|
||||
|
||||
const keyChangelogSeen = "changelog.last_seen_version"
|
||||
|
||||
func loadChangelog() []ChangelogEntry {
|
||||
var out []ChangelogEntry
|
||||
if err := json.Unmarshal(changelogJSON, &out); err != nil {
|
||||
applog.Printf("changelog: parse failed: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetChangelog returns every changelog entry up to the running version (newest
|
||||
// first), without touching the "seen" marker — for a "What's new" button that
|
||||
// reopens the notes on demand.
|
||||
func (a *App) GetChangelog() []ChangelogEntry {
|
||||
out := []ChangelogEntry{}
|
||||
for _, e := range loadChangelog() {
|
||||
if strings.TrimSpace(e.Version) == "" || versionLess(appVersion, e.Version) {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetWhatsNew returns the changelog entries the operator hasn't seen yet — the
|
||||
// notes for every version newer than the last one they ran, up to the current
|
||||
// build — and records the current version as seen so it pops exactly once per
|
||||
// update. Empty when there's nothing new.
|
||||
func (a *App) GetWhatsNew() []ChangelogEntry {
|
||||
if a.settings == nil {
|
||||
return nil
|
||||
}
|
||||
lastSeen, _ := a.settings.GetGlobal(a.ctx, keyChangelogSeen)
|
||||
cur := appVersion
|
||||
a.setSettingGlobal(keyChangelogSeen, cur) // advance the marker now
|
||||
|
||||
out := []ChangelogEntry{}
|
||||
for _, e := range loadChangelog() {
|
||||
if strings.TrimSpace(e.Version) == "" {
|
||||
continue
|
||||
}
|
||||
if versionLess(cur, e.Version) {
|
||||
continue // don't preview notes for a version newer than we run
|
||||
}
|
||||
if lastSeen == "" {
|
||||
// First run of the feature (fresh install, or upgrade from a build that
|
||||
// predates it): show only the CURRENT version's notes, once.
|
||||
if e.Version == cur {
|
||||
out = append(out, e)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if versionLess(lastSeen, e.Version) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"version": "0.20.5",
|
||||
"date": "2026-07-20",
|
||||
"en": [
|
||||
"New FlexRadio CWX CW keyer: key CW straight over the SmartSDR connection — no WinKeyer or SmartCAT needed. Type-ahead and mid-send backspace supported.",
|
||||
"ESC now stops CW on whichever keyer is active (WinKeyer / Icom / Flex), and typing a callsign aborts a running macro.",
|
||||
"New Amplifier settings section (was 'Power Genius'): control SPE Expert amps (1.3K / 1.5K / 2K) over USB serial or an RS232-to-Ethernet bridge — OPERATE/STANDBY and live status. PowerGenius XL still supported.",
|
||||
"Denkovi USB relay boards supported (4/8 relays, FT245), plus generic CH340/LCUS USB-serial relay boards.",
|
||||
"Rename your database from Settings without losing any configuration.",
|
||||
"Awards: DDFM now finds the French department from the address postal code; refs are stored on each QSO for faster columns; 'Publish to catalog' shares an edited award with your team on the next release; award columns are remembered per profile.",
|
||||
"Statistics: per-band mode split (CW / phone / data), and an activity chart with a Day / Week / Month / Year selector (rolling, real dates).",
|
||||
"'Stations on air' widget updates within seconds and fits more stations.",
|
||||
"The audio CW decoder was removed.",
|
||||
"Fixes: Antenna Genius buttons ignored clicks while a macro was sending; File → Exit; and more.",
|
||||
"This 'What's new' summary now appears on the first launch after each update."
|
||||
],
|
||||
"fr": [
|
||||
"Nouveau keyer CW FlexRadio CWX : manipulation CW directement via la connexion SmartSDR — plus besoin de WinKeyer ni de SmartCAT. Type-ahead et retour arrière en cours d'envoi supportés.",
|
||||
"ESC arrête maintenant le CW sur le moteur actif (WinKeyer / Icom / Flex), et taper un indicatif interrompt une macro en cours.",
|
||||
"Nouvelle section Amplificateur (ex « Power Genius ») : pilotage des SPE Expert (1.3K / 1.5K / 2K) en USB série ou via un pont RS232→Ethernet — OPERATE/STANDBY et état en direct. PowerGenius XL toujours supporté.",
|
||||
"Cartes relais USB Denkovi (4/8 relais, FT245), plus cartes USB-série génériques CH340/LCUS.",
|
||||
"Renommer sa base depuis les réglages sans perdre la configuration.",
|
||||
"Awards : DDFM trouve le département français depuis le code postal de l'adresse ; les réfs sont stockées sur chaque QSO (colonnes plus rapides) ; « Publier au catalogue » partage un award édité avec l'équipe à la prochaine mise à jour ; les colonnes d'award sont mémorisées par profil.",
|
||||
"Statistiques : répartition des modes par bande (CW / phone / data), et un graphe d'activité avec sélecteur Jour / Semaine / Mois / Année (glissant, dates réelles).",
|
||||
"Le widget « Stations on air » se met à jour en quelques secondes et affiche plus de stations.",
|
||||
"Le décodeur CW audio a été retiré.",
|
||||
"Corrections : les boutons Antenna Genius ignoraient les clics pendant l'envoi d'une macro ; Fichier → Quitter ; et d'autres.",
|
||||
"Ce résumé « Nouveautés » s'affiche désormais au premier lancement après chaque mise à jour."
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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])
|
||||
}
|
||||
+969
-251
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,37 @@ function pretty(name: string): string {
|
||||
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
|
||||
// would be a new component *type* on every render, so React would unmount and
|
||||
// remount every port button each time the panel re-renders — harmless when that's
|
||||
// rare, but with the CW decoder running the parent re-renders many times a second
|
||||
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
|
||||
// different element instances), so antenna changes silently did nothing.
|
||||
function PortBtn({ port, index, active, tx, onActivate, t }: {
|
||||
port: 1 | 2; index: number; active: boolean; tx: boolean;
|
||||
onActivate: (port: number, antenna: number) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
}) {
|
||||
const letter = port === 1 ? 'A' : 'B';
|
||||
const cls = tx
|
||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||
: active
|
||||
? (port === 1
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(port, active ? 0 : index)}
|
||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
||||
// match the app's light theme with soft gradients + glows. Each antenna row has
|
||||
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||
@@ -61,27 +92,6 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||
if (filtered.length > 0) list = filtered;
|
||||
}
|
||||
|
||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
||||
const letter = port === 1 ? 'A' : 'B';
|
||||
const cls = tx
|
||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||
: active
|
||||
? (port === 1
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(port, active ? 0 : index)}
|
||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||
@@ -124,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||
return (
|
||||
<div key={a.index} className="flex items-center gap-1.5">
|
||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
|
||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} onActivate={onActivate} t={t} />
|
||||
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
||||
{pretty(a.name)}
|
||||
</div>
|
||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
|
||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,9 @@ import {
|
||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||
ListCountries, DXCCForCountry, DXCCName,
|
||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||
ExportAwards, ImportAwards,
|
||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||
ExportAwardForCatalog,
|
||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
// Above this many references the editor stops loading the whole list and
|
||||
@@ -32,11 +34,11 @@ 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;
|
||||
total: number; builtin?: boolean;
|
||||
total: number; builtin?: boolean; version?: number;
|
||||
};
|
||||
|
||||
type AwardOrRule = {
|
||||
@@ -154,6 +156,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
// Version to stamp into a "publish for catalog" export — defaults to one past
|
||||
// the selected award's current version whenever the selection changes.
|
||||
const [catVer, setCatVer] = useState('1');
|
||||
|
||||
// The err banner doubles as a success/notice area (export path, import counts,
|
||||
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
||||
@@ -177,7 +182,91 @@ 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;
|
||||
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
|
||||
|
||||
// ── 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 +304,52 @@ 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.
|
||||
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||
async function exportForCatalog() {
|
||||
setErr('');
|
||||
if (!cur) return;
|
||||
const v = Math.trunc(Number(catVer));
|
||||
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
|
||||
try {
|
||||
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
|
||||
if (p) setErr(t('awed.catalogExportedTo', { path: p }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Import: LOOK FIRST, then ask.
|
||||
//
|
||||
// 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) {
|
||||
@@ -246,7 +370,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -261,15 +385,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 +425,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 +465,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 +475,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 +525,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 +554,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 +614,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 +737,90 @@ 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>
|
||||
{/* Publish the selected award to the catalog: stamp a version and write a
|
||||
file to paste over internal/award/catalog/<code>.json, so a release
|
||||
ships your change to the whole team. */}
|
||||
{cur && (
|
||||
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
|
||||
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
|
||||
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
|
||||
<Button variant="outline" onClick={exportForCatalog}>
|
||||
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<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 +973,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>
|
||||
)}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -6,11 +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';
|
||||
@@ -25,6 +27,7 @@ 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[];
|
||||
@@ -37,13 +40,14 @@ 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,
|
||||
@@ -143,6 +147,67 @@ 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).
|
||||
@@ -253,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' }];
|
||||
@@ -356,7 +454,15 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
// 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');
|
||||
// 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
|
||||
@@ -446,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">
|
||||
@@ -552,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}
|
||||
@@ -693,7 +812,17 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
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';
|
||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={acc} />;
|
||||
// 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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') }
|
||||
@@ -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)} 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)} 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>
|
||||
|
||||
@@ -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;
|
||||
@@ -44,12 +49,16 @@ type Props = {
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
// Reports how many rows the grid shows after its COLUMN filters (the funnel
|
||||
// icons), or null when no column filter is active — so the parent's "Showing X
|
||||
// of Y" can reflect them. Fired on filter change and when the data updates.
|
||||
onFilteredCountChange?: (count: number | null) => void;
|
||||
// One column per defined award; the cell shows the reference this QSO counts
|
||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||
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 +212,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 +242,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, onFilteredCountChange, 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 +288,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,16 +324,26 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||
width: 110,
|
||||
cellClass: 'text-[11px]',
|
||||
// Hidden by DEFAULT (award columns are opt-in). Without this, AG Grid shows
|
||||
// them whenever the saved column state doesn't cover them — a newly-added
|
||||
// award, an empty cache, or a rebuild that runs before the saved state is
|
||||
// re-applied — which is why "all award columns" kept reappearing. The user's
|
||||
// saved state (a column they explicitly showed) still overrides this.
|
||||
hide: true,
|
||||
// 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,
|
||||
@@ -293,36 +353,45 @@ 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Report the post-column-filter row count (funnel filters) to the parent, or
|
||||
// null when no column filter is active, so "Showing X of Y" reflects them.
|
||||
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||
const api = e?.api ?? gridRef.current?.api;
|
||||
if (!api || !onFilteredCountChange) return;
|
||||
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||
}, [onFilteredCountChange]);
|
||||
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
|
||||
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
|
||||
// default — wiping the user's saved visibility (award columns reappear,
|
||||
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
|
||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
||||
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
|
||||
// award column (both change the memo → restoringRef flips true at line 316). Each
|
||||
// rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
|
||||
// saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
|
||||
// LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
|
||||
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
|
||||
// an award reset the other columns AND left restoringRef stuck true (saving off).
|
||||
useEffect(() => {
|
||||
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);
|
||||
}, [awardCols]);
|
||||
}, [awardCols, awardShown]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
@@ -342,10 +411,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);
|
||||
@@ -373,6 +454,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,12 +480,15 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||
onGridReady={onGridReady}
|
||||
onFilterChanged={reportFilteredCount}
|
||||
onModelUpdated={reportFilteredCount}
|
||||
onColumnResized={saveColumnState}
|
||||
onColumnMoved={saveColumnState}
|
||||
onColumnPinned={saveColumnState}
|
||||
onColumnVisible={saveColumnState}
|
||||
onSortChanged={saveColumnState}
|
||||
onRowDoubleClicked={handleRowDoubleClicked}
|
||||
onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
|
||||
onSelectionChanged={onSelectionChanged}
|
||||
onCellContextMenu={onCellContextMenu}
|
||||
preventDefaultOnContextMenu
|
||||
@@ -469,8 +556,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">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,590 @@
|
||||
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,
|
||||
ListDenkoviDevices, ListSerialPorts,
|
||||
} 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; channels?: number; 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, denkovi: 8, usbrelay: 8 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
|
||||
|
||||
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
||||
// the generic USB-serial board, whose channel count the user picks.
|
||||
const chanCount = (d: Device): number =>
|
||||
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
|
||||
|
||||
function blankDevice(): Device {
|
||||
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="0–359" 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 channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined;
|
||||
const n = chanCount({ ...device, type, channels });
|
||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||
onChange({ ...device, type, channels, labels });
|
||||
};
|
||||
const setChannels = (channels: number) => {
|
||||
const n = chanCount({ ...device, channels });
|
||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||
onChange({ ...device, channels, labels });
|
||||
};
|
||||
const isKM = device.type === 'kmtronic';
|
||||
const isDenkovi = device.type === 'denkovi';
|
||||
const isUsbRelay = device.type === 'usbrelay';
|
||||
// COM ports for the generic USB-serial relay picker.
|
||||
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!isUsbRelay) return;
|
||||
ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([]));
|
||||
}, [isUsbRelay]);
|
||||
// Detected FTDI serials for the Denkovi picker.
|
||||
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const detectDenkovi = async () => {
|
||||
setDetecting(true);
|
||||
try {
|
||||
const list = ((await ListDenkoviDevices()) ?? []) as string[];
|
||||
setDenkoviSerials(list);
|
||||
// Auto-fill when there's exactly one and nothing chosen yet.
|
||||
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
|
||||
} catch { setDenkoviSerials([]); }
|
||||
finally { setDetecting(false); }
|
||||
};
|
||||
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
|
||||
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>
|
||||
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||||
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</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>
|
||||
{(isDenkovi || isUsbRelay) && (
|
||||
<div className="space-y-1 max-w-[10rem]">
|
||||
<Label>{t('station.channels')}</Label>
|
||||
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{isDenkovi ? (
|
||||
<div className="space-y-1 max-w-md">
|
||||
<Label>{t('station.ftdiSerial')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
|
||||
list="denkovi-serials"
|
||||
onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||
<datalist id="denkovi-serials">
|
||||
{denkoviSerials.map((s) => <option key={s} value={s} />)}
|
||||
</datalist>
|
||||
<Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
|
||||
{detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||
{t('station.detect')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
|
||||
</div>
|
||||
) : isUsbRelay ? (
|
||||
<div className="space-y-1 max-w-md">
|
||||
<Label>{t('station.comPort')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||
{serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||
<div className={cn('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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
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 BandCat = { band: string; cw: number; phone: number; data: number; total: 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_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
|
||||
// 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, accent = 'var(--chart-1)' }: { title: string; sub?: string; children: React.ReactNode; className?: string; accent?: string }) {
|
||||
return (
|
||||
<section className={cn('rounded-xl border border-border bg-card p-3.5 flex flex-col min-w-0 shadow-sm', className)}>
|
||||
<header className="mb-3 shrink-0">
|
||||
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span className="size-1.5 rounded-full shrink-0" style={{ background: accent }} />
|
||||
{title}
|
||||
</h3>
|
||||
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// A headline number IS the chart — a one-bar bar chart would be noise. Each tile
|
||||
// carries an accent (a categorical chart hue or a semantic token): a soft tint
|
||||
// wash + a left accent bar give it a colourful identity, while the number itself
|
||||
// stays in high-contrast foreground ink so it reads on every theme.
|
||||
function StatTile({ label, value, sub, accent = 'var(--chart-1)' }: { label: string; value: string; sub?: string; accent?: string }) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-xl border border-border bg-card px-4 py-3 min-w-0 shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-[0.08]" style={{ background: accent }} />
|
||||
<div className="pointer-events-none absolute -right-5 -top-7 size-20 rounded-full blur-xl opacity-[0.14]" style={{ background: accent }} />
|
||||
<div className="absolute left-0 inset-y-0 w-1" style={{ background: accent }} />
|
||||
<div className="relative">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
||||
{/* 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>
|
||||
</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', colorful, color = 'var(--chart-1)' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string; colorful?: boolean; color?: string }) {
|
||||
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||
// 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, i) => (
|
||||
<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: colorful ? `var(--chart-${(i % 8) + 1})` : color }}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// Per-band mode split (CW / phone / data) as stacked vertical bars. The three
|
||||
// categories are the subject → categorical colour in a fixed order, matching the
|
||||
// mode colours used elsewhere (CW gold, phone green, data blue).
|
||||
const BAND_SPLIT = [
|
||||
{ key: 'cw', label: 'CW', color: 'var(--chart-3)' },
|
||||
{ key: 'phone', label: 'Phone', color: 'var(--chart-2)' },
|
||||
{ key: 'data', label: 'Data', color: 'var(--chart-1)' },
|
||||
] as const;
|
||||
function StackedBandBars({ data, empty, height = 150 }: { data: BandCat[]; empty: string; height?: number }) {
|
||||
if (!data || data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
const peak = Math.max(1, ...data.map((d) => d.total));
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] min-w-0" style={{ height }}>
|
||||
{data.map((d) => (
|
||||
<div key={d.band} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||
title={`${d.band} — CW ${nf(d.cw)} · Phone ${nf(d.phone)} · Data ${nf(d.data)} (${nf(d.total)})`}>
|
||||
<span className="text-[9px] font-semibold mb-0.5 tabular-nums text-foreground">{nf(d.total)}</span>
|
||||
<div className="w-full flex flex-col justify-end rounded-t-[4px] overflow-hidden"
|
||||
style={{ height: `${Math.max(2, (d.total / peak) * 100)}%` }}>
|
||||
{BAND_SPLIT.map((s) => {
|
||||
const v = d[s.key];
|
||||
if (!v) return null;
|
||||
return <div key={s.key} style={{ flexGrow: v, flexBasis: 0, minHeight: 1, background: s.color }} />;
|
||||
})}
|
||||
</div>
|
||||
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">{d.band}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
|
||||
{BAND_SPLIT.map((s) => (
|
||||
<span key={s.key} className="inline-flex items-center gap-1">
|
||||
<span className="size-2.5 rounded-[3px]" style={{ background: s.color }} /> {s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
||||
// 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 ['', ''];
|
||||
}
|
||||
}
|
||||
|
||||
// ActivityCard — the "activity over time" chart with a granularity selector. Its
|
||||
// own state, module-scoped so a parent re-render doesn't reset the choice. The
|
||||
// timeline is the chronological month series; day/week/month/year are the cyclical
|
||||
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
|
||||
const ACT_GRAN = [
|
||||
{ key: 'timeline', tkey: 'stats.granTimeline' },
|
||||
{ key: 'day', tkey: 'stats.granDay' },
|
||||
{ key: 'week', tkey: 'stats.granWeek' },
|
||||
{ key: 'month', tkey: 'stats.granMonth' },
|
||||
{ key: 'year', tkey: 'stats.granYear' },
|
||||
] as const;
|
||||
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||
const [g, setG] = useState<string>('timeline');
|
||||
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : [];
|
||||
return (
|
||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{ACT_GRAN.map((o) => (
|
||||
<button key={o.key} type="button" onClick={() => setG(o.key)}
|
||||
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||
g === o.key ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{t(o.tkey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{g === 'timeline'
|
||||
? <AreaTrend data={stats.by_month} empty={empty} />
|
||||
: <VBars data={series} empty={empty} showValues height={160} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator),
|
||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
|
||||
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} accent="var(--chart-1)" />
|
||||
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
|
||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" accent="var(--chart-5)" />
|
||||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
|
||||
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
|
||||
</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')} accent="var(--chart-3)">
|
||||
<VBars data={stats.by_band} empty={empty} showValues colorful />
|
||||
</Card>
|
||||
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
||||
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
||||
</Card>
|
||||
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} className="lg:col-span-2" accent="var(--chart-3)">
|
||||
<StackedBandBars data={stats.by_band_category} empty={empty} />
|
||||
</Card>
|
||||
|
||||
<ActivityCard stats={stats} t={t} empty={empty} />
|
||||
|
||||
{/* 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')} accent="var(--chart-5)">
|
||||
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||
<HBars data={stats.by_operator} empty={empty} share color="var(--chart-5)" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')} accent="var(--chart-6)">
|
||||
<Donut data={stats.by_continent} empty={empty} />
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.topEntities')} accent="var(--chart-4)">
|
||||
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" color="var(--chart-4)" />
|
||||
</Card>
|
||||
<div className="flex flex-col gap-2.5 min-w-0">
|
||||
<Card title={t('stats.confirmations')} className="flex-1" accent="var(--success)">
|
||||
<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')} accent="var(--chart-8)">
|
||||
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||||
<HBars data={stats.by_station} empty={empty} share color="var(--chart-8)" />
|
||||
</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 }))}
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
wpm: number;
|
||||
macros: WKMacro[];
|
||||
sent: string; // text echoed back by the keyer as it transmits
|
||||
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
source: 'winkeyer' | 'icom' | 'flex'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||
onSetBreakIn?: (mode: number) => void;
|
||||
onSelectPort: (p: string) => void;
|
||||
@@ -101,14 +101,16 @@ export function WinkeyerPanel({
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
|
||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : 'WinKeyer'}
|
||||
</span>
|
||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||
<div className="flex-1" />
|
||||
{source === 'icom' ? (
|
||||
{source === 'icom' || source === 'flex' ? (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
|
||||
{source === 'flex'
|
||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||
</span>
|
||||
) : !connected ? (
|
||||
<>
|
||||
@@ -183,7 +185,7 @@ export function WinkeyerPanel({
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||
{t('wkp.cwText')}
|
||||
{source === 'winkeyer' && (
|
||||
{(source === 'winkeyer' || source === 'flex') && (
|
||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||
title={t('wkp.sendOnTypeHint')}>
|
||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||
|
||||
@@ -64,7 +64,17 @@ export function Combobox({
|
||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||
// open the list (so tabbing in doesn't pop the dropdown).
|
||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setQuery(v);
|
||||
setOpen(true);
|
||||
// Commit-on-type pushes the value live to the parent (so a CW macro sent
|
||||
// without leaving the field uses what was just typed). With free text that's
|
||||
// any input; a restricted field (allowFreeText=false) commits ONLY a value
|
||||
// that's actually in the list, so a half-typed or invalid report never
|
||||
// becomes the committed value — blur then reverts the leftover text.
|
||||
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||
|
||||
@@ -6,11 +6,32 @@
|
||||
// back to the DB copy and re-seed the cache.
|
||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||
|
||||
// The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with
|
||||
// the active profile id). The localStorage cache, however, is one namespace for
|
||||
// the whole WebView, so without scoping it too a profile switch would keep
|
||||
// serving the previous profile's cached layout and the correct per-profile DB
|
||||
// value would never win. lsScope makes the cache per-profile as well; it's set
|
||||
// once the active profile is known and updated on every profile switch.
|
||||
let lsScope = '';
|
||||
|
||||
// setGridPrefsProfile scopes the localStorage cache to a profile. Call it before
|
||||
// the grids read their state (at startup) and again whenever the active profile
|
||||
// changes so each profile keeps its own column layout / widths.
|
||||
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
||||
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
||||
}
|
||||
|
||||
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
||||
// GetUIPref/SetUIPref is left untouched — the backend already scopes it.
|
||||
function lsKey(key: string): string {
|
||||
return lsScope + key;
|
||||
}
|
||||
|
||||
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||
// to apply instantly, before the async DB round-trip).
|
||||
export function loadLocal(key: string): any[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
const raw = localStorage.getItem(lsKey(key));
|
||||
const v = raw ? JSON.parse(raw) : null;
|
||||
return Array.isArray(v) ? v : null;
|
||||
} catch {
|
||||
@@ -29,15 +50,16 @@ export async function loadRemote(key: string): Promise<any[] | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// saveState write-throughs to both the cache and the DB (fire-and-forget).
|
||||
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
|
||||
// the cache key is profile-scoped; the DB key is scoped by the backend.
|
||||
export function saveState(key: string, state: any[]) {
|
||||
const json = JSON.stringify(state);
|
||||
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
|
||||
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
|
||||
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
||||
}
|
||||
|
||||
// seedLocal writes a value into the cache without touching the DB (used after
|
||||
// hydrating the cache from the DB on a fresh machine).
|
||||
export function seedLocal(key: string, state: any[]) {
|
||||
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
|
||||
try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
+257
-38
File diff suppressed because one or more lines are too long
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,15 @@ 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
|
||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||
// through this global path would fight that per-profile scoping.
|
||||
];
|
||||
|
||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||
|
||||
@@ -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 light→dark; on dark themes dark→light, 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,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.3';
|
||||
export const APP_VERSION = '0.20.5';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+108
-8
@@ -11,6 +11,7 @@ import {awardref} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
import {powergenius} from '../models';
|
||||
import {spe} from '../models';
|
||||
import {solar} from '../models';
|
||||
import {winkeyer} from '../models';
|
||||
import {alerts} from '../models';
|
||||
@@ -18,6 +19,7 @@ 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';
|
||||
|
||||
@@ -33,8 +35,12 @@ 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>;
|
||||
@@ -57,14 +63,16 @@ 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>;
|
||||
|
||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||
|
||||
export function CWDecoderRunning():Promise<boolean>;
|
||||
|
||||
export function ChatAvailable():Promise<boolean>;
|
||||
|
||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||
@@ -133,20 +141,34 @@ 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 DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||
|
||||
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 ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
|
||||
|
||||
export function ExportAwards():Promise<string>;
|
||||
|
||||
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
||||
@@ -169,8 +191,12 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexMox(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
||||
@@ -201,6 +227,8 @@ export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function FlexSetKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMic(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
||||
@@ -225,6 +253,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>;
|
||||
@@ -249,8 +281,16 @@ 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 FlexStopCW():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>;
|
||||
@@ -273,6 +313,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>;
|
||||
@@ -281,7 +323,9 @@ export function GetCATSettings():Promise<main.CATSettings>;
|
||||
|
||||
export function GetCATState():Promise<cat.RigState>;
|
||||
|
||||
export function GetCWDecoderPitch():Promise<number>;
|
||||
export function GetCatalogCodes():Promise<Array<string>>;
|
||||
|
||||
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||
|
||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||
|
||||
@@ -291,6 +335,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>;
|
||||
@@ -317,40 +363,64 @@ export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
|
||||
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||
|
||||
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 GetSPEStatus():Promise<spe.Status>;
|
||||
|
||||
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>;
|
||||
@@ -359,6 +429,8 @@ export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
||||
|
||||
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||
|
||||
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
||||
|
||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||
|
||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
@@ -457,6 +529,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>>;
|
||||
@@ -475,6 +549,8 @@ export function ListContests():Promise<Array<contest.Def>>;
|
||||
|
||||
export function ListCountries():Promise<Array<string>>;
|
||||
|
||||
export function ListDenkoviDevices():Promise<Array<string>>;
|
||||
|
||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||
|
||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||
@@ -489,10 +565,18 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
||||
|
||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||
|
||||
export function LiveLastQSOAgeSec():Promise<number>;
|
||||
|
||||
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>;
|
||||
@@ -531,6 +615,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>;
|
||||
@@ -539,6 +625,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>;
|
||||
@@ -593,6 +681,8 @@ export function QSOAudioRestart():Promise<boolean>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RecomputeAllAwardRefs():Promise<number>;
|
||||
|
||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function RefreshSolar():Promise<void>;
|
||||
@@ -601,6 +691,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||
|
||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function RenameDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
||||
|
||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||
@@ -617,6 +709,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>;
|
||||
@@ -625,8 +719,12 @@ export function RotatorStop():Promise<void>;
|
||||
|
||||
export function RunBackupNow():Promise<string>;
|
||||
|
||||
export function SPESetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
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>;
|
||||
@@ -671,8 +769,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>;
|
||||
@@ -699,8 +801,6 @@ export function SetCATFrequency(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||
@@ -719,9 +819,7 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StopCWDecoder():Promise<void>;
|
||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
@@ -749,6 +847,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>;
|
||||
|
||||
+212
-16
@@ -26,10 +26,18 @@ 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);
|
||||
}
|
||||
@@ -74,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']();
|
||||
}
|
||||
@@ -86,10 +102,6 @@ export function BulkUpdateQSL(arg1, arg2) {
|
||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CWDecoderRunning() {
|
||||
return window['go']['main']['App']['CWDecoderRunning']();
|
||||
}
|
||||
|
||||
export function ChatAvailable() {
|
||||
return window['go']['main']['App']['ChatAvailable']();
|
||||
}
|
||||
@@ -226,10 +238,18 @@ 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']();
|
||||
}
|
||||
|
||||
export function DownloadAndApplyUpdate(arg1) {
|
||||
return window['go']['main']['App']['DownloadAndApplyUpdate'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadClublogCty() {
|
||||
return window['go']['main']['App']['DownloadClublogCty']();
|
||||
}
|
||||
@@ -238,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);
|
||||
}
|
||||
@@ -254,6 +286,14 @@ 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 ExportAwardForCatalog(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExportAwardForCatalog'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ExportAwards() {
|
||||
return window['go']['main']['App']['ExportAwards']();
|
||||
}
|
||||
@@ -298,10 +338,18 @@ export function FlexApplyBandAntenna(arg1) {
|
||||
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function FlexBackspaceCW(arg1) {
|
||||
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
|
||||
}
|
||||
|
||||
export function FlexMox(arg1) {
|
||||
return window['go']['main']['App']['FlexMox'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSendCW(arg1) {
|
||||
return window['go']['main']['App']['FlexSendCW'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetAGCMode(arg1) {
|
||||
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
|
||||
}
|
||||
@@ -362,6 +410,10 @@ export function FlexSetFilter(arg1, arg2) {
|
||||
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function FlexSetKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetMic(arg1) {
|
||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||
}
|
||||
@@ -410,6 +462,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);
|
||||
}
|
||||
@@ -458,10 +518,26 @@ 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 FlexStopCW() {
|
||||
return window['go']['main']['App']['FlexStopCW']();
|
||||
}
|
||||
|
||||
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']();
|
||||
}
|
||||
@@ -506,6 +582,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']();
|
||||
}
|
||||
@@ -522,8 +602,12 @@ export function GetCATState() {
|
||||
return window['go']['main']['App']['GetCATState']();
|
||||
}
|
||||
|
||||
export function GetCWDecoderPitch() {
|
||||
return window['go']['main']['App']['GetCWDecoderPitch']();
|
||||
export function GetCatalogCodes() {
|
||||
return window['go']['main']['App']['GetCatalogCodes']();
|
||||
}
|
||||
|
||||
export function GetChangelog() {
|
||||
return window['go']['main']['App']['GetChangelog']();
|
||||
}
|
||||
|
||||
export function GetChatHistory(arg1) {
|
||||
@@ -542,6 +626,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']();
|
||||
}
|
||||
@@ -594,14 +682,26 @@ export function GetListsSettings() {
|
||||
return window['go']['main']['App']['GetListsSettings']();
|
||||
}
|
||||
|
||||
export function GetLiveStations() {
|
||||
return window['go']['main']['App']['GetLiveStations']();
|
||||
}
|
||||
|
||||
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']();
|
||||
}
|
||||
@@ -614,10 +714,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']();
|
||||
}
|
||||
@@ -630,6 +738,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']();
|
||||
}
|
||||
@@ -638,6 +750,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']();
|
||||
}
|
||||
@@ -646,10 +766,18 @@ export function GetRotatorSettings() {
|
||||
return window['go']['main']['App']['GetRotatorSettings']();
|
||||
}
|
||||
|
||||
export function GetSPEStatus() {
|
||||
return window['go']['main']['App']['GetSPEStatus']();
|
||||
}
|
||||
|
||||
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']();
|
||||
}
|
||||
@@ -658,10 +786,18 @@ 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']();
|
||||
}
|
||||
@@ -678,6 +814,10 @@ export function GetUltrabeamStatus() {
|
||||
return window['go']['main']['App']['GetUltrabeamStatus']();
|
||||
}
|
||||
|
||||
export function GetWhatsNew() {
|
||||
return window['go']['main']['App']['GetWhatsNew']();
|
||||
}
|
||||
|
||||
export function GetWinkeyerSettings() {
|
||||
return window['go']['main']['App']['GetWinkeyerSettings']();
|
||||
}
|
||||
@@ -874,6 +1014,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);
|
||||
}
|
||||
@@ -910,6 +1054,10 @@ export function ListCountries() {
|
||||
return window['go']['main']['App']['ListCountries']();
|
||||
}
|
||||
|
||||
export function ListDenkoviDevices() {
|
||||
return window['go']['main']['App']['ListDenkoviDevices']();
|
||||
}
|
||||
|
||||
export function ListOperatingTree() {
|
||||
return window['go']['main']['App']['ListOperatingTree']();
|
||||
}
|
||||
@@ -938,6 +1086,14 @@ export function ListUDPIntegrations() {
|
||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||
}
|
||||
|
||||
export function LiveLastQSOAgeSec() {
|
||||
return window['go']['main']['App']['LiveLastQSOAgeSec']();
|
||||
}
|
||||
|
||||
export function LoTWUserInfo(arg1) {
|
||||
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||
}
|
||||
|
||||
export function LogUDPLoggedADIF(arg1) {
|
||||
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
||||
}
|
||||
@@ -946,6 +1102,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);
|
||||
}
|
||||
@@ -1022,6 +1186,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);
|
||||
}
|
||||
@@ -1038,6 +1206,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']();
|
||||
}
|
||||
@@ -1146,6 +1318,10 @@ export function QuitApp() {
|
||||
return window['go']['main']['App']['QuitApp']();
|
||||
}
|
||||
|
||||
export function RecomputeAllAwardRefs() {
|
||||
return window['go']['main']['App']['RecomputeAllAwardRefs']();
|
||||
}
|
||||
|
||||
export function RefreshCtyDat() {
|
||||
return window['go']['main']['App']['RefreshCtyDat']();
|
||||
}
|
||||
@@ -1162,6 +1338,10 @@ export function RemovePassphrase(arg1) {
|
||||
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
||||
}
|
||||
|
||||
export function RenameDatabase(arg1) {
|
||||
return window['go']['main']['App']['RenameDatabase'](arg1);
|
||||
}
|
||||
|
||||
export function RenderEQSL(arg1, arg2) {
|
||||
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
||||
}
|
||||
@@ -1194,6 +1374,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);
|
||||
}
|
||||
@@ -1210,10 +1394,18 @@ export function RunBackupNow() {
|
||||
return window['go']['main']['App']['RunBackupNow']();
|
||||
}
|
||||
|
||||
export function SPESetOperate(arg1) {
|
||||
return window['go']['main']['App']['SPESetOperate'](arg1);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1302,10 +1494,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);
|
||||
}
|
||||
@@ -1358,10 +1558,6 @@ export function SetCATMode(arg1) {
|
||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||
}
|
||||
|
||||
export function SetCWDecoderPitch(arg1) {
|
||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||
}
|
||||
|
||||
export function SetClublogCtyEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||
}
|
||||
@@ -1398,12 +1594,8 @@ export function SetUltrabeamDirection(arg1) {
|
||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||
}
|
||||
|
||||
export function StartCWDecoder() {
|
||||
return window['go']['main']['App']['StartCWDecoder']();
|
||||
}
|
||||
|
||||
export function StopCWDecoder() {
|
||||
return window['go']['main']['App']['StopCWDecoder']();
|
||||
export function StationSetRelay(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function SwitchCATRig(arg1) {
|
||||
@@ -1458,6 +1650,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
@@ -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.
|
||||
|
||||
+467
-90
@@ -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":
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
{
|
||||
"def": {
|
||||
"code": "DDFM",
|
||||
"name": "Départements Français Métropolitains",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"ref_display": "name",
|
||||
"type": "QSOFIELDS",
|
||||
"field": "note",
|
||||
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
||||
"or_rules": [
|
||||
{
|
||||
"field": "address",
|
||||
"match_by": "code",
|
||||
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||
"prefix": "D"
|
||||
},
|
||||
{
|
||||
"field": "qth",
|
||||
"match_by": "code",
|
||||
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||
"prefix": "D"
|
||||
}
|
||||
],
|
||||
"dxcc_filter": [
|
||||
227,
|
||||
214
|
||||
],
|
||||
"confirm": [
|
||||
"lotw",
|
||||
"qsl"
|
||||
],
|
||||
"validate": [
|
||||
"lotw"
|
||||
],
|
||||
"total": 96,
|
||||
"builtin": true,
|
||||
"version": 2
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"code": "D01",
|
||||
"name": "Ain",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D02",
|
||||
"name": "Aisne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D03",
|
||||
"name": "Allier",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D04",
|
||||
"name": "Alpes-de-Haute-Provence",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D05",
|
||||
"name": "Hautes-Alpes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D06",
|
||||
"name": "Alpes-Maritimes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D07",
|
||||
"name": "Ardèche",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D08",
|
||||
"name": "Ardennes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D09",
|
||||
"name": "Ariège",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D10",
|
||||
"name": "Aube",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D11",
|
||||
"name": "Aude",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D12",
|
||||
"name": "Aveyron",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D13",
|
||||
"name": "Bouches-du-Rhône",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D14",
|
||||
"name": "Calvados",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D15",
|
||||
"name": "Cantal",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D16",
|
||||
"name": "Charente",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D17",
|
||||
"name": "Charente-Maritime",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D18",
|
||||
"name": "Cher",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D19",
|
||||
"name": "Corrèze",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D21",
|
||||
"name": "Côte-d'Or",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D22",
|
||||
"name": "Côtes-d'Armor",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D23",
|
||||
"name": "Creuse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D24",
|
||||
"name": "Dordogne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D25",
|
||||
"name": "Doubs",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D26",
|
||||
"name": "Drôme",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D27",
|
||||
"name": "Eure",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D28",
|
||||
"name": "Eure-et-Loir",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D29",
|
||||
"name": "Finistère",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D2A",
|
||||
"name": "Corse-du-Sud",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D2B",
|
||||
"name": "Haute-Corse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D30",
|
||||
"name": "Gard",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D31",
|
||||
"name": "Haute-Garonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D32",
|
||||
"name": "Gers",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D33",
|
||||
"name": "Gironde",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D34",
|
||||
"name": "Hérault",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D35",
|
||||
"name": "Ille-et-Vilaine",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D36",
|
||||
"name": "Indre",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D37",
|
||||
"name": "Indre-et-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D38",
|
||||
"name": "Isère",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D39",
|
||||
"name": "Jura",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D40",
|
||||
"name": "Landes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D41",
|
||||
"name": "Loir-et-Cher",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D42",
|
||||
"name": "Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D43",
|
||||
"name": "Haute-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D44",
|
||||
"name": "Loire-Atlantique",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D45",
|
||||
"name": "Loiret",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D46",
|
||||
"name": "Lot",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D47",
|
||||
"name": "Lot-et-Garonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D48",
|
||||
"name": "Lozère",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D49",
|
||||
"name": "Maine-et-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D50",
|
||||
"name": "Manche",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D51",
|
||||
"name": "Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D52",
|
||||
"name": "Haute-Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D53",
|
||||
"name": "Mayenne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D54",
|
||||
"name": "Meurthe-et-Moselle",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D55",
|
||||
"name": "Meuse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D56",
|
||||
"name": "Morbihan",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D57",
|
||||
"name": "Moselle",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D58",
|
||||
"name": "Nièvre",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D59",
|
||||
"name": "Nord",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D60",
|
||||
"name": "Oise",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D61",
|
||||
"name": "Orne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D62",
|
||||
"name": "Pas-de-Calais",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D63",
|
||||
"name": "Puy-de-Dôme",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D64",
|
||||
"name": "Pyrénées-Atlantiques",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D65",
|
||||
"name": "Hautes-Pyrénées",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D66",
|
||||
"name": "Pyrénées-Orientales",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D67",
|
||||
"name": "Bas-Rhin",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D68",
|
||||
"name": "Haut-Rhin",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D69",
|
||||
"name": "Rhône",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D70",
|
||||
"name": "Haute-Saône",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D71",
|
||||
"name": "Saône-et-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D72",
|
||||
"name": "Sarthe",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D73",
|
||||
"name": "Savoie",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D74",
|
||||
"name": "Haute-Savoie",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D75",
|
||||
"name": "Paris",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D76",
|
||||
"name": "Seine-Maritime",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D77",
|
||||
"name": "Seine-et-Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D78",
|
||||
"name": "Yvelines",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D79",
|
||||
"name": "Deux-Sèvres",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D80",
|
||||
"name": "Somme",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D81",
|
||||
"name": "Tarn",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D82",
|
||||
"name": "Tarn-et-Garonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D83",
|
||||
"name": "Var",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D84",
|
||||
"name": "Vaucluse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D85",
|
||||
"name": "Vendée",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D86",
|
||||
"name": "Vienne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D87",
|
||||
"name": "Haute-Vienne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D88",
|
||||
"name": "Vosges",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D89",
|
||||
"name": "Yonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D90",
|
||||
"name": "Territoire de Belfort",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D91",
|
||||
"name": "Essonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D92",
|
||||
"name": "Hauts-de-Seine",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D93",
|
||||
"name": "Seine-Saint-Denis",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D94",
|
||||
"name": "Val-de-Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D95",
|
||||
"name": "Val-d'Oise",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
+27
-5
@@ -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
|
||||
@@ -303,6 +304,13 @@ type FlexTXState struct {
|
||||
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"`
|
||||
@@ -329,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"`
|
||||
@@ -343,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
|
||||
@@ -376,10 +386,22 @@ type FlexController interface {
|
||||
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
|
||||
SetCWBreakInDelay(int) error
|
||||
// CWX keyer — buffered CW keying via SmartSDR's CWX subsystem, so a Flex needs
|
||||
// no WinKeyer / SmartCAT. SendCW queues text (the radio buffers and keys it);
|
||||
// StopCW clears the buffer, aborting the send. BackspaceCW removes the last n
|
||||
// not-yet-keyed characters from the buffer (un-send while sending — for
|
||||
// type-ahead corrections).
|
||||
SendCW(string) error
|
||||
StopCW() error
|
||||
BackspaceCW(int) error
|
||||
SetCWSidetone(bool) error
|
||||
SetSidetoneLevel(int) error
|
||||
SetCWFilter(int) error
|
||||
|
||||
+175
-17
@@ -60,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
|
||||
@@ -89,8 +90,12 @@ type flexSlice struct {
|
||||
apfLevel int
|
||||
wnb bool // wideband noise blanker
|
||||
wnbLevel int
|
||||
filterLo int // slice filter low cut (Hz)
|
||||
filterHi int // slice filter high cut (Hz)
|
||||
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)
|
||||
@@ -104,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
|
||||
@@ -139,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
|
||||
}
|
||||
@@ -157,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{},
|
||||
}
|
||||
@@ -352,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;
|
||||
@@ -639,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 {
|
||||
@@ -659,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":
|
||||
@@ -673,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
|
||||
@@ -683,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
|
||||
}
|
||||
@@ -694,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)
|
||||
}
|
||||
}
|
||||
@@ -791,6 +809,14 @@ func (f *Flex) handleStatus(payload string) {
|
||||
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":
|
||||
@@ -919,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()
|
||||
@@ -933,21 +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 _, idx := range f.sortedSliceIdxLocked() {
|
||||
s := f.slices[idx]
|
||||
if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt {
|
||||
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
|
||||
}
|
||||
@@ -1059,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
|
||||
}
|
||||
@@ -1106,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 {
|
||||
@@ -1267,6 +1328,10 @@ func (f *Flex) FlexState() FlexTXState {
|
||||
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
|
||||
@@ -1291,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
|
||||
@@ -1335,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()
|
||||
@@ -1442,6 +1515,26 @@ 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)) }
|
||||
@@ -1488,6 +1581,37 @@ func (f *Flex) SetCWBreakInDelay(ms int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendCW queues text on the radio's CWX keyer. SmartSDR buffers and keys it, so
|
||||
// no WinKeyer / SmartCAT is needed — and because the radio owns the buffer,
|
||||
// characters fed while it's already sending append and key in order (the basis
|
||||
// for type-ahead). Quotes/backslashes are escaped for the "cwx send \"…\"" form.
|
||||
func (f *Flex) SendCW(text string) error {
|
||||
text = strings.TrimRight(text, "\r\n")
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(text)
|
||||
f.send(`cwx send "` + esc + `"`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopCW clears the CWX buffer, aborting whatever is currently being keyed.
|
||||
func (f *Flex) StopCW() error {
|
||||
f.send("cwx clear")
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackspaceCW removes the last n not-yet-keyed characters from the CWX buffer —
|
||||
// the "un-send while sending" a serial WinKeyer can't do. Used for type-ahead
|
||||
// corrections (backspacing a mistyped call before the radio has keyed it).
|
||||
func (f *Flex) BackspaceCW(n int) error {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
f.send(fmt.Sprintf("cwx erase %d", n))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flex) SetCWSidetone(on bool) error {
|
||||
f.mu.Lock()
|
||||
f.tx.cwSidetone = on
|
||||
@@ -1616,6 +1740,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 })
|
||||
}
|
||||
@@ -1921,6 +2062,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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -136,6 +136,11 @@ type icomNet struct {
|
||||
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
||||
lastRx atomic.Int64
|
||||
|
||||
// dead is set when the rig explicitly tears the session down (control 0x05):
|
||||
// Alive() then returns false immediately so ReadState fails on the next poll and
|
||||
// the manager reconnects cleanly, instead of waiting out the 6 s lastRx timeout.
|
||||
dead atomic.Bool
|
||||
|
||||
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
||||
// Torn down alongside the CI-V/control streams in Close.
|
||||
audio *icomAudio
|
||||
@@ -178,6 +183,9 @@ func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }
|
||||
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
||||
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
||||
func (n *icomNet) Alive() bool {
|
||||
if n.dead.Load() {
|
||||
return false // rig sent an explicit disconnect — reconnect now, don't wait
|
||||
}
|
||||
last := n.lastRx.Load()
|
||||
if last == 0 {
|
||||
return true // just connected, nothing received yet — give it a chance
|
||||
@@ -286,19 +294,34 @@ 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
|
||||
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||
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()
|
||||
}
|
||||
@@ -353,6 +376,7 @@ func (n *icomNet) civPump() {
|
||||
n.resend(icnLE.Uint16(buf[6:]))
|
||||
}
|
||||
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
||||
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
||||
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
||||
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
||||
|
||||
@@ -283,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
|
||||
|
||||
+15
-10
@@ -32,6 +32,7 @@ type OmniRig struct {
|
||||
omnirig *ole.IDispatch
|
||||
rig *ole.IDispatch
|
||||
lastSig string // last logged Split/VFO signature — only log on change
|
||||
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
|
||||
|
||||
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
||||
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
||||
@@ -80,7 +81,8 @@ func (o *OmniRig) Connect() error {
|
||||
o.rig = rigVar.ToIDispatch()
|
||||
|
||||
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
|
||||
o.rigType = rt.ToString()
|
||||
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, o.rigType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -194,17 +196,20 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
||||
s.FreqHz, s.RxFreqHz = freqB, freqA
|
||||
}
|
||||
} else {
|
||||
// Simplex: the operating frequency is OmniRig's generic Freq (the active
|
||||
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
|
||||
// Simplex: read VFO A first, fall back to the generic Freq — exactly like
|
||||
// DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
|
||||
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
|
||||
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
|
||||
// stock ini), so keying off FreqA gives the operator the VFO they expect.
|
||||
s.Split = false
|
||||
s.RxFreqHz = 0
|
||||
s.FreqHz = freqMain
|
||||
if s.FreqHz == 0 {
|
||||
if s.Vfo == "B" || s.Vfo == "BB" {
|
||||
s.FreqHz = freqB
|
||||
} else {
|
||||
s.FreqHz = freqA
|
||||
}
|
||||
switch {
|
||||
case freqA != 0:
|
||||
s.FreqHz = freqA
|
||||
case freqMain != 0:
|
||||
s.FreqHz = freqMain
|
||||
default:
|
||||
s.FreqHz = freqB
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
|
||||
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
|
||||
// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM
|
||||
// at other pitches is ignored), an adaptive envelope/threshold on the LOCKED
|
||||
// tone (level-independent, so weak or strong signals both key cleanly), an
|
||||
// adaptive dot-length (WPM) estimate, and a timing state machine that maps
|
||||
// marks/spaces to Morse and then to characters.
|
||||
//
|
||||
// It is deliberately self-contained and dependency-free so it can be unit
|
||||
// tested with synthetic signals. As with every audio CW decoder, weak signals
|
||||
// and very heavy QRM still degrade it; the pitch lock keeps QRM on other tones
|
||||
// out of the decode.
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
|
||||
type Status struct {
|
||||
WPM int `json:"wpm"`
|
||||
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
|
||||
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
|
||||
Active bool `json:"active"` // a tone is currently keyed down
|
||||
}
|
||||
|
||||
// Decoder consumes PCM and emits decoded characters via onChar (one or more
|
||||
// characters at a time, including " " for word gaps) and periodic onStatus.
|
||||
type Decoder struct {
|
||||
fs int
|
||||
hop int // samples between updates
|
||||
win int // Goertzel window length
|
||||
freqs []float64
|
||||
coeffs []float64 // precomputed 2*cos(w) per freq
|
||||
|
||||
ring []float64 // last win samples
|
||||
acc int // samples since last hop
|
||||
mags []float64 // per-bin magnitude this hop
|
||||
nbuf []float64 // scratch for the noise percentile
|
||||
|
||||
// Fixed-pitch target (Hz). 0 = auto-search; >0 = lock to the nearest bin and
|
||||
// ignore everything else (e.g. follow the radio's CW pitch). Set live from
|
||||
// another goroutine, so it's atomic.
|
||||
targetHz atomic.Int32
|
||||
|
||||
// Pitch lock.
|
||||
lockIdx int // index of the locked tone bin, -1 = unlocked
|
||||
candIdx int // current argmax candidate while unlocked
|
||||
candHops int // consecutive hops the candidate has been dominant
|
||||
quietHops int // consecutive key-up hops while locked
|
||||
noise float64 // broadband noise estimate (percentile of bins)
|
||||
relockHops int // quiet hops before the lock is released
|
||||
acqSNR float64 // tone/noise ratio to acquire after a few stable hops
|
||||
strongSNR float64 // tone/noise ratio to lock immediately (1 hop)
|
||||
|
||||
// Adaptive keying envelope, on the LOCKED bin's magnitude.
|
||||
peak, floor float64
|
||||
state bool // true = mark (key down)
|
||||
stateHops int
|
||||
dotHops float64 // adaptive dot length, in hops
|
||||
markCount int // marks seen since lock (fast WPM adaptation while small)
|
||||
elem []byte // current "." / "-" run for the in-progress character
|
||||
charEmitted bool
|
||||
wordEmitted bool
|
||||
|
||||
lastPitch float64
|
||||
lastRMS float64
|
||||
|
||||
statusEvery int
|
||||
sinceStatus int
|
||||
|
||||
onChar func(string)
|
||||
onStatus func(Status)
|
||||
}
|
||||
|
||||
var morse = map[string]byte{
|
||||
".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
|
||||
"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
|
||||
"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
|
||||
"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
|
||||
"-.--": 'Y', "--..": 'Z',
|
||||
"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
|
||||
".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
|
||||
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
|
||||
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
|
||||
}
|
||||
|
||||
// New builds a decoder for the given sample rate. onChar receives decoded text
|
||||
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
|
||||
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
||||
if sampleRate <= 0 {
|
||||
sampleRate = 16000
|
||||
}
|
||||
d := &Decoder{
|
||||
fs: sampleRate,
|
||||
hop: sampleRate / 250, // ~4 ms resolution
|
||||
win: sampleRate / 72, // ~14 ms Goertzel window (selective, fairly snappy)
|
||||
dotHops: 15, // ~20 WPM seed
|
||||
acqSNR: 1.9, // ignore noise spikes (looser locked onto noise = garbage)
|
||||
strongSNR: 3.2, // only a genuinely strong tone locks in 1 hop
|
||||
lockIdx: -1,
|
||||
candIdx: -1,
|
||||
statusEvery: 25, // ~10 Hz
|
||||
onChar: onChar,
|
||||
onStatus: onStatus,
|
||||
}
|
||||
if d.hop < 1 {
|
||||
d.hop = 1
|
||||
}
|
||||
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
|
||||
// Candidate CW tones: 400–1000 Hz every 25 Hz. Deliberately NOT lower: strong
|
||||
// low-frequency noise/hum (pink/red noise rises toward DC) would otherwise win
|
||||
// the argmax and lock the decoder onto ~250 Hz junk instead of the signal.
|
||||
for f := 400.0; f <= 1000.0; f += 25 {
|
||||
d.freqs = append(d.freqs, f)
|
||||
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
|
||||
}
|
||||
d.mags = make([]float64, len(d.freqs))
|
||||
d.nbuf = make([]float64, len(d.freqs))
|
||||
return d
|
||||
}
|
||||
|
||||
// SetTarget fixes the decode pitch to hz (lock to the nearest bin, ignore other
|
||||
// tones), or returns to auto-search when hz <= 0. Safe to call concurrently.
|
||||
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
|
||||
|
||||
// nearestBin returns the bin index closest to hz.
|
||||
func (d *Decoder) nearestBin(hz float64) int {
|
||||
best, bestD := 0, math.Inf(1)
|
||||
for i, f := range d.freqs {
|
||||
if dd := math.Abs(f - hz); dd < bestD {
|
||||
bestD, best = dd, i
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Reset clears decode state (e.g. when the user re-arms the decoder).
|
||||
func (d *Decoder) Reset() {
|
||||
d.ring = d.ring[:0]
|
||||
d.acc = 0
|
||||
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
|
||||
d.peak, d.floor = 0, 0
|
||||
d.state = false
|
||||
d.stateHops = 0
|
||||
d.dotHops = 15
|
||||
d.markCount = 0
|
||||
d.elem = d.elem[:0]
|
||||
d.charEmitted, d.wordEmitted = false, false
|
||||
}
|
||||
|
||||
// Process feeds a block of mono samples through the decoder.
|
||||
func (d *Decoder) Process(samples []int16) {
|
||||
for _, s := range samples {
|
||||
d.ring = append(d.ring, float64(s))
|
||||
if len(d.ring) > d.win {
|
||||
d.ring = d.ring[len(d.ring)-d.win:]
|
||||
}
|
||||
d.acc++
|
||||
if d.acc >= d.hop && len(d.ring) >= d.win {
|
||||
d.acc = 0
|
||||
d.analyze()
|
||||
d.step()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// analyze runs the Goertzel bank, estimates the noise floor, and maintains the
|
||||
// pitch lock (which tone the envelope detector then follows).
|
||||
func (d *Decoder) analyze() {
|
||||
n := float64(len(d.ring))
|
||||
var sumSq float64
|
||||
maxIdx, maxMag := 0, -1.0
|
||||
for i, coeff := range d.coeffs {
|
||||
var s1, s2 float64
|
||||
for _, x := range d.ring {
|
||||
s0 := x + coeff*s1 - s2
|
||||
s2 = s1
|
||||
s1 = s0
|
||||
}
|
||||
m := math.Sqrt(math.Max(s1*s1+s2*s2-coeff*s1*s2, 0)) / n
|
||||
d.mags[i] = m
|
||||
if m > maxMag {
|
||||
maxMag = m
|
||||
maxIdx = i
|
||||
}
|
||||
}
|
||||
for _, x := range d.ring {
|
||||
sumSq += x * x
|
||||
}
|
||||
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
|
||||
|
||||
// Fixed-pitch mode: lock straight to the target bin, skip the auto search.
|
||||
// A narrow filter at the known pitch is exactly how a skimmer avoids QRM.
|
||||
if th := int(d.targetHz.Load()); th > 0 {
|
||||
d.lockIdx = d.nearestBin(float64(th))
|
||||
d.lastPitch = d.freqs[d.lockIdx]
|
||||
return
|
||||
}
|
||||
|
||||
// Noise floor = 40th percentile of the bins (robust to a few strong tones).
|
||||
copy(d.nbuf, d.mags)
|
||||
sort.Float64s(d.nbuf)
|
||||
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
|
||||
|
||||
if d.lockIdx < 0 {
|
||||
if maxIdx == d.candIdx {
|
||||
d.candHops++
|
||||
} else {
|
||||
d.candIdx, d.candHops = maxIdx, 1
|
||||
}
|
||||
snr := maxMag / (d.noise + 1e-9)
|
||||
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
|
||||
// don't eat the first element of a strong signal), a marginal/weak tone
|
||||
// locks after a couple of stable hops (so we don't lock onto pure noise).
|
||||
if snr > d.strongSNR || (d.candHops >= 2 && snr > d.acqSNR) {
|
||||
d.lockIdx = maxIdx
|
||||
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
||||
d.quietHops = 0
|
||||
d.markCount = 0 // relearn WPM fast for this new signal
|
||||
}
|
||||
}
|
||||
if d.lockIdx >= 0 {
|
||||
d.lastPitch = d.freqs[d.lockIdx]
|
||||
} else {
|
||||
d.lastPitch = 0
|
||||
}
|
||||
}
|
||||
|
||||
// step runs the adaptive envelope on the locked bin and the timing state
|
||||
// machine, one hop. The envelope adapts to the signal level (not an absolute
|
||||
// threshold), so weak and strong signals both key correctly.
|
||||
func (d *Decoder) step() {
|
||||
on := false
|
||||
if d.lockIdx >= 0 {
|
||||
m := d.mags[d.lockIdx]
|
||||
// Peak: fast attack, slow release.
|
||||
if m > d.peak {
|
||||
d.peak += (m - d.peak) * 0.4
|
||||
} else {
|
||||
d.peak += (m - d.peak) * 0.02
|
||||
}
|
||||
// Floor: drops fast toward the signal, but only RISES between marks (when
|
||||
// keyed up). Letting the floor rise during a long dash would shrink the
|
||||
// span until the dash drops below the threshold and fragments into dots —
|
||||
// the cause of the "all dots" garbage on a strong clean signal.
|
||||
if m < d.floor {
|
||||
d.floor += (m - d.floor) * 0.4
|
||||
} else if !d.state {
|
||||
d.floor += (m - d.floor) * 0.02
|
||||
}
|
||||
span := d.peak - d.floor
|
||||
// The frozen floor already stops dashes fragmenting, so keep balanced
|
||||
// thresholds: low enough that short inter-element GAPS are still seen
|
||||
// (otherwise elements merge into >7-symbol runs that decode to nothing).
|
||||
if span > d.floor*0.3+1e-9 {
|
||||
onTh := d.floor + 0.55*span
|
||||
offTh := d.floor + 0.35*span
|
||||
if d.state {
|
||||
on = m > offTh
|
||||
} else {
|
||||
on = m > onTh
|
||||
}
|
||||
}
|
||||
// Release the lock after a long quiet so we can retune to a new signal.
|
||||
if on {
|
||||
d.quietHops = 0
|
||||
} else {
|
||||
d.quietHops++
|
||||
if d.quietHops > d.relockHops {
|
||||
// End of the over: flush any pending character and drop a word
|
||||
// space so the next transmission starts a fresh word (the word-gap
|
||||
// timer above can't fire once the lock is gone).
|
||||
if len(d.elem) > 0 && !d.charEmitted {
|
||||
d.flushChar()
|
||||
d.charEmitted = true
|
||||
}
|
||||
if !d.wordEmitted && d.onChar != nil {
|
||||
d.onChar(" ")
|
||||
d.wordEmitted = true
|
||||
}
|
||||
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if on == d.state {
|
||||
d.stateHops++
|
||||
if !d.state {
|
||||
d.spaceProgress()
|
||||
}
|
||||
} else {
|
||||
if d.state {
|
||||
d.endMark(d.stateHops)
|
||||
}
|
||||
d.state = on
|
||||
d.stateHops = 1
|
||||
if on {
|
||||
d.charEmitted, d.wordEmitted = false, false
|
||||
}
|
||||
}
|
||||
d.emitStatus(on)
|
||||
}
|
||||
|
||||
// endMark classifies a finished key-down run as a dot or dash and adapts the
|
||||
// dot-length estimate. Runs shorter than a third of a dot are rejected as
|
||||
// clicks/noise.
|
||||
func (d *Decoder) endMark(hops int) {
|
||||
h := float64(hops)
|
||||
// Reject clicks/noise: shorter than a third of a dot AND an absolute floor
|
||||
// of ~4 hops (~16 ms, i.e. faster than ~75 WPM) so noise can't drag the
|
||||
// dot-length estimate down to the clamp (which produced 100 WPM garbage).
|
||||
if h < d.dotHops*0.35 || h < 4 {
|
||||
return
|
||||
}
|
||||
if h > d.dotHops*2 {
|
||||
d.elem = append(d.elem, '-')
|
||||
d.adaptDot(h / 3)
|
||||
} else {
|
||||
d.elem = append(d.elem, '.')
|
||||
d.adaptDot(h)
|
||||
}
|
||||
}
|
||||
|
||||
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
||||
// to ~5–60 WPM). The EMA is deliberately GENTLE (0.2) and NOT accelerated on the
|
||||
// opening marks: a fast alpha let short noise blips (misclassified as dots) drag
|
||||
// the dot-length down to the clamp within a few marks — the "60 WPM, all dits"
|
||||
// garbage. The slow EMA is self-correcting because genuine marks pull it back up.
|
||||
func (d *Decoder) adaptDot(obs float64) {
|
||||
const alpha = 0.2
|
||||
d.markCount++
|
||||
d.dotHops = d.dotHops*(1-alpha) + obs*alpha
|
||||
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
||||
d.dotHops = 5
|
||||
}
|
||||
if d.dotHops > 55 {
|
||||
d.dotHops = 55
|
||||
}
|
||||
}
|
||||
|
||||
// spaceProgress flushes the current character once the gap exceeds a character
|
||||
// gap, and a word space once it exceeds a word gap.
|
||||
func (d *Decoder) spaceProgress() {
|
||||
g := float64(d.stateHops)
|
||||
if !d.charEmitted && g > d.dotHops*2 {
|
||||
d.flushChar()
|
||||
d.charEmitted = true
|
||||
}
|
||||
if !d.wordEmitted && g > d.dotHops*5 {
|
||||
if d.onChar != nil {
|
||||
d.onChar(" ")
|
||||
}
|
||||
d.wordEmitted = true
|
||||
}
|
||||
}
|
||||
|
||||
// flushChar looks up the accumulated element string and emits the character.
|
||||
func (d *Decoder) flushChar() {
|
||||
if len(d.elem) == 0 {
|
||||
return
|
||||
}
|
||||
if c, ok := morse[string(d.elem)]; ok {
|
||||
if d.onChar != nil {
|
||||
d.onChar(string(c))
|
||||
}
|
||||
} else if d.onChar != nil && len(d.elem) <= 7 {
|
||||
// Only flag a genuinely Morse-shaped but unknown char with "?". An
|
||||
// over-long element run is noise — drop it silently rather than spam "?".
|
||||
d.onChar("?")
|
||||
}
|
||||
d.elem = d.elem[:0]
|
||||
}
|
||||
|
||||
func (d *Decoder) emitStatus(on bool) {
|
||||
d.sinceStatus++
|
||||
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
|
||||
return
|
||||
}
|
||||
d.sinceStatus = 0
|
||||
hopMs := float64(d.hop) / float64(d.fs) * 1000
|
||||
wpm := 0
|
||||
if d.dotHops > 0 {
|
||||
wpm = int(math.Round(1200 / (d.dotHops * hopMs)))
|
||||
}
|
||||
d.onStatus(Status{WPM: wpm, Pitch: int(math.Round(d.lastPitch)), Level: d.lastRMS, Active: on})
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// reverse Morse map for the synthesizer.
|
||||
func charToMorse() map[byte]string {
|
||||
m := map[byte]string{}
|
||||
for code, ch := range morse {
|
||||
m[ch] = code
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
|
||||
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
|
||||
return keyMessageAmp(msg, fs, wpm, pitch, 9000)
|
||||
}
|
||||
|
||||
func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 {
|
||||
dot := fs * 1200 / (wpm * 1000) // samples per dot
|
||||
c2m := charToMorse()
|
||||
var out []int16
|
||||
phase := 0.0
|
||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||
|
||||
tone := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, int16(amp*math.Sin(phase)))
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
silence := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, 0)
|
||||
}
|
||||
}
|
||||
|
||||
silence(fs / 4) // 250 ms lead-in for AGC warmup
|
||||
for i := 0; i < len(msg); i++ {
|
||||
ch := msg[i]
|
||||
if ch == ' ' {
|
||||
silence(7 * dot)
|
||||
continue
|
||||
}
|
||||
code := c2m[ch]
|
||||
for j := 0; j < len(code); j++ {
|
||||
if code[j] == '.' {
|
||||
tone(dot)
|
||||
} else {
|
||||
tone(3 * dot)
|
||||
}
|
||||
silence(dot) // inter-element gap
|
||||
}
|
||||
silence(3 * dot) // inter-character gap (on top of the trailing element gap)
|
||||
}
|
||||
silence(fs / 4)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestDecodeCleanSignal(t *testing.T) {
|
||||
const fs = 16000
|
||||
var sb strings.Builder
|
||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||
|
||||
// Repeat so AGC warm-up only costs the first word.
|
||||
samples := keyMessage("PARIS PARIS PARIS", fs, 22, 700)
|
||||
// Feed in small chunks like the live capture would.
|
||||
for i := 0; i < len(samples); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
}
|
||||
|
||||
got := strings.ToUpper(sb.String())
|
||||
if !strings.Contains(got, "PARIS") {
|
||||
t.Fatalf("decoded %q, want it to contain PARIS", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeWithQRM(t *testing.T) {
|
||||
const fs = 16000
|
||||
// Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly
|
||||
// quieter, sending different text. The pitch lock should hold on the target.
|
||||
target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000)
|
||||
qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500)
|
||||
mix := make([]int16, len(target))
|
||||
for i := range target {
|
||||
v := int(target[i])
|
||||
if i < len(qrm) {
|
||||
v += int(qrm[i])
|
||||
}
|
||||
if v > 32767 {
|
||||
v = 32767
|
||||
} else if v < -32768 {
|
||||
v = -32768
|
||||
}
|
||||
mix[i] = int16(v)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||
for i := 0; i < len(mix); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(mix) {
|
||||
end = len(mix)
|
||||
}
|
||||
d.Process(mix[i:end])
|
||||
}
|
||||
got := strings.ToUpper(sb.String())
|
||||
if !strings.Contains(got, "PARIS") {
|
||||
t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeFirstCharStrong(t *testing.T) {
|
||||
const fs = 16000
|
||||
var sb strings.Builder
|
||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||
// Strong signal: the very first element (T = a dash) must not be eaten by
|
||||
// lock acquisition. Output should begin with the first character.
|
||||
samples := keyMessageAmp("TEST DE", fs, 20, 700, 16000)
|
||||
for i := 0; i < len(samples); i += 200 {
|
||||
end := i + 200
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
}
|
||||
got := strings.ToUpper(strings.TrimSpace(sb.String()))
|
||||
if !strings.HasPrefix(got, "TEST") {
|
||||
t.Fatalf("first chars lost on a strong signal: decoded %q, want it to start with TEST", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeWithAmplitudeRipple(t *testing.T) {
|
||||
const fs = 16000
|
||||
// A real signal's tone amplitude wobbles within a mark; if the floor chases
|
||||
// it, dashes fragment into dots ("all dots" garbage). Apply ±30% ripple.
|
||||
samples := keyMessageAmp("CQ TEST DE OM", fs, 24, 800, 10000)
|
||||
rp := 0.0
|
||||
for i := range samples {
|
||||
rp += 2 * math.Pi * 35 / float64(fs) // 35 Hz amplitude wobble
|
||||
samples[i] = int16(float64(samples[i]) * (1 + 0.3*math.Sin(rp)))
|
||||
}
|
||||
var sb strings.Builder
|
||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||
for i := 0; i < len(samples); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
}
|
||||
got := strings.ToUpper(sb.String())
|
||||
if !strings.Contains(got, "TEST DE OM") {
|
||||
t.Fatalf("dashes fragmented under amplitude ripple: decoded %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCQFixedPitch(t *testing.T) {
|
||||
const fs = 16000
|
||||
var sb strings.Builder
|
||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||
d.SetTarget(700) // fixed pitch like the user's manual override
|
||||
samples := keyMessageAmp("CQ CQ CQ DE OM", fs, 26, 700, 9000)
|
||||
for i := 0; i < len(samples); i += 200 {
|
||||
end := i + 200
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
}
|
||||
got := strings.ToUpper(sb.String())
|
||||
if n := strings.Count(got, "CQ"); n < 2 {
|
||||
t.Fatalf("first element of CQ dropped: decoded %q (only %d CQ)", got, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeNumbersAndProsign(t *testing.T) {
|
||||
const fs = 16000
|
||||
var sb strings.Builder
|
||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||
samples := keyMessage("TEST 599 TEST", fs, 18, 650)
|
||||
for i := 0; i < len(samples); i += 200 {
|
||||
end := i + 200
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
}
|
||||
got := strings.ToUpper(sb.String())
|
||||
if !strings.Contains(got, "599") {
|
||||
t.Fatalf("decoded %q, want it to contain 599", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Materialised award references per QSO. As soon as a QSO matches an award
|
||||
-- (via the operator's award definitions) or a reference is set by hand, the
|
||||
-- resolved reference(s) are stored here as a compact JSON object keyed by award
|
||||
-- code, e.g. {"DDFM":"74","WAJA":"12"}. The grid's per-award columns then read
|
||||
-- straight from the row like any other column instead of recomputing the whole
|
||||
-- award engine on every page load — much faster, and easy to display anywhere
|
||||
-- (including the shared MySQL logbook). Kept in step by the app: written on log
|
||||
-- / edit / UDP-import and bulk-recomputed when an award definition or reference
|
||||
-- list changes. SQLite ADD COLUMN is metadata-only, fast even on large logbooks.
|
||||
ALTER TABLE qso ADD COLUMN award_refs TEXT;
|
||||
+13
-5
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+45
-18
@@ -89,6 +89,9 @@ type Manager struct {
|
||||
rnd *rand.Rand
|
||||
}
|
||||
|
||||
// maxUploadAttempts bounds retries of a transient upload failure.
|
||||
const maxUploadAttempts = 4
|
||||
|
||||
func NewManager(deps Deps) *Manager {
|
||||
if deps.Client == nil {
|
||||
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
||||
@@ -101,6 +104,24 @@ func NewManager(deps Deps) *Manager {
|
||||
}
|
||||
}
|
||||
|
||||
// attemptUpload uploads a QSO in its OWN goroutine and, on a TRANSIENT failure
|
||||
// (rate-limit / network), re-arms itself with exponential back-off. Each upload is
|
||||
// independent — never serialised through a shared worker, because a single slow
|
||||
// upload (LoTW signs via TQSL; a service on a 30 s timeout) would otherwise block
|
||||
// every following QSO's upload and strand them all at "R" (the regression that hit
|
||||
// the operator on the newest build while everyone on the old concurrent path was
|
||||
// fine).
|
||||
func (m *Manager) attemptUpload(svc Service, id int64, cfg ServiceConfig, attempt int) {
|
||||
go func() {
|
||||
ok, retryable := m.upload(svc, id, cfg)
|
||||
if !ok && retryable && attempt+1 < maxUploadAttempts {
|
||||
backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s…
|
||||
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", svc, id, attempt+2, backoff)
|
||||
time.AfterFunc(backoff, func() { m.attemptUpload(svc, id, cfg, attempt+1) })
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) logf(format string, args ...any) {
|
||||
if m.deps.Logf != nil {
|
||||
m.deps.Logf(format, args...)
|
||||
@@ -175,15 +196,17 @@ func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
||||
m.scheduleUpload(svc, id, cfg)
|
||||
}
|
||||
|
||||
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
|
||||
// scheduleUpload uploads now (immediate) or after a random fuse (delayed). Each
|
||||
// upload runs in its own goroutine (attemptUpload) — never serialised — so a slow
|
||||
// one never holds up the rest.
|
||||
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||
if cfg.UploadMode == ModeDelayed {
|
||||
d := m.delaySeconds()
|
||||
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
||||
time.AfterFunc(d, func() { m.upload(svc, id, cfg) })
|
||||
time.AfterFunc(d, func() { m.attemptUpload(svc, id, cfg, 0) })
|
||||
return
|
||||
}
|
||||
go m.upload(svc, id, cfg)
|
||||
m.attemptUpload(svc, id, cfg, 0)
|
||||
}
|
||||
|
||||
// onCloseServices returns the services configured for on-close auto-upload,
|
||||
@@ -243,25 +266,25 @@ func (m *Manager) FlushOnClose() int {
|
||||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||||
case ServiceQRZ:
|
||||
for _, id := range ids {
|
||||
if m.upload(svc, id, cfg.QRZ) {
|
||||
if ok, _ := m.upload(svc, id, cfg.QRZ); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
case ServiceClublog:
|
||||
for _, id := range ids {
|
||||
if m.upload(svc, id, cfg.Clublog) {
|
||||
if ok, _ := m.upload(svc, id, cfg.Clublog); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
case ServiceHRDLog:
|
||||
for _, id := range ids {
|
||||
if m.upload(svc, id, cfg.HRDLog) {
|
||||
if ok, _ := m.upload(svc, id, cfg.HRDLog); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
case ServiceEQSL:
|
||||
for _, id := range ids {
|
||||
if m.upload(svc, id, cfg.EQSL) {
|
||||
if ok, _ := m.upload(svc, id, cfg.EQSL); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
@@ -312,12 +335,16 @@ func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
|
||||
// upload performs the actual push and returns true on success. It builds a
|
||||
// fresh, lifecycle-independent context so a delayed upload still completes
|
||||
// even if it fires close to shutdown.
|
||||
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
// upload performs one upload. It returns ok=true when the QSO was uploaded (and
|
||||
// marked), and retryable=true when it failed in a way worth retrying later (an
|
||||
// HTTP/service error such as a rate-limit 403) as opposed to a permanent skip
|
||||
// (not eligible, wrong station callsign, no record).
|
||||
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, retryable bool) {
|
||||
// Skip QSOs that aren't eligible (already sent, or sent status doesn't
|
||||
// match the configured Upload flag).
|
||||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
|
||||
// Station-callsign guard. Each logbook belongs to one callsign:
|
||||
@@ -345,7 +372,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
if m.deps.NotifyError != nil {
|
||||
m.deps.NotifyError(svc, id, err)
|
||||
}
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +387,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
|
||||
case ServiceClublog:
|
||||
@@ -369,7 +396,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
||||
case ServiceLoTW:
|
||||
@@ -378,7 +405,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
res, err = UploadLoTW(ctx, cfg, "", record)
|
||||
case ServiceHRDLog:
|
||||
@@ -387,7 +414,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
|
||||
case ServiceEQSL:
|
||||
@@ -396,11 +423,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
||||
default:
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
|
||||
if err != nil || !res.OK {
|
||||
@@ -411,12 +438,12 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||
if m.deps.NotifyError != nil {
|
||||
m.deps.NotifyError(svc, id, err)
|
||||
}
|
||||
return false
|
||||
return false, true // transient (rate-limit / network) → worth a retry
|
||||
}
|
||||
|
||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
||||
if m.deps.MarkUploaded != nil {
|
||||
m.deps.MarkUploaded(svc, id, res.LogID)
|
||||
}
|
||||
return true
|
||||
return true, false
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: 3–12 chars of A–Z/0–9//, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+462
-8
@@ -197,6 +197,14 @@ type QSO struct {
|
||||
// ADIF field names (e.g. "MS_SHOWER"); values are the raw string content.
|
||||
Extras map[string]string `json:"extras,omitempty"`
|
||||
|
||||
// AwardRefs is the materialised award-reference JSON for this QSO — a compact
|
||||
// object keyed by award code, e.g. {"DDFM":"74","WAJA":"12"}. Derived (the app
|
||||
// computes it on log/edit and bulk-recomputes it when awards change) and stored
|
||||
// so the grid's award columns read straight from the row. Written ONLY via
|
||||
// SetAwardRefs, never through the normal insert/update column list, so an edit
|
||||
// that doesn't know about it can't clobber it.
|
||||
AwardRefs string `json:"award_refs,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -250,7 +258,10 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
|
||||
credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids,
|
||||
extras_json`
|
||||
|
||||
const selectCols = `id, ` + columnList + `, created_at, updated_at`
|
||||
// award_refs is read here but is NOT part of columnList (the insert/update
|
||||
// write path) — it is a derived cache written only via SetAwardRefs, so a
|
||||
// normal QSO write can never clobber it.
|
||||
const selectCols = `id, ` + columnList + `, award_refs, created_at, updated_at`
|
||||
|
||||
// columnCount is derived from columnList at init so they can never drift.
|
||||
var columnCount = countColumns(columnList)
|
||||
@@ -490,6 +501,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 +735,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,
|
||||
@@ -776,6 +792,81 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of
|
||||
// the row. A slow caller that only wants to stamp its own app field (e-mailing a
|
||||
// QSL card, saving a recording) must not do a full-row Update: the row it read may
|
||||
// be seconds stale, and writing it all back silently reverts any column another
|
||||
// action changed meanwhile — e.g. an auto-upload flipping clublog_qso_upload_status
|
||||
// from R to Y. Touching only `extras` makes that impossible. Empty value deletes
|
||||
// the key.
|
||||
func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error {
|
||||
if id == 0 || strings.TrimSpace(key) == "" {
|
||||
return fmt.Errorf("missing id or key")
|
||||
}
|
||||
var extrasJSON sql.NullString
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT extras_json FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil {
|
||||
return fmt.Errorf("load extras: %w", err)
|
||||
}
|
||||
m := decodeExtras(extrasJSON.String)
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
if value == "" {
|
||||
delete(m, key)
|
||||
} else {
|
||||
m[key] = value
|
||||
}
|
||||
if _, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET extras_json = ?, updated_at = ? WHERE id = ?`,
|
||||
encodeExtras(m), db.NowISO(), id); err != nil {
|
||||
return fmt.Errorf("set extra %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAwardRefs stores the materialised award-reference JSON for one QSO. Like
|
||||
// SetExtra it is a targeted single-column UPDATE — never a full-row write — so it
|
||||
// cannot clobber a field another action changed meanwhile. updated_at is left
|
||||
// UNTOUCHED on purpose: award_refs is a derived cache, and bumping updated_at
|
||||
// would masquerade as a real edit (re-triggering uploads / sync that watch it).
|
||||
func (r *Repo) SetAwardRefs(ctx context.Context, id int64, jsonStr string) error {
|
||||
if id == 0 {
|
||||
return fmt.Errorf("missing id")
|
||||
}
|
||||
if _, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET award_refs = ? WHERE id = ?`, jsonStr, id); err != nil {
|
||||
return fmt.Errorf("set award_refs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAwardRefsBatch stores materialised award refs for many QSOs in a single
|
||||
// transaction — used by the bulk recompute so a large logbook on a remote MySQL
|
||||
// is one round-trip's worth of work, not N. Like SetAwardRefs it touches only the
|
||||
// award_refs column and leaves updated_at alone (derived cache). A nil/empty map
|
||||
// is a no-op.
|
||||
func (r *Repo) SetAwardRefsBatch(ctx context.Context, byID map[int64]string) error {
|
||||
if len(byID) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stmt, err := tx.PrepareContext(ctx, `UPDATE qso SET award_refs = ? WHERE id = ?`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
for id, js := range byID {
|
||||
if _, err := stmt.ExecContext(ctx, js, id); err != nil {
|
||||
return fmt.Errorf("set award_refs %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||
if q.ID == 0 {
|
||||
@@ -1010,6 +1101,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 +1691,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 +1726,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 +1736,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, ¬es,
|
||||
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
|
||||
); err != nil {
|
||||
@@ -1633,6 +1751,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 +1870,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 +1949,73 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// LastQSOTime returns the start time of the most recently LOGGED QSO for an
|
||||
// operator (highest id wins) — used to seed the live "on air" state at launch so an
|
||||
// operator who just worked someone before (re)starting OpsLog shows online right
|
||||
// away instead of waiting for their next QSO. Empty operator matches every QSO.
|
||||
func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
defer rows.Close()
|
||||
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||
for rows.Next() {
|
||||
var oper, dateStr sql.NullString
|
||||
if err := rows.Scan(&oper, &dateStr); err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
|
||||
continue
|
||||
}
|
||||
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// RecentRateBreakdown counts, in ONE pass over the most recent rows, QSOs whose
|
||||
// start time falls within each trailing window from `now` — for a specific operator
|
||||
// (their own rate, `op`) AND for ALL operators combined (the team/station rate,
|
||||
// `all`). The header rate meter shows both. It scans only recently inserted rows
|
||||
// (ORDER BY id DESC LIMIT), since any QSO in the last hour was inserted recently, so
|
||||
// it stays cheap on a large log. qso_date is parsed with parseTimeLoose (backend-
|
||||
// format agnostic).
|
||||
func (r *Repo) RecentRateBreakdown(ctx context.Context, now time.Time, operator string, windows ...time.Duration) (op []int, all []int, err error) {
|
||||
op = make([]int, len(windows))
|
||||
all = make([]int, len(windows))
|
||||
// 2000 rows covers a full hour even in a busy multi-op run.
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
|
||||
if err != nil {
|
||||
return op, all, err
|
||||
}
|
||||
defer rows.Close()
|
||||
now = now.UTC()
|
||||
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||
for rows.Next() {
|
||||
var oper, dateStr sql.NullString
|
||||
if err := rows.Scan(&oper, &dateStr); err != nil {
|
||||
return op, all, err
|
||||
}
|
||||
t := parseTimeLoose(dateStr.String).UTC()
|
||||
if t.IsZero() || t.After(now) {
|
||||
continue
|
||||
}
|
||||
mine := strings.ToUpper(strings.TrimSpace(oper.String)) == opFilter
|
||||
age := now.Sub(t)
|
||||
for i, w := range windows {
|
||||
if age <= w {
|
||||
all[i]++
|
||||
if mine {
|
||||
op[i]++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return op, all, 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 +2071,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 +2294,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 +2323,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()
|
||||
}
|
||||
@@ -1976,6 +2428,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
creditGranted, creditSubmitted sql.NullString
|
||||
myARRLSect, myVUCCGrids sql.NullString
|
||||
extrasJSON sql.NullString
|
||||
awardRefs sql.NullString
|
||||
createdStr, updatedStr string
|
||||
)
|
||||
if err := s.Scan(
|
||||
@@ -2003,7 +2456,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
||||
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
||||
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
||||
&extrasJSON, &createdStr, &updatedStr,
|
||||
&extrasJSON, &awardRefs, &createdStr, &updatedStr,
|
||||
); err != nil {
|
||||
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
||||
}
|
||||
@@ -2200,6 +2653,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
q.MyARRLSect = myARRLSect.String
|
||||
q.MyVUCCGrids = myVUCCGrids.String
|
||||
q.Extras = decodeExtras(extrasJSON.String)
|
||||
q.AwardRefs = awardRefs.String
|
||||
return q, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,815 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// BandCategory is one band's QSO count split by mode category (CW / phone /
|
||||
// digital), for the per-band mode-split chart.
|
||||
type BandCategory struct {
|
||||
Band string `json:"band"`
|
||||
CW int `json:"cw"`
|
||||
Phone int `json:"phone"`
|
||||
Data int `json:"data"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// modeCategory buckets a mode into "cw", "phone" or "data" (digital). Voice modes
|
||||
// (SSB and the digital-voice family) count as phone; CW is CW; everything else is
|
||||
// data. Mirrors the frontend's mode colouring.
|
||||
func modeCategory(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "CW":
|
||||
return "cw"
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "FREEDV", "C4FM", "DSTAR", "FUSION":
|
||||
return "phone"
|
||||
default:
|
||||
return "data"
|
||||
}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
|
||||
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
|
||||
|
||||
// Rolling chronological activity windows (UTC), anchored to the newest QSO, for
|
||||
// the "activity over time" chart's day/week/month/year granularity selector.
|
||||
// Real dates in order (empty buckets are real, just zero) — no cyclical wrap.
|
||||
ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23)
|
||||
ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO
|
||||
ByDay30 []Bucket `json:"by_day30"` // the last 30 days
|
||||
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
|
||||
|
||||
// ── 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{}
|
||||
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
|
||||
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]++
|
||||
bc := bandCat[b]
|
||||
if bc == nil {
|
||||
bc = &BandCategory{Band: b}
|
||||
bandCat[b] = bc
|
||||
}
|
||||
switch modeCategory(mode.String) {
|
||||
case "cw":
|
||||
bc.CW++
|
||||
case "phone":
|
||||
bc.Phone++
|
||||
default:
|
||||
bc.Data++
|
||||
}
|
||||
bc.Total++
|
||||
}
|
||||
// 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
|
||||
})
|
||||
// Per-band CW/phone/data split, in the SAME band-plan order as ByBand.
|
||||
s.ByBandCategory = []BandCategory{}
|
||||
for _, b := range s.ByBand {
|
||||
if bc := bandCat[b.Key]; bc != nil {
|
||||
s.ByBandCategory = append(s.ByBandCategory, *bc)
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
// Anchor the rolling activity windows to the period end when filtered, else to
|
||||
// now (so "week" is the last 7 days ending today, like the operator expects).
|
||||
ref := to
|
||||
if ref.IsZero() {
|
||||
ref = time.Now()
|
||||
}
|
||||
s.recentSeries(times, ref)
|
||||
s.ensureNonNil()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// recentSeries builds the rolling chronological activity windows for the chart's
|
||||
// day/week/month/year selector, in UTC and anchored to `ref` (the period end when
|
||||
// filtered, else now). Real dates in order — today and the days before it — so the
|
||||
// axis reads chronologically and empty days are just zero, not misordered/wrapped.
|
||||
func (s *Stats) recentSeries(times []entry, ref time.Time) {
|
||||
ref = ref.UTC()
|
||||
day0 := time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, time.UTC) // midnight of the anchor day
|
||||
hour := make([]int, 24)
|
||||
daily := map[string]int{}
|
||||
monthly := map[string]int{}
|
||||
for _, e := range times {
|
||||
t := e.t.UTC()
|
||||
if t.Year() == ref.Year() && t.YearDay() == ref.YearDay() {
|
||||
hour[t.Hour()]++
|
||||
}
|
||||
daily[t.Format("2006-01-02")]++
|
||||
monthly[t.Format("2006-01")]++
|
||||
}
|
||||
// Day: the anchor day, hour by hour.
|
||||
for h := 0; h < 24; h++ {
|
||||
s.ByHour = append(s.ByHour, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]})
|
||||
}
|
||||
// Week: the last 7 days ending today (today is last). Label = weekday + day.
|
||||
for i := 6; i >= 0; i-- {
|
||||
d := day0.AddDate(0, 0, -i)
|
||||
s.ByDay7 = append(s.ByDay7, Bucket{Key: d.Format("Mon 2"), Count: daily[d.Format("2006-01-02")]})
|
||||
}
|
||||
// Month: the last 30 days.
|
||||
for i := 29; i >= 0; i-- {
|
||||
d := day0.AddDate(0, 0, -i)
|
||||
s.ByDay30 = append(s.ByDay30, Bucket{Key: d.Format("2/1"), Count: daily[d.Format("2006-01-02")]})
|
||||
}
|
||||
// Year: the last 12 months.
|
||||
m0 := time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
for i := 11; i >= 0; i-- {
|
||||
m := m0.AddDate(0, -i, 0)
|
||||
s.ByMonth12 = append(s.ByMonth12, Bucket{Key: m.Format("Jan 06"), Count: monthly[m.Format("2006-01")]})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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:45–14: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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build !windows
|
||||
|
||||
// The Denkovi USB board is driven through FTDI's D2XX bit-bang API (ftd2xx.dll),
|
||||
// which OpsLog only wires up on Windows. This stub keeps the package building on
|
||||
// other platforms; every call reports the board is unavailable.
|
||||
package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type denkoviStub struct{ count int }
|
||||
|
||||
// NewDenkovi returns a stub on non-Windows builds.
|
||||
func NewDenkovi(serial string, count int) Device {
|
||||
if count != 4 {
|
||||
count = 8
|
||||
}
|
||||
return denkoviStub{count: count}
|
||||
}
|
||||
|
||||
func (s denkoviStub) Count() int { return s.count }
|
||||
func (denkoviStub) Status(context.Context) ([]bool, error) {
|
||||
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||
}
|
||||
func (denkoviStub) Set(context.Context, int, bool) error {
|
||||
return fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||
}
|
||||
|
||||
// ListDenkovi has no devices to report off Windows.
|
||||
func ListDenkovi() ([]string, error) {
|
||||
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//go:build windows
|
||||
|
||||
// Denkovi USB 8-channel relay board (FT245RL). Despite enumerating as a virtual
|
||||
// COM port, this board is NOT driven by serial/ASCII: the FT245's 8 data lines
|
||||
// each drive a relay, controlled through FTDI's D2XX "bit-bang" mode. One byte
|
||||
// written = the 8 relays at once (bit 0 = relay 1). We load ftd2xx.dll at runtime
|
||||
// (no CGO) and call the D2XX API directly, exactly as the vendor's tool does
|
||||
// (which addresses the board by its FTDI serial, e.g. "DAE0006K").
|
||||
package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
ftdll = syscall.NewLazyDLL("ftd2xx.dll")
|
||||
procOpenEx = ftdll.NewProc("FT_OpenEx")
|
||||
procClose = ftdll.NewProc("FT_Close")
|
||||
procSetBitMode = ftdll.NewProc("FT_SetBitMode")
|
||||
procSetBaudRate = ftdll.NewProc("FT_SetBaudRate")
|
||||
procWrite = ftdll.NewProc("FT_Write")
|
||||
procPurge = ftdll.NewProc("FT_Purge")
|
||||
procCreateInfo = ftdll.NewProc("FT_CreateDeviceInfoList")
|
||||
procGetInfoDetail = ftdll.NewProc("FT_GetDeviceInfoDetail")
|
||||
)
|
||||
|
||||
const (
|
||||
ftOpenBySerial = 1 // FT_OPEN_BY_SERIAL_NUMBER
|
||||
ftBitModeSyncBB = 0x04 // FT_BITMODE_SYNC_BITBANG (the mode Denkovi documents)
|
||||
ftPurgeRX = 1 // FT_PURGE_RX
|
||||
)
|
||||
|
||||
func ftOK(r uintptr) bool { return r == 0 } // FT_OK == 0
|
||||
|
||||
type denkovi struct {
|
||||
serial string
|
||||
count int
|
||||
mu sync.Mutex
|
||||
shadow byte // last output byte (bit n = relay n+1); authoritative state
|
||||
h uintptr // FT handle
|
||||
opened bool
|
||||
}
|
||||
|
||||
// NewDenkovi builds a driver for a Denkovi USB relay board (4 or 8 relays)
|
||||
// identified by its FTDI serial number (shown by the vendor tool / FT_PROG,
|
||||
// e.g. "DAE0006K"). count defaults to 8; a 4-relay board just uses the low 4 bits.
|
||||
func NewDenkovi(serial string, count int) Device {
|
||||
if count != 4 && count != 8 {
|
||||
count = 8
|
||||
}
|
||||
return &denkovi{serial: strings.TrimSpace(serial), count: count}
|
||||
}
|
||||
|
||||
func (d *denkovi) Count() int { return d.count }
|
||||
|
||||
// ensureOpen opens the board and puts it in synchronous bit-bang mode with all 8
|
||||
// lines as outputs. Idempotent.
|
||||
func (d *denkovi) ensureOpen() error {
|
||||
if d.opened {
|
||||
return nil
|
||||
}
|
||||
if err := ftdll.Load(); err != nil {
|
||||
return fmt.Errorf("FTDI D2XX driver (ftd2xx.dll) not found — install the FTDI D2XX driver / Denkovi software: %w", err)
|
||||
}
|
||||
if d.serial == "" {
|
||||
return fmt.Errorf("no FTDI serial number set for the Denkovi board")
|
||||
}
|
||||
ser, err := syscall.BytePtrFromString(d.serial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var h uintptr
|
||||
if r, _, _ := procOpenEx.Call(uintptr(unsafe.Pointer(ser)), ftOpenBySerial, uintptr(unsafe.Pointer(&h))); !ftOK(r) {
|
||||
return fmt.Errorf("cannot open Denkovi board %q (FT_OpenEx status %d) — is it connected and not in use by another app?", d.serial, r)
|
||||
}
|
||||
// All 8 lines output, synchronous bit-bang.
|
||||
if r, _, _ := procSetBitMode.Call(h, 0xFF, ftBitModeSyncBB); !ftOK(r) {
|
||||
procClose.Call(h)
|
||||
return fmt.Errorf("FT_SetBitMode failed (status %d)", r)
|
||||
}
|
||||
procSetBaudRate.Call(h, 9600) // bit-bang pin-update clock; relays don't need speed
|
||||
d.h = h
|
||||
d.opened = true
|
||||
// Make the hardware match our shadow (starts all-off on first open).
|
||||
return d.writeLocked()
|
||||
}
|
||||
|
||||
// writeLocked pushes the shadow byte to the relays. Caller holds d.mu.
|
||||
func (d *denkovi) writeLocked() error {
|
||||
var written uint32
|
||||
b := d.shadow
|
||||
if r, _, _ := procWrite.Call(d.h, uintptr(unsafe.Pointer(&b)), 1, uintptr(unsafe.Pointer(&written))); !ftOK(r) {
|
||||
return fmt.Errorf("FT_Write failed (status %d)", r)
|
||||
}
|
||||
// Synchronous bit-bang echoes each written byte into the RX buffer; drop it so
|
||||
// it doesn't fill over the life of the connection.
|
||||
procPurge.Call(d.h, ftPurgeRX)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *denkovi) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > d.count {
|
||||
return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err := d.ensureOpen(); err != nil {
|
||||
return err
|
||||
}
|
||||
bit := byte(1) << uint(relay-1) // relay 1 → bit 0
|
||||
if on {
|
||||
d.shadow |= bit
|
||||
} else {
|
||||
d.shadow &^= bit
|
||||
}
|
||||
return d.writeLocked()
|
||||
}
|
||||
|
||||
func (d *denkovi) Status(ctx context.Context) ([]bool, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err := d.ensureOpen(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]bool, d.count)
|
||||
for i := 0; i < d.count; i++ {
|
||||
out[i] = d.shadow&(byte(1)<<uint(i)) != 0
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListDenkovi returns the FTDI serial numbers of connected devices, for the
|
||||
// settings UI to pick from. Requires ftd2xx.dll.
|
||||
func ListDenkovi() ([]string, error) {
|
||||
if err := ftdll.Load(); err != nil {
|
||||
return nil, fmt.Errorf("FTDI D2XX driver (ftd2xx.dll) not found — install the FTDI D2XX driver / Denkovi software")
|
||||
}
|
||||
var n uint32
|
||||
if r, _, _ := procCreateInfo.Call(uintptr(unsafe.Pointer(&n))); !ftOK(r) {
|
||||
return nil, fmt.Errorf("FT_CreateDeviceInfoList failed (status %d)", r)
|
||||
}
|
||||
var out []string
|
||||
for i := uint32(0); i < n; i++ {
|
||||
var flags, typ, id, loc uint32
|
||||
serial := make([]byte, 16)
|
||||
desc := make([]byte, 64)
|
||||
var h uintptr
|
||||
r, _, _ := procGetInfoDetail.Call(
|
||||
uintptr(i),
|
||||
uintptr(unsafe.Pointer(&flags)), uintptr(unsafe.Pointer(&typ)),
|
||||
uintptr(unsafe.Pointer(&id)), uintptr(unsafe.Pointer(&loc)),
|
||||
uintptr(unsafe.Pointer(&serial[0])), uintptr(unsafe.Pointer(&desc[0])),
|
||||
uintptr(unsafe.Pointer(&h)),
|
||||
)
|
||||
if !ftOK(r) {
|
||||
continue
|
||||
}
|
||||
if s := cstr(serial); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// cstr trims a C string (up to the first NUL) from a fixed buffer.
|
||||
func cstr(b []byte) string {
|
||||
if i := indexByte(b, 0); i >= 0 {
|
||||
b = b[:i]
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func indexByte(b []byte, c byte) int {
|
||||
for i := range b {
|
||||
if b[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// serialRelay drives the common cheap USB-serial relay boards (CH340 / LCUS-1
|
||||
// style) that use the "A0" command protocol over a virtual COM port:
|
||||
//
|
||||
// [0xA0] [relay 1-based] [state 0|1] [checksum] checksum = (0xA0+relay+state) & 0xFF
|
||||
//
|
||||
// e.g. relay 1 ON = A0 01 01 A2, relay 1 OFF = A0 01 00 A1. These boards are
|
||||
// write-only (no state readback), so Status reflects a shadow of what we sent.
|
||||
// Channel count varies by board (1/2/4/8/16), so it is configurable.
|
||||
type serialRelay struct {
|
||||
portName string
|
||||
count int
|
||||
baud int
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
shadow []bool
|
||||
}
|
||||
|
||||
// NewSerialRelay builds a driver for a CH340/LCUS-style USB-serial relay board on
|
||||
// the given COM port with `count` channels (defaults to 8).
|
||||
func NewSerialRelay(port string, count int) Device {
|
||||
if count < 1 {
|
||||
count = 8
|
||||
}
|
||||
return &serialRelay{portName: strings.TrimSpace(port), count: count, baud: 9600, shadow: make([]bool, count)}
|
||||
}
|
||||
|
||||
func (s *serialRelay) Count() int { return s.count }
|
||||
|
||||
func (s *serialRelay) ensureOpen() error {
|
||||
if s.port != nil {
|
||||
return nil
|
||||
}
|
||||
if s.portName == "" {
|
||||
return fmt.Errorf("no COM port set for the USB relay board")
|
||||
}
|
||||
p, err := serial.Open(s.portName, &serial.Mode{BaudRate: s.baud})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", s.portName, err)
|
||||
}
|
||||
s.port = p
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serialRelay) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > s.count {
|
||||
return fmt.Errorf("relay %d out of range 1..%d", relay, s.count)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.ensureOpen(); err != nil {
|
||||
return err
|
||||
}
|
||||
st := byte(0)
|
||||
if on {
|
||||
st = 1
|
||||
}
|
||||
r := byte(relay)
|
||||
frame := []byte{0xA0, r, st, byte(0xA0) + r + st} // last byte = checksum
|
||||
if _, err := s.port.Write(frame); err != nil {
|
||||
// Drop the handle so the next call reopens (USB unplugged / port reset).
|
||||
s.port.Close()
|
||||
s.port = nil
|
||||
return fmt.Errorf("write to %s: %w", s.portName, err)
|
||||
}
|
||||
s.shadow[relay-1] = on
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serialRelay) Status(ctx context.Context) ([]bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.ensureOpen(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]bool, s.count)
|
||||
copy(out, s.shadow)
|
||||
return out, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// Package spe drives the SPE Expert 1.3K-FA / 1.5K-FA / 2K-FA amplifiers over
|
||||
// their proprietary serial protocol (SPE "Application Programmer's Guide" rev 1.1).
|
||||
// The amp is reached either directly over USB (a virtual COM port) or over TCP via
|
||||
// an RS232-to-Ethernet bridge — both are just an io.ReadWriteCloser to this code.
|
||||
//
|
||||
// Wire format (host → amp): 0x55 0x55 0x55 | CNT | DATA… | CHK
|
||||
// CNT = number of DATA bytes, CHK = sum(DATA) mod 256.
|
||||
// Status reply (amp → host): 0xAA 0xAA 0xAA | LEN | <LEN CSV bytes> | chk0 chk1 | CR LF
|
||||
// LEN is 0x43 (67); the payload is 19 comma-separated fixed fields.
|
||||
//
|
||||
// This MVP implements the two commands anchored by worked examples in the guide:
|
||||
// OPERATE (0x0D, toggles STANDBY↔OPERATE) and STATUS (0x90). Other keystroke codes
|
||||
// exist but the guide's command table did not extract unambiguously, so they are
|
||||
// left out rather than risk sending the wrong key to the amplifier.
|
||||
package spe
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
const (
|
||||
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE
|
||||
cmdStatus byte = 0x90 // request the status string
|
||||
|
||||
syncHost = 0x55
|
||||
syncAmp = 0xAA
|
||||
|
||||
dialTimeout = 5 * time.Second
|
||||
ioTimeout = 3 * time.Second
|
||||
pollEvery = 800 * time.Millisecond
|
||||
)
|
||||
|
||||
// Status is the decoded amplifier state for the UI.
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Model string `json:"model,omitempty"` // "20K" / "13K"
|
||||
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
|
||||
TX bool `json:"tx"` // true = transmitting
|
||||
Input string `json:"input,omitempty"` // "1" / "2"
|
||||
Band string `json:"band,omitempty"` // raw 2-char band code
|
||||
PowerLevel string `json:"power_level,omitempty"` // L / M / H
|
||||
OutputW int `json:"output_w"`
|
||||
SWRATU float64 `json:"swr_atu"`
|
||||
SWRAnt float64 `json:"swr_ant"`
|
||||
VoltPA float64 `json:"volt_pa"`
|
||||
CurrPA float64 `json:"curr_pa"`
|
||||
TempC int `json:"temp_c"` // heatsink (upper) temperature
|
||||
Warnings string `json:"warnings,omitempty"`
|
||||
Alarms string `json:"alarms,omitempty"`
|
||||
}
|
||||
|
||||
// Config selects the transport.
|
||||
type Config struct {
|
||||
Transport string // "serial" | "tcp"
|
||||
ComPort string // serial
|
||||
Baud int // serial
|
||||
Host string // tcp
|
||||
Port int // tcp
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
cfg Config
|
||||
|
||||
mu sync.Mutex // serialises access to the connection
|
||||
conn io.ReadWriteCloser
|
||||
r *bufio.Reader
|
||||
|
||||
statusMu sync.RWMutex
|
||||
status Status
|
||||
|
||||
stop chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
func New(cfg Config) *Client {
|
||||
if cfg.Baud <= 0 {
|
||||
cfg.Baud = 115200
|
||||
}
|
||||
return &Client{cfg: cfg, stop: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
if c.running {
|
||||
return nil
|
||||
}
|
||||
c.running = true
|
||||
go c.pollLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Stop() {
|
||||
if !c.running {
|
||||
return
|
||||
}
|
||||
c.running = false
|
||||
close(c.stop)
|
||||
c.mu.Lock()
|
||||
c.dropLocked()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() Status {
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
return c.status
|
||||
}
|
||||
|
||||
func (c *Client) setErr(err error) {
|
||||
c.statusMu.Lock()
|
||||
c.status.Connected = false
|
||||
c.status.LastError = err.Error()
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
|
||||
// Operate toggles the amplifier between STANDBY and OPERATE (the amp has a single
|
||||
// OPERATE key that flips the state, so we send it only when the desired state
|
||||
// differs from the last-read one).
|
||||
func (c *Client) Operate(on bool) error {
|
||||
if c.GetStatus().Operate == on {
|
||||
return nil
|
||||
}
|
||||
return c.sendCmd(cmdOperate)
|
||||
}
|
||||
|
||||
// ToggleOperate flips STANDBY/OPERATE unconditionally.
|
||||
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
|
||||
|
||||
func (c *Client) pollLoop() {
|
||||
t := time.NewTicker(pollEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-c.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
if err := c.ensureConn(); err != nil {
|
||||
c.setErr(fmt.Errorf("connect: %w", err))
|
||||
continue
|
||||
}
|
||||
if err := c.sendCmd(cmdStatus); err != nil {
|
||||
c.mu.Lock()
|
||||
c.dropLocked()
|
||||
c.mu.Unlock()
|
||||
c.setErr(err)
|
||||
continue
|
||||
}
|
||||
c.readStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) ensureConn() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn != nil {
|
||||
return nil
|
||||
}
|
||||
var rwc io.ReadWriteCloser
|
||||
var err error
|
||||
if c.cfg.Transport == "tcp" {
|
||||
var nc net.Conn
|
||||
nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
|
||||
rwc = nc
|
||||
} else {
|
||||
rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.conn = rwc
|
||||
c.r = bufio.NewReader(rwc)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) dropLocked() {
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
c.r = nil
|
||||
}
|
||||
}
|
||||
|
||||
// sendCmd frames one keystroke code and writes it. Single-byte payload → CHK is
|
||||
// the code itself.
|
||||
func (c *Client) sendCmd(code byte) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
if nc, ok := c.conn.(net.Conn); ok {
|
||||
_ = nc.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||
}
|
||||
pkt := []byte{syncHost, syncHost, syncHost, 0x01, code, code}
|
||||
_, err := c.conn.Write(pkt)
|
||||
return err
|
||||
}
|
||||
|
||||
// readStatus reads one amp packet and, when it's a status string, decodes it. ACK
|
||||
// packets (short) are consumed and ignored.
|
||||
func (c *Client) readStatus() {
|
||||
c.mu.Lock()
|
||||
r := c.r
|
||||
if nc, ok := c.conn.(net.Conn); ok && nc != nil {
|
||||
_ = nc.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
// Sync on three 0xAA bytes.
|
||||
run := 0
|
||||
for run < 3 {
|
||||
b, err := r.ReadByte()
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
c.dropLocked()
|
||||
c.mu.Unlock()
|
||||
c.setErr(err)
|
||||
return
|
||||
}
|
||||
if b == syncAmp {
|
||||
run++
|
||||
} else {
|
||||
run = 0
|
||||
}
|
||||
}
|
||||
length, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data := make([]byte, int(length))
|
||||
if _, err := io.ReadFull(r, data); err != nil {
|
||||
return
|
||||
}
|
||||
// Status strings are the long ones (LEN 0x43 = 67). Short packets are ACKs
|
||||
// (1 checksum byte, no CRLF) — nothing else to consume for those.
|
||||
if length >= 40 {
|
||||
// consume the 2 checksum bytes + CR LF
|
||||
_, _ = r.Discard(4)
|
||||
c.decodeCSV(string(data))
|
||||
} else {
|
||||
_, _ = r.Discard(1) // ACK checksum
|
||||
}
|
||||
}
|
||||
|
||||
// decodeCSV parses the 19-field comma-separated status payload.
|
||||
func (c *Client) decodeCSV(payload string) {
|
||||
f := strings.Split(payload, ",")
|
||||
get := func(i int) string {
|
||||
if i < len(f) {
|
||||
return strings.TrimSpace(f[i])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
pf := func(s string) float64 { v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64); return v }
|
||||
pi := func(s string) int { v, _ := strconv.Atoi(strings.TrimSpace(s)); return v }
|
||||
|
||||
c.statusMu.Lock()
|
||||
defer c.statusMu.Unlock()
|
||||
c.status.Connected = true
|
||||
c.status.LastError = ""
|
||||
c.status.Model = get(0)
|
||||
c.status.Operate = get(1) == "O"
|
||||
c.status.TX = get(2) == "T"
|
||||
c.status.Input = get(4)
|
||||
c.status.Band = get(5)
|
||||
c.status.PowerLevel = get(8)
|
||||
c.status.OutputW = pi(get(9))
|
||||
c.status.SWRATU = pf(get(10))
|
||||
c.status.SWRAnt = pf(get(11))
|
||||
c.status.VoltPA = pf(get(12))
|
||||
c.status.CurrPA = pf(get(13))
|
||||
c.status.TempC = pi(get(14))
|
||||
if w := get(17); w != "" && w != "N" {
|
||||
c.status.Warnings = w
|
||||
} else {
|
||||
c.status.Warnings = ""
|
||||
}
|
||||
if a := get(18); a != "" && a != "N" {
|
||||
c.status.Alarms = a
|
||||
} else {
|
||||
c.status.Alarms = ""
|
||||
}
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user