Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
+259
-39
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||
GetCATSettings,
|
||||
GetSolarData,
|
||||
GetQSORate,
|
||||
LoTWUserInfo,
|
||||
OperatingDefaultForBand,
|
||||
LogUDPLoggedADIF,
|
||||
@@ -68,6 +69,7 @@ import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||
import { StatsPanel } from '@/components/StatsPanel';
|
||||
import { StationControlPanel } from '@/components/StationControlPanel';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||
@@ -608,7 +610,16 @@ export default function App() {
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
|
||||
const [matchCount, setMatchCount] = useState<number | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('recent');
|
||||
// The selected tab is remembered across restarts. Only the always-present tabs
|
||||
// are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on
|
||||
// a feature or CAT backend that isn't known this early, and restoring one that
|
||||
// isn't there would show a blank pane.
|
||||
const ALWAYS_TABS = ['main', 'recent', 'cluster', 'worked', 'awards', 'bandmap'];
|
||||
const [activeTab, setActiveTab] = useState(() => {
|
||||
const saved = localStorage.getItem('opslog.activeTab') || '';
|
||||
return ALWAYS_TABS.includes(saved) ? saved : 'recent';
|
||||
});
|
||||
useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
|
||||
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
||||
const [qslTabOpen, setQslTabOpen] = useState(false);
|
||||
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
||||
@@ -623,6 +634,11 @@ export default function App() {
|
||||
setStatsTabOpen(false);
|
||||
setActiveTab((t) => (t === 'stats' ? 'recent' : t));
|
||||
}
|
||||
const [stationTabOpen, setStationTabOpen] = useState(false);
|
||||
function closeStationTab() {
|
||||
setStationTabOpen(false);
|
||||
setActiveTab((t) => (t === 'station' ? 'recent' : t));
|
||||
}
|
||||
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
|
||||
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
||||
// couple of seconds to round-trip from SQLite at launch. Defaulting
|
||||
@@ -909,8 +925,15 @@ export default function App() {
|
||||
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
|
||||
const [spots, setSpots] = useState<ClusterSpot[]>([]);
|
||||
const SPOTS_CAP = 1000;
|
||||
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
|
||||
const [clusterGroup, setClusterGroup] = useState(true);
|
||||
// Cluster filter selections persist across restarts (writeUiPref → localStorage
|
||||
// + DB, so they also travel with a copied data/ folder). Loaders read the cache
|
||||
// synchronously at first render; a single effect below writes them back.
|
||||
const lsBool = (k: string, d: boolean) => { const v = localStorage.getItem(k); return v === null ? d : v === '1'; };
|
||||
const lsSet = <T,>(k: string): Set<T> => { try { const a = JSON.parse(localStorage.getItem(k) || '[]'); return new Set(Array.isArray(a) ? a : []); } catch { return new Set<T>(); } };
|
||||
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>(() => {
|
||||
const v = localStorage.getItem('opslog.clusterFilterSource'); const n = v ? parseInt(v, 10) : NaN; return Number.isFinite(n) ? n : '';
|
||||
});
|
||||
const [clusterGroup, setClusterGroup] = useState(() => lsBool('opslog.clusterGroup', true));
|
||||
const [clusterCmd, setClusterCmd] = useState('');
|
||||
// Cluster console: the raw traffic. Spots are parsed out of the stream into the
|
||||
// grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded,
|
||||
@@ -938,22 +961,37 @@ export default function App() {
|
||||
if (atBottom) el.scrollTop = el.scrollHeight;
|
||||
}, [clusterLines]);
|
||||
// Multi-band filter: empty set = all bands. The user toggles chips.
|
||||
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
|
||||
const [clusterBands, setClusterBands] = useState<Set<string>>(() => lsSet<string>('opslog.clusterBands'));
|
||||
// Lock-to-entry: when on, the band filter follows the entry's current
|
||||
// band and the mode filter follows the entry's current mode.
|
||||
const [clusterLockBand, setClusterLockBand] = useState(false);
|
||||
const [clusterLockMode, setClusterLockMode] = useState(false);
|
||||
const [clusterLockBand, setClusterLockBand] = useState(() => lsBool('opslog.clusterLockBand', false));
|
||||
const [clusterLockMode, setClusterLockMode] = useState(() => lsBool('opslog.clusterLockMode', false));
|
||||
// Status filter chips. Empty set = show every status (including
|
||||
// already-worked). Otherwise only matching spots pass.
|
||||
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
|
||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
|
||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(() => lsSet<SpotStatusKey>('opslog.clusterStatusFilter'));
|
||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
||||
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set());
|
||||
const [clusterSearch, setClusterSearch] = useState('');
|
||||
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(() => lsSet<SpotModeCat>('opslog.clusterModeFilter'));
|
||||
const [clusterSearch, setClusterSearch] = useState(() => localStorage.getItem('opslog.clusterSearch') || '');
|
||||
// Hide spots already worked (exact call worked, or this band+mode slot done).
|
||||
const [clusterHideWorked, setClusterHideWorked] = useState(false);
|
||||
const [clusterHideWorked, setClusterHideWorked] = useState(() => lsBool('opslog.clusterHideWorked', false));
|
||||
|
||||
// Persist every cluster filter selection whenever it changes, so it is still
|
||||
// set after a close/reopen.
|
||||
useEffect(() => {
|
||||
writeUiPref('opslog.clusterFilterSource', clusterFilterSource === '' ? '' : String(clusterFilterSource));
|
||||
writeUiPref('opslog.clusterGroup', clusterGroup ? '1' : '0');
|
||||
writeUiPref('opslog.clusterBands', JSON.stringify([...clusterBands]));
|
||||
writeUiPref('opslog.clusterLockBand', clusterLockBand ? '1' : '0');
|
||||
writeUiPref('opslog.clusterLockMode', clusterLockMode ? '1' : '0');
|
||||
writeUiPref('opslog.clusterStatusFilter', JSON.stringify([...clusterStatusFilter]));
|
||||
writeUiPref('opslog.clusterModeFilter', JSON.stringify([...clusterModeFilter]));
|
||||
writeUiPref('opslog.clusterSearch', clusterSearch);
|
||||
writeUiPref('opslog.clusterHideWorked', clusterHideWorked ? '1' : '0');
|
||||
}, [clusterFilterSource, clusterGroup, clusterBands, clusterLockBand, clusterLockMode,
|
||||
clusterStatusFilter, clusterModeFilter, clusterSearch, clusterHideWorked]);
|
||||
// Bands shown side-by-side in the Band Map tab (portable).
|
||||
const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
|
||||
try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
|
||||
@@ -1026,7 +1064,16 @@ export default function App() {
|
||||
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
||||
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
||||
// different slots don't share the same colour.
|
||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean }>>({});
|
||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
|
||||
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
||||
// still need resolving without re-subscribing the cluster:spot listener.
|
||||
const spotStatusRef = useRef(spotStatus);
|
||||
useEffect(() => { spotStatusRef.current = spotStatus; }, [spotStatus]);
|
||||
// Incoming spots are staged here for a few ms, their status resolved, then
|
||||
// committed to `spots` together — so a row paints with its NEW BAND/MODE badge
|
||||
// already on, instead of flashing plain text then flipping to the pill.
|
||||
const pendingSpotsRef = useRef<ClusterSpot[]>([]);
|
||||
const pendingSpotTimer = useRef<number | undefined>(undefined);
|
||||
|
||||
// === Modals ===
|
||||
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
||||
@@ -1039,6 +1086,20 @@ export default function App() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
||||
const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number }>({ last10: 0, last60: 0 });
|
||||
useEffect(() => {
|
||||
if (!showQsoRate) return;
|
||||
const load = () => { GetQSORate().then((r) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0 })).catch(() => {}); };
|
||||
load();
|
||||
// Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
|
||||
// trailing windows roll forward even when nothing new is logged.
|
||||
const off = EventsOn('qso:logged', load);
|
||||
const id = window.setInterval(load, 30 * 1000);
|
||||
return () => { off(); window.clearInterval(id); };
|
||||
}, [showQsoRate]);
|
||||
// Optional deep-link: which Preferences section to open. Cleared on
|
||||
// close so the next plain "Preferences" launch reverts to default.
|
||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||
@@ -1253,20 +1314,28 @@ export default function App() {
|
||||
// surface the error — used by the startup retry, since the logbook DB (a remote
|
||||
// MySQL especially) can take a few seconds to connect while the UI is already
|
||||
// mounted, and we don't want to flash "db not available" during that window.
|
||||
const refreshSeqRef = useRef(0); // guards against an older refresh clobbering a newer one's data
|
||||
const refresh = useCallback(async (silent = false): Promise<boolean> => {
|
||||
// Monotonic guard: two refreshes can be in flight at once (e.g. the immediate
|
||||
// one after a UDP auto-log, which reads the QSO at "R", and the debounced one
|
||||
// after extsvc:uploaded, which reads it at "Y"). MySQL query latency can make
|
||||
// the OLDER one resolve LAST and clobber the newer data — the Club Log status
|
||||
// flicking R→Y→R. Only the most-recently-issued refresh is allowed to apply.
|
||||
const seq = ++refreshSeqRef.current;
|
||||
try {
|
||||
const f = buildActiveFilter();
|
||||
const list = await ListQSOFiltered(f as any);
|
||||
const n = await CountQSO();
|
||||
const hasFilter = !!(f.quick_callsign || (f.conditions && f.conditions.length));
|
||||
const matched = hasFilter ? await CountQSOFiltered(f as any) : n;
|
||||
if (seq !== refreshSeqRef.current) return true; // a newer refresh superseded us — drop this stale result
|
||||
setQsos(list);
|
||||
setTotal(n);
|
||||
setMatchCount(matched);
|
||||
setError('');
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
if (!silent) setError(String(e?.message ?? e));
|
||||
if (!silent && seq === refreshSeqRef.current) setError(String(e?.message ?? e));
|
||||
return false;
|
||||
}
|
||||
}, [buildActiveFilter]);
|
||||
@@ -1703,13 +1772,76 @@ export default function App() {
|
||||
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
|
||||
setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
|
||||
});
|
||||
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
||||
// Commit the staged spots: resolve the status for any slot we don't know yet
|
||||
// FIRST, then insert the rows — so they appear with the right badge already
|
||||
// painted instead of flashing plain text then flipping to a pill.
|
||||
const flushPendingSpots = async () => {
|
||||
pendingSpotTimer.current = undefined;
|
||||
const batch = pendingSpotsRef.current;
|
||||
pendingSpotsRef.current = [];
|
||||
if (batch.length === 0) return;
|
||||
// Resolve unknown statuses before the rows go in.
|
||||
try {
|
||||
const known = spotStatusRef.current;
|
||||
const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const s of batch) {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
if (seen.has(k) || known[k]) continue;
|
||||
seen.add(k);
|
||||
unknown.push({
|
||||
call: s.dx_call, band: s.band ?? '',
|
||||
mode: inferSpotMode(s.comment ?? '', s.freq_hz),
|
||||
pota_ref: (s as any).pota_ref ?? '',
|
||||
});
|
||||
}
|
||||
if (unknown.length > 0) {
|
||||
const res = await ClusterSpotStatuses(unknown as any);
|
||||
setSpotStatus((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const r of res) {
|
||||
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
||||
next[k] = {
|
||||
status: r.status ?? '',
|
||||
country: r.country,
|
||||
continent: (r as any).continent,
|
||||
worked_call: !!(r as any).worked_call,
|
||||
new_county: !!(r as any).new_county,
|
||||
new_pota: !!(r as any).new_pota,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
// Now insert the staged rows. Same de-dupe as before: a station re-spotted
|
||||
// (RBN skimmers on a CQ, several nodes relaying) shows as ONE live row that
|
||||
// the freshest spot REPLACES and floats to the top. Historical (SH/DX
|
||||
// replay) rows are left alone.
|
||||
setSpots((arr) => {
|
||||
const next = [sp, ...arr];
|
||||
const key = (x: ClusterSpot) => `${(x.dx_call ?? '').toUpperCase()}|${(x.band ?? '').toLowerCase()}`;
|
||||
const hist = (x: ClusterSpot) => !!(x as any).historical;
|
||||
let next = arr;
|
||||
for (const sp of batch) {
|
||||
const k = key(sp);
|
||||
const filtered = hist(sp) ? next : next.filter((x) => hist(x) || key(x) !== k);
|
||||
next = [sp, ...filtered];
|
||||
}
|
||||
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
|
||||
});
|
||||
};
|
||||
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
||||
// Stage the spot; a short timer resolves its status then commits it.
|
||||
pendingSpotsRef.current.push(sp);
|
||||
// 50 ms is enough to coalesce an RBN burst into one status lookup (the
|
||||
// worked-index is in memory, so resolving is near-instant) while staying
|
||||
// imperceptible.
|
||||
if (pendingSpotTimer.current === undefined) {
|
||||
pendingSpotTimer.current = window.setTimeout(flushPendingSpots, 50);
|
||||
}
|
||||
// Self-spot: someone spotted OUR callsign — show it in the shared header
|
||||
// toast (same place as the other notifications), not a separate banner.
|
||||
// Fired immediately (not staged) so the notification never lags the spot.
|
||||
const mine = myCallRef.current;
|
||||
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
|
||||
const by = cleanSpotter(sp.spotter ?? '') || '?';
|
||||
@@ -1717,7 +1849,10 @@ export default function App() {
|
||||
showToast(`Spotted by ${by}${c ? ` with ${c}` : ''}`);
|
||||
}
|
||||
});
|
||||
return () => { unsubState?.(); unsubSpot?.(); };
|
||||
return () => {
|
||||
unsubState?.(); unsubSpot?.();
|
||||
if (pendingSpotTimer.current !== undefined) window.clearTimeout(pendingSpotTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -1756,6 +1891,12 @@ export default function App() {
|
||||
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
||||
if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
|
||||
});
|
||||
// The DX Call was cleared in WSJT-X / JTDX / MSHV → clear our entry to match.
|
||||
// Only when something is actually in the entry, so an idle digital app doesn't
|
||||
// wipe a call being typed by hand.
|
||||
const unsubClear = EventsOn('udp:clear_call', () => {
|
||||
if (callsignRef.current?.value?.trim() || callsign.trim()) resetEntry();
|
||||
});
|
||||
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
|
||||
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
|
||||
// An explicit click always wins over whatever call is currently in the field.
|
||||
@@ -1780,7 +1921,16 @@ export default function App() {
|
||||
else setError('UDP auto-log: ' + msg);
|
||||
}
|
||||
});
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
|
||||
// ADIF monitor imported new QSOs (backend file watcher) → refresh the grid
|
||||
// and show how many, from which file.
|
||||
const unsubAdifMon = EventsOn('adifmon:imported', async (p: any) => {
|
||||
const n = Number(p?.count ?? 0);
|
||||
if (n <= 0) return;
|
||||
await refresh();
|
||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||
showToast(`ADIF: ${n} QSO imported${file ? ` from ${file}` : ''}`);
|
||||
});
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -1875,17 +2025,21 @@ export default function App() {
|
||||
async function wkSend(rawText: string) {
|
||||
setWkSent('');
|
||||
const resolved = resolveCW(rawText);
|
||||
// Trailing word space so two macros fired back-to-back don't run together in
|
||||
// the keyer buffer ("CQ" + "TEST" → "CQTEST"). The keyer keys a space as a
|
||||
// word gap at the CURRENT speed, so it scales with WPM automatically.
|
||||
const keyed = resolved ? resolved + ' ' : resolved;
|
||||
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
|
||||
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||
if (cwSourceRef.current === 'icom') {
|
||||
// The rig's keyer gives no busy echo back, so show the text we sent and,
|
||||
// for <LOGQSO>, wait the estimated send duration before logging.
|
||||
setWkSent(resolved);
|
||||
await IcomSendCW(resolved).catch((e) => setError(String(e?.message ?? e)));
|
||||
await IcomSendCW(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
|
||||
return;
|
||||
}
|
||||
await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
|
||||
await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
|
||||
// finished sending — so the QSO isn't logged (and the form cleared) while CW
|
||||
// is still going out. We'd like to wait for the busy flag to rise then fall,
|
||||
@@ -1967,14 +2121,14 @@ export default function App() {
|
||||
// "new-slot" because the lookup key carried mode="".
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(async () => {
|
||||
const unknown: { call: string; band: string; mode: string }[] = [];
|
||||
const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const s of spots) {
|
||||
const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
if (seen.has(k) || spotStatus[k]) continue;
|
||||
seen.add(k);
|
||||
unknown.push({ call: s.dx_call, band: s.band ?? '', mode });
|
||||
unknown.push({ call: s.dx_call, band: s.band ?? '', mode, pota_ref: (s as any).pota_ref ?? '' });
|
||||
}
|
||||
if (unknown.length === 0) return;
|
||||
try {
|
||||
@@ -1988,6 +2142,8 @@ export default function App() {
|
||||
country: r.country,
|
||||
continent: (r as any).continent,
|
||||
worked_call: !!(r as any).worked_call,
|
||||
new_county: !!(r as any).new_county,
|
||||
new_pota: !!(r as any).new_pota,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
@@ -2495,6 +2651,7 @@ export default function App() {
|
||||
{ name: 'tools', label: t('menu.tools'), items: [
|
||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||
@@ -2531,6 +2688,7 @@ export default function App() {
|
||||
case 'edit.prefs': setShowSettings(true); break;
|
||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
@@ -2821,12 +2979,12 @@ export default function App() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col flex-[0.55] min-w-[70px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
|
||||
<div className="flex flex-col flex-1 min-w-[130px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
|
||||
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
||||
</div>
|
||||
);
|
||||
const gridBlock = (
|
||||
<div className="flex flex-col w-28 shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label>
|
||||
<div className="flex flex-col w-[76px] shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label>
|
||||
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||
</div>
|
||||
);
|
||||
@@ -2840,8 +2998,8 @@ export default function App() {
|
||||
: d < 30 ? 'border-warning text-warning bg-warning/15'
|
||||
: 'border-danger text-danger bg-danger/15';
|
||||
return (
|
||||
<div className="self-end shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
|
||||
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1.5 text-[10px] font-extrabold tracking-wide leading-none', cls)}>
|
||||
<div className="shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
|
||||
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
|
||||
LoTW
|
||||
</span>
|
||||
</div>
|
||||
@@ -2856,7 +3014,7 @@ export default function App() {
|
||||
// worked-before check doesn't include these, and see what's waiting.
|
||||
const offlinePendingCount = offlineStatus.pending ?? 0;
|
||||
const offlineBlock = offlinePendingCount === 0 ? null : (
|
||||
<div className="relative self-end mb-0.5 shrink-0">
|
||||
<div className="relative shrink-0">
|
||||
<button type="button" onClick={() => { setPendingOpen((o) => !o); GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); }}
|
||||
title={t('offline.tip', { n: offlinePendingCount })}
|
||||
className="relative inline-flex h-8 items-center gap-1.5 rounded-full border border-danger bg-danger/15 px-2.5 text-danger transition-colors hover:bg-danger/25">
|
||||
@@ -2909,7 +3067,7 @@ export default function App() {
|
||||
const hasAlerts = recentAlerts.length > 0;
|
||||
// Only show the bell when there are pending alerts — hidden otherwise.
|
||||
const alertLedBlock = !hasAlerts ? null : (
|
||||
<div className="relative self-end mb-0.5 shrink-0">
|
||||
<div className="relative shrink-0">
|
||||
<button type="button" onClick={() => setAlertsPanelOpen((o) => !o)}
|
||||
title={hasAlerts ? t('alert.pending', { n: recentAlerts.length }) : t('alert.noneShort')}
|
||||
className={cn('relative inline-flex size-8 items-center justify-center rounded-full border transition-colors',
|
||||
@@ -3064,19 +3222,19 @@ export default function App() {
|
||||
const logButtons = (
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1 h-3.5"> </Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
{clusterServerStatuses.some((s) => s.state === 'connected') && (
|
||||
<Button type="button" variant="outline" onClick={() => setShowSpotModal(true)} className="h-8" title="Send a DX spot to the master cluster">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setShowSpotModal(true)} className="h-8 px-2.5 text-xs" title="Send a DX spot to the master cluster">
|
||||
<Satellite className="size-3.5" />
|
||||
{t('btn.spot')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8"
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8 px-2.5 text-xs"
|
||||
title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)">
|
||||
<Eraser className="size-3.5" />
|
||||
{t('btn.clear')}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving} className="h-8">
|
||||
<Button size="sm" onClick={save} disabled={saving} className="h-8 px-3 text-xs">
|
||||
<Send className="size-3.5" />
|
||||
{saving ? '…' : t('btn.logQso')}
|
||||
</Button>
|
||||
@@ -3616,6 +3774,29 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header
|
||||
is a fixed 6-column grid, so adding the meter as its own child pushed
|
||||
the last columns (profile / band map / compact) onto a 2nd row. */}
|
||||
<div className="flex items-center gap-2">
|
||||
{showQsoRate && (
|
||||
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
|
||||
title={t('rate.title')}>
|
||||
<Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} />
|
||||
{/* Contest-style rate: QSOs/hour projected from each window
|
||||
(10-min count ×6; the 60-min count is already per hour). Numbers
|
||||
glow the brand accent when active, dim to muted when idle. */}
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10′</span>
|
||||
<span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60′</span>
|
||||
<span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[9px] uppercase tracking-wider">Q/h</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Space-weather / propagation — compact, in the header. Live from N0NBH
|
||||
(hamqsl.com), auto-refreshed hourly; the same SFI / A / K are stamped
|
||||
onto each logged QSO. Always renders one element so the grid columns
|
||||
@@ -3627,6 +3808,14 @@ export default function App() {
|
||||
const geo = String(solar.geomag_field || '').toUpperCase();
|
||||
const geoCls = /STORM|SEVERE/.test(geo) ? 'text-danger'
|
||||
: /ACTIVE|UNSETTLED/.test(geo) ? 'text-warning' : 'text-success';
|
||||
const num = (v: any) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
|
||||
// Semantic colour by band condition: higher flux/sunspots = better HF
|
||||
// (green when strong); A and K measure geomagnetic disturbance, so LOW
|
||||
// is good (green quiet → yellow unsettled → red storm).
|
||||
const sfiCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n >= 120 ? 'text-success' : n >= 90 ? 'text-foreground' : 'text-warning'; };
|
||||
const ssnCls = (v: any) => { const n = num(v); return n != null && n >= 80 ? 'text-success' : 'text-foreground'; };
|
||||
const aCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n <= 7 ? 'text-success' : n <= 15 ? 'text-warning' : 'text-danger'; };
|
||||
const kCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n <= 2 ? 'text-success' : n <= 3 ? 'text-warning' : 'text-danger'; };
|
||||
const it = (label: string, val: any, cls = 'text-foreground') => (
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">{label}</span>
|
||||
@@ -3634,15 +3823,16 @@ export default function App() {
|
||||
</span>
|
||||
);
|
||||
return (<>
|
||||
{it('SFI', solar.sfi)}
|
||||
{it('SSN', solar.ssn)}
|
||||
{it('A', solar.a_index)}
|
||||
{it('K', solar.k_index)}
|
||||
{it('SFI', solar.sfi, sfiCls(solar.sfi))}
|
||||
{it('SSN', solar.ssn, ssnCls(solar.ssn))}
|
||||
{it('A', solar.a_index, aCls(solar.a_index))}
|
||||
{it('K', solar.k_index, kCls(solar.k_index))}
|
||||
{geo ? <span className={cn('font-bold text-[12px]', geoCls)}>{geo}</span> : null}
|
||||
</>);
|
||||
})()}
|
||||
</div>
|
||||
) : <span />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
||||
<Clock className="size-3" />
|
||||
@@ -3865,9 +4055,6 @@ export default function App() {
|
||||
</div>
|
||||
{qthBlock}
|
||||
{gridBlock}
|
||||
{lotwBlock}
|
||||
{offlineBlock}
|
||||
{alertLedBlock}
|
||||
</div>
|
||||
|
||||
{/* Row 3: tight left detail column (Band/Mode/Country) and
|
||||
@@ -3884,11 +4071,19 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: TX freq, RX freq, RX band — plus the action buttons. */}
|
||||
{/* Bottom: TX freq, RX freq, RX band — then the alert LED + LoTW badge
|
||||
(vertically centred on the input row), then the action buttons. */}
|
||||
<div className="flex gap-2 items-end">
|
||||
{freqBlock}
|
||||
{rxFreqBlock}
|
||||
{bandRxBlock}
|
||||
{(alertLedBlock || lotwBlock || offlineBlock) && (
|
||||
<div className="flex items-center gap-2 h-9 self-end">
|
||||
{alertLedBlock}
|
||||
{lotwBlock}
|
||||
{offlineBlock}
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto">{logButtons}</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -4155,6 +4350,21 @@ export default function App() {
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{stationTabOpen && (
|
||||
<TabsTrigger value="station" className="gap-1.5">
|
||||
{t('station.title')}
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Close Station Control"
|
||||
title="Close"
|
||||
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||
onClick={(e) => { e.stopPropagation(); closeStationTab(); }}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{qslTabOpen && (
|
||||
<TabsTrigger value="qsl" className="gap-1.5">
|
||||
QSL Manager
|
||||
@@ -4495,6 +4705,16 @@ export default function App() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{stationTabOpen && (
|
||||
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||
<StationControlPanel
|
||||
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
||||
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
||||
bearing={dxPath?.bearingShort ?? null}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{contestTabEnabled && (
|
||||
<TabsContent value="contest" className="flex-1 min-h-0 p-0">
|
||||
<ContestPanel session={contest} onChange={updateContest} />
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
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';
|
||||
@@ -189,7 +190,7 @@ function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
|
||||
return (
|
||||
<div className="flex items-center gap-2" title={title}>
|
||||
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent="cyan" />
|
||||
<div ref={ref} tabIndex={disabled || !on ? -1 : 0} onKeyDown={onKey}
|
||||
<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')}>
|
||||
@@ -317,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' }];
|
||||
|
||||
@@ -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';
|
||||
@@ -175,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') }
|
||||
@@ -404,9 +412,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col w-20"><Label>S</Label>
|
||||
<Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div>
|
||||
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
||||
<div className="flex flex-col w-20"><Label>R</Label>
|
||||
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
|
||||
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||
title={t('qedit.fetchTitle')}>
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||
|
||||
@@ -30,7 +30,12 @@ type Props = {
|
||||
// Bump this number to programmatically select every row (e.g. after a search
|
||||
// in the QSL Manager, where the default is "all selected").
|
||||
selectAllSignal?: number;
|
||||
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||
// reuse this grid elsewhere with its OWN independent column config.
|
||||
storageKey?: string;
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onRowClicked?: (q: QSOForm) => void;
|
||||
onRowSelected?: (ids: number[]) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
@@ -49,7 +54,7 @@ type Props = {
|
||||
awardCols?: { code: string; name: string }[];
|
||||
};
|
||||
|
||||
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
||||
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
||||
|
||||
function fmtMhzDots(hz?: number): string {
|
||||
if (!hz) return '';
|
||||
@@ -203,6 +208,13 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
||||
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
||||
{ group: 'My station', label: t('rqg.c.my_name'), colId: 'my_name', headerName: t('rqg.c.my_name'), field: 'my_name' as any, width: 120 },
|
||||
{ group: 'My station', label: t('rqg.c.my_wwff'), colId: 'my_wwff_ref', headerName: t('rqg.c.my_wwff'), field: 'my_wwff_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_sig'), colId: 'my_sig', headerName: t('rqg.c.my_sig'), field: 'my_sig' as any, width: 90 },
|
||||
{ group: 'My station', label: t('rqg.c.my_sig_info'), colId: 'my_sig_info', headerName: t('rqg.c.my_sig_info'), field: 'my_sig_info' as any, width: 120 },
|
||||
{ group: 'My station', label: t('rqg.c.my_arrl_sect'), colId: 'my_arrl_sect', headerName: t('rqg.c.my_arrl_sect'), field: 'my_arrl_sect' as any, width: 90, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_darc_dok'), colId: 'my_darc_dok', headerName: t('rqg.c.my_darc_dok'), field: 'my_darc_dok' as any, width: 90, cellClass: 'font-mono' },
|
||||
{ group: 'My station', label: t('rqg.c.my_vucc_grids'),colId: 'my_vucc_grids', headerName: t('rqg.c.my_vucc_grids'), field: 'my_vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||
|
||||
// ── Misc ──
|
||||
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||
@@ -226,10 +238,20 @@ const GRP_KEYS: Record<string, string> = {
|
||||
};
|
||||
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
||||
// Award columns are governed SOLELY by the awardShown code-set, never by AG
|
||||
// Grid's saved column state. Stripping them here (on both save and restore)
|
||||
// stops a stale saved state from re-hiding a shown award column on every
|
||||
// awardCols rebuild — the desync that made award columns vanish mid-session.
|
||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
// Column-layout persistence keys — scoped by storageKey so a reused instance
|
||||
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
||||
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
@@ -262,6 +284,30 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
// auto-save during that window. Set in the memo (runs at render, before the
|
||||
// column events) and cleared by the re-apply effect below.
|
||||
const restoringRef = useRef(true);
|
||||
|
||||
// Award-column visibility is stored as an explicit set of award CODES, NOT via
|
||||
// AG Grid's column-state round-trip. Those columns load asynchronously and
|
||||
// race the state restore, and AG Grid preserves an existing column's visibility
|
||||
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly
|
||||
// why previously-shown award columns kept vanishing on reopen. A dedicated set
|
||||
// is deterministic: it drives `hide` directly and is re-enforced below.
|
||||
const AWARD_SHOWN_KEY = storageKey ? `hamlog.awardColsShown.${storageKey}` : 'hamlog.awardColsShown';
|
||||
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
|
||||
useEffect(() => {
|
||||
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
|
||||
if (loadLocal(AWARD_SHOWN_KEY)) return;
|
||||
loadRemote(AWARD_SHOWN_KEY).then((remote) => {
|
||||
if (remote && remote.length) {
|
||||
seedLocal(AWARD_SHOWN_KEY, remote);
|
||||
setAwardShown(new Set(remote.map((s: any) => String(s).toUpperCase())));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
const persistAwardShown = useCallback((next: Set<string>) => {
|
||||
setAwardShown(next);
|
||||
saveState(AWARD_SHOWN_KEY, [...next]);
|
||||
}, []);
|
||||
|
||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||
restoringRef.current = true;
|
||||
const base = COL_CATALOG.map((c) => {
|
||||
@@ -274,16 +320,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,21 +349,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
}), []);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
const local = loadLocal(colStateKey);
|
||||
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||
// Fall back to the portable DB copy when the local cache is empty
|
||||
// (fresh machine / after a reinstall), then re-seed the cache.
|
||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||
loadRemote(colStateKey).then((remote) => {
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
|
||||
seedLocal(colStateKey, remote);
|
||||
}
|
||||
});
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) saveState(COL_STATE_KEY, state);
|
||||
if (state) saveState(colStateKey, stripAwardCols(state));
|
||||
}, []);
|
||||
|
||||
// The award columns load asynchronously; when they arrive (or change) the
|
||||
@@ -317,8 +373,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
const local = loadLocal(colStateKey);
|
||||
if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
@@ -342,10 +398,22 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
// state for "which columns are visible" — AG Grid's column state is the
|
||||
// source of truth, and saveColumnState persists it.
|
||||
function isColVisible(colId: string): boolean {
|
||||
// Award columns: the persisted set is the source of truth (see awardShown).
|
||||
if (colId.startsWith('award_')) return awardShown.has(colId.slice('award_'.length).toUpperCase());
|
||||
const col = gridRef.current?.api?.getColumn(colId);
|
||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||
}
|
||||
function setColVisible(colId: string, visible: boolean) {
|
||||
// Award columns are driven by the persisted code set — update it and let the
|
||||
// enforce effect apply visibility. This is what makes them stick across reopen.
|
||||
if (colId.startsWith('award_')) {
|
||||
const code = colId.slice('award_'.length).toUpperCase();
|
||||
const next = new Set(awardShown);
|
||||
if (visible) next.add(code); else next.delete(code);
|
||||
persistAwardShown(next);
|
||||
gridRef.current?.api?.setColumnsVisible([colId], visible);
|
||||
return;
|
||||
}
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
api.setColumnsVisible([colId], visible);
|
||||
@@ -373,6 +441,9 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
api.setColumnsVisible(visible, true);
|
||||
api.setColumnsVisible(hidden, false);
|
||||
saveColumnState();
|
||||
// Award columns are opt-in: reset hides them all.
|
||||
persistAwardShown(new Set());
|
||||
(awardCols ?? []).forEach((a) => api.setColumnsVisible([`award_${a.code}`], false));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -402,6 +473,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
onColumnVisible={saveColumnState}
|
||||
onSortChanged={saveColumnState}
|
||||
onRowDoubleClicked={handleRowDoubleClicked}
|
||||
onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
|
||||
onSelectionChanged={onSelectionChanged}
|
||||
onCellContextMenu={onCellContextMenu}
|
||||
preventDefaultOnContextMenu
|
||||
@@ -469,8 +541,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('rqg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('rqg.none')}</button>
|
||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { persistAwardShown(new Set(awardCols.map((a) => a.code.toUpperCase()))); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], true)); }}>{t('rqg.all')}</button>
|
||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { persistAwardShown(new Set()); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], false)); }}>{t('rqg.none')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||
ChevronDown, ChevronRight,
|
||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
@@ -38,9 +38,12 @@ import {
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
||||
ComputeStationInfo,
|
||||
GetUIPref, SetUIPref,
|
||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -168,6 +171,7 @@ type SectionId =
|
||||
| 'confirmations'
|
||||
| 'external-services'
|
||||
| 'udp'
|
||||
| 'adifmon'
|
||||
| 'lookup'
|
||||
| 'lists-bands'
|
||||
| 'lists-modes'
|
||||
@@ -175,6 +179,7 @@ type SectionId =
|
||||
| 'backup'
|
||||
| 'database'
|
||||
| 'autostart'
|
||||
| 'uscounties'
|
||||
| 'awards'
|
||||
| 'cat'
|
||||
| 'rotator'
|
||||
@@ -183,6 +188,7 @@ type SectionId =
|
||||
| 'antgenius'
|
||||
| 'pgxl'
|
||||
| 'flex'
|
||||
| 'relayauto'
|
||||
| 'audio';
|
||||
|
||||
type TreeNode =
|
||||
@@ -200,6 +206,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
|
||||
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
||||
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
||||
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
|
||||
{ kind: 'item', label: t('sec.audio'), id: 'audio' },
|
||||
];
|
||||
return [
|
||||
@@ -223,6 +230,8 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
]},
|
||||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||||
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
||||
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
||||
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
|
||||
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
||||
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
|
||||
],
|
||||
@@ -238,8 +247,11 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
||||
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
||||
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
||||
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||
adifmon: 'sec.adifmon',
|
||||
uscounties: 'sec.uscounties',
|
||||
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
||||
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
|
||||
relayauto: 'sec.relayauto',
|
||||
};
|
||||
|
||||
// Map section id → friendly name (used in breadcrumb / placeholders).
|
||||
@@ -257,14 +269,16 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
database: 'Database',
|
||||
autostart: 'Autostart',
|
||||
udp: 'UDP integrations',
|
||||
adifmon: 'ADIF monitor',
|
||||
awards: 'Awards',
|
||||
cat: 'CAT interface',
|
||||
rotator: 'PstRotator',
|
||||
rotator: 'Rotator',
|
||||
winkeyer: 'CW Keyer',
|
||||
antenna: 'Ultrabeam / Steppir',
|
||||
antgenius: 'Antenna Genius',
|
||||
pgxl: 'Power Genius',
|
||||
flex: 'FlexRadio',
|
||||
relayauto: 'Relay auto-control',
|
||||
audio: 'Audio devices',
|
||||
};
|
||||
|
||||
@@ -536,6 +550,7 @@ function AutostartPanelComponent() {
|
||||
// (a random install ID + version + OS, sent once a day). Real component so it
|
||||
// can own its state; embedded inside GeneralPanel.
|
||||
function TelemetryToggle() {
|
||||
const { t } = useI18n();
|
||||
const [on, setOn] = useState(true);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -545,8 +560,8 @@ function TelemetryToggle() {
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={on} disabled={!loaded}
|
||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
||||
Send anonymous usage statistics
|
||||
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day — no callsign or QSO data)</span>
|
||||
{t('settings.telemetry')}
|
||||
<span className="text-xs text-muted-foreground">({t('settings.telemetryHint')})</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -556,6 +571,7 @@ function TelemetryToggle() {
|
||||
// events — a small web script on your server renders it for the QRZ page. Only
|
||||
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
||||
function LiveStatusToggle() {
|
||||
const { t } = useI18n();
|
||||
const [on, setOn] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -565,34 +581,195 @@ function LiveStatusToggle() {
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={on} disabled={!loaded}
|
||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
||||
Publish live operator status <span className="text-xs text-muted-foreground">(multi-op on shared MySQL — feeds a QRZ live page)</span>
|
||||
{t('settings.liveStatus')} <span className="text-xs text-muted-foreground">({t('settings.liveStatusHint')})</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM,
|
||||
// VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a
|
||||
// contact that arrives is imported and uploaded automatically like any log entry.
|
||||
type ADIFWatchFileUI = { path: string; enabled: boolean; offset: number };
|
||||
type ADIFMonitorCfgUI = { enabled: boolean; files: ADIFWatchFileUI[] };
|
||||
function ADIFMonitorPanel() {
|
||||
const { t } = useI18n();
|
||||
const [cfg, setCfg] = useState<ADIFMonitorCfgUI>({ enabled: false, files: [] });
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
GetADIFMonitor()
|
||||
.then((c: any) => { if (c) setCfg({ enabled: !!c.enabled, files: (c.files ?? []) as ADIFWatchFileUI[] }); })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoaded(true));
|
||||
}, []);
|
||||
// Offsets are managed backend-side; SaveADIFMonitor ignores the ones we send and
|
||||
// keeps each existing file's read position (a new file starts at end-of-file).
|
||||
const persist = (next: ADIFMonitorCfgUI) => { setCfg(next); SaveADIFMonitor(next as any).catch(() => {}); };
|
||||
const addFile = async () => {
|
||||
try {
|
||||
const p = await PickADIFMonitorFile();
|
||||
if (!p || cfg.files.some((f) => f.path === p)) return;
|
||||
persist({ ...cfg, files: [...cfg.files, { path: p, enabled: true, offset: -1 }] });
|
||||
} catch { /* dialog cancelled */ }
|
||||
};
|
||||
return (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<p className="text-xs text-muted-foreground">{t('adifmon.hint')}</p>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.enabled} disabled={!loaded} onCheckedChange={(c) => persist({ ...cfg, enabled: !!c })} />
|
||||
{t('adifmon.enable')}
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
{cfg.files.length === 0 && <p className="text-xs text-muted-foreground italic">{t('adifmon.empty')}</p>}
|
||||
{cfg.files.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-2 py-1.5">
|
||||
<Checkbox checked={f.enabled}
|
||||
onCheckedChange={(c) => persist({ ...cfg, files: cfg.files.map((x, idx) => idx === i ? { ...x, enabled: !!c } : x) })} />
|
||||
<span className="flex-1 font-mono text-xs truncate" title={f.path}>{f.path}</span>
|
||||
<button type="button" title={t('adifmon.remove')}
|
||||
className="text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => persist({ ...cfg, files: cfg.files.filter((_, idx) => idx !== i) })}>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={addFile}>
|
||||
<FolderOpen className="size-3.5 mr-1" /> {t('adifmon.add')}
|
||||
</Button>
|
||||
<p className="text-[11px] text-muted-foreground">{t('adifmon.note')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
||||
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
||||
// off, a frequency window (kHz), or a set of bands.
|
||||
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
|
||||
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
|
||||
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
|
||||
const relayCountUI = (type: string) => (type === 'kmtronic' ? 8 : 5);
|
||||
function RelayAutoPanel() {
|
||||
const { t } = useI18n();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [rules, setRules] = useState<RelayRuleUI[]>([]);
|
||||
const [devices, setDevices] = useState<StationDevUI[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
Promise.all([GetRelayAuto(), GetStationDevices()])
|
||||
.then(([cfg, devs]: any[]) => {
|
||||
setEnabled(!!cfg?.enabled);
|
||||
setRules((cfg?.rules ?? []) as RelayRuleUI[]);
|
||||
setDevices((devs ?? []) as StationDevUI[]);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoaded(true));
|
||||
}, []);
|
||||
const save = (en: boolean, rs: RelayRuleUI[]) => { SaveRelayAuto({ enabled: en, rules: rs } as any).catch(() => {}); };
|
||||
const ruleFor = (dev: string, relay: number): RelayRuleUI =>
|
||||
rules.find((r) => r.device_id === dev && r.relay === relay) ?? { device_id: dev, relay, mode: 'off', freq_lo_khz: 0, freq_hi_khz: 0, bands: [] };
|
||||
// Apply a patch and persist (commit=true) or keep local only (commit=false, for
|
||||
// freq inputs that persist on blur so we don't switch relays on every keystroke).
|
||||
const patchRule = (dev: string, relay: number, patch: Partial<RelayRuleUI>, commit = true) => {
|
||||
const next = { ...ruleFor(dev, relay), ...patch };
|
||||
const others = rules.filter((r) => !(r.device_id === dev && r.relay === relay));
|
||||
const all = [...others, next];
|
||||
setRules(all);
|
||||
if (commit) save(enabled, all);
|
||||
};
|
||||
const toggleBand = (dev: string, relay: number, band: string) => {
|
||||
const cur = ruleFor(dev, relay);
|
||||
const has = cur.bands.includes(band);
|
||||
patchRule(dev, relay, { bands: has ? cur.bands.filter((b) => b !== band) : [...cur.bands, band] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<p className="text-xs text-muted-foreground">{t('relayauto.hint')}</p>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={enabled} disabled={!loaded} onCheckedChange={(c) => { const v = !!c; setEnabled(v); save(v, rules); }} />
|
||||
{t('relayauto.enable')}
|
||||
</label>
|
||||
|
||||
{devices.length === 0 && loaded && (
|
||||
<p className="text-xs text-muted-foreground italic">{t('relayauto.noDevices')}</p>
|
||||
)}
|
||||
|
||||
{devices.map((dev) => (
|
||||
<div key={dev.id} className="rounded-md border border-border">
|
||||
<div className="px-3 py-1.5 border-b border-border bg-muted/40 text-xs font-semibold">{dev.name || dev.id}</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{Array.from({ length: relayCountUI(dev.type) }, (_, i) => i + 1).map((relay) => {
|
||||
const r = ruleFor(dev.id, relay);
|
||||
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
|
||||
return (
|
||||
<div key={relay} className="flex items-start gap-3 px-3 py-2">
|
||||
<span className="w-28 shrink-0 text-xs font-mono pt-1.5 truncate" title={label}>{label}</span>
|
||||
<Select value={r.mode || 'off'} onValueChange={(v) => patchRule(dev.id, relay, { mode: v })}>
|
||||
<SelectTrigger className="h-8 w-32 text-xs shrink-0"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="off">{t('relayauto.modeOff')}</SelectItem>
|
||||
<SelectItem value="freq">{t('relayauto.modeFreq')}</SelectItem>
|
||||
<SelectItem value="band">{t('relayauto.modeBand')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
{r.mode === 'freq' && (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.from')}
|
||||
defaultValue={r.freq_lo_khz || ''}
|
||||
onChange={(e) => patchRule(dev.id, relay, { freq_lo_khz: parseFloat(e.target.value) || 0 }, false)}
|
||||
onBlur={() => save(enabled, rules)} />
|
||||
<span className="text-muted-foreground">–</span>
|
||||
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.to')}
|
||||
defaultValue={r.freq_hi_khz || ''}
|
||||
onChange={(e) => patchRule(dev.id, relay, { freq_hi_khz: parseFloat(e.target.value) || 0 }, false)}
|
||||
onBlur={() => save(enabled, rules)} />
|
||||
<span className="text-muted-foreground">kHz</span>
|
||||
</div>
|
||||
)}
|
||||
{r.mode === 'band' && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{RELAY_BANDS.map((b) => {
|
||||
const on = r.bands.includes(b);
|
||||
return (
|
||||
<button key={b} type="button" onClick={() => toggleBand(dev.id, relay, b)}
|
||||
className={cn('px-1.5 py-0.5 rounded text-[11px] font-mono border transition-colors',
|
||||
on ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{b}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// MainViewPanes lets the operator choose what the Main tab's left and right
|
||||
// panes show, independently: the great-circle map, the locator street map, the
|
||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
||||
const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'map1', label: 'Map — great-circle + beam' },
|
||||
{ value: 'map2', label: 'Map — locator (street)' },
|
||||
{ value: 'cluster', label: 'Cluster spots' },
|
||||
{ value: 'worked', label: 'Worked before' },
|
||||
{ value: 'recent', label: 'Recent QSOs' },
|
||||
{ value: 'netcontrol', label: 'Net control' },
|
||||
];
|
||||
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
|
||||
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [left, setLeft] = useState('map1');
|
||||
const [right, setRight] = useState('map2');
|
||||
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
|
||||
const options = [
|
||||
...MAIN_PANE_OPTIONS,
|
||||
...(flexAvailable ? [{ value: 'flex', label: 'FlexRadio controls' }] : []),
|
||||
...(icomAvailable ? [{ value: 'icom', label: 'Icom console' }] : []),
|
||||
].sort((a, b) => a.label.localeCompare(b.label));
|
||||
...MAIN_PANE_VALUES,
|
||||
...(flexAvailable ? ['flex'] : []),
|
||||
...(icomAvailable ? ['icom'] : []),
|
||||
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
useEffect(() => {
|
||||
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
||||
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_VALUES.includes(v);
|
||||
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
||||
}, []);
|
||||
@@ -605,11 +782,11 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
||||
};
|
||||
return (
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">Main view</h4>
|
||||
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</p>
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p>
|
||||
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">Left pane</span>
|
||||
<span className="text-muted-foreground">{t('settings.leftPane')}</span>
|
||||
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -618,7 +795,7 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
||||
</Select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">Right pane</span>
|
||||
<span className="text-muted-foreground">{t('settings.rightPane')}</span>
|
||||
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -821,14 +998,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
digital_default: 'FT8',
|
||||
});
|
||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||
enabled: false, host: '127.0.0.1', port: 12000, has_elevation: false,
|
||||
});
|
||||
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
|
||||
} as any);
|
||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
||||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number }>({
|
||||
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50,
|
||||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean }>({
|
||||
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false,
|
||||
});
|
||||
const [ubTesting, setUbTesting] = useState(false);
|
||||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
@@ -908,6 +1085,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||
// Password-encryption (secret vault) state.
|
||||
const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false });
|
||||
@@ -941,6 +1119,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||||
});
|
||||
const [emailMsg, setEmailMsg] = useState('');
|
||||
const [showSmtpPass, setShowSmtpPass] = useState(false);
|
||||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||||
// eQSL card e-mail (subject/body templates + auto-send on log).
|
||||
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
|
||||
@@ -1010,6 +1189,34 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
|
||||
finally { setLotwUsersBusy(false); }
|
||||
};
|
||||
// US Counties (offline FCC ULS) — download progress arrives via events.
|
||||
const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string }>({ count: 0 });
|
||||
const [ulsBusy, setUlsBusy] = useState(false);
|
||||
const [ulsProgress, setUlsProgress] = useState<{ stage: string; pct: number } | null>(null);
|
||||
const [ulsMsg, setUlsMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [backfillBusy, setBackfillBusy] = useState(false);
|
||||
const [backfillMsg, setBackfillMsg] = useState<string | null>(null);
|
||||
useEffect(() => { ULSStatus().then((s) => setUlsStatus(s as any)).catch(() => {}); }, []);
|
||||
useEffect(() => {
|
||||
const off1 = EventsOn('uls:progress', (p: any) => setUlsProgress({ stage: p?.stage ?? '', pct: p?.pct ?? 0 }));
|
||||
const off2 = EventsOn('uls:done', async (r: any) => {
|
||||
setUlsBusy(false); setUlsProgress(null);
|
||||
setUlsMsg(r?.ok ? { ok: true, text: t('uscty.done', { n: r?.count ?? 0 }) } : { ok: false, text: r?.error || 'error' });
|
||||
try { const s = await ULSStatus(); setUlsStatus(s as any); } catch {}
|
||||
});
|
||||
return () => { off1(); off2(); };
|
||||
}, []);
|
||||
const downloadUls = async () => {
|
||||
setUlsBusy(true); setUlsMsg(null); setUlsProgress({ stage: '', pct: 0 });
|
||||
try { await DownloadULSCounties(); }
|
||||
catch (e: any) { setUlsBusy(false); setUlsProgress(null); setUlsMsg({ ok: false, text: String(e?.message ?? e) }); }
|
||||
};
|
||||
const runBackfill = async () => {
|
||||
setBackfillBusy(true); setBackfillMsg(null);
|
||||
try { const r: any = await BackfillUSCounties(); setBackfillMsg(t('uscty.backfillDone', { c: r?.county ?? 0, g: r?.grid ?? 0, s: r?.scanned ?? 0 })); }
|
||||
catch (e: any) { setBackfillMsg(String(e?.message ?? e)); }
|
||||
finally { setBackfillBusy(false); }
|
||||
};
|
||||
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
@@ -2245,7 +2452,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
setRotatorTest(null);
|
||||
try {
|
||||
await TestRotator(rotator as any);
|
||||
setRotatorTest({ ok: true, msg: t('cat.rotatorOk') });
|
||||
setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
||||
} catch (e: any) {
|
||||
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally {
|
||||
@@ -2373,6 +2580,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-border/60 pt-3 space-y-1">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={ultrabeam.tx_inhibit} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, tx_inhibit: !!c }))} />
|
||||
{t('hw.motorTxInhibit')}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground pl-6">{t('hw.motorTxInhibitHint')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || (isSerial ? !ultrabeam.com.trim() : !ultrabeam.host.trim())}>
|
||||
{ubTesting ? t('hw.connecting') : t('hw.testConn')}
|
||||
@@ -2467,41 +2681,73 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}
|
||||
|
||||
function RotatorPanel() {
|
||||
const isRG = (rotator as any).type === 'rotgenius';
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Rotator"
|
||||
hint={t('rot.hint')}
|
||||
hint={isRG ? undefined : t('rot.hint')}
|
||||
/>
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
||||
Enable PstRotator control
|
||||
{t('rot.enable')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('rot.type')}</Label>
|
||||
{/* Switching to Rotator Genius moves the default port to its native
|
||||
9006; back to PstRotator restores 12000. */}
|
||||
<Select value={(rotator as any).type ?? 'pst'}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : 12000 } as any))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isRG && (
|
||||
<div className="space-y-1">
|
||||
<Label>{t('rot.rotatorNum')}</Label>
|
||||
<Select value={String((rotator as any).rotator_num ?? 1)}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, rotator_num: parseInt(v, 10) || 1 } as any))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1</SelectItem>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Host</Label>
|
||||
<Label>Host / IP</Label>
|
||||
<Input
|
||||
value={rotator.host ?? ''}
|
||||
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
||||
placeholder="127.0.0.1"
|
||||
placeholder={isRG ? '192.168.1.60' : '127.0.0.1'}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>UDP port</Label>
|
||||
<Label>{isRG ? 'TCP port' : 'UDP port'}</Label>
|
||||
<Input
|
||||
type="number" min={1} max={65535}
|
||||
value={rotator.port}
|
||||
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || 12000 }))}
|
||||
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
||||
This rotator supports elevation (VHF / satellite)
|
||||
</label>
|
||||
{!isRG && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
||||
This rotator supports elevation (VHF / satellite)
|
||||
</label>
|
||||
)}
|
||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
||||
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
||||
@@ -2509,9 +2755,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||
Stop
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||
Park
|
||||
</Button>
|
||||
{!isRG && (
|
||||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||
Park
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{rotatorTest && (
|
||||
<div className={cn(
|
||||
@@ -4123,6 +4371,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
|
||||
{t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={showQsoRate} onCheckedChange={(c) => { const v = !!c; setShowQsoRate(v); writeUiPref('opslog.showQsoRate', v ? '1' : '0'); }} />
|
||||
{t('gen.showQsoRate')} <span className="text-xs text-muted-foreground">{t('gen.showQsoRateHint')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
||||
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
||||
@@ -4252,7 +4504,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Label className="text-sm">{t('em.username')}</Label>
|
||||
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
|
||||
<Label className="text-sm">{t('es.password')}</Label>
|
||||
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||
<div className="relative">
|
||||
<Input type={showSmtpPass ? 'text' : 'password'} className="h-8 pr-9" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||
<button type="button" tabIndex={-1} onClick={() => setShowSmtpPass((v) => !v)}
|
||||
title={showSmtpPass ? t('es.hidePass') : t('es.showPass')}
|
||||
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
disabled={!emailCfg.auth}>
|
||||
{showSmtpPass ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Label className="text-sm">{t('em.fromAddr')}</Label>
|
||||
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
||||
<Label className="text-sm">{t('em.replyTo')}</Label>
|
||||
@@ -4291,6 +4551,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
);
|
||||
}
|
||||
|
||||
function USCountiesPanel() {
|
||||
const loaded = ulsStatus.count > 0;
|
||||
return (
|
||||
<div className="max-w-2xl space-y-5">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-1">{t('uscty.title')}</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{t('uscty.intro')}</p>
|
||||
</div>
|
||||
|
||||
{/* Required-download notice */}
|
||||
<div className="rounded-md border border-warning/40 bg-warning/10 p-3 text-xs text-foreground/90 leading-relaxed">
|
||||
{t('uscty.needDownload')}
|
||||
</div>
|
||||
|
||||
{/* Status + download */}
|
||||
<div className="rounded-md border border-border p-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs">
|
||||
<div className="font-medium">{t('uscty.dbStatus')}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{loaded
|
||||
? t('uscty.loaded', { n: ulsStatus.count.toLocaleString(), date: ulsStatus.updated_at ? new Date(ulsStatus.updated_at).toLocaleDateString() : '—' })
|
||||
: t('uscty.notLoaded')}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={downloadUls} disabled={ulsBusy}>
|
||||
{ulsBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : <ArrowDown className="size-3.5 mr-1.5" />}
|
||||
{loaded ? t('uscty.update') : t('uscty.download')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ulsProgress && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] text-muted-foreground flex justify-between">
|
||||
<span>{ulsProgress.stage}</span><span>{ulsProgress.pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded bg-muted overflow-hidden">
|
||||
<div className="h-full bg-primary transition-all" style={{ width: `${ulsProgress.pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ulsMsg && (
|
||||
<div className={cn('text-xs', ulsMsg.ok ? 'text-success' : 'text-destructive')}>{ulsMsg.text}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backfill existing QSOs */}
|
||||
<div className="rounded-md border border-border p-3 space-y-2">
|
||||
<div className="text-xs font-medium">{t('uscty.backfillTitle')}</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('uscty.backfillIntro')}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" variant="secondary" onClick={runBackfill} disabled={backfillBusy || !loaded}>
|
||||
{backfillBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||||
{t('uscty.backfillRun')}
|
||||
</Button>
|
||||
{backfillMsg && <span className="text-xs text-muted-foreground">{backfillMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Map sections to their content + icon (for placeholder).
|
||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||
general: GeneralPanel,
|
||||
@@ -4305,8 +4627,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
'lists-modes': ModesPanel,
|
||||
cluster: ClusterPanel,
|
||||
udp: UDPIntegrationsPanelWrapper,
|
||||
// Rendered as a real element (not called as a bare function) so its own hooks
|
||||
// — useState/useEffect/useI18n — get a proper component context; PANELS[x]()
|
||||
// is a plain call and hook-holding panels must go through JSX like this.
|
||||
adifmon: () => <ADIFMonitorPanel />,
|
||||
relayauto: () => <RelayAutoPanel />,
|
||||
backup: BackupPanel,
|
||||
database: DatabasePanel,
|
||||
uscounties: USCountiesPanel,
|
||||
autostart: () => <AutostartPanelComponent />,
|
||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||
cat: CATPanel,
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||
|
||||
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] };
|
||||
type Relay = { number: number; label: string; on: boolean };
|
||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' };
|
||||
|
||||
function blankDevice(): Device {
|
||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||
}
|
||||
|
||||
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
|
||||
|
||||
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||||
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||
// GoTo box, quick N/E/S/W presets, and Stop. Heading is polled by the panel and
|
||||
// passed in (so the panel can also order this widget); it drives the shared
|
||||
// rotator backend.
|
||||
function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: RotatorProps & {
|
||||
hd: Heading; refetch: () => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const [goto, setGoto] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const turn = (az: number) => {
|
||||
const a = ((Math.round(az) % 360) + 360) % 360;
|
||||
RotatorGoTo(a, -1).then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||
};
|
||||
const poll = refetch;
|
||||
|
||||
const presets: [string, number][] = [['N', 0], ['E', 90], ['S', 180], ['W', 270]];
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Compass className="size-4 text-primary" />
|
||||
<div className="text-sm font-semibold">{t('station.rotator')}</div>
|
||||
<span className={cn('ml-auto size-2 rounded-full', hd.ok ? 'bg-success' : 'bg-warning')}
|
||||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||
</div>
|
||||
<div className="p-3 flex gap-4 items-start">
|
||||
<RotorCompass
|
||||
bearing={bearing ?? null}
|
||||
headings={hd.ok ? [hd.azimuth] : []}
|
||||
centerLat={centerLat ?? null}
|
||||
centerLon={centerLon ?? null}
|
||||
rotorEnabled={hd.ok}
|
||||
onGoto={(az) => turn(az)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="font-mono">
|
||||
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{presets.map(([lbl, az]) => (
|
||||
<button key={lbl} type="button" onClick={() => turn(az)}
|
||||
className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Input value={goto} onChange={(e) => setGoto(e.target.value.replace(/[^0-9]/g, ''))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && goto) turn(parseInt(goto, 10)); }}
|
||||
placeholder="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 n = RELAY_COUNT[type] ?? 5;
|
||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||
onChange({ ...device, type, labels });
|
||||
};
|
||||
const isKM = device.type === 'kmtronic';
|
||||
return (
|
||||
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
|
||||
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.type')}</Label>
|
||||
<Select value={device.type} onValueChange={setType}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.name')}</Label>
|
||||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||||
<Label>{t('station.host')}</Label>
|
||||
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||
</div>
|
||||
{isKM && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.user')}</Label>
|
||||
<Input value={device.user ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.pass')}</Label>
|
||||
<Input type="password" value={device.pass ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, pass: e.target.value })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.labels')}</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{device.labels.map((lab, i) => (
|
||||
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
||||
onChange={(e) => { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
|
||||
import { GetLogStats, GetContestRuns } from '../../wailsjs/go/main/App';
|
||||
import { GetLogStats, GetContestRuns, GetOperators } from '../../wailsjs/go/main/App';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -80,7 +80,7 @@ function StatTile({ label, value, sub }: { label: string; value: string; sub?: s
|
||||
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||
// on a tooltip to get a number.
|
||||
|
||||
function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empty: string; share?: boolean }) {
|
||||
function HBars({ data, max, empty, share, labelWidth = 'w-20' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string }) {
|
||||
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
||||
// silently drop the 9th operator, and "who worked what" is the whole question.
|
||||
@@ -92,7 +92,7 @@ function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empt
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
{top.map((d) => (
|
||||
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||
<span className="w-20 shrink-0 truncate text-[11px] text-muted-foreground text-right">{d.key}</span>
|
||||
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
|
||||
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||||
<div
|
||||
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||
@@ -115,7 +115,7 @@ function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empt
|
||||
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
|
||||
// information.
|
||||
|
||||
function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; height?: number }) {
|
||||
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
|
||||
@@ -129,12 +129,13 @@ function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; h
|
||||
{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="text-[9px] text-muted-foreground mb-0.5 opacity-0 group-hover:opacity-100 transition-opacity tabular-nums whitespace-nowrap">
|
||||
<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: 'var(--chart-1)' }}
|
||||
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 : ''}
|
||||
@@ -490,10 +491,14 @@ export function StatsPanel() {
|
||||
// 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) => {
|
||||
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.
|
||||
@@ -501,7 +506,7 @@ export function StatsPanel() {
|
||||
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
||||
setBusy(true); setErr('');
|
||||
try {
|
||||
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0)) as any;
|
||||
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.
|
||||
@@ -522,9 +527,9 @@ export function StatsPanel() {
|
||||
// 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);
|
||||
load(period, from, to, contest, operator);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [period, from, to, contest]);
|
||||
}, [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"
|
||||
@@ -569,6 +574,20 @@ export function StatsPanel() {
|
||||
))}
|
||||
</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')],
|
||||
@@ -705,7 +724,7 @@ export function StatsPanel() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
||||
<VBars data={stats.by_band} empty={empty} />
|
||||
<VBars data={stats.by_band} empty={empty} showValues colorful />
|
||||
</Card>
|
||||
<Card title={t('stats.byMode')}>
|
||||
<HBars data={stats.by_mode} max={8} empty={empty} />
|
||||
@@ -718,7 +737,7 @@ export function StatsPanel() {
|
||||
{/* 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')} sub={t('stats.byOperatorSub')}>
|
||||
<Card title={t('stats.byOperator')}>
|
||||
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||
<HBars data={stats.by_operator} empty={empty} share />
|
||||
</div>
|
||||
@@ -728,7 +747,7 @@ export function StatsPanel() {
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.topEntities')}>
|
||||
<HBars data={stats.top_entities} max={12} empty={empty} />
|
||||
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" />
|
||||
</Card>
|
||||
<div className="flex flex-col gap-2.5 min-w-0">
|
||||
<Card title={t('stats.confirmations')} className="flex-1">
|
||||
|
||||
+106
-24
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,5 +1,6 @@
|
||||
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
|
||||
@@ -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,12 @@ const PORTABLE_KEYS = [
|
||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||
// Cluster filter selections — restored on reopen.
|
||||
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||
'opslog.activeTab', // last selected tab
|
||||
'hamlog.awardColsShown', // which award columns are shown in the QSO grid
|
||||
];
|
||||
|
||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||
|
||||
@@ -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.7';
|
||||
export const APP_VERSION = '0.20.0';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+35
-1
@@ -64,6 +64,8 @@ export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Reco
|
||||
|
||||
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>;
|
||||
@@ -150,6 +152,8 @@ export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Prom
|
||||
|
||||
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>>;
|
||||
@@ -274,6 +278,8 @@ export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexTune(arg1:boolean):Promise<void>;
|
||||
|
||||
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||
|
||||
export function GetActiveProfile():Promise<profile.Profile>;
|
||||
|
||||
export function GetAlertEmailTo():Promise<string>;
|
||||
@@ -352,7 +358,7 @@ export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||
|
||||
export function GetLogFilePath():Promise<string>;
|
||||
|
||||
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number):Promise<qso.Stats>;
|
||||
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number,arg5:string):Promise<qso.Stats>;
|
||||
|
||||
export function GetLogbookRevision():Promise<string>;
|
||||
|
||||
@@ -364,6 +370,8 @@ 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>;
|
||||
@@ -376,18 +384,28 @@ export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||
|
||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||
|
||||
export function GetQSORate():Promise<main.QSORate>;
|
||||
|
||||
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||
|
||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||
|
||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||
|
||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||
|
||||
export function GetSlotStats():Promise<qso.SlotStats>;
|
||||
|
||||
export function GetSolarData():Promise<solar.Data>;
|
||||
|
||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||
|
||||
export function GetStationDevices():Promise<Array<main.StationDevice>>;
|
||||
|
||||
export function GetStationSettings():Promise<main.StationSettings>;
|
||||
|
||||
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||
|
||||
export function GetTelemetryEnabled():Promise<boolean>;
|
||||
|
||||
export function GetUIPref(arg1:string):Promise<string>;
|
||||
@@ -534,6 +552,10 @@ 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>;
|
||||
@@ -582,6 +604,8 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
|
||||
|
||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||
|
||||
export function PickADIFMonitorFile():Promise<string>;
|
||||
|
||||
export function PickAudioFolder():Promise<string>;
|
||||
|
||||
export function PickBackupFolder():Promise<string>;
|
||||
@@ -672,6 +696,8 @@ export function RunBackupNow():Promise<string>;
|
||||
|
||||
export function SaveADIFFile():Promise<string>;
|
||||
|
||||
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
||||
|
||||
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
||||
|
||||
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||
@@ -716,8 +742,12 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||
|
||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||
|
||||
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||
|
||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||
|
||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||
|
||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||
@@ -766,6 +796,8 @@ export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||
|
||||
export function StopCWDecoder():Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
@@ -794,6 +826,8 @@ export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||
|
||||
export function ULSStatus():Promise<main.ULSStatusResult>;
|
||||
|
||||
export function UltrabeamRetract():Promise<void>;
|
||||
|
||||
export function UnlockSecrets(arg1:string):Promise<void>;
|
||||
|
||||
@@ -86,6 +86,10 @@ 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']();
|
||||
}
|
||||
@@ -258,6 +262,10 @@ 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);
|
||||
}
|
||||
@@ -506,6 +514,10 @@ 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']();
|
||||
}
|
||||
@@ -662,8 +674,8 @@ export function GetLogFilePath() {
|
||||
return window['go']['main']['App']['GetLogFilePath']();
|
||||
}
|
||||
|
||||
export function GetLogStats(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4);
|
||||
export function GetLogStats(arg1, arg2, arg3, arg4, arg5) {
|
||||
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
|
||||
export function GetLogbookRevision() {
|
||||
@@ -686,6 +698,10 @@ 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']();
|
||||
}
|
||||
@@ -710,6 +726,14 @@ export function GetQSO(arg1) {
|
||||
return window['go']['main']['App']['GetQSO'](arg1);
|
||||
}
|
||||
|
||||
export function GetQSORate() {
|
||||
return window['go']['main']['App']['GetQSORate']();
|
||||
}
|
||||
|
||||
export function GetRelayAuto() {
|
||||
return window['go']['main']['App']['GetRelayAuto']();
|
||||
}
|
||||
|
||||
export function GetRotatorHeading() {
|
||||
return window['go']['main']['App']['GetRotatorHeading']();
|
||||
}
|
||||
@@ -722,6 +746,10 @@ 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']();
|
||||
}
|
||||
@@ -730,10 +758,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']();
|
||||
}
|
||||
@@ -1026,6 +1062,14 @@ export function LookupCallsign(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||
}
|
||||
|
||||
export function MotorReadElements() {
|
||||
return window['go']['main']['App']['MotorReadElements']();
|
||||
}
|
||||
|
||||
export function MotorSetElement(arg1, arg2) {
|
||||
return window['go']['main']['App']['MotorSetElement'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function MoveDatabase(arg1) {
|
||||
return window['go']['main']['App']['MoveDatabase'](arg1);
|
||||
}
|
||||
@@ -1122,6 +1166,10 @@ export function PGXLSetFanMode(arg1) {
|
||||
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||
}
|
||||
|
||||
export function PickADIFMonitorFile() {
|
||||
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||
}
|
||||
|
||||
export function PickAudioFolder() {
|
||||
return window['go']['main']['App']['PickAudioFolder']();
|
||||
}
|
||||
@@ -1302,6 +1350,10 @@ export function SaveADIFFile() {
|
||||
return window['go']['main']['App']['SaveADIFFile']();
|
||||
}
|
||||
|
||||
export function SaveADIFMonitor(arg1) {
|
||||
return window['go']['main']['App']['SaveADIFMonitor'](arg1);
|
||||
}
|
||||
|
||||
export function SaveAlertRule(arg1) {
|
||||
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
||||
}
|
||||
@@ -1390,10 +1442,18 @@ export function SaveQSLDefaults(arg1) {
|
||||
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRelayAuto(arg1) {
|
||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRotatorSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveStationDevices(arg1) {
|
||||
return window['go']['main']['App']['SaveStationDevices'](arg1);
|
||||
}
|
||||
|
||||
export function SaveStationSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||
}
|
||||
@@ -1490,6 +1550,10 @@ export function StartCWDecoder() {
|
||||
return window['go']['main']['App']['StartCWDecoder']();
|
||||
}
|
||||
|
||||
export function StationSetRelay(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function StopCWDecoder() {
|
||||
return window['go']['main']['App']['StopCWDecoder']();
|
||||
}
|
||||
@@ -1546,6 +1610,10 @@ export function TestUltrabeam(arg1) {
|
||||
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
||||
}
|
||||
|
||||
export function ULSStatus() {
|
||||
return window['go']['main']['App']['ULSStatus']();
|
||||
}
|
||||
|
||||
export function UltrabeamRetract() {
|
||||
return window['go']['main']['App']['UltrabeamRetract']();
|
||||
}
|
||||
|
||||
@@ -1296,6 +1296,55 @@ export namespace lotwusers {
|
||||
|
||||
export namespace main {
|
||||
|
||||
export class ADIFWatchFile {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
offset: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ADIFWatchFile(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.path = source["path"];
|
||||
this.enabled = source["enabled"];
|
||||
this.offset = source["offset"];
|
||||
}
|
||||
}
|
||||
export class ADIFMonitorConfig {
|
||||
enabled: boolean;
|
||||
files: ADIFWatchFile[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ADIFMonitorConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.files = this.convertValues(source["files"], ADIFWatchFile);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
export class AntGeniusSettings {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
@@ -1575,6 +1624,22 @@ export namespace main {
|
||||
this.to = source["to"];
|
||||
}
|
||||
}
|
||||
export class BackfillUSCountiesResult {
|
||||
scanned: number;
|
||||
county: number;
|
||||
grid: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new BackfillUSCountiesResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.scanned = source["scanned"];
|
||||
this.county = source["county"];
|
||||
this.grid = source["grid"];
|
||||
}
|
||||
}
|
||||
export class BackupSettings {
|
||||
enabled: boolean;
|
||||
folder: string;
|
||||
@@ -2313,6 +2378,75 @@ export namespace main {
|
||||
this.pickable = source["pickable"];
|
||||
}
|
||||
}
|
||||
export class QSORate {
|
||||
last10: number;
|
||||
last60: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QSORate(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.last10 = source["last10"];
|
||||
this.last60 = source["last60"];
|
||||
}
|
||||
}
|
||||
export class RelayAutoRule {
|
||||
device_id: string;
|
||||
relay: number;
|
||||
mode: string;
|
||||
freq_lo_khz: number;
|
||||
freq_hi_khz: number;
|
||||
bands: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RelayAutoRule(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.device_id = source["device_id"];
|
||||
this.relay = source["relay"];
|
||||
this.mode = source["mode"];
|
||||
this.freq_lo_khz = source["freq_lo_khz"];
|
||||
this.freq_hi_khz = source["freq_hi_khz"];
|
||||
this.bands = source["bands"];
|
||||
}
|
||||
}
|
||||
export class RelayAutoConfig {
|
||||
enabled: boolean;
|
||||
rules: RelayAutoRule[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RelayAutoConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.rules = this.convertValues(source["rules"], RelayAutoRule);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
export class RotatorHeading {
|
||||
enabled: boolean;
|
||||
ok: boolean;
|
||||
@@ -2333,9 +2467,11 @@ export namespace main {
|
||||
}
|
||||
export class RotatorSettings {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
has_elevation: boolean;
|
||||
rotator_num: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RotatorSettings(source);
|
||||
@@ -2344,9 +2480,11 @@ export namespace main {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.type = source["type"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.has_elevation = source["has_elevation"];
|
||||
this.rotator_num = source["rotator_num"];
|
||||
}
|
||||
}
|
||||
export class SecretStatus {
|
||||
@@ -2367,6 +2505,7 @@ export namespace main {
|
||||
call: string;
|
||||
band: string;
|
||||
mode: string;
|
||||
pota_ref?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SpotQuery(source);
|
||||
@@ -2377,6 +2516,7 @@ export namespace main {
|
||||
this.call = source["call"];
|
||||
this.band = source["band"];
|
||||
this.mode = source["mode"];
|
||||
this.pota_ref = source["pota_ref"];
|
||||
}
|
||||
}
|
||||
export class SpotStatus {
|
||||
@@ -2387,6 +2527,8 @@ export namespace main {
|
||||
continent?: string;
|
||||
status: string;
|
||||
worked_call: boolean;
|
||||
new_county: boolean;
|
||||
new_pota: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SpotStatus(source);
|
||||
@@ -2401,6 +2543,8 @@ export namespace main {
|
||||
this.continent = source["continent"];
|
||||
this.status = source["status"];
|
||||
this.worked_call = source["worked_call"];
|
||||
this.new_county = source["new_county"];
|
||||
this.new_pota = source["new_pota"];
|
||||
}
|
||||
}
|
||||
export class StartupStatus {
|
||||
@@ -2419,6 +2563,86 @@ export namespace main {
|
||||
this.db_path = source["db_path"];
|
||||
}
|
||||
}
|
||||
export class StationDevice {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
host: string;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
labels: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new StationDevice(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.type = source["type"];
|
||||
this.name = source["name"];
|
||||
this.host = source["host"];
|
||||
this.user = source["user"];
|
||||
this.pass = source["pass"];
|
||||
this.labels = source["labels"];
|
||||
}
|
||||
}
|
||||
export class StationRelay {
|
||||
number: number;
|
||||
label: string;
|
||||
on: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new StationRelay(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.number = source["number"];
|
||||
this.label = source["label"];
|
||||
this.on = source["on"];
|
||||
}
|
||||
}
|
||||
export class StationDeviceStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
connected: boolean;
|
||||
error?: string;
|
||||
relays: StationRelay[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new StationDeviceStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.type = source["type"];
|
||||
this.connected = source["connected"];
|
||||
this.error = source["error"];
|
||||
this.relays = this.convertValues(source["relays"], StationRelay);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class StationInfoComputed {
|
||||
country: string;
|
||||
dxcc: number;
|
||||
@@ -2441,6 +2665,7 @@ export namespace main {
|
||||
this.lon = source["lon"];
|
||||
}
|
||||
}
|
||||
|
||||
export class StationSettings {
|
||||
callsign: string;
|
||||
operator: string;
|
||||
@@ -2463,6 +2688,20 @@ export namespace main {
|
||||
this.my_pota_ref = source["my_pota_ref"];
|
||||
}
|
||||
}
|
||||
export class ULSStatusResult {
|
||||
count: number;
|
||||
updated_at: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ULSStatusResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.count = source["count"];
|
||||
this.updated_at = source["updated_at"];
|
||||
}
|
||||
}
|
||||
export class UltrabeamSettings {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
@@ -2473,6 +2712,7 @@ export namespace main {
|
||||
baud: number;
|
||||
follow: boolean;
|
||||
step_khz: number;
|
||||
tx_inhibit: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UltrabeamSettings(source);
|
||||
@@ -2489,15 +2729,18 @@ export namespace main {
|
||||
this.baud = source["baud"];
|
||||
this.follow = source["follow"];
|
||||
this.step_khz = source["step_khz"];
|
||||
this.tx_inhibit = source["tx_inhibit"];
|
||||
}
|
||||
}
|
||||
export class UltrabeamStatusInfo {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
connected: boolean;
|
||||
direction: number;
|
||||
frequency: number;
|
||||
band: number;
|
||||
moving: boolean;
|
||||
elements: number[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UltrabeamStatusInfo(source);
|
||||
@@ -2506,11 +2749,13 @@ export namespace main {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.type = source["type"];
|
||||
this.connected = source["connected"];
|
||||
this.direction = source["direction"];
|
||||
this.frequency = source["frequency"];
|
||||
this.band = source["band"];
|
||||
this.moving = source["moving"];
|
||||
this.elements = source["elements"];
|
||||
}
|
||||
}
|
||||
export class UpdateInfo {
|
||||
@@ -3568,6 +3813,36 @@ export namespace qso {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class SlotStats {
|
||||
slots_worked: number;
|
||||
slots_confirmed: number;
|
||||
dxcc_worked: number;
|
||||
dxcc_confirmed: number;
|
||||
ph_worked: number;
|
||||
ph_confirmed: number;
|
||||
cw_worked: number;
|
||||
cw_confirmed: number;
|
||||
dig_worked: number;
|
||||
dig_confirmed: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SlotStats(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.slots_worked = source["slots_worked"];
|
||||
this.slots_confirmed = source["slots_confirmed"];
|
||||
this.dxcc_worked = source["dxcc_worked"];
|
||||
this.dxcc_confirmed = source["dxcc_confirmed"];
|
||||
this.ph_worked = source["ph_worked"];
|
||||
this.ph_confirmed = source["ph_confirmed"];
|
||||
this.cw_worked = source["cw_worked"];
|
||||
this.cw_confirmed = source["cw_confirmed"];
|
||||
this.dig_worked = source["dig_worked"];
|
||||
this.dig_confirmed = source["dig_confirmed"];
|
||||
}
|
||||
}
|
||||
export class Stats {
|
||||
total: number;
|
||||
unique_calls: number;
|
||||
|
||||
+60
-1
@@ -318,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",
|
||||
}
|
||||
@@ -643,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) {
|
||||
@@ -1145,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":
|
||||
|
||||
@@ -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,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
@@ -352,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
|
||||
|
||||
@@ -109,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
|
||||
@@ -1708,6 +1709,23 @@ func (f *Flex) SetTune(on bool) error {
|
||||
return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on })
|
||||
}
|
||||
|
||||
// SetTXInhibit blocks (on=true) or allows transmission at the radio. Used to keep
|
||||
// the operator from keying while a motorized antenna's elements are moving —
|
||||
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
|
||||
// footswitch or an external keyer, which a software PTT block could not.
|
||||
//
|
||||
// It also labels the interlock reason "OpsLog" so SmartSDR shows WHY TX is
|
||||
// blocked. The reason is best-effort (a separate interlock object); the inhibit
|
||||
// is what actually blocks TX, so a rig that ignores the reason still stays safe.
|
||||
func (f *Flex) SetTXInhibit(on bool) error {
|
||||
if on {
|
||||
f.send("interlock set reason=OpsLog")
|
||||
} else {
|
||||
f.send("interlock set reason=")
|
||||
}
|
||||
return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
|
||||
}
|
||||
|
||||
func (f *Flex) SetVOX(on bool) error {
|
||||
return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on })
|
||||
}
|
||||
|
||||
+15
-1
@@ -286,19 +286,33 @@ func (n *icomNet) ctrlPump() {
|
||||
switch icnLE.Uint16(buf[4:]) {
|
||||
case 0x07: // ping
|
||||
_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
|
||||
case 0x00: // idle keepalive from the rig — nothing to do
|
||||
case 0x01: // retransmit request — resend from the CONTROL sent-buffer
|
||||
if k >= 8 {
|
||||
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
||||
}
|
||||
case 0x05: // rig-initiated disconnect — it dropped US
|
||||
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||
default:
|
||||
// Anything else on the control stream is (almost always) the rig's
|
||||
// reply to our token renewal. Log it: a 0x40-length token packet
|
||||
// carries a result code, and if the rig is REJECTING renewals this is
|
||||
// where the ~2-3 min disconnect originates. The hex makes the cause
|
||||
// visible in the friend's log without a protocol analyzer.
|
||||
if k >= 0x18 {
|
||||
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:0x18])
|
||||
} else {
|
||||
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:k])
|
||||
}
|
||||
}
|
||||
}
|
||||
if time.Since(lastIdle) > 100*time.Millisecond {
|
||||
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
|
||||
lastIdle = time.Now()
|
||||
}
|
||||
if time.Since(lastToken) > 45*time.Second {
|
||||
// Renew well inside the rig's ~2-min token timeout. 30 s (was 45) leaves room
|
||||
// for one lost renewal + its retransmit before the token would lapse.
|
||||
if time.Since(lastToken) > 30*time.Second {
|
||||
n.renewToken()
|
||||
lastToken = time.Now()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -534,8 +534,14 @@ func (s *session) emitLine(text string, sent bool) {
|
||||
// ---------- 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.
|
||||
|
||||
@@ -52,6 +52,11 @@ type Event struct {
|
||||
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.
|
||||
@@ -64,7 +69,8 @@ type Server struct {
|
||||
stopped bool
|
||||
mu sync.Mutex
|
||||
|
||||
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
||||
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 {
|
||||
@@ -213,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:
|
||||
@@ -268,8 +285,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
default:
|
||||
return
|
||||
}
|
||||
// Empty events are useless; skip.
|
||||
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" {
|
||||
// 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 {
|
||||
|
||||
+201
-6
@@ -724,6 +724,10 @@ var bulkEditableCols = map[string]bool{
|
||||
"my_antenna": true,
|
||||
"my_sig": true,
|
||||
"my_sig_info": true,
|
||||
"my_name": true,
|
||||
"my_arrl_sect": true,
|
||||
"my_darc_dok": true,
|
||||
"my_vucc_grids": true,
|
||||
// Contest — the exchange/label fields that are constant across a run.
|
||||
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
||||
"contest_id": true,
|
||||
@@ -1601,7 +1605,7 @@ func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
|
||||
// column to this list AND populate it in scanAwardQSO below, or that award will
|
||||
// silently see an empty value during stats/computation.
|
||||
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
|
||||
`grid, vucc_grids, country, state, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
|
||||
`grid, vucc_grids, country, state, cnty, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
|
||||
`name, qth, address, comment, notes, ` +
|
||||
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
|
||||
|
||||
@@ -1636,6 +1640,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
||||
qsoDateStr string
|
||||
freqHz sql.NullInt64
|
||||
grid, vucc, country, state sql.NullString
|
||||
cnty sql.NullString
|
||||
cont, iotaRef, sota, pota sql.NullString
|
||||
dxcc, cqz, ituz sql.NullInt64
|
||||
name, qth, address sql.NullString
|
||||
@@ -1645,7 +1650,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
||||
)
|
||||
if err := s.Scan(
|
||||
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
|
||||
&grid, &vucc, &country, &state, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
|
||||
&grid, &vucc, &country, &state, &cnty, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
|
||||
&name, &qth, &address, &comment, ¬es,
|
||||
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
|
||||
); err != nil {
|
||||
@@ -1660,6 +1665,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
||||
q.VUCCGrids = vucc.String
|
||||
q.Country = country.String
|
||||
q.State = state.String
|
||||
q.County = cnty.String
|
||||
q.Continent = cont.String
|
||||
if cqz.Valid {
|
||||
v := int(cqz.Int64)
|
||||
@@ -1778,6 +1784,78 @@ func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WorkedCountyKeys returns the set of counties already worked, keyed by the
|
||||
// caller-supplied normaliser (award.USCountyKey — passed in to avoid importing
|
||||
// the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county
|
||||
// are considered. Empty keys (unresolvable state/county) are skipped.
|
||||
func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty string) string) (map[string]struct{}, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
|
||||
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]struct{}, 1024)
|
||||
for rows.Next() {
|
||||
var state, cnty string
|
||||
if err := rows.Scan(&state, &cnty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if k := keyFn(state, cnty); k != "" {
|
||||
out[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WorkedCallBandModeKeys returns the set of every worked "CALL|BAND|MODE" key
|
||||
// (all upper-cased), loaded in one pass. It backs the in-memory worked-index the
|
||||
// alert engine checks per cluster spot — a DB query per spot cannot keep up with
|
||||
// an FT8 skimmer firehose (and hammers a remote MySQL).
|
||||
func (r *Repo) WorkedCallBandModeKeys(ctx context.Context) (map[string]struct{}, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT upper(callsign), upper(band), upper(mode) FROM qso WHERE callsign != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]struct{}, 4096)
|
||||
for rows.Next() {
|
||||
var c, b, m string
|
||||
if err := rows.Scan(&c, &b, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[c+"|"+b+"|"+m] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WorkedPOTARefs returns the set of POTA park references already worked
|
||||
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
|
||||
// (an n-fer); each is added separately.
|
||||
func (r *Repo) WorkedPOTARefs(ctx context.Context) (map[string]struct{}, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT pota_ref FROM qso WHERE pota_ref IS NOT NULL AND pota_ref != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]struct{}, 256)
|
||||
for rows.Next() {
|
||||
var ref string
|
||||
if err := rows.Scan(&ref); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range strings.Split(ref, ",") {
|
||||
if p = strings.ToUpper(strings.TrimSpace(p)); p != "" {
|
||||
out[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Count returns the total number of QSOs in the database.
|
||||
func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
@@ -1785,6 +1863,40 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RecentRate counts QSOs whose start time falls within each trailing window from
|
||||
// `now` — the live "QSO rate" meter shown in the header. It scans only the most
|
||||
// recently inserted rows (ORDER BY id DESC LIMIT), since any QSO in the last hour
|
||||
// was inserted recently; that keeps it cheap even on a large log. qso_date is the
|
||||
// repo's text column, parsed with parseTimeLoose (backend-format agnostic).
|
||||
func (r *Repo) RecentRate(ctx context.Context, now time.Time, windows ...time.Duration) ([]int, error) {
|
||||
counts := make([]int, len(windows))
|
||||
// 400 rows covers a full hour even at a blistering contest rate (>300/h); any
|
||||
// QSO inside the trailing windows is among the most recently inserted.
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
}
|
||||
defer rows.Close()
|
||||
now = now.UTC()
|
||||
for rows.Next() {
|
||||
var dateStr sql.NullString
|
||||
if err := rows.Scan(&dateStr); err != nil {
|
||||
return counts, err
|
||||
}
|
||||
t := parseTimeLoose(dateStr.String).UTC()
|
||||
if t.IsZero() || t.After(now) {
|
||||
continue
|
||||
}
|
||||
age := now.Sub(t)
|
||||
for i, w := range windows {
|
||||
if age <= w {
|
||||
counts[i]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts, rows.Err()
|
||||
}
|
||||
|
||||
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
|
||||
// used by the ADIF importer to skip records that would re-create the
|
||||
// same contact. The key is callsign|YYYY-MM-DDTHH:MM|band|mode — minute
|
||||
@@ -1954,18 +2066,100 @@ func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, b
|
||||
// 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{
|
||||
@@ -1981,7 +2175,7 @@ var confirmedCols = map[string]bool{
|
||||
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
|
||||
// {qrzcom_qso_download_status}.
|
||||
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
|
||||
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}}
|
||||
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Mode: map[string]bool{}, Slot: map[string]bool{}}
|
||||
var conds []string
|
||||
for _, c := range cols {
|
||||
if confirmedCols[c] {
|
||||
@@ -2010,7 +2204,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
|
||||
}
|
||||
sets.DXCC[dxcc] = true
|
||||
sets.Band[BandKey(dxcc, band)] = true
|
||||
sets.Slot[SlotKey(dxcc, band, mode)] = true
|
||||
sets.Mode[ModeClassKey(dxcc, mode)] = true
|
||||
sets.Slot[SlotClassKey(dxcc, band, mode)] = true
|
||||
}
|
||||
return sets, rows.Err()
|
||||
}
|
||||
|
||||
+53
-7
@@ -210,9 +210,49 @@ func yes(s string) bool {
|
||||
// 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.
|
||||
func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int) (Stats, error) {
|
||||
// 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,
|
||||
@@ -257,6 +297,17 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
||||
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.
|
||||
@@ -288,12 +339,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
||||
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
||||
bandC[b]++
|
||||
}
|
||||
// An empty OPERATOR means "the station owner logged it himself" — bucket
|
||||
// it explicitly rather than dropping the QSO from the operator chart.
|
||||
op := strings.ToUpper(strings.TrimSpace(oper.String))
|
||||
if op == "" {
|
||||
op = "—"
|
||||
}
|
||||
// op was resolved above (with the operator filter applied).
|
||||
opC[op]++
|
||||
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
||||
stationC[st]++
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
+33
-23
@@ -74,6 +74,12 @@ type Client struct {
|
||||
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
|
||||
@@ -85,11 +91,6 @@ type Client struct {
|
||||
pendingDirAt time.Time
|
||||
pendingDirSet bool
|
||||
|
||||
// After a Home/Retract the controller drops out of AUTOTRACK and ignores
|
||||
// frequency sets until it is turned back ON. Set on Retract, cleared by
|
||||
// re-enabling on the next SetFrequency.
|
||||
needAutotrack bool
|
||||
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
}
|
||||
@@ -231,6 +232,8 @@ func (c *Client) queryStatus() (*Status, error) {
|
||||
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)
|
||||
@@ -250,10 +253,14 @@ func parseStatus(b []byte) (*Status, error) {
|
||||
freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10
|
||||
active := b[6]
|
||||
dir := decodeDir(b[7])
|
||||
// active==0xFF means "command just received" (not motion); the 0x01 bit is
|
||||
// documented as always set. Treat anything else non-zero as motors busy.
|
||||
// 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 & ^byte(0x01)) != 0 {
|
||||
if active != 0xFF && active&motorBits != 0 {
|
||||
moving = 1
|
||||
}
|
||||
return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil
|
||||
@@ -299,6 +306,9 @@ func (c *Client) writeCmd(pkt []byte) error {
|
||||
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()
|
||||
@@ -309,16 +319,19 @@ func (c *Client) writeCmd(pkt []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetFrequency tunes the elements to freqKhz with the given direction. If a prior
|
||||
// Retract dropped AUTOTRACK, re-enable it first — otherwise the set is ignored.
|
||||
// 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 c.needAutotrack {
|
||||
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil {
|
||||
return err
|
||||
}
|
||||
c.needAutotrack = false
|
||||
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 {
|
||||
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil { // set freq + dir
|
||||
return err
|
||||
}
|
||||
c.statusMu.Lock()
|
||||
@@ -343,8 +356,9 @@ func (c *Client) SetDirection(direction int) error {
|
||||
return c.SetFrequency(khz, direction)
|
||||
}
|
||||
|
||||
// Retract homes the elements into the hubs (storage). This leaves AUTOTRACK off,
|
||||
// so the next SetFrequency re-enables it.
|
||||
// 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()
|
||||
@@ -355,9 +369,5 @@ func (c *Client) Retract() error {
|
||||
khz = 14000 // any in-range value; the controller just homes
|
||||
}
|
||||
}
|
||||
if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'S')); err != nil {
|
||||
return err
|
||||
}
|
||||
c.needAutotrack = true
|
||||
return nil
|
||||
return c.writeCmd(buildSet(khz*1000, DirNormal, 'S'))
|
||||
}
|
||||
|
||||
@@ -50,10 +50,32 @@ func TestBuildSetDirectionAndCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A REAL status frame captured off an SDA controller (F4BPO's, 2026-07-15):
|
||||
// 40 41 00 4C C5 84 00 87 30 38 0D — 50.313 MHz, idle, bidirectional, interface
|
||||
// version "08". Using the actual device output (rather than hand-built bytes)
|
||||
// pins parseStatus to real hardware, and independently confirms the ÷10 wire
|
||||
// scale: 0x4CC584 × 10 = 50 313 000 Hz, a genuine 6 m frequency.
|
||||
func TestParseStatusRealFrame(t *testing.T) {
|
||||
frame := []byte{0x40, 0x41, 0x00, 0x4C, 0xC5, 0x84, 0x00, 0x87, 0x30, 0x38, 0x0D}
|
||||
st, err := parseStatus(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Frequency != 50313 {
|
||||
t.Errorf("freq = %d kHz, want 50313 (50.313 MHz)", st.Frequency)
|
||||
}
|
||||
if st.Direction != DirBi { // 0x87 & 0xE0 = 0x80 = bidirectional
|
||||
t.Errorf("direction = %d, want %d (bidirectional)", st.Direction, DirBi)
|
||||
}
|
||||
if st.MotorsMoving != 0 {
|
||||
t.Errorf("moving = %d, want 0 (motors byte 0x00)", st.MotorsMoving)
|
||||
}
|
||||
}
|
||||
|
||||
// parseStatus decodes what the controller sends back — the inverse of buildSet's
|
||||
// frequency field, plus the direction nibble.
|
||||
// frequency field, plus the direction nibble and the motors-busy byte.
|
||||
func TestParseStatus(t *testing.T) {
|
||||
frame := []byte{0x00, 0x00, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '1', '2', 0x0D}
|
||||
frame := []byte{0x40, 0x41, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '0', '8', 0x0D}
|
||||
st, err := parseStatus(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// Package uls resolves a US amateur callsign to its county and grid, offline,
|
||||
// from the FCC ULS licence database cross-referenced with a ZIP→county/lat-lon
|
||||
// table. It backs the US Counties (USA-CA) award and county hunting: an FCC
|
||||
// spot or a bare CW/SSB spot carries only a callsign, and this turns that into
|
||||
// a county with no per-lookup API call.
|
||||
//
|
||||
// Data lives in its OWN local SQLite file (data/uls.db), never in the logbook:
|
||||
// it is ~800k rows of static reference data that would only bloat the log (and
|
||||
// crawl over a remote MySQL link). It is downloaded on demand, not shipped.
|
||||
//
|
||||
// Sources, both public and free:
|
||||
// - FCC ULS Amateur, full database: l_amat.zip → EN.dat (pipe-delimited).
|
||||
// Fields used: [4] call_sign, [17] state, [18] zip_code.
|
||||
// - GeoNames US postal codes: US.zip → US.txt (tab-delimited).
|
||||
// Fields used: [1] zip, [4] state, [5] county, [9] lat, [10] lon.
|
||||
//
|
||||
// The county from a ZIP is the ZIP's primary county — a ZIP can straddle a line,
|
||||
// so this is ~98% right for fixed stations (rovers/portables need the from-air
|
||||
// grid, which arrives separately via heard_geo). Good enough to hunt with.
|
||||
package uls
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// errFCCMaintenance is raised when the FCC ULS download host bounces us to its
|
||||
// maintenance page instead of serving the file (a frequent, FCC-side event).
|
||||
var errFCCMaintenance = errors.New("fcc uls under maintenance")
|
||||
|
||||
// Default download URLs (overridable in Import for tests).
|
||||
const (
|
||||
fccAmateurURL = "https://data.fcc.gov/download/pub/uls/complete/l_amat.zip"
|
||||
geoNamesURL = "https://download.geonames.org/export/zip/US.zip"
|
||||
)
|
||||
|
||||
// Location is a resolved callsign's home county + grid.
|
||||
type Location struct {
|
||||
State string `json:"state"`
|
||||
County string `json:"county"` // GeoNames county name (e.g. "Middlesex")
|
||||
Grid string `json:"grid"` // 6-char Maidenhead from the ZIP centroid
|
||||
}
|
||||
|
||||
// CNTY renders the ADIF "STATE,County" form for stamping a QSO's cnty field.
|
||||
func (l Location) CNTY() string {
|
||||
if l.State == "" || l.County == "" {
|
||||
return ""
|
||||
}
|
||||
return l.State + "," + l.County
|
||||
}
|
||||
|
||||
// Store owns the local uls.db connection.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
mu sync.RWMutex // guards the whole DB during a re-import (DELETE+bulk INSERT)
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the ULS SQLite store at path.
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS uls_callsign (
|
||||
callsign TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
county TEXT NOT NULL DEFAULT '',
|
||||
grid TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS uls_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);`); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// Count returns how many callsigns are loaded.
|
||||
func (s *Store) Count() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var n int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM uls_callsign`).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// UpdatedAt returns when the store was last imported (zero if never).
|
||||
func (s *Store) UpdatedAt() time.Time {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var v string
|
||||
if err := s.db.QueryRow(`SELECT value FROM uls_meta WHERE key='updated_at'`).Scan(&v); err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
t, _ := time.Parse(time.RFC3339, v)
|
||||
return t
|
||||
}
|
||||
|
||||
// Resolve looks up a callsign's home county + grid. ok=false if unknown or the
|
||||
// store is empty.
|
||||
func (s *Store) Resolve(callsign string) (Location, bool) {
|
||||
call := strings.ToUpper(strings.TrimSpace(callsign))
|
||||
if call == "" {
|
||||
return Location{}, false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var l Location
|
||||
err := s.db.QueryRow(`SELECT state, county, grid FROM uls_callsign WHERE callsign=?`, call).
|
||||
Scan(&l.State, &l.County, &l.Grid)
|
||||
if err != nil || l.County == "" {
|
||||
return Location{}, false
|
||||
}
|
||||
return l, true
|
||||
}
|
||||
|
||||
// Progress reports import stages to the caller (0..100 within a stage).
|
||||
type Progress func(stage string, pct int)
|
||||
|
||||
// zipRow is one ZIP's primary county + centroid.
|
||||
type zipRow struct {
|
||||
state, county string
|
||||
lat, lon float64
|
||||
}
|
||||
|
||||
// Import downloads the FCC ULS + GeoNames data, rebuilds the callsign→county
|
||||
// table, and records the timestamp. It replaces the table atomically: on any
|
||||
// error the previous contents are left intact. tmpDir is where the (large) zips
|
||||
// are streamed; "" uses the OS temp dir.
|
||||
func (s *Store) Import(ctx context.Context, tmpDir string, prog Progress) error {
|
||||
if prog == nil {
|
||||
prog = func(string, int) {}
|
||||
}
|
||||
if tmpDir == "" {
|
||||
tmpDir = os.TempDir()
|
||||
}
|
||||
|
||||
// 1) ZIP→county/lat-lon crosswalk (small).
|
||||
prog("Downloading ZIP crosswalk", 0)
|
||||
geoPath := filepath.Join(tmpDir, "opslog_geonames_us.zip")
|
||||
if err := download(ctx, geoNamesURL, geoPath, nil); err != nil {
|
||||
return fmt.Errorf("download GeoNames: %w", err)
|
||||
}
|
||||
defer os.Remove(geoPath)
|
||||
prog("Parsing ZIP crosswalk", 50)
|
||||
zipmap, err := parseGeoNames(geoPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse GeoNames: %w", err)
|
||||
}
|
||||
if len(zipmap) == 0 {
|
||||
return fmt.Errorf("GeoNames crosswalk is empty")
|
||||
}
|
||||
|
||||
// 2) FCC ULS full amateur database (large).
|
||||
prog("Downloading FCC ULS database", 0)
|
||||
amatPath := filepath.Join(tmpDir, "opslog_l_amat.zip")
|
||||
if err := download(ctx, fccAmateurURL, amatPath, func(pct int) { prog("Downloading FCC ULS database", pct) }); err != nil {
|
||||
return fmt.Errorf("download FCC ULS: %w", err)
|
||||
}
|
||||
defer os.Remove(amatPath)
|
||||
|
||||
// 3) Parse EN.dat, join the crosswalk, rebuild the table.
|
||||
prog("Building county database", 0)
|
||||
return s.rebuild(ctx, amatPath, zipmap, prog)
|
||||
}
|
||||
|
||||
// rebuild streams EN.dat out of the FCC zip and replaces uls_callsign in one
|
||||
// transaction (old data survives a failure).
|
||||
func (s *Store) rebuild(ctx context.Context, amatZip string, zipmap map[string]zipRow, prog Progress) error {
|
||||
zr, err := zip.OpenReader(amatZip)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open FCC zip: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
var en *zip.File
|
||||
for _, f := range zr.File {
|
||||
if strings.EqualFold(filepath.Base(f.Name), "EN.dat") {
|
||||
en = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if en == nil {
|
||||
return fmt.Errorf("EN.dat not found in FCC zip")
|
||||
}
|
||||
rc, err := en.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open EN.dat: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM uls_callsign`); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.PrepareContext(ctx, `INSERT OR REPLACE INTO uls_callsign(callsign,state,county,grid) VALUES(?,?,?,?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
sc := bufio.NewScanner(rc)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
var n, kept int
|
||||
for sc.Scan() {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
n++
|
||||
f := strings.Split(sc.Text(), "|")
|
||||
if len(f) < 19 {
|
||||
continue
|
||||
}
|
||||
call := strings.ToUpper(strings.TrimSpace(f[4]))
|
||||
if call == "" {
|
||||
continue
|
||||
}
|
||||
zip5 := zip5Of(f[18])
|
||||
zr, ok := zipmap[zip5]
|
||||
if !ok {
|
||||
continue // no crosswalk entry → can't place it
|
||||
}
|
||||
state := strings.ToUpper(strings.TrimSpace(f[17]))
|
||||
if state == "" {
|
||||
state = zr.state
|
||||
}
|
||||
if _, err := stmt.ExecContext(ctx, call, state, zr.county, grid6(zr.lat, zr.lon)); err != nil {
|
||||
return err
|
||||
}
|
||||
kept++
|
||||
if kept%50000 == 0 {
|
||||
prog("Building county database", int(math.Min(99, float64(kept)/8000)))
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return fmt.Errorf("read EN.dat: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO uls_meta(key,value) VALUES('updated_at',?)`,
|
||||
time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
prog("Done", 100)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseGeoNames reads US.txt out of the GeoNames zip into a zip→row map,
|
||||
// keeping the first (primary) county seen for each ZIP.
|
||||
func parseGeoNames(zipPath string) (map[string]zipRow, error) {
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer zr.Close()
|
||||
var txt *zip.File
|
||||
for _, f := range zr.File {
|
||||
if strings.EqualFold(filepath.Base(f.Name), "US.txt") {
|
||||
txt = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if txt == nil {
|
||||
return nil, fmt.Errorf("US.txt not found")
|
||||
}
|
||||
rc, err := txt.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
out := make(map[string]zipRow, 45000)
|
||||
sc := bufio.NewScanner(rc)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 256*1024)
|
||||
for sc.Scan() {
|
||||
f := strings.Split(sc.Text(), "\t")
|
||||
if len(f) < 11 {
|
||||
continue
|
||||
}
|
||||
zip5 := strings.TrimSpace(f[1])
|
||||
if zip5 == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := out[zip5]; dup {
|
||||
continue
|
||||
}
|
||||
lat := parseFloat(f[9])
|
||||
lon := parseFloat(f[10])
|
||||
out[zip5] = zipRow{
|
||||
state: strings.ToUpper(strings.TrimSpace(f[4])),
|
||||
county: strings.TrimSpace(f[5]),
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
}
|
||||
}
|
||||
return out, sc.Err()
|
||||
}
|
||||
|
||||
// download streams url to dest, reporting percent when the content length is
|
||||
// known and prog is non-nil.
|
||||
func download(ctx context.Context, url, dest string, prog func(pct int)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Catch the FCC maintenance bounce BEFORE following it: data.fcc.gov redirects
|
||||
// to www.fcc.gov/system-maintenance during maintenance windows, and that page
|
||||
// then HTTP/2-stream-errors — which surfaced as a cryptic "INTERNAL_ERROR"
|
||||
// instead of a plain "try again later".
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(r *http.Request, via []*http.Request) error {
|
||||
if strings.Contains(r.URL.String(), "system-maintenance") {
|
||||
return errFCCMaintenance
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, errFCCMaintenance) || strings.Contains(err.Error(), "system-maintenance") {
|
||||
return fmt.Errorf("the FCC ULS download service is under maintenance (fcc.gov redirected to its maintenance page) — please try again later")
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var body io.Reader = resp.Body
|
||||
if prog != nil && resp.ContentLength > 0 {
|
||||
body = &progReader{r: resp.Body, total: resp.ContentLength, prog: prog}
|
||||
}
|
||||
_, err = io.Copy(f, body)
|
||||
return err
|
||||
}
|
||||
|
||||
type progReader struct {
|
||||
r io.Reader
|
||||
total int64
|
||||
read int64
|
||||
last int
|
||||
prog func(pct int)
|
||||
}
|
||||
|
||||
func (p *progReader) Read(b []byte) (int, error) {
|
||||
n, err := p.r.Read(b)
|
||||
p.read += int64(n)
|
||||
if pct := int(p.read * 100 / p.total); pct != p.last {
|
||||
p.last = pct
|
||||
p.prog(pct)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func zip5Of(z string) string {
|
||||
z = strings.TrimSpace(z)
|
||||
if len(z) > 5 {
|
||||
z = z[:5]
|
||||
}
|
||||
return z
|
||||
}
|
||||
|
||||
func parseFloat(s string) float64 {
|
||||
var v float64
|
||||
fmt.Sscanf(strings.TrimSpace(s), "%g", &v)
|
||||
return v
|
||||
}
|
||||
|
||||
// grid6 converts latitude/longitude to a 6-character Maidenhead locator.
|
||||
func grid6(lat, lon float64) string {
|
||||
if lat == 0 && lon == 0 {
|
||||
return ""
|
||||
}
|
||||
lon += 180
|
||||
lat += 90
|
||||
if lon < 0 || lon >= 360 || lat < 0 || lat >= 180 {
|
||||
return ""
|
||||
}
|
||||
f0 := int(lon / 20)
|
||||
f1 := int(lat / 10)
|
||||
sq0 := int(math.Mod(lon, 20) / 2)
|
||||
sq1 := int(math.Mod(lat, 10) / 1)
|
||||
su0 := int(math.Mod(lon, 2) / (2.0 / 24))
|
||||
su1 := int(math.Mod(lat, 1) / (1.0 / 24))
|
||||
return string([]byte{
|
||||
byte('A' + f0), byte('A' + f1),
|
||||
byte('0' + sq0), byte('0' + sq1),
|
||||
byte('a' + su0), byte('a' + su1),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package uls
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGrid6(t *testing.T) {
|
||||
cases := []struct {
|
||||
lat, lon float64
|
||||
want string
|
||||
}{
|
||||
{38.90, -77.03, "FM18lw"}, // Washington DC
|
||||
{40.71, -74.00, "FN30xr"}, // New York
|
||||
{34.05, -118.24, "DM04vd"},// Los Angeles
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := grid6(c.lat, c.lon); got[:4] != c.want[:4] {
|
||||
t.Errorf("grid6(%v,%v)=%q want field/square %q", c.lat, c.lon, got, c.want[:4])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseGeoNames runs against the real GeoNames US.zip if present in the
|
||||
// scratchpad (downloaded during development); skipped otherwise.
|
||||
func TestParseGeoNames(t *testing.T) {
|
||||
path := os.Getenv("GEONAMES_ZIP")
|
||||
if path == "" {
|
||||
t.Skip("set GEONAMES_ZIP to the US.zip path to run")
|
||||
}
|
||||
m, err := parseGeoNames(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m) < 30000 {
|
||||
t.Fatalf("expected >30k ZIPs, got %d", len(m))
|
||||
}
|
||||
// A known ZIP: 20500 = The White House, DC.
|
||||
if r, ok := m["20500"]; !ok || r.state != "DC" {
|
||||
t.Errorf("ZIP 20500 = %+v (ok=%v)", r, ok)
|
||||
}
|
||||
}
|
||||
@@ -470,6 +470,27 @@ func (c *Client) queryProgress() ([]int, error) {
|
||||
return []int{total, current}, nil
|
||||
}
|
||||
|
||||
// ReadElements reads the current per-element lengths for the active band
|
||||
// (CMD_READ_BANDS). The controller is write-only for ModifyElement, so this is
|
||||
// the only way to see the current lengths — needed so the operator isn't
|
||||
// adjusting blind. The reply payload layout is not documented in the code, so we
|
||||
// LOG it verbatim (once) and parse a best guess: element lengths as 16-bit
|
||||
// little-endian values, matching how ModifyElement WRITES a length. Confirm the
|
||||
// format from the logged bytes on real hardware, then tighten the parse.
|
||||
func (c *Client) ReadElements() ([]int, error) {
|
||||
payload, err := c.sendCommand(CMD_READ_BANDS, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("Ultrabeam: READ_BANDS payload (% X) — %d bytes", payload, len(payload))
|
||||
// Best-guess parse: consecutive 16-bit LE values = element lengths in mm.
|
||||
out := make([]int, 0, len(payload)/2)
|
||||
for i := 0; i+1 < len(payload); i += 2 {
|
||||
out = append(out, int(payload[i])|int(payload[i+1])<<8)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetFrequency changes frequency and optional direction (command 3)
|
||||
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||
// Trace WHO asked for the change — the caller's function + line — so an
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
// Relay auto-control: drives the Station Control relay boards automatically from
|
||||
// the rig's current frequency / band — the equivalent of PstRotator's "Automatic
|
||||
// Control". Each relay carries at most one rule:
|
||||
// - "freq": ON while the frequency is inside [lo,hi] kHz, OFF otherwise;
|
||||
// - "band": ON while the current band is one of the listed bands, OFF otherwise;
|
||||
// - "off"/empty: not managed (left to manual control).
|
||||
//
|
||||
// Evaluated on every CAT frequency/band change. A relay is only switched when its
|
||||
// desired state actually changed since the last apply, so a slow relay board isn't
|
||||
// hammered while you tune within the same range.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const keyRelayAuto = "relayauto.config"
|
||||
|
||||
// RelayAutoRule is one relay's automatic-control rule.
|
||||
type RelayAutoRule struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Relay int `json:"relay"` // 1-based
|
||||
Mode string `json:"mode"` // "off" | "freq" | "band"
|
||||
FreqLoKHz float64 `json:"freq_lo_khz"`
|
||||
FreqHiKHz float64 `json:"freq_hi_khz"`
|
||||
Bands []string `json:"bands"`
|
||||
}
|
||||
|
||||
// RelayAutoConfig is the whole auto-control setup: a master switch + the rules.
|
||||
type RelayAutoConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Rules []RelayAutoRule `json:"rules"`
|
||||
}
|
||||
|
||||
// GetRelayAuto returns the relay auto-control configuration for the settings UI.
|
||||
func (a *App) GetRelayAuto() RelayAutoConfig {
|
||||
var cfg RelayAutoConfig
|
||||
if a.settings == nil {
|
||||
return cfg
|
||||
}
|
||||
s, _ := a.settings.GetGlobal(a.ctx, keyRelayAuto)
|
||||
if strings.TrimSpace(s) != "" {
|
||||
_ = json.Unmarshal([]byte(s), &cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// SaveRelayAuto persists the configuration and applies it immediately from the
|
||||
// current rig state, so toggling a rule takes effect without waiting for the next
|
||||
// frequency change.
|
||||
func (a *App) SaveRelayAuto(cfg RelayAutoConfig) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.settings.SetGlobal(a.ctx, keyRelayAuto, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
a.relayAutoOn.Store(cfg.Enabled) // keep the CAT hot-path flag in sync
|
||||
// Re-apply from the live frequency so a just-changed rule takes hold now. Also
|
||||
// forget the last-applied cache so a rule the user just switched to "off" and
|
||||
// back gets re-sent even if the value is unchanged.
|
||||
a.relayAutoMu.Lock()
|
||||
a.relayAutoLast = map[string]bool{}
|
||||
a.relayAutoMu.Unlock()
|
||||
if a.cat != nil {
|
||||
st := a.cat.State()
|
||||
go a.applyRelayAuto(st.FreqHz, st.Band)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func relayAutoKey(dev string, relay int) string { return dev + "|" + strconv.Itoa(relay) }
|
||||
|
||||
func bandInList(bands []string, band string) bool {
|
||||
band = strings.ToLower(strings.TrimSpace(band))
|
||||
if band == "" {
|
||||
return false
|
||||
}
|
||||
for _, b := range bands {
|
||||
if strings.ToLower(strings.TrimSpace(b)) == band {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// applyRelayAuto evaluates every rule against the current frequency/band and
|
||||
// switches only the relays whose desired state changed since the last apply.
|
||||
func (a *App) applyRelayAuto(freqHz int64, band string) {
|
||||
a.relayAutoMu.Lock()
|
||||
defer a.relayAutoMu.Unlock()
|
||||
|
||||
cfg := a.GetRelayAuto()
|
||||
if !cfg.Enabled || len(cfg.Rules) == 0 {
|
||||
return
|
||||
}
|
||||
if a.relayAutoLast == nil {
|
||||
a.relayAutoLast = map[string]bool{}
|
||||
}
|
||||
khz := float64(freqHz) / 1000.0
|
||||
|
||||
changed := false
|
||||
for _, r := range cfg.Rules {
|
||||
if r.Relay < 1 {
|
||||
continue
|
||||
}
|
||||
var want bool
|
||||
switch r.Mode {
|
||||
case "freq":
|
||||
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
|
||||
continue // unconfigured range → leave the relay alone
|
||||
}
|
||||
lo, hi := r.FreqLoKHz, r.FreqHiKHz
|
||||
if hi < lo {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
want = khz >= lo && khz <= hi
|
||||
case "band":
|
||||
if len(r.Bands) == 0 {
|
||||
continue
|
||||
}
|
||||
want = bandInList(r.Bands, band)
|
||||
default:
|
||||
continue // "off"/empty → not managed
|
||||
}
|
||||
|
||||
key := relayAutoKey(r.DeviceID, r.Relay)
|
||||
if last, ok := a.relayAutoLast[key]; ok && last == want {
|
||||
continue // no change → don't hammer the board
|
||||
}
|
||||
if err := a.StationSetRelay(r.DeviceID, r.Relay, want); err != nil {
|
||||
applog.Printf("relay auto: set %s relay %d = %v failed: %v", r.DeviceID, r.Relay, want, err)
|
||||
continue // don't cache a failed write — retry next change
|
||||
}
|
||||
a.relayAutoLast[key] = want
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed && a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "station:relay_auto", nil) // nudge the Station Control UI to re-poll
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.19.7"
|
||||
appVersion = "0.20.0"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user