Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d30b305ff2 | ||
|
|
5abe4bd0c3 | ||
|
|
3ed9f29d9a | ||
|
|
04eaa91bd8 | ||
|
|
443698b507 | ||
|
|
db2908b3ef | ||
|
|
0838c2ec89 | ||
|
|
80c5fdc095 | ||
|
|
a1be0dfe68 | ||
|
|
ac4039393d | ||
|
|
bb53085c21 | ||
|
|
031dfa8f46 | ||
|
|
6322a425d8 | ||
|
|
9f08df1c39 | ||
|
|
aeeb658269 | ||
|
|
8e088576c7 | ||
|
|
eb2ff8ed59 | ||
|
|
dd3b51a2ae | ||
|
|
1a155e3627 | ||
|
|
cd13921322 | ||
|
|
46e3619a38 | ||
|
|
dcf006905c | ||
|
|
7cf2dfeaf9 | ||
|
|
69229964f4 | ||
|
|
09848adddc | ||
|
|
f853dd479e | ||
|
|
829c236d6c | ||
|
|
c9fd1379e1 | ||
|
|
28b6f04ea4 | ||
|
|
e6744c1d0a | ||
|
|
5152366717 | ||
|
|
e8691a43ba | ||
|
|
b83a4f4455 | ||
|
|
9d7091b1b8 | ||
|
|
f650183936 | ||
|
|
33a6342a07 | ||
|
|
75548812d0 | ||
|
|
5b96f53930 | ||
|
|
d354709939 | ||
|
|
3cd80ead81 | ||
|
|
9e2ffdb758 | ||
|
|
9718b8a78f | ||
|
|
ee844564de | ||
|
|
04b6431726 | ||
|
|
0c6f8e2d68 | ||
|
|
4fe0405432 | ||
|
|
1f0f75baf8 | ||
|
|
7f95a71426 | ||
|
|
08f4b61523 |
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/award"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The catalog is the channel through which a shipped award is both DELIVERED and
|
||||||
|
// CORRECTED. These tests pin the three rules that make that safe:
|
||||||
|
// - a new catalog award reaches an operator who already has awards stored;
|
||||||
|
// - a fixed one (higher Version) replaces the stored copy;
|
||||||
|
// - unless the operator has edited it, in which case their work wins.
|
||||||
|
|
||||||
|
func catalogDef(t *testing.T, code string) award.Def {
|
||||||
|
t.Helper()
|
||||||
|
for _, d := range award.Defaults() {
|
||||||
|
if d.Code == code {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("%s is not in the catalog", code)
|
||||||
|
return award.Def{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDef(defs []award.Def, code string) (award.Def, bool) {
|
||||||
|
for _, d := range defs {
|
||||||
|
if d.Code == code {
|
||||||
|
return d, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return award.Def{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCatalogAddsMissingAward(t *testing.T) {
|
||||||
|
stored := []award.Def{{Code: "MYOWN", Name: "Mine", Valid: true}}
|
||||||
|
got, updated, changed := mergeCatalog(stored)
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("merge reported no change, but every catalog award was missing")
|
||||||
|
}
|
||||||
|
if len(updated) != 0 {
|
||||||
|
t.Errorf("updated = %v, want none: an ADDED award is not an UPDATED one", updated)
|
||||||
|
}
|
||||||
|
if _, ok := findDef(got, "FFMA"); !ok {
|
||||||
|
t.Error("FFMA was not added — a newly shipped award never reaches an existing install")
|
||||||
|
}
|
||||||
|
if _, ok := findDef(got, "MYOWN"); !ok {
|
||||||
|
t.Error("the operator's own award was dropped by the merge")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCatalogUpdatesOlderVersion(t *testing.T) {
|
||||||
|
// The stored copy is an older revision of a shipped award, with a broken rule.
|
||||||
|
old := catalogDef(t, "FFMA")
|
||||||
|
old.Version = catalogDef(t, "FFMA").Version - 1
|
||||||
|
old.Field = "wrong"
|
||||||
|
old.Valid = false // the operator disabled it — a preference, not a definition
|
||||||
|
|
||||||
|
got, updated, changed := mergeCatalog([]award.Def{old})
|
||||||
|
if !changed || len(updated) == 0 {
|
||||||
|
t.Fatal("a higher catalog version did not replace the stored definition — a shipped award could never be FIXED")
|
||||||
|
}
|
||||||
|
d, _ := findDef(got, "FFMA")
|
||||||
|
if d.Field != "grid4" {
|
||||||
|
t.Errorf("FFMA field = %q, want the catalog's %q", d.Field, "grid4")
|
||||||
|
}
|
||||||
|
if d.Valid {
|
||||||
|
t.Error("the update re-enabled an award the operator had switched off; that is their choice to make, not ours")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCatalogSkipsUserEdited(t *testing.T) {
|
||||||
|
old := catalogDef(t, "FFMA")
|
||||||
|
old.Version = catalogDef(t, "FFMA").Version - 1
|
||||||
|
old.Field = "mine"
|
||||||
|
old.UserEdited = true
|
||||||
|
|
||||||
|
got, updated, _ := mergeCatalog([]award.Def{old})
|
||||||
|
if len(updated) != 0 {
|
||||||
|
t.Fatalf("updated = %v: an award the operator has edited must never be overwritten", updated)
|
||||||
|
}
|
||||||
|
if d, _ := findDef(got, "FFMA"); d.Field != "mine" {
|
||||||
|
t.Errorf("field = %q, want the operator's %q", d.Field, "mine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkUserEditedOnlyOnRealChange(t *testing.T) {
|
||||||
|
prev := []award.Def{
|
||||||
|
{Code: "A", Name: "A", Field: "state", Valid: true, Version: 2},
|
||||||
|
{Code: "B", Name: "B", Field: "cqz", Valid: true, Version: 2},
|
||||||
|
}
|
||||||
|
next := []award.Def{
|
||||||
|
{Code: "A", Name: "A", Field: "state", Valid: true}, // untouched
|
||||||
|
{Code: "B", Name: "B", Field: "county", Valid: true}, // changed
|
||||||
|
{Code: "C", Name: "C", Field: "note", Valid: true}, // brand new
|
||||||
|
}
|
||||||
|
markUserEdited(next, prev)
|
||||||
|
|
||||||
|
if next[0].UserEdited {
|
||||||
|
t.Error("A was flagged as edited although nothing about it changed — every save would freeze every award out of future updates")
|
||||||
|
}
|
||||||
|
if !next[1].UserEdited {
|
||||||
|
t.Error("B changed field and was not flagged; a catalog update would overwrite the operator's work")
|
||||||
|
}
|
||||||
|
if next[2].UserEdited {
|
||||||
|
t.Error("C is a brand-new award; there is no shipped version to protect it from")
|
||||||
|
}
|
||||||
|
// A save must not pretend to be a new shipped revision.
|
||||||
|
if next[0].Version != 2 || next[1].Version != 2 {
|
||||||
|
t.Errorf("versions = %d/%d, want both 2: saving is not shipping", next[0].Version, next[1].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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])
|
||||||
|
}
|
||||||
+217
-42
@@ -68,6 +68,7 @@ import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
|||||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||||
import { StatsPanel } from '@/components/StatsPanel';
|
import { StatsPanel } from '@/components/StatsPanel';
|
||||||
|
import { StationControlPanel } from '@/components/StationControlPanel';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
@@ -608,7 +609,16 @@ export default function App() {
|
|||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
|
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
|
||||||
const [matchCount, setMatchCount] = useState<number | null>(null);
|
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.
|
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
||||||
const [qslTabOpen, setQslTabOpen] = useState(false);
|
const [qslTabOpen, setQslTabOpen] = useState(false);
|
||||||
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
||||||
@@ -623,6 +633,11 @@ export default function App() {
|
|||||||
setStatsTabOpen(false);
|
setStatsTabOpen(false);
|
||||||
setActiveTab((t) => (t === 'stats' ? 'recent' : t));
|
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
|
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
|
||||||
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
||||||
// couple of seconds to round-trip from SQLite at launch. Defaulting
|
// couple of seconds to round-trip from SQLite at launch. Defaulting
|
||||||
@@ -909,8 +924,15 @@ export default function App() {
|
|||||||
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
|
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
|
||||||
const [spots, setSpots] = useState<ClusterSpot[]>([]);
|
const [spots, setSpots] = useState<ClusterSpot[]>([]);
|
||||||
const SPOTS_CAP = 1000;
|
const SPOTS_CAP = 1000;
|
||||||
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
|
// Cluster filter selections persist across restarts (writeUiPref → localStorage
|
||||||
const [clusterGroup, setClusterGroup] = useState(true);
|
// + 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('');
|
const [clusterCmd, setClusterCmd] = useState('');
|
||||||
// Cluster console: the raw traffic. Spots are parsed out of the stream into the
|
// 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,
|
// grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded,
|
||||||
@@ -938,22 +960,37 @@ export default function App() {
|
|||||||
if (atBottom) el.scrollTop = el.scrollHeight;
|
if (atBottom) el.scrollTop = el.scrollHeight;
|
||||||
}, [clusterLines]);
|
}, [clusterLines]);
|
||||||
// Multi-band filter: empty set = all bands. The user toggles chips.
|
// 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
|
// Lock-to-entry: when on, the band filter follows the entry's current
|
||||||
// band and the mode filter follows the entry's current mode.
|
// band and the mode filter follows the entry's current mode.
|
||||||
const [clusterLockBand, setClusterLockBand] = useState(false);
|
const [clusterLockBand, setClusterLockBand] = useState(() => lsBool('opslog.clusterLockBand', false));
|
||||||
const [clusterLockMode, setClusterLockMode] = useState(false);
|
const [clusterLockMode, setClusterLockMode] = useState(() => lsBool('opslog.clusterLockMode', false));
|
||||||
// Status filter chips. Empty set = show every status (including
|
// Status filter chips. Empty set = show every status (including
|
||||||
// already-worked). Otherwise only matching spots pass.
|
// already-worked). Otherwise only matching spots pass.
|
||||||
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
|
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
|
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||||
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
||||||
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set());
|
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(() => lsSet<SpotModeCat>('opslog.clusterModeFilter'));
|
||||||
const [clusterSearch, setClusterSearch] = useState('');
|
const [clusterSearch, setClusterSearch] = useState(() => localStorage.getItem('opslog.clusterSearch') || '');
|
||||||
// Hide spots already worked (exact call worked, or this band+mode slot done).
|
// 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).
|
// Bands shown side-by-side in the Band Map tab (portable).
|
||||||
const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
|
const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
|
||||||
try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
|
try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
|
||||||
@@ -1026,7 +1063,16 @@ export default function App() {
|
|||||||
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
||||||
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
||||||
// different slots don't share the same colour.
|
// 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 ===
|
// === Modals ===
|
||||||
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
||||||
@@ -1180,22 +1226,29 @@ export default function App() {
|
|||||||
// Effective antenna heading(s): the rotor azimuth, transformed by the
|
// Effective antenna heading(s): the rotor azimuth, transformed by the
|
||||||
// Ultrabeam pattern when one is active — reversed (180°) points opposite,
|
// Ultrabeam pattern when one is active — reversed (180°) points opposite,
|
||||||
// bidirectional radiates both ways, normal is the heading itself.
|
// bidirectional radiates both ways, normal is the heading itself.
|
||||||
|
// Headings are rounded to whole degrees: a rotor reports a jittering float, and
|
||||||
|
// a fraction of a degree changes nothing on a compass or a beam lobe — but it
|
||||||
|
// does invalidate every memo downstream and force the map to rebuild its whole
|
||||||
|
// overlay on each reading.
|
||||||
|
const rotorAz = useMemo<number | null>(() => (
|
||||||
|
rotatorHeading.enabled && rotatorHeading.ok
|
||||||
|
? Math.round((((rotatorHeading.azimuth % 360) + 360) % 360)) % 360
|
||||||
|
: null
|
||||||
|
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
|
||||||
|
|
||||||
const beamHeadings = useMemo<number[]>(() => {
|
const beamHeadings = useMemo<number[]>(() => {
|
||||||
if (!(rotatorHeading.enabled && rotatorHeading.ok)) return [];
|
if (rotorAz == null) return [];
|
||||||
const base = ((rotatorHeading.azimuth % 360) + 360) % 360;
|
|
||||||
if (ubStatus.enabled && ubStatus.connected) {
|
if (ubStatus.enabled && ubStatus.connected) {
|
||||||
if (ubStatus.direction === 1) return [(base + 180) % 360];
|
if (ubStatus.direction === 1) return [(rotorAz + 180) % 360];
|
||||||
if (ubStatus.direction === 2) return [base, (base + 180) % 360];
|
if (ubStatus.direction === 2) return [rotorAz, (rotorAz + 180) % 360];
|
||||||
}
|
}
|
||||||
return [base];
|
return [rotorAz];
|
||||||
}, [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
}, [rotorAz, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||||
|
|
||||||
// Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
|
// Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
|
||||||
// show where the antenna physically points (boom) vs where it radiates when
|
// show where the antenna physically points (boom) vs where it radiates when
|
||||||
// the Ultrabeam is reversed/bidirectional.
|
// the Ultrabeam is reversed/bidirectional.
|
||||||
const boomHeading = useMemo<number | null>(() => (
|
const boomHeading = rotorAz;
|
||||||
rotatorHeading.enabled && rotatorHeading.ok ? ((rotatorHeading.azimuth % 360) + 360) % 360 : null
|
|
||||||
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
|
|
||||||
const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
|
const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
|
||||||
if (!(ubStatus.enabled && ubStatus.connected)) return null;
|
if (!(ubStatus.enabled && ubStatus.connected)) return null;
|
||||||
return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
|
return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
|
||||||
@@ -1696,13 +1749,76 @@ export default function App() {
|
|||||||
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
|
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
|
||||||
setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_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) => {
|
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;
|
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
|
// Self-spot: someone spotted OUR callsign — show it in the shared header
|
||||||
// toast (same place as the other notifications), not a separate banner.
|
// 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;
|
const mine = myCallRef.current;
|
||||||
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
|
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
|
||||||
const by = cleanSpotter(sp.spotter ?? '') || '?';
|
const by = cleanSpotter(sp.spotter ?? '') || '?';
|
||||||
@@ -1710,7 +1826,10 @@ export default function App() {
|
|||||||
showToast(`Spotted by ${by}${c ? ` with ${c}` : ''}`);
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1749,6 +1868,12 @@ export default function App() {
|
|||||||
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
||||||
if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
|
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
|
// 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).
|
// (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.
|
// An explicit click always wins over whatever call is currently in the field.
|
||||||
@@ -1773,7 +1898,7 @@ export default function App() {
|
|||||||
else setError('UDP auto-log: ' + msg);
|
else setError('UDP auto-log: ' + msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { unsubDX?.(); unsubRC?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
|
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1960,14 +2085,14 @@ export default function App() {
|
|||||||
// "new-slot" because the lookup key carried mode="".
|
// "new-slot" because the lookup key carried mode="".
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = window.setTimeout(async () => {
|
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>();
|
const seen = new Set<string>();
|
||||||
for (const s of spots) {
|
for (const s of spots) {
|
||||||
const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||||
const k = spotStatusKey(s.dx_call, s.band ?? '', 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;
|
if (seen.has(k) || spotStatus[k]) continue;
|
||||||
seen.add(k);
|
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;
|
if (unknown.length === 0) return;
|
||||||
try {
|
try {
|
||||||
@@ -1981,6 +2106,8 @@ export default function App() {
|
|||||||
country: r.country,
|
country: r.country,
|
||||||
continent: (r as any).continent,
|
continent: (r as any).continent,
|
||||||
worked_call: !!(r as any).worked_call,
|
worked_call: !!(r as any).worked_call,
|
||||||
|
new_county: !!(r as any).new_county,
|
||||||
|
new_pota: !!(r as any).new_pota,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
@@ -2488,6 +2615,7 @@ export default function App() {
|
|||||||
{ name: 'tools', label: t('menu.tools'), items: [
|
{ name: 'tools', label: t('menu.tools'), items: [
|
||||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
{ 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: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||||
@@ -2524,6 +2652,7 @@ export default function App() {
|
|||||||
case 'edit.prefs': setShowSettings(true); break;
|
case 'edit.prefs': setShowSettings(true); break;
|
||||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); 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.qsldesigner': setQslDesignerOpen(true); break;
|
||||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||||
@@ -2814,12 +2943,12 @@ export default function App() {
|
|||||||
</div>
|
</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'); }} />
|
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const gridBlock = (
|
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'); }} />
|
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2833,8 +2962,8 @@ export default function App() {
|
|||||||
: d < 30 ? 'border-warning text-warning bg-warning/15'
|
: d < 30 ? 'border-warning text-warning bg-warning/15'
|
||||||
: 'border-danger text-danger bg-danger/15';
|
: 'border-danger text-danger bg-danger/15';
|
||||||
return (
|
return (
|
||||||
<div className="self-end shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
|
<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.5 text-[10px] font-extrabold tracking-wide leading-none', cls)}>
|
<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
|
LoTW
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2849,7 +2978,7 @@ export default function App() {
|
|||||||
// worked-before check doesn't include these, and see what's waiting.
|
// worked-before check doesn't include these, and see what's waiting.
|
||||||
const offlinePendingCount = offlineStatus.pending ?? 0;
|
const offlinePendingCount = offlineStatus.pending ?? 0;
|
||||||
const offlineBlock = offlinePendingCount === 0 ? null : (
|
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(() => {}); }}
|
<button type="button" onClick={() => { setPendingOpen((o) => !o); GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); }}
|
||||||
title={t('offline.tip', { n: offlinePendingCount })}
|
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">
|
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">
|
||||||
@@ -2902,7 +3031,7 @@ export default function App() {
|
|||||||
const hasAlerts = recentAlerts.length > 0;
|
const hasAlerts = recentAlerts.length > 0;
|
||||||
// Only show the bell when there are pending alerts — hidden otherwise.
|
// Only show the bell when there are pending alerts — hidden otherwise.
|
||||||
const alertLedBlock = !hasAlerts ? null : (
|
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)}
|
<button type="button" onClick={() => setAlertsPanelOpen((o) => !o)}
|
||||||
title={hasAlerts ? t('alert.pending', { n: recentAlerts.length }) : t('alert.noneShort')}
|
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',
|
className={cn('relative inline-flex size-8 items-center justify-center rounded-full border transition-colors',
|
||||||
@@ -2990,6 +3119,18 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
// CQ/ITU zones moved to the Info (F2) tab (DetailsPanel).
|
// CQ/ITU zones moved to the Info (F2) tab (DetailsPanel).
|
||||||
|
// Type a frequency, press Enter → tune the radio there. Only when a rig is
|
||||||
|
// actually connected (these same fields are the manual-log entry when it isn't),
|
||||||
|
// and only on a plausible HF/VHF value so a half-typed "14." doesn't QSY the rig
|
||||||
|
// to 14 kHz. noteManualEdit() holds off the incoming poll so the field doesn't
|
||||||
|
// snap back before the radio's echo confirms the move.
|
||||||
|
const tuneRadioTo = (mhzStr: string) => {
|
||||||
|
if (!(catState.enabled && catState.connected)) return;
|
||||||
|
const mhz = parseFloat(mhzStr);
|
||||||
|
if (!Number.isFinite(mhz) || mhz < 0.1 || mhz > 3000) return;
|
||||||
|
noteManualEdit();
|
||||||
|
SetCATFrequency(Math.round(mhz * 1_000_000)).catch(() => {});
|
||||||
|
};
|
||||||
const freqBlock = (
|
const freqBlock = (
|
||||||
<div className="flex flex-col w-32">
|
<div className="flex flex-col w-32">
|
||||||
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label>
|
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label>
|
||||||
@@ -2998,8 +3139,10 @@ export default function App() {
|
|||||||
className="font-mono"
|
className="font-mono"
|
||||||
value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
|
value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
|
||||||
placeholder="14.250"
|
placeholder="14.250"
|
||||||
|
title={catState.connected ? t('field.freqTuneHint') : undefined}
|
||||||
onFocus={() => setFreqFocused(true)}
|
onFocus={() => setFreqFocused(true)}
|
||||||
onBlur={() => setFreqFocused(false)}
|
onBlur={() => setFreqFocused(false)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') tuneRadioTo(freqMhz); }}
|
||||||
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
|
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -3011,8 +3154,10 @@ export default function App() {
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
|
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
|
||||||
placeholder="14.255"
|
placeholder="14.255"
|
||||||
|
title={catState.connected ? t('field.freqTuneHint') : undefined}
|
||||||
onFocus={() => setFreqFocused(true)}
|
onFocus={() => setFreqFocused(true)}
|
||||||
onBlur={() => setFreqFocused(false)}
|
onBlur={() => setFreqFocused(false)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') tuneRadioTo(rxFreqMhz); }}
|
||||||
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
|
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
|
||||||
className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')}
|
className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')}
|
||||||
/>
|
/>
|
||||||
@@ -3041,19 +3186,19 @@ export default function App() {
|
|||||||
const logButtons = (
|
const logButtons = (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Label className="mb-1 h-3.5"> </Label>
|
<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') && (
|
{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" />
|
<Satellite className="size-3.5" />
|
||||||
{t('btn.spot')}
|
{t('btn.spot')}
|
||||||
</Button>
|
</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)">
|
title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)">
|
||||||
<Eraser className="size-3.5" />
|
<Eraser className="size-3.5" />
|
||||||
{t('btn.clear')}
|
{t('btn.clear')}
|
||||||
</Button>
|
</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" />
|
<Send className="size-3.5" />
|
||||||
{saving ? '…' : t('btn.logQso')}
|
{saving ? '…' : t('btn.logQso')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -3480,10 +3625,10 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
|
{/* Motorized-antenna pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
|
||||||
{ubStatus.enabled && (
|
{ubStatus.enabled && (
|
||||||
<div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
|
<div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
|
||||||
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam pattern') : 'Ultrabeam: connecting…'}>
|
title={ubStatus.connected ? (ubStatus.moving ? 'Antenna: moving…' : 'Antenna pattern') : 'Antenna: connecting…'}>
|
||||||
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
|
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
|
||||||
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
|
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
|
||||||
</button>
|
</button>
|
||||||
@@ -3842,9 +3987,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
{qthBlock}
|
{qthBlock}
|
||||||
{gridBlock}
|
{gridBlock}
|
||||||
{lotwBlock}
|
|
||||||
{offlineBlock}
|
|
||||||
{alertLedBlock}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3: tight left detail column (Band/Mode/Country) and
|
{/* Row 3: tight left detail column (Band/Mode/Country) and
|
||||||
@@ -3861,11 +4003,19 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex gap-2 items-end">
|
||||||
{freqBlock}
|
{freqBlock}
|
||||||
{rxFreqBlock}
|
{rxFreqBlock}
|
||||||
{bandRxBlock}
|
{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 className="ml-auto">{logButtons}</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -4132,6 +4282,21 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</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 && (
|
{qslTabOpen && (
|
||||||
<TabsTrigger value="qsl" className="gap-1.5">
|
<TabsTrigger value="qsl" className="gap-1.5">
|
||||||
QSL Manager
|
QSL Manager
|
||||||
@@ -4472,6 +4637,16 @@ export default function App() {
|
|||||||
</TabsContent>
|
</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 && (
|
{contestTabEnabled && (
|
||||||
<TabsContent value="contest" className="flex-1 min-h-0 p-0">
|
<TabsContent value="contest" className="flex-1 min-h-0 p-0">
|
||||||
<ContestPanel session={contest} onChange={updateContest} />
|
<ContestPanel session={contest} onChange={updateContest} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen } from 'lucide-react';
|
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||||
|
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// Above this many references the editor stops loading the whole list and
|
// Above this many references the editor stops loading the whole list and
|
||||||
@@ -32,7 +33,7 @@ export type AwardDef = {
|
|||||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||||
ref_display?: string; // grid column shows: ref | name | both
|
ref_display?: string; // grid column shows: ref | name | both
|
||||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||||
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
leading_str?: string; trailing_str?: string; dynamic?: boolean;
|
||||||
or_rules?: AwardOrRule[];
|
or_rules?: AwardOrRule[];
|
||||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||||
@@ -186,6 +187,15 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {});
|
GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {});
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// Shipped fixes we did NOT apply, because this award carries the operator's own
|
||||||
|
// changes and we will not overwrite those behind their back. Offered, not forced.
|
||||||
|
type AwardUpdate = { code: string; name: string; from: number; to: number };
|
||||||
|
const [updates, setUpdates] = useState<AwardUpdate[]>([]);
|
||||||
|
const loadUpdates = useCallback(() => {
|
||||||
|
GetAwardUpdates().then((u: any) => setUpdates(Array.isArray(u) ? u : [])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { if (open) loadUpdates(); }, [open, loadUpdates]);
|
||||||
|
|
||||||
// Pending import awaiting the operator's decision on the awards that collide.
|
// Pending import awaiting the operator's decision on the awards that collide.
|
||||||
type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean };
|
type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean };
|
||||||
type ImportPreview = { path: string; awards: ImportEntry[] };
|
type ImportPreview = { path: string; awards: ImportEntry[] };
|
||||||
@@ -201,6 +211,57 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
}, [importPreview]);
|
}, [importPreview]);
|
||||||
|
|
||||||
const cur = defs[sel];
|
const cur = defs[sel];
|
||||||
|
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
||||||
|
|
||||||
|
// ── Award tester: run the award's rules against a real QSO and show every step.
|
||||||
|
type Rejected = { candidate: string; reason: string };
|
||||||
|
type Step = {
|
||||||
|
rule: string; field: string; match_by?: string; exact?: boolean; pattern?: string;
|
||||||
|
field_value?: string; candidates?: string[]; kept?: string[]; rejected?: Rejected[];
|
||||||
|
skipped?: boolean; error?: string;
|
||||||
|
};
|
||||||
|
type Explanation = {
|
||||||
|
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
||||||
|
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
|
||||||
|
};
|
||||||
|
type TestRow = { qso: any; explanation: Explanation };
|
||||||
|
const [testCall, setTestCall] = useState('');
|
||||||
|
const [testRows, setTestRows] = useState<TestRow[] | null>(null);
|
||||||
|
const [testErr, setTestErr] = useState('');
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const runTest = async () => {
|
||||||
|
if (!cur) return;
|
||||||
|
setTesting(true); setTestErr(''); setTestRows(null);
|
||||||
|
try {
|
||||||
|
const r = await ExplainAward(cur.code, testCall);
|
||||||
|
setTestRows((Array.isArray(r) ? r : []) as TestRow[]);
|
||||||
|
} catch (e: any) {
|
||||||
|
setTestErr(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The tester reads the SAVED award, not the unsaved edits in this dialog — so say
|
||||||
|
// so, rather than let the operator test a rule they only think they applied.
|
||||||
|
useEffect(() => { setTestRows(null); setTestErr(''); }, [sel]);
|
||||||
|
|
||||||
|
// Several QSOs with the same station usually trace IDENTICALLY, and twenty copies
|
||||||
|
// of the same trace is noise. But they don't always: scope is judged per QSO
|
||||||
|
// (band, mode, date — FFMA's 1983 cut-off), a manual override lives ON a QSO, and
|
||||||
|
// two contacts can even hold different QTHs. That divergence is often the actual
|
||||||
|
// answer ("why does my 2019 QSO count and my 2024 one not?"), so we group by
|
||||||
|
// trace instead of dropping it: one card per distinct outcome, with its count.
|
||||||
|
const testGroups = useMemo(() => {
|
||||||
|
if (!testRows) return null;
|
||||||
|
const groups: { key: string; rows: TestRow[] }[] = [];
|
||||||
|
for (const row of testRows) {
|
||||||
|
const key = JSON.stringify(row.explanation);
|
||||||
|
const g = groups.find((x) => x.key === key);
|
||||||
|
if (g) g.rows.push(row);
|
||||||
|
else groups.push({ key, rows: [row] });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [testRows]);
|
||||||
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
||||||
const toggleIn = (key: keyof AwardDef, v: string) => {
|
const toggleIn = (key: keyof AwardDef, v: string) => {
|
||||||
const arr = ((cur?.[key] as string[]) ?? []);
|
const arr = ((cur?.[key] as string[]) ?? []);
|
||||||
@@ -312,6 +373,10 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
// has been exported. That deserves to be visible at a glance, not
|
// has been exported. That deserves to be visible at a glance, not
|
||||||
// discovered the hard way.
|
// discovered the hard way.
|
||||||
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
|
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
|
||||||
|
// A pending update is only reachable from the award's own banner, so
|
||||||
|
// the list has to say which award to open — otherwise the fix waits
|
||||||
|
// behind a click nobody knows to make.
|
||||||
|
const hasUpdate = updates.some((u) => (u.code ?? '').toUpperCase() === (d.code ?? '').toUpperCase());
|
||||||
return (
|
return (
|
||||||
<button key={i} onClick={() => setSel(i)}
|
<button key={i} onClick={() => setSel(i)}
|
||||||
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
||||||
@@ -319,6 +384,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||||||
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
||||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||||
|
{hasUpdate && (
|
||||||
|
<span className="ml-auto shrink-0" title={t('awed.updateAvailable')}>
|
||||||
|
<ArrowUpCircle className="size-3.5 text-info" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{onlyHere && (
|
{onlyHere && (
|
||||||
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
|
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
|
||||||
title={t('awed.onlyHereTip')}>
|
title={t('awed.onlyHereTip')}>
|
||||||
@@ -337,6 +407,37 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
{/* Right: tabbed editor for selected award */}
|
{/* Right: tabbed editor for selected award */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
||||||
|
{/* A fix shipped for an award this operator has customised. We did NOT
|
||||||
|
apply it — that would destroy their work — so we offer it, and say
|
||||||
|
plainly what accepting costs. */}
|
||||||
|
{cur && selUpdate && (
|
||||||
|
<div className="mx-4 mt-3 rounded border border-info/40 bg-info/10 px-3 py-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowUpCircle className="size-4 text-info shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium">{t('awed.updateAvailable')}</div>
|
||||||
|
<div className="text-muted-foreground">{t('awed.updateOverwrites')}</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-7 px-2 text-[11px]"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await ApplyAwardUpdate(cur.code);
|
||||||
|
// The backend rewrote this award (and its references) — pull the
|
||||||
|
// new state back, or the editor would keep showing, and on the
|
||||||
|
// next Save re-persist, the definition we just replaced.
|
||||||
|
setDefs(((await GetAwardDefs()) ?? []) as any);
|
||||||
|
loadMeta();
|
||||||
|
loadUpdates();
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}}>{t('awed.updateApply')}</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-[11px]"
|
||||||
|
onClick={async () => {
|
||||||
|
try { await DismissAwardUpdate(cur.code); loadUpdates(); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}}>{t('awed.updateKeepMine')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!cur ? (
|
{!cur ? (
|
||||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -346,6 +447,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||||
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
||||||
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="test">{t('awed.tabTest')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
@@ -355,14 +457,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
||||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||||
{/* "Built-in" is what you tick before dropping an award into the
|
{/* No "Built-in" checkbox: an award OpsLog ships IS built-in, and
|
||||||
shipped catalog. Leave it off and "Reset to defaults" DELETES
|
the catalog derives that on load. Asking the author to tick a
|
||||||
the award on the user's machine — even though you shipped it.
|
box to declare it would be one more step nobody can guess —
|
||||||
Editing the JSON by hand to fix that is exactly what we're
|
forget it and the award silently misses every future catalog
|
||||||
avoiding here. */}
|
correction. "Protected" stays: whether an award can be deleted
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.builtinTip')}>
|
IS a real choice. */}
|
||||||
<Checkbox checked={!!cur.builtin} onCheckedChange={(c) => patch({ builtin: !!c })} /> {t('awed.builtin')}
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
|
||||||
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
|
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
|
||||||
</label>
|
</label>
|
||||||
@@ -407,7 +507,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
}</SelectContent>
|
}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field2>
|
</Field2>
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> {t('awed.allowMultiple')}</label>
|
{/* No "allow multiple references" switch: a QSO always counts for
|
||||||
|
every reference its field holds (an n-fer POTA activation, a
|
||||||
|
VUCC grid-line contact). The old checkbox was read by nothing. */}
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
|
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
|
||||||
|
|
||||||
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
||||||
@@ -494,8 +596,105 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label={t('awed.grantCodes')}><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
|
{/* "Grant codes" and "export credit_granted" used to live here. No
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> {t('awed.exportCreditGranted')}</label>
|
ADIF export has ever written CREDIT_GRANTED, so both controls
|
||||||
|
did nothing at all. The stored values are kept (see award.Def);
|
||||||
|
put the controls back when the export is actually wired up. */}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Test ──
|
||||||
|
Runs the SAVED award against a real QSO and shows every rule:
|
||||||
|
what it scanned, what it produced, and why each candidate was
|
||||||
|
rejected. An award that matches nothing used to be a black box. */}
|
||||||
|
<TabsContent value="test" className="mt-0 space-y-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">{t('awed.testCallsign')}</Label>
|
||||||
|
<Input className="h-8 w-40 font-mono uppercase" value={testCall} placeholder="I2IFT"
|
||||||
|
onChange={(e) => setTestCall(e.target.value.toUpperCase())}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') runTest(); }} />
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-8" onClick={runTest} disabled={testing || !testCall.trim()}>
|
||||||
|
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Search className="size-3.5 mr-1" />}
|
||||||
|
{t('awed.testRun')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground pb-1">{t('awed.testSavedOnly')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testErr && <div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{testErr}</div>}
|
||||||
|
|
||||||
|
{testGroups?.map((g, ri) => {
|
||||||
|
const row = g.rows[0];
|
||||||
|
const ex = row.explanation;
|
||||||
|
const matched = (ex.result ?? []).length > 0;
|
||||||
|
const qsoLabel = (q: any) => `${String(q?.qso_date ?? '').slice(0, 10)} · ${q?.band} · ${q?.mode}`;
|
||||||
|
return (
|
||||||
|
<div key={ri} className="rounded border border-border overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs border-b border-border">
|
||||||
|
<span className="font-mono font-semibold">{row.qso?.callsign}</span>
|
||||||
|
<span className="text-muted-foreground font-mono">{qsoLabel(row.qso)}</span>
|
||||||
|
{g.rows.length > 1 && (
|
||||||
|
<span className="text-muted-foreground" title={g.rows.slice(1).map((r) => qsoLabel(r.qso)).join('\n')}>
|
||||||
|
{t('awed.testSameAs', { n: String(g.rows.length - 1) })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn('ml-auto px-1.5 rounded text-[10px] font-semibold uppercase tracking-wide',
|
||||||
|
matched ? 'bg-success/15 text-success' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||||
|
{matched ? (ex.result ?? []).join(', ') : t('awed.testNoMatch')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!ex.in_scope ? (
|
||||||
|
<div className="px-3 py-2 text-xs">
|
||||||
|
<span className="font-medium">{t('awed.testOutOfScope')}</span>{' '}
|
||||||
|
<span className="text-muted-foreground">{ex.scope_error}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{(ex.steps ?? []).map((s, si) => (
|
||||||
|
<div key={si} className={cn('px-3 py-2 text-xs', s.skipped && 'opacity-50')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="font-semibold uppercase text-[10px] tracking-wide">{s.rule}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{s.field}{s.match_by ? ` / ${s.match_by}` : ''}{s.exact ? ` / ${t('awed.exact')}` : ''}
|
||||||
|
</span>
|
||||||
|
{s.skipped && <span className="text-muted-foreground italic">— {t('awed.testSkipped')}</span>}
|
||||||
|
{s.error && <span className="text-destructive">— {s.error}</span>}
|
||||||
|
</div>
|
||||||
|
{!s.skipped && !s.error && (
|
||||||
|
<div className="mt-1 space-y-0.5 pl-3 border-l-2 border-border">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t('awed.testFieldValue')}: </span>
|
||||||
|
{s.field_value
|
||||||
|
? <span className="font-mono">{s.field_value}</span>
|
||||||
|
: <span className="italic text-muted-foreground">{t('awed.testEmptyField')}</span>}
|
||||||
|
</div>
|
||||||
|
{(s.kept ?? []).map((k) => (
|
||||||
|
<div key={k} className="text-success font-mono">✓ {k}</div>
|
||||||
|
))}
|
||||||
|
{(s.rejected ?? []).map((r, i2) => (
|
||||||
|
<div key={i2} className="font-mono text-muted-foreground">
|
||||||
|
✗ {r.candidate} <span className="font-sans">— {r.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!(s.kept ?? []).length && !(s.rejected ?? []).length && s.field_value && (
|
||||||
|
<div className="italic text-muted-foreground">{t('awed.testNoCandidate')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(ex.manual ?? []).length > 0 && (
|
||||||
|
<div className="px-3 py-2 text-xs">
|
||||||
|
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
||||||
|
<span className="font-mono text-success">{(ex.manual ?? []).join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── References ── */}
|
{/* ── References ── */}
|
||||||
@@ -744,26 +943,18 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{/* One per row: side by side, each half spends 120px of its width on
|
||||||
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
the label column and the input is left too narrow to read a group
|
||||||
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
name ("Basilicata" → "Basili"). */}
|
||||||
</div>
|
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
||||||
|
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||||
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
|
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
{/* Score / Bonus were here. Nothing computes an award score, so both
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
boxes were pure decoration. The columns stay in the database — a
|
||||||
<Label className="text-xs text-muted-foreground">{t('awed.score')}</Label>
|
third-party list may carry the values — but they are not offered
|
||||||
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} />
|
for editing until something actually reads them. */}
|
||||||
</div>
|
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
|
||||||
<Label className="text-xs text-muted-foreground">{t('awed.bonus')}</Label>
|
|
||||||
<Input type="number" className="h-8 font-mono w-full" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
|
||||||
<Label className="text-xs text-muted-foreground">{t('awed.grid')}</Label>
|
|
||||||
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
{ 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', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', 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
|
// Contest
|
||||||
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
||||||
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
{ 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: '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_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
||||||
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
||||||
// Contacted station (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: '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: '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 },
|
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export type SpotStatusEntry = {
|
|||||||
country?: string;
|
country?: string;
|
||||||
continent?: string;
|
continent?: string;
|
||||||
worked_call?: boolean;
|
worked_call?: boolean;
|
||||||
|
new_county?: boolean;
|
||||||
|
new_pota?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
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 =
|
// 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
|
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||||
// there's nothing notable to show.
|
// 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) {
|
switch (s?.status) {
|
||||||
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
|
case 'new': return tok('danger', t('clg2.newDxcc'));
|
||||||
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
|
case 'new-band': return tok('warning', t('clg2.newBand'));
|
||||||
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
|
case 'new-mode': return tok('caution', t('clg2.newMode'));
|
||||||
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
|
case 'new-slot': return tok('caution', t('clg2.newSlot'));
|
||||||
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null;
|
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',
|
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
sort: 'desc',
|
sort: 'desc',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
||||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// Only STATUS calls get a colour: new DXCC entity → filled cell (no padded
|
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
|
||||||
// pill, so calls stay aligned), worked-call → blue. A plain spot inherits the
|
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
|
||||||
// theme's normal text colour (var(--foreground)) so callsigns blend in with
|
// a plain spot inherits the theme's normal text colour so callsigns blend in
|
||||||
// the rest of the row across every theme instead of always shouting orange.
|
// with the rest of the row instead of always shouting a colour.
|
||||||
cellStyle: (p: any): any => {
|
cellRenderer: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 };
|
if (s?.status === 'new') return cellChip(p.value, 'danger');
|
||||||
if (s?.worked_call) return { color: '#0369a1', fontWeight: 700 };
|
const color = s?.worked_call ? 'var(--info)' : undefined;
|
||||||
return { fontWeight: 700 };
|
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
||||||
},
|
},
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
@@ -141,26 +173,42 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
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,
|
defaultVisible: true,
|
||||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
// 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) => {
|
valueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return t('clg2.newDxcc');
|
const parts: string[] = [];
|
||||||
if (s?.status === 'new-band') return t('clg2.newBand');
|
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
|
||||||
if (s?.status === 'new-mode') return t('clg2.newMode');
|
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
|
||||||
if (s?.status === 'new-slot') return t('clg2.newSlot');
|
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
|
||||||
return s?.worked_call ? t('clg2.wkdCall') : '';
|
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) => {
|
cellRenderer: (p: any) => {
|
||||||
const b = statusBadge(t, statusFor(p));
|
const s = statusFor(p);
|
||||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
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 (
|
return (
|
||||||
<span style={{
|
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
||||||
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
|
{badges.map((bd, i) => (
|
||||||
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
<span key={i} style={{
|
||||||
}}>{b.text}</span>
|
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) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
@@ -176,7 +224,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
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),
|
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,
|
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
// NEW BAND for this entity → small warning pill around the band text.
|
||||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
|
||||||
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
|
||||||
: undefined),
|
|
||||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -203,16 +249,11 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
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),
|
// Only NEW MODE pills the mode cell — there the mode itself is genuinely new
|
||||||
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
|
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
||||||
cellStyle: (p: any) => {
|
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
||||||
const st = statusFor(p)?.status;
|
// that case is signalled by the Status badge alone.
|
||||||
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
|
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
|
||||||
// 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>,
|
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const st = statusFor(p)?.status;
|
const st = statusFor(p)?.status;
|
||||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
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',
|
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
||||||
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
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',
|
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?.[
|
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
||||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||||
]?.country ?? '',
|
]?.country ?? '',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
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?.[
|
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
||||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||||
]?.continent ?? '',
|
]?.continent ?? '',
|
||||||
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
||||||
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
||||||
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#9a8870', fontSize: 10 },
|
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
||||||
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
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',
|
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
||||||
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
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">
|
<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" />
|
<Mic className="size-3.5 text-primary" />
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
<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')} />
|
<span className={cn('size-2 rounded-full', status.playing ? 'bg-danger animate-pulse' : 'bg-success')} />
|
||||||
{status.playing && <span className="text-[10px] text-warning font-medium">tx...</span>}
|
{status.playing && <span className="text-[10px] text-danger font-medium">TX</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
||||||
<Square className="size-3" /> {t('dvkp.stop')}
|
<Square className="size-3" /> {t('dvkp.stop')}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
FlexMox, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
GetPGXLStatus, PGXLSetFanMode,
|
GetPGXLStatus, PGXLSetFanMode,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||||
|
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
||||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { sMeterRST } from '@/lib/rst';
|
import { sMeterRST } from '@/lib/rst';
|
||||||
@@ -25,6 +27,7 @@ type FlexState = {
|
|||||||
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
|
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
|
||||||
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
|
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
|
||||||
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
|
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
|
||||||
|
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
|
||||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||||
wnb: boolean; wnb_level: number;
|
wnb: boolean; wnb_level: number;
|
||||||
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
||||||
@@ -44,6 +47,7 @@ const ZERO: FlexState = {
|
|||||||
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
|
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
|
||||||
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
||||||
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
|
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
|
||||||
|
rit: false, rit_freq: 0, xit: false, xit_freq: 0,
|
||||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
||||||
wnb: false, wnb_level: 0, tx_filter_low: 0, tx_filter_high: 0,
|
wnb: false, wnb_level: 0, tx_filter_low: 0, tx_filter_high: 0,
|
||||||
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
|
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
|
||||||
@@ -143,6 +147,67 @@ function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, slide
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OffsetRow — RIT / XIT: a switch plus one signed offset field you scrub with the
|
||||||
|
// wheel (or ± , or the arrow keys once focused; hold Ctrl/Shift for 100 Hz steps).
|
||||||
|
// Deliberately the same control as the Icom panel's ShiftRow, so the two rigs are
|
||||||
|
// driven identically.
|
||||||
|
//
|
||||||
|
// The offset is NOT cleared when the switch goes off: SmartSDR keeps it, so
|
||||||
|
// flipping RIT back on returns you exactly where you were, like the radio's own
|
||||||
|
// knob. Zeroing it is a separate, deliberate act — that is what the 0 button is for.
|
||||||
|
const OFFSET_MAX = 99999; // SmartSDR's limit
|
||||||
|
function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
|
||||||
|
label: string; on: boolean; onToggle: () => void; hz: number; onHz: (v: number) => void;
|
||||||
|
disabled?: boolean; title?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const step = useRef((_d: number) => {});
|
||||||
|
step.current = (d: number) => onHz(Math.max(-OFFSET_MAX, Math.min(OFFSET_MAX, (hz || 0) + d)));
|
||||||
|
|
||||||
|
// React's onWheel is passive, so preventDefault() there is ignored and the panel
|
||||||
|
// scrolls under the cursor instead of the value changing. Bind it natively.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
|
||||||
|
step.current(e.deltaY < 0 ? mag : -mag);
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onKey = (e: React.KeyboardEvent) => {
|
||||||
|
const up = e.key === 'ArrowUp' || e.key === 'ArrowRight';
|
||||||
|
const dn = e.key === 'ArrowDown' || e.key === 'ArrowLeft';
|
||||||
|
if (!up && !dn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
|
||||||
|
step.current(up ? mag : -mag);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2" title={title}>
|
||||||
|
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent="cyan" />
|
||||||
|
<div ref={ref} data-offsetrow tabIndex={disabled || !on ? -1 : 0} onKeyDown={onKey}
|
||||||
|
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-info/50',
|
||||||
|
on && !disabled ? 'border-border bg-muted/40 cursor-ns-resize' : 'border-border/60 bg-muted/20 opacity-60')}>
|
||||||
|
<button type="button" disabled={disabled || !on} onClick={() => step.current(-10)}
|
||||||
|
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground">−</button>
|
||||||
|
<span className={cn('text-sm font-mono font-bold tabular-nums', on && hz ? 'text-info' : 'text-muted-foreground')}>
|
||||||
|
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz || 0)} Hz
|
||||||
|
</span>
|
||||||
|
<button type="button" disabled={disabled || !on} onClick={() => step.current(10)}
|
||||||
|
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground">+</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" disabled={disabled || !hz} onClick={() => onHz(0)}
|
||||||
|
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted disabled:opacity-30">0</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
||||||
// `display` overrides the numeric readout; `segColor` colours segments by their
|
// `display` overrides the numeric readout; `segColor` colours segments by their
|
||||||
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
||||||
@@ -253,6 +318,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
|
|
||||||
const off = !st.available;
|
const off = !st.available;
|
||||||
const rxOff = off || !st.rx_avail;
|
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 isCW = (st.mode || '').toUpperCase().includes('CW');
|
||||||
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
||||||
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
||||||
@@ -454,6 +552,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{!isCW ? (
|
{!isCW ? (
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -560,6 +659,18 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* RIT / XIT — on the RECEIVE side: RIT is what you reach for while
|
||||||
|
listening (chasing a station that drifts off your transmit frequency),
|
||||||
|
so it belongs with the other things you touch mid-QSO. Both act on the
|
||||||
|
ACTIVE slice and follow slice focus like every control here. */}
|
||||||
|
<div className="space-y-1.5 pb-3 border-b border-border/60">
|
||||||
|
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
|
||||||
|
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
|
||||||
|
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
|
||||||
|
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
|
||||||
|
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
|
||||||
|
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
||||||
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||||||
@@ -701,7 +812,17 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
||||||
}
|
}
|
||||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={acc} />;
|
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||||
|
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
|
||||||
|
// value itself is fine — only the bar's range was wrong. Give it a
|
||||||
|
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
||||||
|
// only override an unusable reported range, not a sane one.
|
||||||
|
let lo = m.lo, hi = m.hi;
|
||||||
|
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||||
|
lo = 0;
|
||||||
|
hi = m.hi >= 25 ? m.hi : 25;
|
||||||
|
}
|
||||||
|
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ function loadBasemap(): BasemapKey {
|
|||||||
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
||||||
|
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
|
||||||
|
// down: satellite loads TWO tile layers, so its tile count — and the composited
|
||||||
|
// layers WebView2 has to hold — is double every other basemap's.
|
||||||
|
function addBasemap(
|
||||||
|
m: L.Map,
|
||||||
|
key: BasemapKey,
|
||||||
|
base: React.MutableRefObject<L.TileLayer | null>,
|
||||||
|
labels: React.MutableRefObject<L.TileLayer | null>,
|
||||||
|
) {
|
||||||
|
if (base.current) { m.removeLayer(base.current); base.current = null; }
|
||||||
|
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
||||||
|
const bm = BASEMAPS[key];
|
||||||
|
const opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 };
|
||||||
|
base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||||
|
}
|
||||||
|
|
||||||
function dot(color: string): L.DivIcon {
|
function dot(color: string): L.DivIcon {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
@@ -103,11 +121,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
// One-time map creation.
|
// One-time map creation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (worldRef.current && !worldMap.current) {
|
if (worldRef.current && !worldMap.current) {
|
||||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
|
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
|
||||||
|
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
|
||||||
|
// composited paths re-rasterised on every pan, zoom and redraw, which is
|
||||||
|
// enough to blow WebView2's raster budget and leave the window painting in
|
||||||
|
// patches. On canvas it is a single layer.
|
||||||
|
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
|
||||||
.setView([20, 0], 1);
|
.setView([20, 0], 1);
|
||||||
const bm = BASEMAPS[basemap];
|
addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
|
||||||
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
|
||||||
worldOverlay.current = L.layerGroup().addTo(m);
|
worldOverlay.current = L.layerGroup().addTo(m);
|
||||||
worldMap.current = m;
|
worldMap.current = m;
|
||||||
const sv = loadMapView();
|
const sv = loadMapView();
|
||||||
@@ -115,7 +136,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
||||||
}
|
}
|
||||||
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
||||||
return () => window.clearTimeout(t);
|
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
|
||||||
|
// every overlay redraw (as before) forced a full repaint of the map each time
|
||||||
|
// the rotor moved a degree.
|
||||||
|
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
|
||||||
|
if (worldRef.current) ro.observe(worldRef.current);
|
||||||
|
return () => { window.clearTimeout(t); ro.disconnect(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Swap the basemap (and its optional place-name overlay) when the operator
|
// Swap the basemap (and its optional place-name overlay) when the operator
|
||||||
@@ -123,12 +149,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const m = worldMap.current;
|
const m = worldMap.current;
|
||||||
if (!m) return;
|
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; }
|
|
||||||
if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; }
|
|
||||||
const bm = BASEMAPS[basemap];
|
|
||||||
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
|
||||||
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
|
||||||
}, [basemap]);
|
}, [basemap]);
|
||||||
|
|
||||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||||
@@ -224,9 +245,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
wm.setView([from.lat, from.lon], 3);
|
wm.setView([from.lat, from.lon], 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => { wm.invalidateSize(); }, 0);
|
// No invalidateSize() here — a ResizeObserver handles the only case that needs
|
||||||
|
// it. Forcing a full map repaint on every redraw is what made a moving rotor
|
||||||
|
// thrash the compositor.
|
||||||
|
// Headings are rounded in the deps: a rotor reports a jittering float, and a
|
||||||
|
// tenth of a degree is invisible on a 5500 km lobe but rebuilds every polyline.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]);
|
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth,
|
||||||
|
boomAzimuth == null ? null : Math.round(boomAzimuth), autoZoom, zoomSignal]);
|
||||||
|
|
||||||
const path = pathBetween(fromGrid, toGrid);
|
const path = pathBetween(fromGrid, toGrid);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
import {
|
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).
|
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||||
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
||||||
|
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
||||||
|
|
||||||
// Add/edit-contact dialog.
|
// Add/edit-contact dialog.
|
||||||
const [contactOpen, setContactOpen] = useState(false);
|
const [contactOpen, setContactOpen] = useState(false);
|
||||||
const [contact, setContact] = useState<Station>(emptyStation());
|
const [contact, setContact] = useState<Station>(emptyStation());
|
||||||
const [looking, setLooking] = useState(false);
|
const [looking, setLooking] = useState(false);
|
||||||
|
|
||||||
const activeGrid = useRef<any>(null);
|
|
||||||
const rosterGrid = useRef<any>(null);
|
const rosterGrid = useRef<any>(null);
|
||||||
|
|
||||||
// Worked-before for the clicked station (see if/when we contacted it before).
|
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||||
@@ -230,18 +231,6 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} 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>[]>(() => [
|
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||||
@@ -292,26 +281,20 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
{t('ncp.onAirActive')}
|
{t('ncp.onAirActive')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div className="flex flex-col min-h-0 flex-1">
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<RecentQSOsGrid
|
||||||
<AgGridReact<QSOForm>
|
rows={active}
|
||||||
ref={activeGrid}
|
total={active.length}
|
||||||
theme={hamlogTheme}
|
storageKey="net.onair"
|
||||||
rowData={active}
|
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||||
columnDefs={activeCols}
|
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||||
defaultColDef={defaultColDef}
|
onRowSelected={setSelectedActiveIds}
|
||||||
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>
|
</div>
|
||||||
{isOpen && active.length > 0 && (
|
{isOpen && active.length > 0 && (
|
||||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
<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]"
|
<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')}
|
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,7 +318,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 overflow-auto">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
{!wbCall ? (
|
{!wbCall ? (
|
||||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
|
||||||
) : wbBusy ? (
|
) : wbBusy ? (
|
||||||
@@ -343,30 +326,11 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
) : !wb || wb.count === 0 ? (
|
) : !wb || wb.count === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
|
<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">
|
<RecentQSOsGrid
|
||||||
<thead className="sticky top-0 bg-muted/30 text-muted-foreground">
|
rows={(wb.entries ?? []) as any}
|
||||||
<tr className="text-left">
|
total={wb.count}
|
||||||
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th>
|
storageKey="net.wb"
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -32,7 +32,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
|||||||
|
|
||||||
type Confirmation = {
|
type Confirmation = {
|
||||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
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 = [
|
const SERVICES = [
|
||||||
@@ -208,18 +208,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
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(() => {
|
useEffect(() => {
|
||||||
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
||||||
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
||||||
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
refreshCounts(); // a download may have added confirmations
|
||||||
});
|
});
|
||||||
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
||||||
setConfirmations((list ?? []) as Confirmation[]);
|
setConfirmations((list ?? []) as Confirmation[]);
|
||||||
setViewMode('confirmations');
|
setViewMode('confirmations');
|
||||||
|
refreshCounts();
|
||||||
});
|
});
|
||||||
return () => { offLog(); offDone(); offConf(); };
|
return () => { offLog(); offDone(); offConf(); };
|
||||||
}, []);
|
}, [refreshCounts]);
|
||||||
|
|
||||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
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) => {
|
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||||
switch (confFilter) {
|
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 'dxcc': return c.new_dxcc;
|
||||||
case 'band': return c.new_band;
|
case 'band': return c.new_band;
|
||||||
|
case 'mode': return c.new_mode;
|
||||||
case 'slot': return c.new_slot;
|
case 'slot': return c.new_slot;
|
||||||
default: return true;
|
default: return true;
|
||||||
}
|
}
|
||||||
@@ -353,6 +363,22 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<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 && (
|
{service === 'pota' && potaRes && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<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}
|
{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="new">{t('qslm.filterNew')}</SelectItem>
|
||||||
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
||||||
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
||||||
|
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
|
||||||
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -491,6 +518,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<td className="py-1 px-2">
|
<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_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_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>
|
: 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>}
|
: <span className="text-muted-foreground/50">—</span>}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
import { Trash2, Search, Loader2 } 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 { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
@@ -80,11 +81,23 @@ function StatusCell({ value }: { value?: string }) {
|
|||||||
if (v === '') {
|
if (v === '') {
|
||||||
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
||||||
}
|
}
|
||||||
|
// One colour per state, and each colour means something:
|
||||||
|
// Yes green — confirmed, the thing you wanted
|
||||||
|
// Requested blue — in flight, waiting on the other end. Not a problem.
|
||||||
|
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
|
||||||
|
// This is the ONLY state asking for action, so it gets the
|
||||||
|
// only alarming colour.
|
||||||
|
// No neutral — nothing done yet. Every freshly logged QSO is "No" on
|
||||||
|
// every row; painting that orange (as it used to be, in the
|
||||||
|
// same orange as Requested) made the table shout about a
|
||||||
|
// non-problem and told you nothing apart.
|
||||||
|
// Ignore dashed — deliberately excluded, on purpose.
|
||||||
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
||||||
const cls = v === 'Y' ? 'bg-success text-success-foreground'
|
const cls = v === 'Y' ? 'bg-success text-success-foreground border border-success'
|
||||||
: v === 'R' ? 'bg-warning text-warning-foreground'
|
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
|
||||||
: v === 'I' ? 'bg-muted-foreground text-background'
|
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
|
||||||
: 'bg-warning text-warning-foreground';
|
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
|
||||||
|
: 'bg-muted text-muted-foreground border border-border';
|
||||||
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +176,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
return base;
|
return base;
|
||||||
}, [modes, qso.mode]);
|
}, [modes, qso.mode]);
|
||||||
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
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.
|
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
||||||
const splitHz = (hz?: number) => hz
|
const splitHz = (hz?: number) => hz
|
||||||
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||||
@@ -392,9 +412,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-20"><Label>S</Label>
|
<div className="flex flex-col w-20"><Label>S</Label>
|
||||||
<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>
|
<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}
|
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||||
title={t('qedit.fetchTitle')}>
|
title={t('qedit.fetchTitle')}>
|
||||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||||
@@ -571,9 +591,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: live status grid for every channel */}
|
{/* Right: live status grid for every channel.
|
||||||
<div className="w-72 shrink-0">
|
Sized by its content, not pinned to a width: a fixed 288px box
|
||||||
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
|
left the label column too narrow, so "QSL (paper)" wrapped onto
|
||||||
|
two lines and padded out the whole row. There is spare width to
|
||||||
|
the right — spend it on the label. */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<table className="border-separate" style={{ borderSpacing: 4 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
<th className="text-left font-semibold">{t('qedit.thType')}</th>
|
<th className="text-left font-semibold">{t('qedit.thType')}</th>
|
||||||
@@ -584,7 +608,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<tbody>
|
<tbody>
|
||||||
{CONFIRMATIONS.map((c) => (
|
{CONFIRMATIONS.map((c) => (
|
||||||
<tr key={c.key} className="text-xs">
|
<tr key={c.key} className="text-xs">
|
||||||
<td className="font-medium pr-2 py-0.5">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
||||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
||||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ type Props = {
|
|||||||
// Bump this number to programmatically select every row (e.g. after a search
|
// Bump this number to programmatically select every row (e.g. after a search
|
||||||
// in the QSL Manager, where the default is "all selected").
|
// in the QSL Manager, where the default is "all selected").
|
||||||
selectAllSignal?: number;
|
selectAllSignal?: number;
|
||||||
|
// 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;
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||||
|
onRowClicked?: (q: QSOForm) => void;
|
||||||
onRowSelected?: (ids: number[]) => void;
|
onRowSelected?: (ids: number[]) => void;
|
||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
@@ -49,7 +54,7 @@ type Props = {
|
|||||||
awardCols?: { code: string; name: string }[];
|
awardCols?: { code: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
||||||
|
|
||||||
function fmtMhzDots(hz?: number): string {
|
function fmtMhzDots(hz?: number): string {
|
||||||
if (!hz) return '';
|
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_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_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_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 ──
|
// ── 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 },
|
{ 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 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 { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
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);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// 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
|
// auto-save during that window. Set in the memo (runs at render, before the
|
||||||
// column events) and cleared by the re-apply effect below.
|
// column events) and cleared by the re-apply effect below.
|
||||||
const restoringRef = useRef(true);
|
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>[]>(() => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
restoringRef.current = true;
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
@@ -274,16 +320,26 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||||
width: 110,
|
width: 110,
|
||||||
cellClass: 'text-[11px]',
|
cellClass: 'text-[11px]',
|
||||||
// Hidden by DEFAULT (award columns are opt-in). Without this, AG Grid shows
|
// Visibility comes from the persisted award-code set, so a column the user
|
||||||
// them whenever the saved column state doesn't cover them — a newly-added
|
// showed reappears on reopen and one they didn't stays hidden.
|
||||||
// award, an empty cache, or a rebuild that runs before the saved state is
|
hide: !awardShown.has(a.code.toUpperCase()),
|
||||||
// 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,
|
|
||||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
}));
|
}));
|
||||||
return [...base, ...awards];
|
return [...base, ...awards];
|
||||||
}, [awardCols, COL_CATALOG, t]);
|
}, [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>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@@ -293,21 +349,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(colStateKey);
|
||||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
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
|
// Fall back to the portable DB copy when the local cache is empty
|
||||||
// (fresh machine / after a reinstall), then re-seed the cache.
|
// (fresh machine / after a reinstall), then re-seed the cache.
|
||||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
loadRemote(colStateKey).then((remote) => {
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
|
||||||
seedLocal(COL_STATE_KEY, remote);
|
seedLocal(colStateKey, remote);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (state) saveState(colStateKey, stripAwardCols(state));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The award columns load asynchronously; when they arrive (or change) the
|
// 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.
|
// every rebuild so the user's choices win. No-op before the grid is ready.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(colStateKey);
|
||||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
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.
|
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
@@ -342,10 +398,22 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
// state for "which columns are visible" — AG Grid's column state is the
|
// state for "which columns are visible" — AG Grid's column state is the
|
||||||
// source of truth, and saveColumnState persists it.
|
// source of truth, and saveColumnState persists it.
|
||||||
function isColVisible(colId: string): boolean {
|
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);
|
const col = gridRef.current?.api?.getColumn(colId);
|
||||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||||
}
|
}
|
||||||
function setColVisible(colId: string, visible: boolean) {
|
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;
|
const api = gridRef.current?.api;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
api.setColumnsVisible([colId], visible);
|
api.setColumnsVisible([colId], visible);
|
||||||
@@ -373,6 +441,9 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
api.setColumnsVisible(visible, true);
|
api.setColumnsVisible(visible, true);
|
||||||
api.setColumnsVisible(hidden, false);
|
api.setColumnsVisible(hidden, false);
|
||||||
saveColumnState();
|
saveColumnState();
|
||||||
|
// Award columns are opt-in: reset hides them all.
|
||||||
|
persistAwardShown(new Set());
|
||||||
|
(awardCols ?? []).forEach((a) => api.setColumnsVisible([`award_${a.code}`], false));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -402,6 +473,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
onColumnVisible={saveColumnState}
|
onColumnVisible={saveColumnState}
|
||||||
onSortChanged={saveColumnState}
|
onSortChanged={saveColumnState}
|
||||||
onRowDoubleClicked={handleRowDoubleClicked}
|
onRowDoubleClicked={handleRowDoubleClicked}
|
||||||
|
onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
|
||||||
onSelectionChanged={onSelectionChanged}
|
onSelectionChanged={onSelectionChanged}
|
||||||
onCellContextMenu={onCellContextMenu}
|
onCellContextMenu={onCellContextMenu}
|
||||||
preventDefaultOnContextMenu
|
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">
|
<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>
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
||||||
<div className="flex gap-0.5">
|
<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-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={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('rqg.none')}</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>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
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';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||||
|
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
GetUIPref, SetUIPref,
|
GetUIPref, SetUIPref,
|
||||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||||
@@ -175,6 +176,7 @@ type SectionId =
|
|||||||
| 'backup'
|
| 'backup'
|
||||||
| 'database'
|
| 'database'
|
||||||
| 'autostart'
|
| 'autostart'
|
||||||
|
| 'uscounties'
|
||||||
| 'awards'
|
| 'awards'
|
||||||
| 'cat'
|
| 'cat'
|
||||||
| 'rotator'
|
| 'rotator'
|
||||||
@@ -223,6 +225,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
]},
|
]},
|
||||||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||||||
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
||||||
|
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
|
||||||
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
||||||
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
|
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
|
||||||
],
|
],
|
||||||
@@ -238,6 +241,7 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
|||||||
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
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',
|
'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',
|
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||||
|
uscounties: 'sec.uscounties',
|
||||||
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
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',
|
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
|
||||||
};
|
};
|
||||||
@@ -259,9 +263,9 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
udp: 'UDP integrations',
|
udp: 'UDP integrations',
|
||||||
awards: 'Awards',
|
awards: 'Awards',
|
||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'PstRotator',
|
rotator: 'Rotator',
|
||||||
winkeyer: 'CW Keyer',
|
winkeyer: 'CW Keyer',
|
||||||
antenna: 'UltraBeam',
|
antenna: 'Ultrabeam / Steppir',
|
||||||
antgenius: 'Antenna Genius',
|
antgenius: 'Antenna Genius',
|
||||||
pgxl: 'Power Genius',
|
pgxl: 'Power Genius',
|
||||||
flex: 'FlexRadio',
|
flex: 'FlexRadio',
|
||||||
@@ -536,6 +540,7 @@ function AutostartPanelComponent() {
|
|||||||
// (a random install ID + version + OS, sent once a day). Real component so it
|
// (a random install ID + version + OS, sent once a day). Real component so it
|
||||||
// can own its state; embedded inside GeneralPanel.
|
// can own its state; embedded inside GeneralPanel.
|
||||||
function TelemetryToggle() {
|
function TelemetryToggle() {
|
||||||
|
const { t } = useI18n();
|
||||||
const [on, setOn] = useState(true);
|
const [on, setOn] = useState(true);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -545,8 +550,8 @@ function TelemetryToggle() {
|
|||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={on} disabled={!loaded}
|
<Checkbox checked={on} disabled={!loaded}
|
||||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
||||||
Send anonymous usage statistics
|
{t('settings.telemetry')}
|
||||||
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day — no callsign or QSO data)</span>
|
<span className="text-xs text-muted-foreground">({t('settings.telemetryHint')})</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -556,6 +561,7 @@ function TelemetryToggle() {
|
|||||||
// events — a small web script on your server renders it for the QRZ page. Only
|
// 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).
|
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
||||||
function LiveStatusToggle() {
|
function LiveStatusToggle() {
|
||||||
|
const { t } = useI18n();
|
||||||
const [on, setOn] = useState(false);
|
const [on, setOn] = useState(false);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -565,7 +571,7 @@ function LiveStatusToggle() {
|
|||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={on} disabled={!loaded}
|
<Checkbox checked={on} disabled={!loaded}
|
||||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
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>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -574,25 +580,20 @@ function LiveStatusToggle() {
|
|||||||
// panes show, independently: the great-circle map, the locator street map, the
|
// panes show, independently: the great-circle map, the locator street map, the
|
||||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
// 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.
|
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
||||||
const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
|
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
|
||||||
{ 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' },
|
|
||||||
];
|
|
||||||
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
|
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 [left, setLeft] = useState('map1');
|
||||||
const [right, setRight] = useState('map2');
|
const [right, setRight] = useState('map2');
|
||||||
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
|
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
|
||||||
const options = [
|
const options = [
|
||||||
...MAIN_PANE_OPTIONS,
|
...MAIN_PANE_VALUES,
|
||||||
...(flexAvailable ? [{ value: 'flex', label: 'FlexRadio controls' }] : []),
|
...(flexAvailable ? ['flex'] : []),
|
||||||
...(icomAvailable ? [{ value: 'icom', label: 'Icom console' }] : []),
|
...(icomAvailable ? ['icom'] : []),
|
||||||
].sort((a, b) => a.label.localeCompare(b.label));
|
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
useEffect(() => {
|
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(() => '')])
|
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||||
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -605,11 +606,11 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">Main view</h4>
|
<h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4>
|
||||||
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</p>
|
<p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p>
|
||||||
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
||||||
<label className="flex flex-col gap-1 text-xs">
|
<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)}>
|
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -618,7 +619,7 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-xs">
|
<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)}>
|
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -821,14 +822,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
digital_default: 'FT8',
|
digital_default: 'FT8',
|
||||||
});
|
});
|
||||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
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 [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
// Ultrabeam antenna (TCP) settings.
|
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
||||||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number; follow: boolean; step_khz: number }>({
|
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, host: '', port: 23, follow: false, step_khz: 50,
|
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 [ubTesting, setUbTesting] = useState(false);
|
||||||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
@@ -941,6 +942,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||||||
});
|
});
|
||||||
const [emailMsg, setEmailMsg] = useState('');
|
const [emailMsg, setEmailMsg] = useState('');
|
||||||
|
const [showSmtpPass, setShowSmtpPass] = useState(false);
|
||||||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||||||
// eQSL card e-mail (subject/body templates + auto-send on log).
|
// eQSL card e-mail (subject/body templates + auto-send on log).
|
||||||
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
|
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
|
||||||
@@ -1010,6 +1012,34 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
|
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
|
||||||
finally { setLotwUsersBusy(false); }
|
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 [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
@@ -2245,7 +2275,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setRotatorTest(null);
|
setRotatorTest(null);
|
||||||
try {
|
try {
|
||||||
await TestRotator(rotator as any);
|
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) {
|
} catch (e: any) {
|
||||||
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2267,36 +2297,92 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UltrabeamPanel() {
|
function UltrabeamPanel() {
|
||||||
|
const isSteppir = ultrabeam.type === 'steppir';
|
||||||
|
const isSerial = isSteppir && ultrabeam.transport === 'serial';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={t('hw.ultrabeam')}
|
title={t('hw.motorAntenna')}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
|
||||||
Enable Ultrabeam control
|
{t('hw.motorEnable')}
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1 col-span-2">
|
|
||||||
<Label>Host / IP</Label>
|
|
||||||
<Input
|
|
||||||
value={ultrabeam.host ?? ''}
|
|
||||||
onChange={(e) => setUltrabeam((s) => ({ ...s, host: e.target.value }))}
|
|
||||||
placeholder="192.168.1.50"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>TCP port</Label>
|
<Label>{t('hw.motorType')}</Label>
|
||||||
<Input
|
{/* Ultrabeam is TCP only; picking it forces the transport back to TCP
|
||||||
type="number" min={1} max={65535}
|
so the serial fields never apply to it. */}
|
||||||
value={ultrabeam.port}
|
<Select value={ultrabeam.type ?? 'ultrabeam'}
|
||||||
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
|
onValueChange={(v) => setUltrabeam((s) => ({ ...s, type: v, transport: v === 'ultrabeam' ? 'tcp' : s.transport }))}>
|
||||||
className="font-mono"
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
/>
|
<SelectContent>
|
||||||
|
<SelectItem value="ultrabeam">Ultrabeam</SelectItem>
|
||||||
|
<SelectItem value="steppir">SteppIR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{isSteppir && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('hw.motorTransport')}</Label>
|
||||||
|
<Select value={ultrabeam.transport ?? 'tcp'}
|
||||||
|
onValueChange={(v) => setUltrabeam((s) => ({ ...s, transport: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tcp">{t('hw.motorTcp')}</SelectItem>
|
||||||
|
<SelectItem value="serial">{t('hw.motorSerial')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isSerial ? (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>{t('hw.motorCom')}</Label>
|
||||||
|
<Input
|
||||||
|
value={ultrabeam.com ?? ''}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, com: e.target.value }))}
|
||||||
|
placeholder="COM3"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('hw.motorBaud')}</Label>
|
||||||
|
<Select value={String(ultrabeam.baud || 9600)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, baud: parseInt(v, 10) || 9600 }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[1200, 4800, 9600, 19200].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Host / IP</Label>
|
||||||
|
<Input
|
||||||
|
value={ultrabeam.host ?? ''}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, host: e.target.value }))}
|
||||||
|
placeholder="192.168.1.50"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>TCP port</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={1} max={65535}
|
||||||
|
value={ultrabeam.port}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSteppir && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('hw.steppirHint')}</p>
|
||||||
|
)}
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
||||||
@@ -2317,8 +2403,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || !ultrabeam.host.trim()}>
|
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || (isSerial ? !ultrabeam.com.trim() : !ultrabeam.host.trim())}>
|
||||||
{ubTesting ? t('hw.connecting') : t('hw.testConn')}
|
{ubTesting ? t('hw.connecting') : t('hw.testConn')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2411,41 +2504,73 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RotatorPanel() {
|
function RotatorPanel() {
|
||||||
|
const isRG = (rotator as any).type === 'rotgenius';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Rotator"
|
title="Rotator"
|
||||||
hint={t('rot.hint')}
|
hint={isRG ? undefined : t('rot.hint')}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
||||||
Enable PstRotator control
|
{t('rot.enable')}
|
||||||
</label>
|
</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="grid grid-cols-3 gap-3">
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Host</Label>
|
<Label>Host / IP</Label>
|
||||||
<Input
|
<Input
|
||||||
value={rotator.host ?? ''}
|
value={rotator.host ?? ''}
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
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"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>UDP port</Label>
|
<Label>{isRG ? 'TCP port' : 'UDP port'}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number" min={1} max={65535}
|
type="number" min={1} max={65535}
|
||||||
value={rotator.port}
|
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"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
{!isRG && (
|
||||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
This rotator supports elevation (VHF / satellite)
|
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
||||||
</label>
|
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">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
||||||
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
||||||
@@ -2453,9 +2578,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}>
|
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
{!isRG && (
|
||||||
Park
|
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||||
</Button>
|
Park
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{rotatorTest && (
|
{rotatorTest && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -4196,7 +4323,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Label className="text-sm">{t('em.username')}</Label>
|
<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 })} />
|
<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>
|
<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>
|
<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 })} />
|
<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>
|
<Label className="text-sm">{t('em.replyTo')}</Label>
|
||||||
@@ -4235,6 +4370,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).
|
// Map sections to their content + icon (for placeholder).
|
||||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||||
general: GeneralPanel,
|
general: GeneralPanel,
|
||||||
@@ -4251,6 +4448,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
udp: UDPIntegrationsPanelWrapper,
|
udp: UDPIntegrationsPanelWrapper,
|
||||||
backup: BackupPanel,
|
backup: BackupPanel,
|
||||||
database: DatabasePanel,
|
database: DatabasePanel,
|
||||||
|
uscounties: USCountiesPanel,
|
||||||
autostart: () => <AutostartPanelComponent />,
|
autostart: () => <AutostartPanelComponent />,
|
||||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||||
cat: CATPanel,
|
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 { useEffect, useMemo, useState } from 'react';
|
||||||
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-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 { useI18n } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
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
|
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||||
// on a tooltip to get a number.
|
// on a tooltip to get a number.
|
||||||
|
|
||||||
function HBars({ data, max, empty, share }: { 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 —
|
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||||
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
||||||
// silently drop the 9th operator, and "who worked what" is the whole question.
|
// silently drop the 9th operator, and "who worked what" is the whole question.
|
||||||
@@ -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">
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
{top.map((d) => (
|
{top.map((d) => (
|
||||||
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||||
<span className="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="flex-1 min-w-0 h-[14px] flex items-center">
|
||||||
<div
|
<div
|
||||||
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||||
@@ -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
|
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
|
||||||
// information.
|
// 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));
|
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>;
|
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
|
// 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) => (
|
{data.map((d, i) => (
|
||||||
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
<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)}`}>
|
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)}
|
{nf(d.count)}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
className="w-full rounded-t-[4px] transition-[height] duration-300"
|
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">
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
|
||||||
{i % every === 0 ? d.key : ''}
|
{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.
|
// that contest AND lets the window derive from its own span — no date typing.
|
||||||
const [runs, setRuns] = useState<ContestRun[]>([]);
|
const [runs, setRuns] = useState<ContestRun[]>([]);
|
||||||
const [contest, setContest] = useState('');
|
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(() => { 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
|
// 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
|
// with it — the backend derives them. Sending a period as well would be two
|
||||||
// filters fighting over the same axis.
|
// filters fighting over the same axis.
|
||||||
@@ -501,7 +506,7 @@ export function StatsPanel() {
|
|||||||
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
||||||
setBusy(true); setErr('');
|
setBusy(true); setErr('');
|
||||||
try {
|
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
|
// 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
|
// .length on null unmounts the whole React tree — a white screen. Normalise
|
||||||
// once, here, rather than guarding at every use site and missing one.
|
// 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.
|
// are set — otherwise every keystroke in the date box would re-scan the log.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!contest && period === 'custom' && !(from && to)) return;
|
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
|
// 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.
|
// 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"
|
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
|
||||||
@@ -569,6 +574,20 @@ export function StatsPanel() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</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')}>
|
<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')],
|
['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">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||||
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
||||||
<VBars data={stats.by_band} empty={empty} />
|
<VBars data={stats.by_band} empty={empty} showValues colorful />
|
||||||
</Card>
|
</Card>
|
||||||
<Card title={t('stats.byMode')}>
|
<Card title={t('stats.byMode')}>
|
||||||
<HBars data={stats.by_mode} max={8} empty={empty} />
|
<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
|
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||||
instead of truncating. */}
|
instead of truncating. */}
|
||||||
<Card title={t('stats.byOperator')} sub={t('stats.byOperatorSub')}>
|
<Card title={t('stats.byOperator')}>
|
||||||
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||||
<HBars data={stats.by_operator} empty={empty} share />
|
<HBars data={stats.by_operator} empty={empty} share />
|
||||||
</div>
|
</div>
|
||||||
@@ -728,7 +747,7 @@ export function StatsPanel() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title={t('stats.topEntities')}>
|
<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>
|
</Card>
|
||||||
<div className="flex flex-col gap-2.5 min-w-0">
|
<div className="flex flex-col gap-2.5 min-w-0">
|
||||||
<Card title={t('stats.confirmations')} className="flex-1">
|
<Card title={t('stats.confirmations')} className="flex-1">
|
||||||
|
|||||||
+86
-30
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}`;
|
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
|
||||||
return `5${strength}`;
|
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 { writeUiPref } from './uiPref';
|
||||||
|
import { GetUIPref } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
// 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
|
// 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 }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
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) => {
|
const setTheme = useCallback((t: ThemeChoice) => {
|
||||||
|
userPicked.current = true;
|
||||||
setThemeState(t);
|
setThemeState(t);
|
||||||
applyThemeToDom(t);
|
applyThemeToDom(t);
|
||||||
writeUiPref(LS_KEY, 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.
|
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme !== 'auto') return;
|
if (theme !== 'auto') return;
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||||
|
// 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:
|
// 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).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.19.6';
|
export const APP_VERSION = '0.19.9';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+39
-1
@@ -38,6 +38,8 @@ export function ApplyAwardImport(arg1:string,arg2:Record<string, string>):Promis
|
|||||||
|
|
||||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
|
export function ApplyAwardUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||||
|
|
||||||
export function AudioMonitorActive():Promise<boolean>;
|
export function AudioMonitorActive():Promise<boolean>;
|
||||||
@@ -62,6 +64,8 @@ export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Reco
|
|||||||
|
|
||||||
export function AwardsFolder():Promise<string>;
|
export function AwardsFolder():Promise<string>;
|
||||||
|
|
||||||
|
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
||||||
|
|
||||||
export function BrowseExecutable():Promise<string>;
|
export function BrowseExecutable():Promise<string>;
|
||||||
|
|
||||||
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||||
@@ -138,6 +142,8 @@ export function DisconnectClusterServer(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
||||||
|
|
||||||
|
export function DismissAwardUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DownloadAllReferenceLists():Promise<string>;
|
export function DownloadAllReferenceLists():Promise<string>;
|
||||||
|
|
||||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||||
@@ -146,8 +152,12 @@ export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Prom
|
|||||||
|
|
||||||
export function DownloadLoTWUsers():Promise<number>;
|
export function DownloadLoTWUsers():Promise<number>;
|
||||||
|
|
||||||
|
export function DownloadULSCounties():Promise<void>;
|
||||||
|
|
||||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||||
|
|
||||||
|
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||||
|
|
||||||
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
||||||
@@ -234,6 +244,10 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetRIT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetRITFreq(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||||
@@ -258,6 +272,10 @@ export function FlexSetWNB(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetWNBLevel(arg1:number):Promise<void>;
|
export function FlexSetWNBLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetXIT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexTune(arg1:boolean):Promise<void>;
|
export function FlexTune(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
@@ -282,6 +300,8 @@ export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
|
|||||||
|
|
||||||
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
||||||
|
|
||||||
|
export function GetAwardUpdates():Promise<Array<main.AwardUpdate>>;
|
||||||
|
|
||||||
export function GetAwards():Promise<Array<award.Result>>;
|
export function GetAwards():Promise<Array<award.Result>>;
|
||||||
|
|
||||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||||
@@ -336,7 +356,7 @@ export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
|||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
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>;
|
export function GetLogbookRevision():Promise<string>;
|
||||||
|
|
||||||
@@ -348,6 +368,8 @@ export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
|||||||
|
|
||||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||||
|
|
||||||
|
export function GetOperators():Promise<Array<string>>;
|
||||||
|
|
||||||
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||||
|
|
||||||
export function GetPGXLStatus():Promise<powergenius.Status>;
|
export function GetPGXLStatus():Promise<powergenius.Status>;
|
||||||
@@ -366,12 +388,18 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
|||||||
|
|
||||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
|
|
||||||
|
export function GetSlotStats():Promise<qso.SlotStats>;
|
||||||
|
|
||||||
export function GetSolarData():Promise<solar.Data>;
|
export function GetSolarData():Promise<solar.Data>;
|
||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
|
export function GetStationDevices():Promise<Array<main.StationDevice>>;
|
||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
|
|
||||||
|
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||||
|
|
||||||
export function GetTelemetryEnabled():Promise<boolean>;
|
export function GetTelemetryEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function GetUIPref(arg1:string):Promise<string>;
|
export function GetUIPref(arg1:string):Promise<string>;
|
||||||
@@ -518,6 +546,10 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
|||||||
|
|
||||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
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 MoveDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
||||||
@@ -702,6 +734,8 @@ export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
|||||||
|
|
||||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||||
@@ -750,6 +784,8 @@ export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function StartCWDecoder():Promise<void>;
|
export function StartCWDecoder():Promise<void>;
|
||||||
|
|
||||||
|
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||||
|
|
||||||
export function StopCWDecoder():Promise<void>;
|
export function StopCWDecoder():Promise<void>;
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
@@ -778,6 +814,8 @@ export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
|||||||
|
|
||||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function ULSStatus():Promise<main.ULSStatusResult>;
|
||||||
|
|
||||||
export function UltrabeamRetract():Promise<void>;
|
export function UltrabeamRetract():Promise<void>;
|
||||||
|
|
||||||
export function UnlockSecrets(arg1:string):Promise<void>;
|
export function UnlockSecrets(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export function ApplyAwardPreset(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ApplyAwardUpdate(arg1) {
|
||||||
|
return window['go']['main']['App']['ApplyAwardUpdate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -82,6 +86,10 @@ export function AwardsFolder() {
|
|||||||
return window['go']['main']['App']['AwardsFolder']();
|
return window['go']['main']['App']['AwardsFolder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BackfillUSCounties() {
|
||||||
|
return window['go']['main']['App']['BackfillUSCounties']();
|
||||||
|
}
|
||||||
|
|
||||||
export function BrowseExecutable() {
|
export function BrowseExecutable() {
|
||||||
return window['go']['main']['App']['BrowseExecutable']();
|
return window['go']['main']['App']['BrowseExecutable']();
|
||||||
}
|
}
|
||||||
@@ -234,6 +242,10 @@ export function DiscoverFlexRadios() {
|
|||||||
return window['go']['main']['App']['DiscoverFlexRadios']();
|
return window['go']['main']['App']['DiscoverFlexRadios']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DismissAwardUpdate(arg1) {
|
||||||
|
return window['go']['main']['App']['DismissAwardUpdate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadAllReferenceLists() {
|
export function DownloadAllReferenceLists() {
|
||||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||||
}
|
}
|
||||||
@@ -250,10 +262,18 @@ export function DownloadLoTWUsers() {
|
|||||||
return window['go']['main']['App']['DownloadLoTWUsers']();
|
return window['go']['main']['App']['DownloadLoTWUsers']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadULSCounties() {
|
||||||
|
return window['go']['main']['App']['DownloadULSCounties']();
|
||||||
|
}
|
||||||
|
|
||||||
export function DuplicateProfile(arg1, arg2) {
|
export function DuplicateProfile(arg1, arg2) {
|
||||||
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExplainAward(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExportADIF(arg1, arg2) {
|
export function ExportADIF(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -426,6 +446,14 @@ export function FlexSetProcessorLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetRIT(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetRIT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetRITFreq(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetRXAntenna(arg1) {
|
export function FlexSetRXAntenna(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
||||||
}
|
}
|
||||||
@@ -474,6 +502,14 @@ export function FlexSetWNBLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetWNBLevel'](arg1);
|
return window['go']['main']['App']['FlexSetWNBLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetXIT(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetXIT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetXITFreq(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexTune(arg1) {
|
export function FlexTune(arg1) {
|
||||||
return window['go']['main']['App']['FlexTune'](arg1);
|
return window['go']['main']['App']['FlexTune'](arg1);
|
||||||
}
|
}
|
||||||
@@ -522,6 +558,10 @@ export function GetAwardStats(arg1) {
|
|||||||
return window['go']['main']['App']['GetAwardStats'](arg1);
|
return window['go']['main']['App']['GetAwardStats'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAwardUpdates() {
|
||||||
|
return window['go']['main']['App']['GetAwardUpdates']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAwards() {
|
export function GetAwards() {
|
||||||
return window['go']['main']['App']['GetAwards']();
|
return window['go']['main']['App']['GetAwards']();
|
||||||
}
|
}
|
||||||
@@ -630,8 +670,8 @@ export function GetLogFilePath() {
|
|||||||
return window['go']['main']['App']['GetLogFilePath']();
|
return window['go']['main']['App']['GetLogFilePath']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLogStats(arg1, arg2, arg3, arg4) {
|
export function GetLogStats(arg1, arg2, arg3, arg4, arg5) {
|
||||||
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4, arg5);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLogbookRevision() {
|
export function GetLogbookRevision() {
|
||||||
@@ -654,6 +694,10 @@ export function GetOnlineOperators() {
|
|||||||
return window['go']['main']['App']['GetOnlineOperators']();
|
return window['go']['main']['App']['GetOnlineOperators']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetOperators() {
|
||||||
|
return window['go']['main']['App']['GetOperators']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPGXLSettings() {
|
export function GetPGXLSettings() {
|
||||||
return window['go']['main']['App']['GetPGXLSettings']();
|
return window['go']['main']['App']['GetPGXLSettings']();
|
||||||
}
|
}
|
||||||
@@ -690,6 +734,10 @@ export function GetSecretStatus() {
|
|||||||
return window['go']['main']['App']['GetSecretStatus']();
|
return window['go']['main']['App']['GetSecretStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSlotStats() {
|
||||||
|
return window['go']['main']['App']['GetSlotStats']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetSolarData() {
|
export function GetSolarData() {
|
||||||
return window['go']['main']['App']['GetSolarData']();
|
return window['go']['main']['App']['GetSolarData']();
|
||||||
}
|
}
|
||||||
@@ -698,10 +746,18 @@ export function GetStartupStatus() {
|
|||||||
return window['go']['main']['App']['GetStartupStatus']();
|
return window['go']['main']['App']['GetStartupStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetStationDevices() {
|
||||||
|
return window['go']['main']['App']['GetStationDevices']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetStationSettings() {
|
export function GetStationSettings() {
|
||||||
return window['go']['main']['App']['GetStationSettings']();
|
return window['go']['main']['App']['GetStationSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetStationStatus() {
|
||||||
|
return window['go']['main']['App']['GetStationStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetTelemetryEnabled() {
|
export function GetTelemetryEnabled() {
|
||||||
return window['go']['main']['App']['GetTelemetryEnabled']();
|
return window['go']['main']['App']['GetTelemetryEnabled']();
|
||||||
}
|
}
|
||||||
@@ -994,6 +1050,14 @@ export function LookupCallsign(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function MoveDatabase(arg1) {
|
||||||
return window['go']['main']['App']['MoveDatabase'](arg1);
|
return window['go']['main']['App']['MoveDatabase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1362,6 +1426,10 @@ export function SaveRotatorSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveStationDevices(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveStationDevices'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveStationSettings(arg1) {
|
export function SaveStationSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1458,6 +1526,10 @@ export function StartCWDecoder() {
|
|||||||
return window['go']['main']['App']['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() {
|
export function StopCWDecoder() {
|
||||||
return window['go']['main']['App']['StopCWDecoder']();
|
return window['go']['main']['App']['StopCWDecoder']();
|
||||||
}
|
}
|
||||||
@@ -1514,6 +1586,10 @@ export function TestUltrabeam(arg1) {
|
|||||||
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ULSStatus() {
|
||||||
|
return window['go']['main']['App']['ULSStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function UltrabeamRetract() {
|
export function UltrabeamRetract() {
|
||||||
return window['go']['main']['App']['UltrabeamRetract']();
|
return window['go']['main']['App']['UltrabeamRetract']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,9 +260,7 @@ export namespace award {
|
|||||||
pattern: string;
|
pattern: string;
|
||||||
leading_str?: string;
|
leading_str?: string;
|
||||||
trailing_str?: string;
|
trailing_str?: string;
|
||||||
multi?: boolean;
|
|
||||||
dynamic?: boolean;
|
dynamic?: boolean;
|
||||||
add_prefixes?: string[];
|
|
||||||
or_rules?: OrRule[];
|
or_rules?: OrRule[];
|
||||||
dxcc_filter: number[];
|
dxcc_filter: number[];
|
||||||
valid_bands?: string[];
|
valid_bands?: string[];
|
||||||
@@ -274,6 +272,8 @@ export namespace award {
|
|||||||
export_credit_granted?: boolean;
|
export_credit_granted?: boolean;
|
||||||
total: number;
|
total: number;
|
||||||
builtin: boolean;
|
builtin: boolean;
|
||||||
|
version?: number;
|
||||||
|
user_edited?: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Def(source);
|
return new Def(source);
|
||||||
@@ -300,9 +300,7 @@ export namespace award {
|
|||||||
this.pattern = source["pattern"];
|
this.pattern = source["pattern"];
|
||||||
this.leading_str = source["leading_str"];
|
this.leading_str = source["leading_str"];
|
||||||
this.trailing_str = source["trailing_str"];
|
this.trailing_str = source["trailing_str"];
|
||||||
this.multi = source["multi"];
|
|
||||||
this.dynamic = source["dynamic"];
|
this.dynamic = source["dynamic"];
|
||||||
this.add_prefixes = source["add_prefixes"];
|
|
||||||
this.or_rules = this.convertValues(source["or_rules"], OrRule);
|
this.or_rules = this.convertValues(source["or_rules"], OrRule);
|
||||||
this.dxcc_filter = source["dxcc_filter"];
|
this.dxcc_filter = source["dxcc_filter"];
|
||||||
this.valid_bands = source["valid_bands"];
|
this.valid_bands = source["valid_bands"];
|
||||||
@@ -314,6 +312,116 @@ export namespace award {
|
|||||||
this.export_credit_granted = source["export_credit_granted"];
|
this.export_credit_granted = source["export_credit_granted"];
|
||||||
this.total = source["total"];
|
this.total = source["total"];
|
||||||
this.builtin = source["builtin"];
|
this.builtin = source["builtin"];
|
||||||
|
this.version = source["version"];
|
||||||
|
this.user_edited = source["user_edited"];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Rejected {
|
||||||
|
candidate: string;
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Rejected(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.candidate = source["candidate"];
|
||||||
|
this.reason = source["reason"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Step {
|
||||||
|
rule: string;
|
||||||
|
field: string;
|
||||||
|
match_by?: string;
|
||||||
|
exact?: boolean;
|
||||||
|
pattern?: string;
|
||||||
|
field_value?: string;
|
||||||
|
candidates?: string[];
|
||||||
|
kept?: string[];
|
||||||
|
rejected?: Rejected[];
|
||||||
|
skipped?: boolean;
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Step(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.rule = source["rule"];
|
||||||
|
this.field = source["field"];
|
||||||
|
this.match_by = source["match_by"];
|
||||||
|
this.exact = source["exact"];
|
||||||
|
this.pattern = source["pattern"];
|
||||||
|
this.field_value = source["field_value"];
|
||||||
|
this.candidates = source["candidates"];
|
||||||
|
this.kept = source["kept"];
|
||||||
|
this.rejected = this.convertValues(source["rejected"], Rejected);
|
||||||
|
this.skipped = source["skipped"];
|
||||||
|
this.error = source["error"];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Explanation {
|
||||||
|
code: string;
|
||||||
|
in_scope: boolean;
|
||||||
|
scope_error?: string;
|
||||||
|
predefined: boolean;
|
||||||
|
ref_count: number;
|
||||||
|
steps: Step[];
|
||||||
|
manual?: string[];
|
||||||
|
result: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Explanation(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.code = source["code"];
|
||||||
|
this.in_scope = source["in_scope"];
|
||||||
|
this.scope_error = source["scope_error"];
|
||||||
|
this.predefined = source["predefined"];
|
||||||
|
this.ref_count = source["ref_count"];
|
||||||
|
this.steps = this.convertValues(source["steps"], Step);
|
||||||
|
this.manual = source["manual"];
|
||||||
|
this.result = source["result"];
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -365,6 +473,7 @@ export namespace award {
|
|||||||
this.validated_bands = source["validated_bands"];
|
this.validated_bands = source["validated_bands"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Result {
|
export class Result {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -613,6 +722,10 @@ export namespace cat {
|
|||||||
anf_level: number;
|
anf_level: number;
|
||||||
wnb: boolean;
|
wnb: boolean;
|
||||||
wnb_level: number;
|
wnb_level: number;
|
||||||
|
rit: boolean;
|
||||||
|
rit_freq: number;
|
||||||
|
xit: boolean;
|
||||||
|
xit_freq: number;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
cw_speed: number;
|
cw_speed: number;
|
||||||
cw_pitch: number;
|
cw_pitch: number;
|
||||||
@@ -676,6 +789,10 @@ export namespace cat {
|
|||||||
this.anf_level = source["anf_level"];
|
this.anf_level = source["anf_level"];
|
||||||
this.wnb = source["wnb"];
|
this.wnb = source["wnb"];
|
||||||
this.wnb_level = source["wnb_level"];
|
this.wnb_level = source["wnb_level"];
|
||||||
|
this.rit = source["rit"];
|
||||||
|
this.rit_freq = source["rit_freq"];
|
||||||
|
this.xit = source["xit"];
|
||||||
|
this.xit_freq = source["xit_freq"];
|
||||||
this.mode = source["mode"];
|
this.mode = source["mode"];
|
||||||
this.cw_speed = source["cw_speed"];
|
this.cw_speed = source["cw_speed"];
|
||||||
this.cw_pitch = source["cw_pitch"];
|
this.cw_pitch = source["cw_pitch"];
|
||||||
@@ -1267,6 +1384,38 @@ export namespace main {
|
|||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class AwardExplain {
|
||||||
|
qso: qso.QSO;
|
||||||
|
explanation: award.Explanation;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AwardExplain(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.qso = this.convertValues(source["qso"], qso.QSO);
|
||||||
|
this.explanation = this.convertValues(source["explanation"], award.Explanation);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 AwardImportPreviewEntry {
|
export class AwardImportPreviewEntry {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1408,6 +1557,40 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class AwardUpdate {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AwardUpdate(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.code = source["code"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.from = source["from"];
|
||||||
|
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 {
|
export class BackupSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
folder: string;
|
folder: string;
|
||||||
@@ -2166,9 +2349,11 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
export class RotatorSettings {
|
export class RotatorSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
has_elevation: boolean;
|
has_elevation: boolean;
|
||||||
|
rotator_num: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RotatorSettings(source);
|
return new RotatorSettings(source);
|
||||||
@@ -2177,9 +2362,11 @@ export namespace main {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
|
this.type = source["type"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
this.has_elevation = source["has_elevation"];
|
this.has_elevation = source["has_elevation"];
|
||||||
|
this.rotator_num = source["rotator_num"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SecretStatus {
|
export class SecretStatus {
|
||||||
@@ -2200,6 +2387,7 @@ export namespace main {
|
|||||||
call: string;
|
call: string;
|
||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
|
pota_ref?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new SpotQuery(source);
|
return new SpotQuery(source);
|
||||||
@@ -2210,6 +2398,7 @@ export namespace main {
|
|||||||
this.call = source["call"];
|
this.call = source["call"];
|
||||||
this.band = source["band"];
|
this.band = source["band"];
|
||||||
this.mode = source["mode"];
|
this.mode = source["mode"];
|
||||||
|
this.pota_ref = source["pota_ref"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SpotStatus {
|
export class SpotStatus {
|
||||||
@@ -2220,6 +2409,8 @@ export namespace main {
|
|||||||
continent?: string;
|
continent?: string;
|
||||||
status: string;
|
status: string;
|
||||||
worked_call: boolean;
|
worked_call: boolean;
|
||||||
|
new_county: boolean;
|
||||||
|
new_pota: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new SpotStatus(source);
|
return new SpotStatus(source);
|
||||||
@@ -2234,6 +2425,8 @@ export namespace main {
|
|||||||
this.continent = source["continent"];
|
this.continent = source["continent"];
|
||||||
this.status = source["status"];
|
this.status = source["status"];
|
||||||
this.worked_call = source["worked_call"];
|
this.worked_call = source["worked_call"];
|
||||||
|
this.new_county = source["new_county"];
|
||||||
|
this.new_pota = source["new_pota"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class StartupStatus {
|
export class StartupStatus {
|
||||||
@@ -2252,6 +2445,86 @@ export namespace main {
|
|||||||
this.db_path = source["db_path"];
|
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 {
|
export class StationInfoComputed {
|
||||||
country: string;
|
country: string;
|
||||||
dxcc: number;
|
dxcc: number;
|
||||||
@@ -2274,6 +2547,7 @@ export namespace main {
|
|||||||
this.lon = source["lon"];
|
this.lon = source["lon"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StationSettings {
|
export class StationSettings {
|
||||||
callsign: string;
|
callsign: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
@@ -2296,12 +2570,31 @@ export namespace main {
|
|||||||
this.my_pota_ref = source["my_pota_ref"];
|
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 {
|
export class UltrabeamSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
|
transport: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
com: string;
|
||||||
|
baud: number;
|
||||||
follow: boolean;
|
follow: boolean;
|
||||||
step_khz: number;
|
step_khz: number;
|
||||||
|
tx_inhibit: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new UltrabeamSettings(source);
|
return new UltrabeamSettings(source);
|
||||||
@@ -2310,19 +2603,26 @@ export namespace main {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.transport = source["transport"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
|
this.com = source["com"];
|
||||||
|
this.baud = source["baud"];
|
||||||
this.follow = source["follow"];
|
this.follow = source["follow"];
|
||||||
this.step_khz = source["step_khz"];
|
this.step_khz = source["step_khz"];
|
||||||
|
this.tx_inhibit = source["tx_inhibit"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class UltrabeamStatusInfo {
|
export class UltrabeamStatusInfo {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
direction: number;
|
direction: number;
|
||||||
frequency: number;
|
frequency: number;
|
||||||
band: number;
|
band: number;
|
||||||
moving: boolean;
|
moving: boolean;
|
||||||
|
elements: number[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new UltrabeamStatusInfo(source);
|
return new UltrabeamStatusInfo(source);
|
||||||
@@ -2331,11 +2631,13 @@ export namespace main {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
|
this.type = source["type"];
|
||||||
this.connected = source["connected"];
|
this.connected = source["connected"];
|
||||||
this.direction = source["direction"];
|
this.direction = source["direction"];
|
||||||
this.frequency = source["frequency"];
|
this.frequency = source["frequency"];
|
||||||
this.band = source["band"];
|
this.band = source["band"];
|
||||||
this.moving = source["moving"];
|
this.moving = source["moving"];
|
||||||
|
this.elements = source["elements"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class UpdateInfo {
|
export class UpdateInfo {
|
||||||
@@ -3393,6 +3695,36 @@ export namespace qso {
|
|||||||
return a;
|
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 {
|
export class Stats {
|
||||||
total: number;
|
total: number;
|
||||||
unique_calls: number;
|
unique_calls: number;
|
||||||
|
|||||||
+308
-30
@@ -16,6 +16,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -66,15 +67,17 @@ type Def struct {
|
|||||||
|
|
||||||
// --- Type & matching ---
|
// --- Type & matching ---
|
||||||
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||||
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||||
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
||||||
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
||||||
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
||||||
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||||
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||||
Multi bool `json:"multi,omitempty"` // a QSO may count for several references
|
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
// NOTE: there is no "one reference per QSO" switch, and there was never any
|
||||||
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
|
||||||
|
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
|
||||||
|
// `Multi` flag was read by nothing; its checkbox changed nothing.
|
||||||
|
|
||||||
// OrRules are ordered FALLBACK searches for the primary one above: they are
|
// OrRules are ordered FALLBACK searches for the primary one above: they are
|
||||||
// tried IN ORDER and only while nothing has matched yet — the first rule that
|
// tried IN ORDER and only while nothing has matched yet — the first rule that
|
||||||
@@ -94,11 +97,45 @@ type Def struct {
|
|||||||
// --- Confirmation ---
|
// --- Confirmation ---
|
||||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||||
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||||
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
|
||||||
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
|
||||||
|
// controls have been removed from the editor — a checkbox that quietly does
|
||||||
|
// nothing is worse than no checkbox, because it is trusted. Wire these up (in
|
||||||
|
// internal/adif) before showing them again.
|
||||||
|
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
||||||
|
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
||||||
|
|
||||||
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
|
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
|
||||||
Builtin bool `json:"builtin"` // shipped default (informational)
|
Builtin bool `json:"builtin"` // shipped default (informational)
|
||||||
|
|
||||||
|
// --- Catalog updates ---
|
||||||
|
// Version is the revision of a SHIPPED award. Bump it in the catalog JSON when
|
||||||
|
// you fix a definition (a better OR chain, a corrected reference list) and want
|
||||||
|
// that fix to reach operators who already run the award: on startup, a catalog
|
||||||
|
// award whose Version is higher than the stored one REPLACES it, definition and
|
||||||
|
// references. Awards created by the operator have no version and are never touched.
|
||||||
|
Version int `json:"version,omitempty"`
|
||||||
|
// UserEdited marks an award the operator has changed. A catalog update then
|
||||||
|
// SKIPS it: their work outranks ours. Set the moment the award (or its reference
|
||||||
|
// list) is saved to something other than what the catalog ships.
|
||||||
|
UserEdited bool `json:"user_edited,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameContent reports whether two definitions describe the same award — ignoring
|
||||||
|
// the bookkeeping fields (version, the user-edited flag, the derived builtin bit),
|
||||||
|
// which say where a definition came from, not what it does. Used to decide whether
|
||||||
|
// a save actually changed anything.
|
||||||
|
func (d Def) SameContent(o Def) bool {
|
||||||
|
a, b := d, o
|
||||||
|
a.Version, b.Version = 0, 0
|
||||||
|
a.UserEdited, b.UserEdited = false, false
|
||||||
|
a.Builtin, b.Builtin = false, false
|
||||||
|
ja, err1 := json.Marshal(a)
|
||||||
|
jb, err2 := json.Marshal(b)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return string(ja) == string(jb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrRule is one additional search OR'd with the award's primary matching rule.
|
// OrRule is one additional search OR'd with the award's primary matching rule.
|
||||||
@@ -200,6 +237,14 @@ func Catalog() []CatalogEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An award OpsLog SHIPS is built-in, by definition. Derive it here instead of
|
||||||
|
// trusting the flag in the file: you drop in an award you exported, its JSON
|
||||||
|
// says builtin:false (you wrote it, it wasn't built-in then), and it would
|
||||||
|
// quietly miss every future catalog correction. Making the author remember to
|
||||||
|
// flip a flag is exactly the kind of step nobody can guess.
|
||||||
|
for i := range out {
|
||||||
|
out[i].Def.Builtin = true
|
||||||
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
|
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -273,7 +318,7 @@ func Migrate(defs []Def) ([]Def, bool) {
|
|||||||
func Fields() []string {
|
func Fields() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
||||||
"state", "cont", "country", "grid", "grid4",
|
"state", "us_county", "cont", "country", "grid", "grid4",
|
||||||
"iota", "sota_ref", "pota_ref", "wwff",
|
"iota", "sota_ref", "pota_ref", "wwff",
|
||||||
"name", "qth", "address", "comment", "note",
|
"name", "qth", "address", "comment", "note",
|
||||||
}
|
}
|
||||||
@@ -598,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).
|
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
|
||||||
func EmissionOf(mode string) string { return emissionOf(mode) }
|
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
|
// labelRef fills a worked reference's name/group from the reference list (or the
|
||||||
// name resolver as a fallback).
|
// name resolver as a fallback).
|
||||||
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
||||||
@@ -678,25 +780,149 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Explain: why did (or didn't) this QSO count for this award? ──────────────
|
||||||
|
//
|
||||||
|
// An award that silently matches nothing is the hardest kind of bug to see: the
|
||||||
|
// UI shows an empty column and the operator has no way to tell whether the QSO is
|
||||||
|
// out of scope, the field is empty, the rule looked in the wrong place, or the
|
||||||
|
// reference simply isn't on the list. Explain replays the matcher on ONE QSO and
|
||||||
|
// reports every step it took.
|
||||||
|
|
||||||
|
// Rejected is a candidate a rule produced that did not survive.
|
||||||
|
type Rejected struct {
|
||||||
|
Candidate string `json:"candidate"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step is one matching rule as it actually ran.
|
||||||
|
type Step struct {
|
||||||
|
Rule string `json:"rule"` // "primary", "OR 1", …
|
||||||
|
Field string `json:"field"` // the QSO field it scanned
|
||||||
|
MatchBy string `json:"match_by,omitempty"` // code | description | pattern
|
||||||
|
Exact bool `json:"exact,omitempty"` // whole field IS the reference
|
||||||
|
Pattern string `json:"pattern,omitempty"` // the rule's regex, if any
|
||||||
|
FieldValue string `json:"field_value,omitempty"` // what the field actually held
|
||||||
|
Candidates []string `json:"candidates,omitempty"` // what the rule produced, before validation
|
||||||
|
Kept []string `json:"kept,omitempty"` // what survived the reference list
|
||||||
|
Rejected []Rejected `json:"rejected,omitempty"` // and what didn't, with the reason
|
||||||
|
Skipped bool `json:"skipped,omitempty"` // never ran: an earlier rule already matched
|
||||||
|
Error string `json:"error,omitempty"` // e.g. a bad regex, which SKIPS the rule
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explanation is the full account of one QSO against one award.
|
||||||
|
type Explanation struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
InScope bool `json:"in_scope"`
|
||||||
|
ScopeError string `json:"scope_error,omitempty"` // why the QSO is out of scope
|
||||||
|
Predefined bool `json:"predefined"` // matches are validated against a reference list
|
||||||
|
RefCount int `json:"ref_count"` // size of that list
|
||||||
|
Steps []Step `json:"steps"`
|
||||||
|
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
|
||||||
|
Result []string `json:"result"` // what the QSO finally counts for
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explain runs the matcher on a single QSO and reports what it did. It goes
|
||||||
|
// through the SAME code path as Compute — not a re-implementation — so what it
|
||||||
|
// shows is what actually happens.
|
||||||
|
func Explain(d Def, metas []RefMeta, q *qso.QSO) Explanation {
|
||||||
|
ex := Explanation{Code: d.Code, Steps: []Step{}, Result: []string{}}
|
||||||
|
rl := NewRefList(metas)
|
||||||
|
ex.Predefined = len(metas) > 0 && !d.Dynamic
|
||||||
|
ex.RefCount = len(metas)
|
||||||
|
|
||||||
|
var why string
|
||||||
|
if !inScopeWhy(&d, q, &why) {
|
||||||
|
ex.ScopeError = why
|
||||||
|
return ex // out of scope: no rule ever runs, and saying so IS the answer
|
||||||
|
}
|
||||||
|
ex.InScope = true
|
||||||
|
|
||||||
|
var re *regexp.Regexp
|
||||||
|
if p := strings.TrimSpace(d.Pattern); p != "" {
|
||||||
|
c, err := compileAwardRE(p)
|
||||||
|
if err != nil {
|
||||||
|
ex.Steps = append(ex.Steps, Step{Rule: "primary", Field: d.Field, Pattern: d.Pattern,
|
||||||
|
Error: "bad regex: " + err.Error()})
|
||||||
|
return ex
|
||||||
|
}
|
||||||
|
re = c
|
||||||
|
}
|
||||||
|
candidatesTrace(&d, re, q, rl, len(metas) > 0, &ex)
|
||||||
|
return ex
|
||||||
|
}
|
||||||
|
|
||||||
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
||||||
|
return candidatesTrace(d, re, q, rl, hasList, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidatesTrace is the matcher. ex is optional: when non-nil (only Explain
|
||||||
|
// passes it) each rule records what it scanned, what it produced and what was
|
||||||
|
// rejected. The tracing MUST live inside the real matcher rather than in a
|
||||||
|
// parallel "explain" implementation — a trace that can drift from the code it
|
||||||
|
// describes is worse than no trace, because it is believed.
|
||||||
|
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
|
||||||
predefined := hasList && !d.Dynamic
|
predefined := hasList && !d.Dynamic
|
||||||
|
|
||||||
|
// run executes one rule and, when tracing, records it.
|
||||||
|
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
|
||||||
|
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
|
||||||
|
kept := keepRefs(predefined, rl, raw)
|
||||||
|
if ex != nil {
|
||||||
|
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
|
||||||
|
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
|
||||||
|
Candidates: raw, Kept: kept}
|
||||||
|
keptSet := map[string]struct{}{}
|
||||||
|
for _, k := range kept {
|
||||||
|
keptSet[k] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, c := range raw {
|
||||||
|
n := normalizeRef(c)
|
||||||
|
if _, ok := keptSet[n]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Rejected = append(s.Rejected, rejection(predefined, rl, n))
|
||||||
|
}
|
||||||
|
ex.Steps = append(ex.Steps, s)
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
// Primary search first; the OR rules are ordered FALLBACKS — try the next
|
// Primary search first; the OR rules are ordered FALLBACKS — try the next
|
||||||
// only while nothing has matched yet, and stop at the first that yields a
|
// only while nothing has matched yet, and stop at the first that yields a
|
||||||
// reference (short-circuit). So a province already found by NAME isn't also
|
// reference (short-circuit). So a province already found by NAME isn't also
|
||||||
// re-derived, possibly differently, from a later city-regex rule.
|
// re-derived, possibly differently, from a later city-regex rule.
|
||||||
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
|
//
|
||||||
for i := 0; len(found) == 0 && i < len(d.OrRules); i++ {
|
// The short-circuit tests what a rule REALLY yielded — the references that
|
||||||
|
// survive the predefined list — not its raw candidates. A rule can always
|
||||||
|
// produce a raw candidate and still find nothing: "the whole ADDRESS field is
|
||||||
|
// the code" hands back "SERIATE (BG) 24068 ITALY", which is not a province.
|
||||||
|
// Testing the raw candidate would call that a hit, skip every fallback, and
|
||||||
|
// only then drop it as unlisted — leaving the QSO unmatched even though the
|
||||||
|
// next rule ("find the code inside the QTH") would have found BG.
|
||||||
|
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "")
|
||||||
|
for i := range d.OrRules {
|
||||||
r := &d.OrRules[i]
|
r := &d.OrRules[i]
|
||||||
|
label := fmt.Sprintf("OR %d", i+1)
|
||||||
|
if len(found) > 0 {
|
||||||
|
if ex != nil {
|
||||||
|
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Exact: r.ExactMatch,
|
||||||
|
Pattern: r.Pattern, Skipped: true})
|
||||||
|
}
|
||||||
|
continue // an earlier rule already matched — fallbacks short-circuit
|
||||||
|
}
|
||||||
var rre *regexp.Regexp
|
var rre *regexp.Regexp
|
||||||
if p := strings.TrimSpace(r.Pattern); p != "" {
|
if p := strings.TrimSpace(r.Pattern); p != "" {
|
||||||
c, err := compileAwardRE(p)
|
c, err := compileAwardRE(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ex != nil {
|
||||||
|
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Pattern: r.Pattern,
|
||||||
|
Error: "bad regex: " + err.Error()})
|
||||||
|
}
|
||||||
continue // skip a rule with a bad regex rather than failing the award
|
continue // skip a rule with a bad regex rather than failing the award
|
||||||
}
|
}
|
||||||
rre = c
|
rre = c
|
||||||
}
|
}
|
||||||
found = searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)
|
found = run(label, r.Field, r.MatchBy, r.Pattern, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge operator-assigned references (manual override, ManualRefsKey). Lets
|
// Merge operator-assigned references (manual override, ManualRefsKey). Lets
|
||||||
@@ -706,18 +932,55 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
|
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
|
||||||
// awards panel and the per-QSO refs editor — honours overrides too. For a
|
// awards panel and the per-QSO refs editor — honours overrides too. For a
|
||||||
// predefined award the ref is still validated against the list below.
|
// predefined award the ref is still validated against the list below.
|
||||||
for _, c := range manualRefs(q, d.Code) {
|
manual := keepRefs(predefined, rl, manualRefs(q, d.Code))
|
||||||
found = append(found, normalizeRef(c))
|
if ex != nil {
|
||||||
|
ex.Manual = manual
|
||||||
}
|
}
|
||||||
|
found = append(found, manual...)
|
||||||
|
out := dedupe(found)
|
||||||
|
if ex != nil {
|
||||||
|
ex.Result = out
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejection explains why a candidate the operator can SEE in the trace did not
|
||||||
|
// become a reference. "Nothing matched" is the least useful thing a matcher can
|
||||||
|
// say; every one of this week's award bugs was a rejection with a plain reason
|
||||||
|
// that nothing was printing.
|
||||||
|
func rejection(predefined bool, rl refList, code string) Rejected {
|
||||||
|
switch {
|
||||||
|
case code == "":
|
||||||
|
return Rejected{Candidate: code, Reason: "empty"}
|
||||||
|
case !predefined:
|
||||||
|
return Rejected{Candidate: code, Reason: "duplicate"}
|
||||||
|
}
|
||||||
|
m, ok := rl.byCode[code]
|
||||||
|
if !ok {
|
||||||
|
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
|
||||||
|
}
|
||||||
|
if !m.Valid {
|
||||||
|
return Rejected{Candidate: code, Reason: "listed but disabled"}
|
||||||
|
}
|
||||||
|
return Rejected{Candidate: code, Reason: "duplicate"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepRefs reduces a rule's raw candidates to the references that actually count.
|
||||||
|
// For a predefined award that means the codes on the list, enabled. The
|
||||||
|
// award-level DXCCFilter already scopes which QSOs are considered (see inScope),
|
||||||
|
// so we do NOT additionally require the QSO's entity to match the reference's own
|
||||||
|
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
|
||||||
|
// 291). Per-reference DXCC stays metadata for the picker.
|
||||||
|
func keepRefs(predefined bool, rl refList, found []string) []string {
|
||||||
if !predefined {
|
if !predefined {
|
||||||
return dedupe(found)
|
out := make([]string, 0, len(found))
|
||||||
|
for _, c := range found {
|
||||||
|
if c = normalizeRef(c); c != "" {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedupe(out)
|
||||||
}
|
}
|
||||||
// Enforce the predefined list: keep only listed, valid references. The
|
|
||||||
// award-level DXCCFilter already scopes which QSOs are considered (see
|
|
||||||
// inScope), so we do NOT additionally require the QSO's entity to match the
|
|
||||||
// reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is
|
|
||||||
// DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker.
|
|
||||||
var out []string
|
var out []string
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, c := range found {
|
for _, c := range found {
|
||||||
@@ -858,24 +1121,37 @@ func natLess(a, b string) bool {
|
|||||||
|
|
||||||
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
||||||
// bands, modes, emission category, validity dates).
|
// bands, modes, emission category, validity dates).
|
||||||
func inScope(d *Def, q *qso.QSO) bool {
|
func inScope(d *Def, q *qso.QSO) bool { return inScopeWhy(d, q, nil) }
|
||||||
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
|
||||||
|
// inScopeWhy is inScope with an optional explanation. why is filled ONLY when the
|
||||||
|
// QSO is out of scope AND a caller asked for the reason (Explain does; Compute,
|
||||||
|
// which runs this for every QSO × every award, passes nil and pays nothing).
|
||||||
|
// Keeping both behind one function is the point: a scope check that disagrees with
|
||||||
|
// the scope check it explains would be worse than no explanation at all.
|
||||||
|
func inScopeWhy(d *Def, q *qso.QSO, why *string) bool {
|
||||||
|
fail := func(format string, args ...any) bool {
|
||||||
|
if why != nil {
|
||||||
|
*why = fmt.Sprintf(format, args...)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
||||||
|
return fail("DXCC %d is not in the award's filter %v", q.DXCC, d.DXCCFilter)
|
||||||
|
}
|
||||||
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
|
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
|
||||||
return false
|
return fail("band %q is not among the valid bands %v", q.Band, d.ValidBands)
|
||||||
}
|
}
|
||||||
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
|
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
|
||||||
return false
|
return fail("mode %q is not among the valid modes %v", q.Mode, d.ValidModes)
|
||||||
}
|
}
|
||||||
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
|
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
|
||||||
return false
|
return fail("mode %q is %s emission; the award accepts %v", q.Mode, emissionOf(q.Mode), d.Emission)
|
||||||
}
|
}
|
||||||
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
|
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
|
||||||
return false
|
return fail("QSO of %s predates the award's start date (%s)", q.QSODate.Format("2006-01-02"), d.ValidFrom)
|
||||||
}
|
}
|
||||||
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
|
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
|
||||||
return false
|
return fail("QSO of %s is after the award's end date (%s)", q.QSODate.Format("2006-01-02"), d.ValidTo)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -926,6 +1202,8 @@ func fieldRaw(field string, q *qso.QSO) string {
|
|||||||
return q.Callsign
|
return q.Callsign
|
||||||
case "state":
|
case "state":
|
||||||
return q.State
|
return q.State
|
||||||
|
case "us_county":
|
||||||
|
return USCountyKey(q.State, q.County)
|
||||||
case "cont":
|
case "cont":
|
||||||
return q.Continent
|
return q.Continent
|
||||||
case "country":
|
case "country":
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package award
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
)
|
)
|
||||||
@@ -287,6 +289,160 @@ func TestComputeGrid4VUCC(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FFMA ships a fixed list of the 488 grids of the contiguous 48 states, taken
|
||||||
|
// from arrl.org/ffma. The count is the award's own checksum — if this test ever
|
||||||
|
// fails on the count, the catalog file is wrong, not the test.
|
||||||
|
func TestCatalogFFMA(t *testing.T) {
|
||||||
|
raw, ok := CatalogRefs("FFMA")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("FFMA has no reference list in the embedded catalog")
|
||||||
|
}
|
||||||
|
var refs []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
DXCC int `json:"dxcc"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &refs); err != nil {
|
||||||
|
t.Fatalf("FFMA references: %v", err)
|
||||||
|
}
|
||||||
|
if len(refs) != 488 {
|
||||||
|
t.Fatalf("FFMA has %d grids, want exactly 488", len(refs))
|
||||||
|
}
|
||||||
|
|
||||||
|
var def Def
|
||||||
|
metas := make([]RefMeta, 0, len(refs))
|
||||||
|
for _, r := range refs {
|
||||||
|
// Rule 4(c): a grid may be activated from Canadian or Mexican soil, or from
|
||||||
|
// water. Pinning a reference to a DXCC entity would reject those contacts.
|
||||||
|
if r.DXCC != 0 {
|
||||||
|
t.Fatalf("FFMA grid %s is pinned to DXCC %d — rule 4(c) allows working it from outside the US", r.Code, r.DXCC)
|
||||||
|
}
|
||||||
|
metas = append(metas, RefMeta{Code: r.Code, Valid: r.Valid})
|
||||||
|
}
|
||||||
|
for _, d := range Defaults() {
|
||||||
|
if d.Code == "FFMA" {
|
||||||
|
def = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def.Total != 488 || def.Field != "grid4" {
|
||||||
|
t.Fatalf("FFMA def: total=%d field=%q, want 488 / grid4", def.Total, def.Field)
|
||||||
|
}
|
||||||
|
|
||||||
|
d1983 := time.Date(1990, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "K5ABC", Band: "6m", Grid: "EM00AA", QSODate: d1983, LOTWRcvd: "Y"}, // counts
|
||||||
|
{Callsign: "VE3XYZ", Band: "6m", Grid: "FN25AA", QSODate: d1983, LOTWRcvd: "Y"}, // a VE3 in an FFMA grid: counts (4c)
|
||||||
|
{Callsign: "W1LINE", Band: "6m", VUCCGrids: "FN31,FN32", QSODate: d1983, QSLRcvd: "Y"}, // grid line: 2 grids (4d)
|
||||||
|
{Callsign: "G4XXX", Band: "6m", Grid: "IO91AA", QSODate: d1983, LOTWRcvd: "Y"}, // not an FFMA grid
|
||||||
|
{Callsign: "K5ABC", Band: "2m", Grid: "EM10AA", QSODate: d1983, LOTWRcvd: "Y"}, // wrong band
|
||||||
|
{Callsign: "K5OLD", Band: "6m", Grid: "EM20AA", QSODate: time.Date(1982, 6, 1, 0, 0, 0, 0, time.UTC), LOTWRcvd: "Y"}, // before 1983 (rule 2)
|
||||||
|
}
|
||||||
|
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"FFMA": metas}, nil)[0]
|
||||||
|
var got []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
got = append(got, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(got)
|
||||||
|
want := []string{"EM00", "FN25", "FN31", "FN32"}
|
||||||
|
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||||
|
t.Fatalf("FFMA worked = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if r.Worked != len(want) || r.Total != 488 {
|
||||||
|
t.Errorf("FFMA worked=%d total=%d, want %d / 488", r.Worked, r.Total, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAIP: the primary rule takes the WHOLE address as the province code (exact
|
||||||
|
// match), which can never be one — but it does produce a raw candidate. The OR
|
||||||
|
// fallback (find the code inside the QTH, "SERIATE (BG)") is the rule that works,
|
||||||
|
// and it must still run: a rule that yields nothing VALID is not a hit.
|
||||||
|
func TestComputeOrFallbackAfterUnlistedPrimary(t *testing.T) {
|
||||||
|
def := Def{
|
||||||
|
Code: "WAIP", Name: "Worked All Italian Provinces", Valid: true,
|
||||||
|
Type: TypeReference, Field: "address", MatchBy: "code", ExactMatch: true,
|
||||||
|
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}}, // not exact → search inside the field
|
||||||
|
Confirm: []string{"lotw", "qsl"},
|
||||||
|
}
|
||||||
|
metas := []RefMeta{
|
||||||
|
{Code: "BG", Name: "Bergamo", Valid: true},
|
||||||
|
{Code: "MI", Name: "Milano", Valid: true},
|
||||||
|
}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy", LOTWRcvd: "Y"},
|
||||||
|
}
|
||||||
|
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"WAIP": metas}, nil)[0]
|
||||||
|
if r.Worked != 1 {
|
||||||
|
t.Fatalf("WAIP worked = %d, want 1 (BG, from the QTH fallback)", r.Worked)
|
||||||
|
}
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Ref == "BG" && rf.Worked {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("BG not worked; refs = %v", refCodes(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explain must account for the WAIP case exactly: the primary rule produces a
|
||||||
|
// candidate that is NOT a province, says so, and the QTH fallback then finds BG.
|
||||||
|
func TestExplainAccountsForEveryRule(t *testing.T) {
|
||||||
|
def := Def{
|
||||||
|
Code: "WAIP", Valid: true, Type: TypeReference,
|
||||||
|
Field: "address", MatchBy: "code", ExactMatch: true,
|
||||||
|
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}},
|
||||||
|
Confirm: []string{"lotw"},
|
||||||
|
}
|
||||||
|
metas := []RefMeta{{Code: "BG", Name: "Bergamo", Valid: true}}
|
||||||
|
q := &qso.QSO{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy"}
|
||||||
|
|
||||||
|
ex := Explain(def, metas, q)
|
||||||
|
if !ex.InScope || len(ex.Steps) != 2 {
|
||||||
|
t.Fatalf("in scope=%v, %d steps, want in scope with 2 (primary + OR 1): %+v", ex.InScope, len(ex.Steps), ex)
|
||||||
|
}
|
||||||
|
// The primary rule LOOKS like it found something. Saying only "no match" here is
|
||||||
|
// what cost hours: the trace has to show the candidate and why it lost.
|
||||||
|
p := ex.Steps[0]
|
||||||
|
if len(p.Candidates) == 0 {
|
||||||
|
t.Error("primary produced no candidate; the whole point is that it produces a bogus one")
|
||||||
|
}
|
||||||
|
if len(p.Kept) != 0 || len(p.Rejected) == 0 {
|
||||||
|
t.Fatalf("primary kept=%v rejected=%v, want nothing kept and a stated reason", p.Kept, p.Rejected)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Rejected[0].Reason, "not in the award's reference list") {
|
||||||
|
t.Errorf("reason = %q, want it to name the real cause", p.Rejected[0].Reason)
|
||||||
|
}
|
||||||
|
if or1 := ex.Steps[1]; or1.Skipped || len(or1.Kept) != 1 || or1.Kept[0] != "BG" {
|
||||||
|
t.Errorf("OR 1 = %+v, want it to run and find BG", or1)
|
||||||
|
}
|
||||||
|
if len(ex.Result) != 1 || ex.Result[0] != "BG" {
|
||||||
|
t.Errorf("result = %v, want [BG]", ex.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A trace that disagrees with the matcher is worse than none: it is believed.
|
||||||
|
if got := MatchQSO(def, metas, q); strings.Join(got, ",") != strings.Join(ex.Result, ",") {
|
||||||
|
t.Errorf("Explain says %v but MatchQSO says %v — the trace does not describe the code", ex.Result, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplainOutOfScopeSaysWhy(t *testing.T) {
|
||||||
|
def := Def{Code: "FFMA", Valid: true, Field: "grid4", MatchBy: "code", ExactMatch: true,
|
||||||
|
ValidBands: []string{"6m"}, ValidFrom: "1983-01-01", Confirm: []string{"lotw"}}
|
||||||
|
q := &qso.QSO{Callsign: "K1ABC", Band: "2m", Grid: "EM00AA",
|
||||||
|
QSODate: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||||
|
|
||||||
|
ex := Explain(def, []RefMeta{{Code: "EM00", Valid: true}}, q)
|
||||||
|
if ex.InScope {
|
||||||
|
t.Fatal("a 2 m QSO is not in scope for a 6 m-only award")
|
||||||
|
}
|
||||||
|
if !strings.Contains(ex.ScopeError, "2m") {
|
||||||
|
t.Errorf("scope error = %q, want it to name the band that fails", ex.ScopeError)
|
||||||
|
}
|
||||||
|
if len(ex.Steps) != 0 {
|
||||||
|
t.Errorf("%d steps ran on an out-of-scope QSO; none should", len(ex.Steps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func refCodes(r Result) []string {
|
func refCodes(r Result) []string {
|
||||||
out := make([]string, 0, len(r.Refs))
|
out := make([]string, 0, len(r.Refs))
|
||||||
for _, rf := range r.Refs {
|
for _, rf := range r.Refs {
|
||||||
@@ -508,3 +664,23 @@ func TestCatalogSurvivesOneBadFile(t *testing.T) {
|
|||||||
// Catalog() skips unparseable files rather than returning nil, so the others
|
// Catalog() skips unparseable files rather than returning nil, so the others
|
||||||
// still load. (Verified structurally: the loader `continue`s on error.)
|
// still load. (Verified structurally: the loader `continue`s on error.)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anything OpsLog SHIPS is built-in, whatever the file says.
|
||||||
|
//
|
||||||
|
// You create an award, export it (its JSON records builtin:false — it wasn't
|
||||||
|
// built-in when you wrote it), then drop that same file into the catalog to ship
|
||||||
|
// it. If we trusted the flag, the award would go out to everyone marked "not
|
||||||
|
// built-in" and would then silently miss every future catalog correction. Making
|
||||||
|
// the author remember to flip a flag first is a step nobody can guess — so the
|
||||||
|
// catalog derives it instead.
|
||||||
|
func TestCatalogForcesBuiltin(t *testing.T) {
|
||||||
|
for _, e := range Catalog() {
|
||||||
|
if !e.Def.Builtin {
|
||||||
|
t.Errorf("%s: shipped in the catalog but Builtin=false — it would miss catalog corrections", e.Def.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The realistic case: a user-authored award whose JSON says builtin:false.
|
||||||
|
if len(Catalog()) == 0 {
|
||||||
|
t.Fatal("empty catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A synthetic log with the shape a real one has: a spread of bands, modes,
|
||||||
|
// entities, and the free-text fields the harder awards actually scan.
|
||||||
|
func benchLog(n int) []qso.QSO {
|
||||||
|
bands := []string{"160m", "80m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"}
|
||||||
|
modes := []string{"SSB", "CW", "FT8", "FT4", "RTTY"}
|
||||||
|
entities := []int{291, 248, 227, 230, 339, 5, 108, 110, 6, 281}
|
||||||
|
towns := []string{"SERIATE (BG)", "MILANO (MI)", "ROMA (RM)", "TORINO (TO)", "NAPOLI (NA)"}
|
||||||
|
out := make([]qso.QSO, n)
|
||||||
|
base := time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
for i := range out {
|
||||||
|
e := entities[i%len(entities)]
|
||||||
|
out[i] = qso.QSO{
|
||||||
|
ID: int64(i + 1),
|
||||||
|
Callsign: fmt.Sprintf("I%dABC", i%10),
|
||||||
|
Band: bands[i%len(bands)],
|
||||||
|
Mode: modes[i%len(modes)],
|
||||||
|
DXCC: &e,
|
||||||
|
QSODate: base.Add(time.Duration(i) * time.Hour),
|
||||||
|
QTH: towns[i%len(towns)],
|
||||||
|
Address: "Via Roma 12, 24068 " + towns[i%len(towns)] + " Italy",
|
||||||
|
Grid: "JN45AA",
|
||||||
|
State: "CA",
|
||||||
|
LOTWRcvd: "Y",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAIP is the worst realistic shape: a predefined list, an exact-match primary
|
||||||
|
// that always produces a doomed candidate, then a fallback chain that tokenises a
|
||||||
|
// free-text field. If anything is slow, it is this.
|
||||||
|
func benchWAIP() (Def, []RefMeta) {
|
||||||
|
d := Def{
|
||||||
|
Code: "WAIP", Valid: true, Type: TypeReference,
|
||||||
|
Field: "address", MatchBy: "code", ExactMatch: true,
|
||||||
|
OrRules: []OrRule{
|
||||||
|
{Field: "qth", MatchBy: "description"},
|
||||||
|
{Field: "qth", MatchBy: "code"},
|
||||||
|
{Field: "address", MatchBy: "code"},
|
||||||
|
},
|
||||||
|
DXCCFilter: []int{248},
|
||||||
|
Confirm: []string{"lotw", "qsl"},
|
||||||
|
}
|
||||||
|
codes := []string{"AG", "AL", "AN", "AO", "AP", "AQ", "AR", "AT", "AV", "BA", "BG", "BI", "BL", "BN", "BO", "BR", "BS", "BT", "BZ", "CA", "CB", "CE", "CH", "CL", "CN", "CO", "CR", "CS", "CT", "CZ", "EN", "FC", "FE", "FG", "FI", "FM", "FR", "GE", "GO", "GR", "IM", "IS", "KR", "LC", "LE", "LI", "LO", "LT", "LU", "MB", "MC", "ME", "MI", "MN", "MO", "MS", "MT", "NA", "NO", "NU", "OR", "PA", "PC", "PD", "PE", "PG", "PI", "PN", "PO", "PR", "PT", "PU", "PV", "PZ", "RA", "RC", "RE", "RG", "RI", "RM", "RN", "RO", "SA", "SI", "SO", "SP", "SR", "SS", "SU", "SV", "TA", "TE", "TN", "TO", "TP", "TR", "TS", "TV", "UD", "VA", "VB", "VC", "VE", "VI", "VR", "VT", "VV"}
|
||||||
|
metas := make([]RefMeta, 0, len(codes))
|
||||||
|
for _, c := range codes {
|
||||||
|
metas = append(metas, RefMeta{Code: c, Name: "Province " + c, Valid: true})
|
||||||
|
}
|
||||||
|
return d, metas
|
||||||
|
}
|
||||||
|
|
||||||
|
// One award, whole log — what GetAward(code) does when the Awards panel opens.
|
||||||
|
func BenchmarkComputeOneAward(b *testing.B) {
|
||||||
|
d, metas := benchWAIP()
|
||||||
|
for _, n := range []int{10_000, 50_000, 100_000} {
|
||||||
|
log := benchLog(n)
|
||||||
|
b.Run(fmt.Sprintf("WAIP/%dqso", n), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
Compute([]Def{d}, log, map[string][]RefMeta{"WAIP": metas}, nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every shipped award at once — GetAwards(), the heaviest thing the app can ask.
|
||||||
|
func BenchmarkComputeWholeCatalog(b *testing.B) {
|
||||||
|
defs := Defaults()
|
||||||
|
metas := map[string][]RefMeta{}
|
||||||
|
wd, wm := benchWAIP()
|
||||||
|
defs = append(defs, wd)
|
||||||
|
metas["WAIP"] = wm
|
||||||
|
for _, c := range Catalog() {
|
||||||
|
if raw, ok := CatalogRefs(c.Def.Code); ok {
|
||||||
|
var refs []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &refs) == nil {
|
||||||
|
m := make([]RefMeta, 0, len(refs))
|
||||||
|
for _, r := range refs {
|
||||||
|
m = append(m, RefMeta{Code: r.Code, Name: r.Name, Valid: r.Valid})
|
||||||
|
}
|
||||||
|
metas[c.Def.Code] = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range []int{10_000, 100_000} {
|
||||||
|
log := benchLog(n)
|
||||||
|
b.Run(fmt.Sprintf("%dawards/%dqso", len(defs), n), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
Compute(defs, log, metas, nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T17:00:44Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "RAC",
|
||||||
|
"name": "RAC Canadian Provinces",
|
||||||
|
"description": "RAC Canadian Provinces",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"url": "https://www.rac.ca/canadaward/",
|
||||||
|
"valid_from": "1977-01-07",
|
||||||
|
"ref_display": "name",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "state",
|
||||||
|
"match_by": "code",
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AB",
|
||||||
|
"name": "Alberta",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BC",
|
||||||
|
"name": "British Columbia",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MB",
|
||||||
|
"name": "Manitoba",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NB",
|
||||||
|
"name": "New Brunswick",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NL",
|
||||||
|
"name": "Newfoundland and Labrador",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NS",
|
||||||
|
"name": "Nova Scotia",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NT",
|
||||||
|
"name": "Northwest Territories",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NU",
|
||||||
|
"name": "Nunavut",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ON",
|
||||||
|
"name": "Ontario",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PE",
|
||||||
|
"name": "Prince Edward Island",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QC",
|
||||||
|
"name": "Quebec",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SK",
|
||||||
|
"name": "Saskatchewan",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "YT",
|
||||||
|
"name": "Yukon",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "USA-CA",
|
||||||
|
"name": "USA-CA (US Counties)",
|
||||||
|
"description": "CQ United States of America Counties Award. Matches the QSO's county, tolerating LoTW \"ST,County\" and bare county-name shapes. Independent cities of Virginia/Nevada and DC are excluded per the award rules.",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "us_county",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"ref_display": "name",
|
||||||
|
"url": "https://cq-amateur-radio.com/cq_awards/cq_usa_ca_awards/cq_usa_ca_awards.html",
|
||||||
|
"dxcc_filter": [
|
||||||
|
291,
|
||||||
|
110,
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 3077,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T17:00:44Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "VUCC",
|
||||||
|
"name": "VHF/UHF Century Club",
|
||||||
|
"description": "VHF/UHF Century Club",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"url": "https://www.arrl.org/files/file/Awards%20Application%20Forms/VUCCRULE1a.pdf",
|
||||||
|
"valid_from": "1970-01-01",
|
||||||
|
"valid_to": "9999-01-30",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "grid4",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"valid_bands": [
|
||||||
|
"6m",
|
||||||
|
"4m",
|
||||||
|
"2m",
|
||||||
|
"70cm",
|
||||||
|
"23cm",
|
||||||
|
"13cm",
|
||||||
|
"1.25m"
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,929 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T21:57:23Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAIP",
|
||||||
|
"name": "ARI Worked All Italian Provinces",
|
||||||
|
"valid": true,
|
||||||
|
"url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
|
||||||
|
"ref_url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "code"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
248
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": false
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AG",
|
||||||
|
"name": "Agrigento",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AL",
|
||||||
|
"name": "Alessandria",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AN",
|
||||||
|
"name": "Ancona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AO",
|
||||||
|
"name": "Aosta",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Val d'Aosta",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AP",
|
||||||
|
"name": "Ascoli Piceno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AQ",
|
||||||
|
"name": "L'Aquila",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AR",
|
||||||
|
"name": "Arezzo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AT",
|
||||||
|
"name": "Asti",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AV",
|
||||||
|
"name": "Avellino",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BA",
|
||||||
|
"name": "Bari",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BG",
|
||||||
|
"name": "Bergamo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BI",
|
||||||
|
"name": "Biella",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BL",
|
||||||
|
"name": "Belluno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BN",
|
||||||
|
"name": "Benevento",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BO",
|
||||||
|
"name": "Bologna",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BR",
|
||||||
|
"name": "Brindisi",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BS",
|
||||||
|
"name": "Brescia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BT",
|
||||||
|
"name": "Barletta-Andria-Trani",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BZ",
|
||||||
|
"name": "Bolzano/Bozen",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Trentino-AltoAdige/Südtirol",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CA",
|
||||||
|
"name": "Cagliari",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CB",
|
||||||
|
"name": "Campobasso",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Molise",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CE",
|
||||||
|
"name": "Caserta",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CH",
|
||||||
|
"name": "Chieti",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CI",
|
||||||
|
"name": "Carbonia-Iglesias",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CL",
|
||||||
|
"name": "Caltanissetta",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CN",
|
||||||
|
"name": "Cuneo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CO",
|
||||||
|
"name": "Como",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CR",
|
||||||
|
"name": "Cremona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CS",
|
||||||
|
"name": "Cosenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CT",
|
||||||
|
"name": "Catania",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CZ",
|
||||||
|
"name": "Catanzaro",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "EN",
|
||||||
|
"name": "Enna",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FC",
|
||||||
|
"name": "Forlì-Cesena",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FE",
|
||||||
|
"name": "Ferrara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FG",
|
||||||
|
"name": "Foggia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FI",
|
||||||
|
"name": "Firenze",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FM",
|
||||||
|
"name": "Fermo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FR",
|
||||||
|
"name": "Frosinone",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GE",
|
||||||
|
"name": "Genova",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GO",
|
||||||
|
"name": "Gorizia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GR",
|
||||||
|
"name": "Grosseto",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "IM",
|
||||||
|
"name": "Imperia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "IS",
|
||||||
|
"name": "Isernia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Molise",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "KR",
|
||||||
|
"name": "Crotone",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LC",
|
||||||
|
"name": "Lecco",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LE",
|
||||||
|
"name": "Lecce",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LI",
|
||||||
|
"name": "Livorno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LO",
|
||||||
|
"name": "Lodi",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LT",
|
||||||
|
"name": "Latina",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LU",
|
||||||
|
"name": "Lucca",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MB",
|
||||||
|
"name": "Monza e Brianza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MC",
|
||||||
|
"name": "Macerata",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ME",
|
||||||
|
"name": "Messina",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MI",
|
||||||
|
"name": "Milano",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MN",
|
||||||
|
"name": "Mantova",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MO",
|
||||||
|
"name": "Modena",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MS",
|
||||||
|
"name": "Massa-Carrara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MT",
|
||||||
|
"name": "Matera",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Basilicata",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NA",
|
||||||
|
"name": "Napoli",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NO",
|
||||||
|
"name": "Novara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NU",
|
||||||
|
"name": "Nuoro",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OG",
|
||||||
|
"name": "Ogliastra",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OR",
|
||||||
|
"name": "Oristano",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OT",
|
||||||
|
"name": "Olbia-Tempio",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PA",
|
||||||
|
"name": "Palermo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PC",
|
||||||
|
"name": "Piacenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PD",
|
||||||
|
"name": "Padova",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PE",
|
||||||
|
"name": "Pescara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PG",
|
||||||
|
"name": "Perugia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Umbria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PI",
|
||||||
|
"name": "Pisa",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PN",
|
||||||
|
"name": "Pordenone",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PO",
|
||||||
|
"name": "Prato",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PR",
|
||||||
|
"name": "Parma",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PT",
|
||||||
|
"name": "Pistoia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PU",
|
||||||
|
"name": "Pesaro-Urbino",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PV",
|
||||||
|
"name": "Pavia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PZ",
|
||||||
|
"name": "Potenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Basilicata",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RA",
|
||||||
|
"name": "Ravenna",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RC",
|
||||||
|
"name": "Reggio Calabria",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RE",
|
||||||
|
"name": "Reggio Emilia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RG",
|
||||||
|
"name": "Ragusa",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RI",
|
||||||
|
"name": "Rieti",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RM",
|
||||||
|
"name": "Roma",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RN",
|
||||||
|
"name": "Rimini",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RO",
|
||||||
|
"name": "Rovigo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SA",
|
||||||
|
"name": "Salerno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SI",
|
||||||
|
"name": "Siena",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SO",
|
||||||
|
"name": "Sondrio",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SP",
|
||||||
|
"name": "La Spezia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SR",
|
||||||
|
"name": "Siracusa",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SS",
|
||||||
|
"name": "Sassari",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SV",
|
||||||
|
"name": "Savona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TA",
|
||||||
|
"name": "Taranto",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TE",
|
||||||
|
"name": "Teramo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TN",
|
||||||
|
"name": "Trento",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Trentino-AltoAdige/Südtirol",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TO",
|
||||||
|
"name": "Torino",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TP",
|
||||||
|
"name": "Trapani",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TR",
|
||||||
|
"name": "Terni",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Umbria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TS",
|
||||||
|
"name": "Trieste",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TV",
|
||||||
|
"name": "Treviso",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "UD",
|
||||||
|
"name": "Udine",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VA",
|
||||||
|
"name": "Varese",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VB",
|
||||||
|
"name": "Verbano-Cusio-Ossola",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VC",
|
||||||
|
"name": "Vercelli",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VE",
|
||||||
|
"name": "Venezia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VI",
|
||||||
|
"name": "Vicenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VR",
|
||||||
|
"name": "Verona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VS",
|
||||||
|
"name": "Medio Campidano",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VT",
|
||||||
|
"name": "Viterbo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VV",
|
||||||
|
"name": "Vibo Valentia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,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", …)
|
// - WAC → continent code ("EU", "NA", …)
|
||||||
// - WAS → ADIF STATE code ("AL", …)
|
// - WAS → ADIF STATE code ("AL", …)
|
||||||
// - DDFM → "D06" (the award pattern captures the leading D)
|
// - DDFM → "D06" (the award pattern captures the leading D)
|
||||||
|
// - USA-CA → canonical "STATE/COUNTY" key (see award.USCountyKey)
|
||||||
func BuiltinRefs(code string) ([]Ref, bool) {
|
func BuiltinRefs(code string) ([]Ref, bool) {
|
||||||
switch code {
|
switch code {
|
||||||
case "DXCC":
|
case "DXCC":
|
||||||
@@ -29,6 +30,8 @@ func BuiltinRefs(code string) ([]Ref, bool) {
|
|||||||
return usStates().Refs, true
|
return usStates().Refs, true
|
||||||
case "DDFM":
|
case "DDFM":
|
||||||
return frenchDepartments(), true
|
return frenchDepartments(), true
|
||||||
|
case "USA-CA":
|
||||||
|
return usCounties(), true
|
||||||
}
|
}
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -304,6 +304,13 @@ type FlexTXState struct {
|
|||||||
ANFLevel int `json:"anf_level"`
|
ANFLevel int `json:"anf_level"`
|
||||||
WNB bool `json:"wnb"`
|
WNB bool `json:"wnb"`
|
||||||
WNBLevel int `json:"wnb_level"`
|
WNBLevel int `json:"wnb_level"`
|
||||||
|
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
|
||||||
|
// moving the slice. The offset survives the switch being turned off, so
|
||||||
|
// re-enabling restores it, exactly like the radio's own knob.
|
||||||
|
RIT bool `json:"rit"`
|
||||||
|
RITFreq int `json:"rit_freq"`
|
||||||
|
XIT bool `json:"xit"`
|
||||||
|
XITFreq int `json:"xit_freq"`
|
||||||
// CW / mode-specific controls.
|
// CW / mode-specific controls.
|
||||||
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
||||||
CWSpeed int `json:"cw_speed"`
|
CWSpeed int `json:"cw_speed"`
|
||||||
@@ -345,6 +352,7 @@ type FlexController interface {
|
|||||||
SetRFPower(int) error
|
SetRFPower(int) error
|
||||||
SetTunePower(int) error
|
SetTunePower(int) error
|
||||||
SetTune(bool) error
|
SetTune(bool) error
|
||||||
|
SetTXInhibit(bool) error
|
||||||
SetVOX(bool) error
|
SetVOX(bool) error
|
||||||
SetVOXLevel(int) error
|
SetVOXLevel(int) error
|
||||||
SetVOXDelay(int) error
|
SetVOXDelay(int) error
|
||||||
@@ -378,6 +386,10 @@ type FlexController interface {
|
|||||||
SetAPFLevel(int) error
|
SetAPFLevel(int) error
|
||||||
SetWNB(bool) error
|
SetWNB(bool) error
|
||||||
SetWNBLevel(int) error
|
SetWNBLevel(int) error
|
||||||
|
SetRIT(bool) error
|
||||||
|
SetRITFreq(int) error
|
||||||
|
SetXIT(bool) error
|
||||||
|
SetXITFreq(int) error
|
||||||
// CW keyer + mode-specific controls.
|
// CW keyer + mode-specific controls.
|
||||||
SetCWSpeed(int) error
|
SetCWSpeed(int) error
|
||||||
SetCWPitch(int) error
|
SetCWPitch(int) error
|
||||||
|
|||||||
+65
-3
@@ -90,8 +90,12 @@ type flexSlice struct {
|
|||||||
apfLevel int
|
apfLevel int
|
||||||
wnb bool // wideband noise blanker
|
wnb bool // wideband noise blanker
|
||||||
wnbLevel int
|
wnbLevel int
|
||||||
filterLo int // slice filter low cut (Hz)
|
rit bool // receive incremental tuning enabled
|
||||||
filterHi int // slice filter high cut (Hz)
|
ritFreq int // RIT offset in Hz (negative = down)
|
||||||
|
xit bool // transmit incremental tuning enabled
|
||||||
|
xitFreq int // XIT offset in Hz
|
||||||
|
filterLo int // slice filter low cut (Hz)
|
||||||
|
filterHi int // slice filter high cut (Hz)
|
||||||
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
|
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
|
||||||
txAnt string // selected TX antenna
|
txAnt string // selected TX antenna
|
||||||
antList []string // antennas valid for this slice (RX side)
|
antList []string // antennas valid for this slice (RX side)
|
||||||
@@ -105,6 +109,7 @@ type flexTX struct {
|
|||||||
tunePower int
|
tunePower int
|
||||||
tune bool
|
tune bool
|
||||||
transmitting bool // interlock state == TRANSMITTING
|
transmitting bool // interlock state == TRANSMITTING
|
||||||
|
inhibit bool // transmit inhibited (e.g. while a motorized antenna moves)
|
||||||
voxEnable bool
|
voxEnable bool
|
||||||
voxLevel int
|
voxLevel int
|
||||||
voxDelay int
|
voxDelay int
|
||||||
@@ -707,7 +712,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
for _, id := range newIDs {
|
for _, id := range newIDs {
|
||||||
mi := f.meterMeta[id]
|
mi := f.meterMeta[id]
|
||||||
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s → sub", id, mi.src, mi.name, mi.unit)
|
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s lo=%g hi=%g → sub", id, mi.src, mi.name, mi.unit, mi.lo, mi.hi)
|
||||||
f.subscribeMeter(id)
|
f.subscribeMeter(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -804,6 +809,14 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.wnb = val == "1"
|
s.wnb = val == "1"
|
||||||
case "wnb_level":
|
case "wnb_level":
|
||||||
s.wnbLevel = atoiDefault(val, s.wnbLevel)
|
s.wnbLevel = atoiDefault(val, s.wnbLevel)
|
||||||
|
case "rit_on":
|
||||||
|
s.rit = val == "1"
|
||||||
|
case "rit_freq":
|
||||||
|
s.ritFreq = atoiDefault(val, s.ritFreq)
|
||||||
|
case "xit_on":
|
||||||
|
s.xit = val == "1"
|
||||||
|
case "xit_freq":
|
||||||
|
s.xitFreq = atoiDefault(val, s.xitFreq)
|
||||||
case "filter_lo":
|
case "filter_lo":
|
||||||
s.filterLo = atoiDefault(val, s.filterLo)
|
s.filterLo = atoiDefault(val, s.filterLo)
|
||||||
case "filter_hi":
|
case "filter_hi":
|
||||||
@@ -1315,6 +1328,10 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
st.APFLevel = rx.apfLevel
|
st.APFLevel = rx.apfLevel
|
||||||
st.WNB = rx.wnb
|
st.WNB = rx.wnb
|
||||||
st.WNBLevel = rx.wnbLevel
|
st.WNBLevel = rx.wnbLevel
|
||||||
|
st.RIT = rx.rit
|
||||||
|
st.RITFreq = rx.ritFreq
|
||||||
|
st.XIT = rx.xit
|
||||||
|
st.XITFreq = rx.xitFreq
|
||||||
st.FilterLo = rx.filterLo
|
st.FilterLo = rx.filterLo
|
||||||
st.FilterHi = rx.filterHi
|
st.FilterHi = rx.filterHi
|
||||||
st.RXAnt = rx.rxAnt
|
st.RXAnt = rx.rxAnt
|
||||||
@@ -1383,6 +1400,14 @@ func (f *Flex) sendSlice(param string, val any) error {
|
|||||||
rx.rxAnt = fmt.Sprint(val)
|
rx.rxAnt = fmt.Sprint(val)
|
||||||
case "txant":
|
case "txant":
|
||||||
rx.txAnt = fmt.Sprint(val)
|
rx.txAnt = fmt.Sprint(val)
|
||||||
|
case "rit_on":
|
||||||
|
rx.rit = val == "1"
|
||||||
|
case "rit_freq":
|
||||||
|
rx.ritFreq = toInt(val)
|
||||||
|
case "xit_on":
|
||||||
|
rx.xit = val == "1"
|
||||||
|
case "xit_freq":
|
||||||
|
rx.xitFreq = toInt(val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -1490,6 +1515,26 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on))
|
|||||||
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
||||||
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
||||||
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
||||||
|
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
|
||||||
|
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
|
||||||
|
// switch is off, so turning RIT back on restores the last offset — same as the
|
||||||
|
// radio's own knob.
|
||||||
|
func (f *Flex) SetRIT(on bool) error { return f.sendSlice("rit_on", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetRITFreq(hz int) error { return f.sendSlice("rit_freq", clampOffset(hz)) }
|
||||||
|
func (f *Flex) SetXIT(on bool) error { return f.sendSlice("xit_on", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetXITFreq(hz int) error { return f.sendSlice("xit_freq", clampOffset(hz)) }
|
||||||
|
|
||||||
|
// clampOffset keeps a RIT/XIT offset inside what SmartSDR accepts (±99 999 Hz).
|
||||||
|
func clampOffset(hz int) int {
|
||||||
|
if hz > 99999 {
|
||||||
|
return 99999
|
||||||
|
}
|
||||||
|
if hz < -99999 {
|
||||||
|
return -99999
|
||||||
|
}
|
||||||
|
return hz
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
||||||
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
||||||
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
|
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
|
||||||
@@ -1664,6 +1709,23 @@ func (f *Flex) SetTune(on bool) error {
|
|||||||
return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on })
|
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 {
|
func (f *Flex) SetVOX(on bool) error {
|
||||||
return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on })
|
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:]) {
|
switch icnLE.Uint16(buf[4:]) {
|
||||||
case 0x07: // ping
|
case 0x07: // ping
|
||||||
_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
|
_, _ = 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
|
case 0x01: // retransmit request — resend from the CONTROL sent-buffer
|
||||||
if k >= 8 {
|
if k >= 8 {
|
||||||
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case 0x05: // rig-initiated disconnect — it dropped US
|
case 0x05: // rig-initiated disconnect — it dropped US
|
||||||
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||||
|
default:
|
||||||
|
// 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 {
|
if time.Since(lastIdle) > 100*time.Millisecond {
|
||||||
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
|
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
|
||||||
lastIdle = time.Now()
|
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()
|
n.renewToken()
|
||||||
lastToken = time.Now()
|
lastToken = time.Now()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
|||||||
b.dspMu.Unlock()
|
b.dspMu.Unlock()
|
||||||
return s, nil
|
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
|
return RigState{}, err // control link dead → let the Manager reconnect
|
||||||
}
|
}
|
||||||
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
||||||
|
|||||||
@@ -534,8 +534,14 @@ func (s *session) emitLine(text string, sent bool) {
|
|||||||
// ---------- parsing ----------
|
// ---------- parsing ----------
|
||||||
|
|
||||||
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
// 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(
|
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.
|
// Pacing for the per-server init commands.
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ type Event struct {
|
|||||||
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
||||||
DecodeSNR int // reported SNR (dB)
|
DecodeSNR int // reported SNR (dB)
|
||||||
DecodeCQ bool // the decode was a CQ
|
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.
|
// Server is a single inbound UDP listener.
|
||||||
@@ -64,7 +69,8 @@ type Server struct {
|
|||||||
stopped bool
|
stopped bool
|
||||||
mu sync.Mutex
|
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 {
|
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.Mode = w.Mode
|
||||||
ev.FreqHz = w.FreqHz
|
ev.FreqHz = w.FreqHz
|
||||||
ev.LoggedADIF = w.LoggedADIF
|
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:
|
case ServiceADIF:
|
||||||
// JTAlert / GridTracker forward a text ADIF record after a QSO is
|
// JTAlert / GridTracker forward a text ADIF record after a QSO is
|
||||||
// logged. Guard against keep-alive / non-ADIF chatter on the socket:
|
// 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:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Empty events are useless; skip.
|
// Empty events are useless; skip — EXCEPT a clear signal, which is meant to be
|
||||||
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" {
|
// empty (the DX Call was cleared in the digital app).
|
||||||
|
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
+167
-6
@@ -724,6 +724,10 @@ var bulkEditableCols = map[string]bool{
|
|||||||
"my_antenna": true,
|
"my_antenna": true,
|
||||||
"my_sig": true,
|
"my_sig": true,
|
||||||
"my_sig_info": 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.
|
// Contest — the exchange/label fields that are constant across a run.
|
||||||
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
||||||
"contest_id": true,
|
"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
|
// column to this list AND populate it in scanAwardQSO below, or that award will
|
||||||
// silently see an empty value during stats/computation.
|
// silently see an empty value during stats/computation.
|
||||||
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
|
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, ` +
|
`name, qth, address, comment, notes, ` +
|
||||||
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
|
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
|
||||||
|
|
||||||
@@ -1636,6 +1640,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
|||||||
qsoDateStr string
|
qsoDateStr string
|
||||||
freqHz sql.NullInt64
|
freqHz sql.NullInt64
|
||||||
grid, vucc, country, state sql.NullString
|
grid, vucc, country, state sql.NullString
|
||||||
|
cnty sql.NullString
|
||||||
cont, iotaRef, sota, pota sql.NullString
|
cont, iotaRef, sota, pota sql.NullString
|
||||||
dxcc, cqz, ituz sql.NullInt64
|
dxcc, cqz, ituz sql.NullInt64
|
||||||
name, qth, address sql.NullString
|
name, qth, address sql.NullString
|
||||||
@@ -1645,7 +1650,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
|||||||
)
|
)
|
||||||
if err := s.Scan(
|
if err := s.Scan(
|
||||||
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
|
&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,
|
&name, &qth, &address, &comment, ¬es,
|
||||||
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
|
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -1660,6 +1665,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
|||||||
q.VUCCGrids = vucc.String
|
q.VUCCGrids = vucc.String
|
||||||
q.Country = country.String
|
q.Country = country.String
|
||||||
q.State = state.String
|
q.State = state.String
|
||||||
|
q.County = cnty.String
|
||||||
q.Continent = cont.String
|
q.Continent = cont.String
|
||||||
if cqz.Valid {
|
if cqz.Valid {
|
||||||
v := int(cqz.Int64)
|
v := int(cqz.Int64)
|
||||||
@@ -1778,6 +1784,78 @@ func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error)
|
|||||||
return out, rows.Err()
|
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.
|
// Count returns the total number of QSOs in the database.
|
||||||
func (r *Repo) Count(ctx context.Context) (int64, error) {
|
func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||||
var n int64
|
var n int64
|
||||||
@@ -1954,18 +2032,100 @@ func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, b
|
|||||||
// ConfirmedSets captures which DXCC / band / slot combinations are already
|
// ConfirmedSets captures which DXCC / band / slot combinations are already
|
||||||
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
|
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
|
||||||
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
|
// 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 {
|
type ConfirmedSets struct {
|
||||||
DXCC map[int]bool // dxcc entity confirmed
|
DXCC map[int]bool // dxcc entity confirmed
|
||||||
Band map[string]bool // "dxcc|band"
|
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 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 {
|
func SlotKey(dxcc int, band, mode string) string {
|
||||||
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
|
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
|
// confirmedCols whitelists the received-status columns ConfirmedSlots may
|
||||||
// OR together (guards the dynamic SQL).
|
// OR together (guards the dynamic SQL).
|
||||||
var confirmedCols = map[string]bool{
|
var confirmedCols = map[string]bool{
|
||||||
@@ -1981,7 +2141,7 @@ var confirmedCols = map[string]bool{
|
|||||||
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
|
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
|
||||||
// {qrzcom_qso_download_status}.
|
// {qrzcom_qso_download_status}.
|
||||||
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
|
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
|
var conds []string
|
||||||
for _, c := range cols {
|
for _, c := range cols {
|
||||||
if confirmedCols[c] {
|
if confirmedCols[c] {
|
||||||
@@ -2010,7 +2170,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
|
|||||||
}
|
}
|
||||||
sets.DXCC[dxcc] = true
|
sets.DXCC[dxcc] = true
|
||||||
sets.Band[BandKey(dxcc, band)] = 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()
|
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
|
// 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
|
// span — so rate, best-hour and off-air figures are computed over the contest
|
||||||
// itself without the operator having to look its dates up.
|
// 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
|
var s Stats
|
||||||
contestID = strings.ToUpper(strings.TrimSpace(contestID))
|
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, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT callsign, qso_date, band, mode, cont, country, dxcc,
|
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
|
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
|
// 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/
|
// this after the counting (the obvious mistake) would leave the mode/band/
|
||||||
// operator charts showing the whole log while only the trend was filtered.
|
// 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 != "" {
|
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
||||||
bandC[b]++
|
bandC[b]++
|
||||||
}
|
}
|
||||||
// An empty OPERATOR means "the station owner logged it himself" — bucket
|
// op was resolved above (with the operator filter applied).
|
||||||
// it explicitly rather than dropping the QSO from the operator chart.
|
|
||||||
op := strings.ToUpper(strings.TrimSpace(oper.String))
|
|
||||||
if op == "" {
|
|
||||||
op = "—"
|
|
||||||
}
|
|
||||||
opC[op]++
|
opC[op]++
|
||||||
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
||||||
stationC[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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
// Package steppir controls a SteppIR SDA-100 / SDA-2000 antenna controller over
|
||||||
|
// its "Transceiver Interface" serial protocol, reached either directly on a COM
|
||||||
|
// port or over TCP through an RS232↔Ethernet bridge (the same way OpsLog talks to
|
||||||
|
// an Ultrabeam). The client mirrors the ultrabeam.Client surface so the app can
|
||||||
|
// drive either behind one interface.
|
||||||
|
//
|
||||||
|
// Protocol (cross-checked against the SteppIR "Transceiver Interface Operation"
|
||||||
|
// note, the we7u/steppir library, and the la1k.no write-up — three independent
|
||||||
|
// sources that agree, which is what makes the byte layout trustworthy):
|
||||||
|
//
|
||||||
|
// SET : "@A" <freq> 00 <dir> <cmd> 00 0x0D (11 bytes)
|
||||||
|
// <freq> = int32 big-endian of (Hz / 10)
|
||||||
|
// <dir> = 0x00 normal · 0x40 180° · 0x80 bidirectional · 0x20 3/4-wave
|
||||||
|
// <cmd> = '1' set freq+dir · 'R' autotrack ON · 'U' autotrack OFF
|
||||||
|
// 'S' home/retract · 'V' calibrate
|
||||||
|
// STATUS: "?A" 0x0D → 11 bytes back:
|
||||||
|
// [2:6] int32 big-endian frequency (× 10 = Hz)
|
||||||
|
// [6] active-motor bitmask (0xFF = command received / setup)
|
||||||
|
// [7] & 0xE0 direction
|
||||||
|
//
|
||||||
|
// Timing: the controller needs ≥100 ms between commands and dislikes status
|
||||||
|
// polls faster than ~10/s. The poll loop runs at 2 s, well inside that.
|
||||||
|
package steppir
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direction values, matching the app-wide convention (also used by Ultrabeam):
|
||||||
|
// 0 normal, 1 reverse (180°), 2 bidirectional.
|
||||||
|
const (
|
||||||
|
DirNormal = 0
|
||||||
|
Dir180 = 1
|
||||||
|
DirBi = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// SteppIR direction bytes on the wire.
|
||||||
|
const (
|
||||||
|
wireNormal = 0x00
|
||||||
|
wire180 = 0x40
|
||||||
|
wireBi = 0x80
|
||||||
|
)
|
||||||
|
|
||||||
|
// Transport says how to reach the controller.
|
||||||
|
type Transport struct {
|
||||||
|
Mode string // "tcp" | "serial"
|
||||||
|
Host string // tcp
|
||||||
|
Port int // tcp
|
||||||
|
COM string // serial device (COM3, /dev/ttyUSB0)
|
||||||
|
Baud int // serial baud (controller default 9600; 1200-19200 valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is the antenna state, in the same shape the app reads from the
|
||||||
|
// Ultrabeam so the two are interchangeable at the UI.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Frequency int `json:"frequency"` // kHz
|
||||||
|
Band int `json:"band"` // 0 (SteppIR does not report a band index)
|
||||||
|
Direction int `json:"direction"` // 0 normal, 1 180°, 2 bidirectional
|
||||||
|
MotorsMoving int `json:"motors_moving"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
tr Transport
|
||||||
|
|
||||||
|
connMu sync.Mutex
|
||||||
|
conn io.ReadWriteCloser
|
||||||
|
|
||||||
|
// ioMu serialises EVERY exchange on the shared connection — a status query
|
||||||
|
// (write "?A" then read 11 bytes) and a command write must never interleave,
|
||||||
|
// or their bytes mix on the wire and both frames are corrupted. The status
|
||||||
|
// poll runs on one goroutine, tuning on another, so this is essential.
|
||||||
|
ioMu sync.Mutex
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
lastStatus *Status
|
||||||
|
lastSetKHz int
|
||||||
|
|
||||||
|
// A just-commanded direction is held until the controller's poll reports it —
|
||||||
|
// the motors take a second or two, and a stale poll would otherwise snap the
|
||||||
|
// UI back. Same trick as the Ultrabeam client.
|
||||||
|
pendingDir int
|
||||||
|
pendingDirAt time.Time
|
||||||
|
pendingDirSet bool
|
||||||
|
|
||||||
|
stopChan chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(tr Transport) *Client {
|
||||||
|
if tr.Baud <= 0 {
|
||||||
|
tr.Baud = 9600
|
||||||
|
}
|
||||||
|
return &Client{tr: tr, stopChan: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
c.running = true
|
||||||
|
go c.pollLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stopChan)
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastSetKHz returns the frequency last commanded, or 0.
|
||||||
|
func (c *Client) LastSetKHz() int {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.lastSetKHz
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() (*Status, error) {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
if c.lastStatus == nil {
|
||||||
|
return &Status{Connected: false}, nil
|
||||||
|
}
|
||||||
|
return c.lastStatus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// open dials the transport. Callers hold connMu.
|
||||||
|
func (c *Client) open() (io.ReadWriteCloser, error) {
|
||||||
|
switch c.tr.Mode {
|
||||||
|
case "serial":
|
||||||
|
if c.tr.COM == "" {
|
||||||
|
return nil, fmt.Errorf("steppir: no serial port configured")
|
||||||
|
}
|
||||||
|
p, err := serial.Open(c.tr.COM, &serial.Mode{BaudRate: c.tr.Baud})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A finite read timeout so a silent controller doesn't wedge the poll loop.
|
||||||
|
_ = p.SetReadTimeout(2 * time.Second)
|
||||||
|
return p, nil
|
||||||
|
default: // tcp
|
||||||
|
if c.tr.Host == "" {
|
||||||
|
return nil, fmt.Errorf("steppir: no host configured")
|
||||||
|
}
|
||||||
|
d := net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
return d.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn == nil {
|
||||||
|
conn, err := c.open()
|
||||||
|
if err != nil {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
c.setDisconnected()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
st, err := c.queryStatus()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("steppir: status query failed, reconnecting: %v", err)
|
||||||
|
c.closeConn()
|
||||||
|
c.setDisconnected()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st.Connected = true
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if c.pendingDirSet {
|
||||||
|
if time.Since(c.pendingDirAt) > 4*time.Second || st.Direction == c.pendingDir {
|
||||||
|
c.pendingDirSet = false
|
||||||
|
} else {
|
||||||
|
st.Direction = c.pendingDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.lastStatus = st
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setDisconnected() {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{Connected: false}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) closeConn() {
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDeadline applies a read/write deadline on TCP; serial uses its own timeout.
|
||||||
|
func setDeadline(conn io.ReadWriteCloser, d time.Duration) {
|
||||||
|
if nc, ok := conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetDeadline(time.Now().Add(d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) queryStatus() (*Status, error) {
|
||||||
|
c.connMu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.connMu.Unlock()
|
||||||
|
if conn == nil {
|
||||||
|
return nil, fmt.Errorf("steppir: not connected")
|
||||||
|
}
|
||||||
|
c.ioMu.Lock()
|
||||||
|
defer c.ioMu.Unlock()
|
||||||
|
setDeadline(conn, 3*time.Second)
|
||||||
|
if _, err := conn.Write([]byte("?A\r")); err != nil {
|
||||||
|
return nil, fmt.Errorf("write status cmd: %w", err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 11)
|
||||||
|
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||||
|
return nil, fmt.Errorf("read status: %w", err)
|
||||||
|
}
|
||||||
|
return parseStatus(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStatus decodes an 11-byte status frame.
|
||||||
|
func parseStatus(b []byte) (*Status, error) {
|
||||||
|
if len(b) < 11 {
|
||||||
|
return nil, fmt.Errorf("steppir: short status frame (%d bytes)", len(b))
|
||||||
|
}
|
||||||
|
freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10
|
||||||
|
active := b[6]
|
||||||
|
dir := decodeDir(b[7])
|
||||||
|
// active-motors byte: one bit per element that is currently moving.
|
||||||
|
// 0x04 driver · 0x08 DIR1 · 0x10 reflector · 0x20 DIR2 (mask 0x3C)
|
||||||
|
// Bit 0 (0x01) is documented as always set — not a motor. 0xFF means the
|
||||||
|
// controller just received a command, not motion. So "moving" is precisely
|
||||||
|
// "any real motor bit set", ignoring the always-on bit and the ack value.
|
||||||
|
const motorBits = 0x3C
|
||||||
|
moving := 0
|
||||||
|
if active != 0xFF && active&motorBits != 0 {
|
||||||
|
moving = 1
|
||||||
|
}
|
||||||
|
return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeDir(b byte) int {
|
||||||
|
switch b & 0xE0 {
|
||||||
|
case wireBi:
|
||||||
|
return DirBi
|
||||||
|
case wire180:
|
||||||
|
return Dir180
|
||||||
|
default:
|
||||||
|
return DirNormal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirWireByte(dir int) byte {
|
||||||
|
switch dir {
|
||||||
|
case Dir180:
|
||||||
|
return wire180
|
||||||
|
case DirBi:
|
||||||
|
return wireBi
|
||||||
|
default:
|
||||||
|
return wireNormal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSet frames a SET command: "@A" <freq be32 of Hz/10> 00 <dir> <cmd> 00 CR.
|
||||||
|
func buildSet(freqHz int, dir int, cmd byte) []byte {
|
||||||
|
var f [4]byte
|
||||||
|
binary.BigEndian.PutUint32(f[:], uint32(freqHz/10))
|
||||||
|
out := make([]byte, 0, 11)
|
||||||
|
out = append(out, '@', 'A')
|
||||||
|
out = append(out, f[:]...)
|
||||||
|
out = append(out, 0x00, dirWireByte(dir), cmd, 0x00, 0x0D)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) writeCmd(pkt []byte) error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.connMu.Unlock()
|
||||||
|
if conn == nil {
|
||||||
|
return fmt.Errorf("steppir: not connected")
|
||||||
|
}
|
||||||
|
c.ioMu.Lock()
|
||||||
|
defer c.ioMu.Unlock()
|
||||||
|
log.Printf("steppir: → % X", pkt)
|
||||||
|
setDeadline(conn, 3*time.Second)
|
||||||
|
if _, err := conn.Write(pkt); err != nil {
|
||||||
|
c.closeConn()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The controller needs breathing room between commands.
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency tunes the elements to freqKhz with the given direction.
|
||||||
|
//
|
||||||
|
// AUTOTRACK is (re-)enabled first, EVERY time: the controller ignores frequency
|
||||||
|
// sets unless it is in AUTOTRACK mode ("when not in AUTOTRACK only CALIBRATE and
|
||||||
|
// RETRACT work"), and it can be out of AUTOTRACK at power-on, after a Home, or if
|
||||||
|
// switched off on the front panel. Sending the 'R' command each tune is cheap and
|
||||||
|
// makes tuning work regardless of the controller's current mode — which is what
|
||||||
|
// was silently failing before.
|
||||||
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||||
|
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil { // AUTOTRACK ON
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil { // set freq + dir
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastSetKHz = freqKhz
|
||||||
|
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDirection changes the pattern. SteppIR has no standalone direction command —
|
||||||
|
// it is a SET with the current frequency and the new direction byte.
|
||||||
|
func (c *Client) SetDirection(direction int) error {
|
||||||
|
khz := c.LastSetKHz()
|
||||||
|
if khz <= 0 {
|
||||||
|
if st, _ := c.GetStatus(); st != nil {
|
||||||
|
khz = st.Frequency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if khz <= 0 {
|
||||||
|
return fmt.Errorf("steppir: no frequency known yet — cannot set direction")
|
||||||
|
}
|
||||||
|
return c.SetFrequency(khz, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retract homes the elements into the hubs (storage). This drops the controller
|
||||||
|
// out of AUTOTRACK, but that is handled transparently: the next SetFrequency
|
||||||
|
// re-issues AUTOTRACK ON before tuning.
|
||||||
|
func (c *Client) Retract() error {
|
||||||
|
// A valid frequency must accompany the command; reuse the last one.
|
||||||
|
khz := c.LastSetKHz()
|
||||||
|
if khz <= 0 {
|
||||||
|
if st, _ := c.GetStatus(); st != nil && st.Frequency > 0 {
|
||||||
|
khz = st.Frequency
|
||||||
|
} else {
|
||||||
|
khz = 14000 // any in-range value; the controller just homes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.writeCmd(buildSet(khz*1000, DirNormal, 'S'))
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package steppir
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The exact bytes are the correctness checksum. If buildSet ever drifts from the
|
||||||
|
// three-source-agreed layout, this fails — a wrong packet is a silently mistuned
|
||||||
|
// antenna, far worse than a compile error.
|
||||||
|
func TestBuildSetLayout(t *testing.T) {
|
||||||
|
// 14.074 MHz, normal, set-freq. freq/10 = 1_407_400 = 0x00 0x15 0x79 0xA8.
|
||||||
|
pkt := buildSet(14_074_000, DirNormal, '1')
|
||||||
|
want := []byte{'@', 'A', 0x00, 0x15, 0x79, 0xA8, 0x00, 0x00, '1', 0x00, 0x0D}
|
||||||
|
if len(pkt) != 11 {
|
||||||
|
t.Fatalf("packet is %d bytes, want 11", len(pkt))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if pkt[i] != want[i] {
|
||||||
|
t.Fatalf("byte %d = 0x%02X, want 0x%02X\n got %X\nwant %X", i, pkt[i], want[i], pkt, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Frequency must round-trip: bytes [2:6] × 10 = Hz.
|
||||||
|
if got := int(binary.BigEndian.Uint32(pkt[2:6])) * 10; got != 14_074_000 {
|
||||||
|
t.Fatalf("freq round-trip = %d, want 14074000", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSetDirectionAndCommand(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
dir int
|
||||||
|
cmd byte
|
||||||
|
wantDir byte
|
||||||
|
wantCmd byte
|
||||||
|
}{
|
||||||
|
{DirNormal, '1', 0x00, '1'},
|
||||||
|
{Dir180, '1', 0x40, '1'},
|
||||||
|
{DirBi, '1', 0x80, '1'},
|
||||||
|
{DirNormal, 'S', 0x00, 'S'}, // retract / home
|
||||||
|
{DirNormal, 'R', 0x00, 'R'}, // autotrack on
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
pkt := buildSet(21_000_000, c.dir, c.cmd)
|
||||||
|
if pkt[7] != c.wantDir {
|
||||||
|
t.Errorf("dir %d → byte 0x%02X, want 0x%02X", c.dir, pkt[7], c.wantDir)
|
||||||
|
}
|
||||||
|
if pkt[8] != c.wantCmd {
|
||||||
|
t.Errorf("cmd %q → byte 0x%02X, want 0x%02X", c.cmd, pkt[8], c.wantCmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A REAL status frame captured off an SDA controller (F4BPO's, 2026-07-15):
|
||||||
|
// 40 41 00 4C C5 84 00 87 30 38 0D — 50.313 MHz, idle, bidirectional, interface
|
||||||
|
// version "08". Using the actual device output (rather than hand-built bytes)
|
||||||
|
// pins parseStatus to real hardware, and independently confirms the ÷10 wire
|
||||||
|
// scale: 0x4CC584 × 10 = 50 313 000 Hz, a genuine 6 m frequency.
|
||||||
|
func TestParseStatusRealFrame(t *testing.T) {
|
||||||
|
frame := []byte{0x40, 0x41, 0x00, 0x4C, 0xC5, 0x84, 0x00, 0x87, 0x30, 0x38, 0x0D}
|
||||||
|
st, err := parseStatus(frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Frequency != 50313 {
|
||||||
|
t.Errorf("freq = %d kHz, want 50313 (50.313 MHz)", st.Frequency)
|
||||||
|
}
|
||||||
|
if st.Direction != DirBi { // 0x87 & 0xE0 = 0x80 = bidirectional
|
||||||
|
t.Errorf("direction = %d, want %d (bidirectional)", st.Direction, DirBi)
|
||||||
|
}
|
||||||
|
if st.MotorsMoving != 0 {
|
||||||
|
t.Errorf("moving = %d, want 0 (motors byte 0x00)", st.MotorsMoving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStatus decodes what the controller sends back — the inverse of buildSet's
|
||||||
|
// frequency field, plus the direction nibble and the motors-busy byte.
|
||||||
|
func TestParseStatus(t *testing.T) {
|
||||||
|
frame := []byte{0x40, 0x41, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '0', '8', 0x0D}
|
||||||
|
st, err := parseStatus(frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Frequency != 14074 {
|
||||||
|
t.Errorf("freq = %d kHz, want 14074", st.Frequency)
|
||||||
|
}
|
||||||
|
if st.Direction != Dir180 {
|
||||||
|
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
|
||||||
|
}
|
||||||
|
if st.MotorsMoving != 0 { // 0x01 is the always-set bit, not motion
|
||||||
|
t.Errorf("moving = %d, want 0 (only the always-on bit set)", st.MotorsMoving)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Motors busy: a bit beyond 0x01 is set.
|
||||||
|
frame[6] = 0x07
|
||||||
|
st, _ = parseStatus(frame)
|
||||||
|
if st.MotorsMoving == 0 {
|
||||||
|
t.Error("active-motors 0x07 should read as moving")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0xFF is "command received", not motion.
|
||||||
|
frame[6] = 0xFF
|
||||||
|
st, _ = parseStatus(frame)
|
||||||
|
if st.MotorsMoving != 0 {
|
||||||
|
t.Error("active-motors 0xFF (command received) must not read as moving")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
// 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"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
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
|
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)
|
// SetFrequency changes frequency and optional direction (command 3)
|
||||||
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||||
// Trace WHO asked for the change — the caller's function + line — so an
|
// Trace WHO asked for the change — the caller's function + line — so an
|
||||||
|
|||||||
@@ -49,19 +49,40 @@ func main() {
|
|||||||
app := NewApp()
|
app := NewApp()
|
||||||
app.startupProfile = profileArg(os.Args[1:])
|
app.startupProfile = profileArg(os.Args[1:])
|
||||||
|
|
||||||
|
// Restore the window's SIZE and maximised state at CREATION, from the geometry
|
||||||
|
// saved on last close. Doing it here (not after startup) is what makes the
|
||||||
|
// window open already at the right size instead of maximising then snapping
|
||||||
|
// smaller. Position can't be set through options, so it is applied while the
|
||||||
|
// window is still hidden (domReady) — invisible, so no jump. First run, or a
|
||||||
|
// window closed maximised, keeps the historical maximised default.
|
||||||
|
width, height := 1400, 900
|
||||||
|
startState := options.Maximised
|
||||||
|
if dataDir, err := userDataDir(); err == nil {
|
||||||
|
if ws, ok := readWindowState(dataDir); ok && !ws.Maximised &&
|
||||||
|
ws.Width >= 1100 && ws.Height >= 700 && ws.Width <= 8000 && ws.Height <= 6000 {
|
||||||
|
width, height = ws.Width, ws.Height
|
||||||
|
startState = options.Normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create application with options
|
// Create application with options
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
Title: "OpsLog",
|
Title: "OpsLog",
|
||||||
Width: 1400,
|
Width: width,
|
||||||
Height: 900,
|
Height: height,
|
||||||
MinWidth: 1100,
|
MinWidth: 1100,
|
||||||
MinHeight: 700,
|
MinHeight: 700,
|
||||||
WindowStartState: options.Maximised,
|
WindowStartState: startState,
|
||||||
|
// Start hidden and reveal only once the saved position has been applied and
|
||||||
|
// the DOM has painted (OnDomReady → domReady) — so the window appears
|
||||||
|
// already at its final size and position, with no post-launch jump.
|
||||||
|
StartHidden: true,
|
||||||
AssetServer: &assetserver.Options{
|
AssetServer: &assetserver.Options{
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
},
|
},
|
||||||
BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1},
|
BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1},
|
||||||
OnStartup: app.startup,
|
OnStartup: app.startup,
|
||||||
|
OnDomReady: app.domReady,
|
||||||
OnBeforeClose: app.beforeClose,
|
OnBeforeClose: app.beforeClose,
|
||||||
OnShutdown: app.shutdown,
|
OnShutdown: app.shutdown,
|
||||||
Bind: []interface{}{
|
Bind: []interface{}{
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.19.6"
|
appVersion = "0.19.9"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user